formula-parser-payroll 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +202 -0
  2. package/dist/database/database-connector.d.ts +60 -0
  3. package/dist/database/database-connector.d.ts.map +1 -0
  4. package/dist/database/database-connector.js +9 -0
  5. package/dist/database/database-connector.js.map +1 -0
  6. package/dist/database/helpers.d.ts +44 -0
  7. package/dist/database/helpers.d.ts.map +1 -0
  8. package/dist/database/helpers.js +120 -0
  9. package/dist/database/helpers.js.map +1 -0
  10. package/dist/database/index.d.ts +11 -0
  11. package/dist/database/index.d.ts.map +1 -0
  12. package/dist/database/index.js +29 -0
  13. package/dist/database/index.js.map +1 -0
  14. package/dist/database/payroll-formula.service.d.ts +118 -0
  15. package/dist/database/payroll-formula.service.d.ts.map +1 -0
  16. package/dist/database/payroll-formula.service.js +794 -0
  17. package/dist/database/payroll-formula.service.js.map +1 -0
  18. package/dist/database/types.d.ts +117 -0
  19. package/dist/database/types.d.ts.map +1 -0
  20. package/dist/database/types.js +9 -0
  21. package/dist/database/types.js.map +1 -0
  22. package/dist/formula-engine.d.ts +18 -0
  23. package/dist/formula-engine.d.ts.map +1 -0
  24. package/dist/formula-engine.js +356 -0
  25. package/dist/formula-engine.js.map +1 -0
  26. package/dist/formula-parser.d.ts +60 -0
  27. package/dist/formula-parser.d.ts.map +1 -0
  28. package/dist/formula-parser.js +366 -0
  29. package/dist/formula-parser.js.map +1 -0
  30. package/dist/index.d.ts +13 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +44 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/nestjs/database-connector.d.ts +60 -0
  35. package/dist/nestjs/database-connector.d.ts.map +1 -0
  36. package/dist/nestjs/database-connector.js +9 -0
  37. package/dist/nestjs/database-connector.js.map +1 -0
  38. package/dist/nestjs/helpers.d.ts +44 -0
  39. package/dist/nestjs/helpers.d.ts.map +1 -0
  40. package/dist/nestjs/helpers.js +120 -0
  41. package/dist/nestjs/helpers.js.map +1 -0
  42. package/dist/nestjs/index.d.ts +11 -0
  43. package/dist/nestjs/index.d.ts.map +1 -0
  44. package/dist/nestjs/index.js +29 -0
  45. package/dist/nestjs/index.js.map +1 -0
  46. package/dist/nestjs/payroll-formula.service.d.ts +91 -0
  47. package/dist/nestjs/payroll-formula.service.d.ts.map +1 -0
  48. package/dist/nestjs/payroll-formula.service.js +640 -0
  49. package/dist/nestjs/payroll-formula.service.js.map +1 -0
  50. package/dist/nestjs/types.d.ts +117 -0
  51. package/dist/nestjs/types.d.ts.map +1 -0
  52. package/dist/nestjs/types.js +9 -0
  53. package/dist/nestjs/types.js.map +1 -0
  54. package/dist/types.d.ts +168 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +9 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +39 -0
@@ -0,0 +1,794 @@
1
+ "use strict";
2
+ /**
3
+ * NestJS Payroll Formula Parser Service
4
+ *
5
+ * This service provides database-integrated formula parsing.
6
+ * It fetches employee data and component values from the database
7
+ * and then uses the core formula parser.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.PayrollFormulaService = void 0;
11
+ const formula_engine_1 = require("../formula-engine");
12
+ const formula_parser_1 = require("../formula-parser");
13
+ const helpers_1 = require("./helpers");
14
+ /**
15
+ * Payroll Formula Parser Service
16
+ *
17
+ * Use this service when you need to parse formulas with just formula and empId.
18
+ * This service handles fetching employee data and component values from the database.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * const service = new PayrollFormulaService();
23
+ * const result = await service.parseByEmpId({
24
+ * formula: 'SALARY * 0.1 + AL_001',
25
+ * empId: 'EMP001',
26
+ * auth: authContext,
27
+ * });
28
+ * console.log(result.result);
29
+ * ```
30
+ */
31
+ class PayrollFormulaService {
32
+ constructor() {
33
+ // Performance optimization: Cache for reserved words and component lists
34
+ this.reservedWordsCache = new Map();
35
+ this.componentListCache = new Map();
36
+ this.CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache
37
+ this.CHUNK_SIZE = 50; // Process employees in chunks of 50 to prevent memory overflow
38
+ }
39
+ /**
40
+ * Parse formula by employee ID(s)
41
+ * This method fetches all required data from database and evaluates the formula
42
+ *
43
+ * When listEmpId is provided: returns Record<empId, hasil>
44
+ * When empId is provided: returns FormulaParseResultLegacy
45
+ */
46
+ async parseByEmpId(input) {
47
+ const { formula: rawFormula, empId, listEmpId, auth } = input;
48
+ console.log('>>> PayrollFormulaService.parseByEmpId CALLED', { rawFormula, listEmpIdLen: listEmpId?.length });
49
+ // If listEmpId is provided, process multiple employees IN PARALLEL WITH CHUNKING
50
+ if (listEmpId && listEmpId.length > 0) {
51
+ const resultData = {};
52
+ // OPTIMIZATION: Process employees in chunks to prevent memory overflow
53
+ // For large employee lists (1000+), processing all at once can overwhelm memory
54
+ const chunks = this.chunkArray(listEmpId, this.CHUNK_SIZE);
55
+ console.log(`>>> Processing ${listEmpId.length} employees in ${chunks.length} chunks of ${this.CHUNK_SIZE}`);
56
+ for (const chunk of chunks) {
57
+ // Process each chunk in parallel
58
+ const promises = chunk.map(emp => this.parseForSingleEmployee(rawFormula, emp, auth));
59
+ const results = await Promise.all(promises);
60
+ // Map results back to employee IDs
61
+ chunk.forEach((emp, idx) => {
62
+ resultData[emp] = results[idx].hasil;
63
+ });
64
+ }
65
+ return resultData;
66
+ }
67
+ // Single employee processing
68
+ if (!empId) {
69
+ return {
70
+ hasil: 0,
71
+ message: 'Either empId or listEmpId must be provided',
72
+ };
73
+ }
74
+ return this.parseForSingleEmployee(rawFormula, empId, auth);
75
+ }
76
+ /**
77
+ * Check if a formula is valid
78
+ * This ports the logic from payroll-server's formulaValidtyCheck
79
+ */
80
+ async formulaValidityCheck(input) {
81
+ const { formula: rawFormula, auth } = input;
82
+ try {
83
+ if (!rawFormula) {
84
+ throw new Error('Formula cannot be empty');
85
+ }
86
+ const formula = rawFormula.replace(/"/g, `'`);
87
+ // 1. Get reserved words from DB
88
+ const getWordList = await this.getReservedWords(auth);
89
+ const reservedWords = new Set(getWordList.words2); // words2 contains allowdeductCode
90
+ // 2. Extra keywords that are always valid
91
+ // Added more from server logic
92
+ const extraValid = new Set([
93
+ 'IF', 'AND', 'OR', 'NOT', 'TRUE', 'FALSE', 'SALARY',
94
+ 'MIN', 'MAX', 'MOD', 'ROUND', 'ROUNDDOWN', 'ROUNDUP', 'TRUNC',
95
+ 'DATE', 'DAYS', 'TODAY', 'DATETIME_NOW', 'ISDATE', 'DAY', 'MONTH', 'YEAR',
96
+ 'DAYSINMONTH', 'DAYOFWEEK', 'ISNUMERIC', 'HOUR', 'MINUTE', 'SECOND',
97
+ 'DATEDIFF', 'DATEADD', 'CONCATENATE', 'CONCATENATESKIPNULL', 'FINDLIST', 'CREATEDATE',
98
+ 'LENGTHOFSERVICE', 'CHILDDEPENDENTS',
99
+ 'CUSTOMFIELD', // Base word
100
+ ]);
101
+ // 3. Handle String Literals first!
102
+ // Anything inside quotes should NOT be validated as a variable.
103
+ // Replace quoted strings with placeholders
104
+ const formulaNoStrings = formula.replace(/'[^']*'/g, 'STRING_LITERAL');
105
+ // 4. Extract all alpha tokens (potential variables/functions)
106
+ // This regex matches words like GRADE, CUSTOMFIELD5, AL_001, etc.
107
+ // We use the formula WITHOUT strings to check tokens.
108
+ const tokens = formulaNoStrings.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g) || [];
109
+ for (const token of tokens) {
110
+ const upperToken = token.toUpperCase();
111
+ // Skip placeholders
112
+ if (upperToken === 'STRING_LITERAL')
113
+ continue;
114
+ // Skip if it's a reserved word or a known function/keyword
115
+ if (reservedWords.has(upperToken) || extraValid.has(upperToken)) {
116
+ continue;
117
+ }
118
+ // Special handling for CUSTOMFIELD1 - CUSTOMFIELD10
119
+ if (/^CUSTOMFIELD\d+$/.test(upperToken)) {
120
+ continue;
121
+ }
122
+ // Skip if it's a payroll component code (AL_xxx, DE_xxx, NE_xxx)
123
+ if (/^(AL|DE|NE)_\d+$/i.test(token)) {
124
+ continue;
125
+ }
126
+ // Potential typo or invalid keyword
127
+ throw new Error(`Failed not defined reserve word or formula is not valid: ${token}`);
128
+ }
129
+ // 5. Final syntax check by attempting a dummy parse
130
+ // We need to replace all valid tokens with dummy values, otherwise the parser will complain about #NAME?
131
+ let dummyFormula = formula;
132
+ // Sort tokens by length (desc) to avoid partial replacement
133
+ const sortedTokens = [...new Set(tokens)].sort((a, b) => b.length - a.length);
134
+ for (const token of sortedTokens) {
135
+ // Replace token with "0" (as string) or 0 (as number)
136
+ // Since we don't know the type, let's use 0 which is safe for most math/logic
137
+ // Use regex for word boundary replacement
138
+ const regex = new RegExp(`\\b${token}\\b`, 'g');
139
+ dummyFormula = dummyFormula.replace(regex, '0');
140
+ }
141
+ // Restore string literals (they were replaced by 'STRING_LITERAL' in token check step? No, that was a copy)
142
+ // wait, 'tokens' were extracted from 'formulaNoStrings'.
143
+ // 'dummyFormula' starts from original 'formula' (which has strings).
144
+ // If we replace tokens in original formula, we might accidentally replace content inside strings!
145
+ // But we know 'tokens' are extracted from outside strings.
146
+ // Safer approach:
147
+ // We already validated all tokens are "legal".
148
+ // The only reason to run parseFormula is to check parentheses balance, commas in functions, etc.
149
+ // So let's run parseFormula on the dummy-filled string.
150
+ // But we must be careful not to replace text inside quotes.
151
+ // E.g. IF(GRADE = "GRADE", ...) -> The second GRADE is string.
152
+ // Strategy:
153
+ // 1. Hide strings placeholders
154
+ const regexKutip = /'[^']*'/g;
155
+ let match;
156
+ const placeholders = {};
157
+ let pIdx = 0;
158
+ let formulaWithPlaceholders = formula.replace(regexKutip, (m) => {
159
+ const key = `__STR_${pIdx++}__`;
160
+ placeholders[key] = m; // equivalent to "0" for parsing safety?
161
+ // Actually, for syntax check, a string literal is just a value.
162
+ // Let's replace it with "0" (string "0") to be safe?
163
+ // Or just keep it as a string literal but simple one like 'S'
164
+ return "'S'";
165
+ });
166
+ // 2. Replace variables in non-string part with 0
167
+ // We must NOT replace Functions (IF, MAX, etc) with 0. Only variables.
168
+ const functionsToPreserve = new Set([
169
+ 'IF', 'AND', 'OR', 'NOT',
170
+ 'MIN', 'MAX', 'MOD', 'ROUND', 'ROUNDDOWN', 'ROUNDUP', 'TRUNC',
171
+ 'DATE', 'DAYS', 'TODAY', 'DATETIME_NOW', 'ISDATE', 'DAY', 'MONTH', 'YEAR',
172
+ 'DAYSINMONTH', 'DAYOFWEEK', 'ISNUMERIC', 'HOUR', 'MINUTE', 'SECOND',
173
+ 'DATEDIFF', 'DATEADD', 'CONCATENATE', 'CONCATENATESKIPNULL', 'FINDLIST', 'CREATEDATE',
174
+ 'LENGTHOFSERVICE'
175
+ ]);
176
+ for (const token of sortedTokens) {
177
+ const upperToken = token.toUpperCase();
178
+ // If it is a function, DO NOT replace it.
179
+ if (functionsToPreserve.has(upperToken)) {
180
+ continue;
181
+ }
182
+ // `TRUE` and `FALSE` are boolean literals, parser handles them. No need to replace.
183
+ if (upperToken === 'TRUE' || upperToken === 'FALSE') {
184
+ continue;
185
+ }
186
+ const regex = new RegExp(`\\b${token}\\b`, 'g');
187
+ formulaWithPlaceholders = formulaWithPlaceholders.replace(regex, '0');
188
+ }
189
+ // 3. Test parse
190
+ try {
191
+ const result = (0, formula_engine_1.parseFormula)(formulaWithPlaceholders);
192
+ // We ignore the result value, just checking for throw
193
+ }
194
+ catch (parseError) {
195
+ // If it's #NAME?, it means we missed a token?
196
+ // Any other error is syntax error.
197
+ const err = parseError;
198
+ if (err.message.includes('#NAME?')) {
199
+ // This can happen if hot-formula-parser doesn't support a specific function
200
+ // that we thought was valid in extraValid (e.g. LENGTHOFSERVICE is custom registered).
201
+ // But Custom functions SHOULD be registered in formula-engine.ts
202
+ // If we validated token as legal, but parser says #NAME, it might be a function name we didn't replace?
203
+ // Functions like 'IF', 'ROUND' are in extraValid.
204
+ // We should NOT replace function names with 0!
205
+ // Ah! My token extraction logic extracts everything including IF, ROUND.
206
+ // But my validation loop "continues" (skips error) if it is in extraValid.
207
+ // So 'IF' is in tokens?
208
+ // Yes: const tokens = formulaNoStrings.match(...)
209
+ // Wait, I should ONLY replace "Variables", not "Functions".
210
+ throw parseError; // Rethrow to see details if needed
211
+ }
212
+ throw parseError;
213
+ }
214
+ return { result: 'success' };
215
+ }
216
+ catch (e) {
217
+ throw e;
218
+ }
219
+ }
220
+ /**
221
+ * Clear all caches (useful for testing or when data is updated)
222
+ */
223
+ clearCache() {
224
+ this.reservedWordsCache.clear();
225
+ this.componentListCache.clear();
226
+ }
227
+ /**
228
+ * Clear cache for specific company
229
+ */
230
+ clearCacheForCompany(companyId, taxCountry) {
231
+ const cacheKey = `${companyId}_${taxCountry}`;
232
+ this.reservedWordsCache.delete(cacheKey);
233
+ this.componentListCache.delete(cacheKey);
234
+ }
235
+ /**
236
+ * Parse formula for a single employee
237
+ * Internal method used by parseByEmpId
238
+ */
239
+ async parseForSingleEmployee(rawFormula, empId, auth) {
240
+ try {
241
+ // Normalize formula
242
+ const formula = rawFormula.replace(/"/g, `'`);
243
+ // OPTIMIZATION: Pre-extract actual component codes from formula
244
+ // This is more accurate than just checking if components exist
245
+ const componentMatches = formula.match(/\b(AL_\d+|DE_\d+|NE_\d+|SALARY)\b/gi) || [];
246
+ const uniqueComponents = new Set(componentMatches.map(c => c.toUpperCase()));
247
+ const hasComponents = uniqueComponents.size > 0;
248
+ let allCompo = [];
249
+ let empCompo = [];
250
+ let compoCodes = [];
251
+ if (hasComponents) {
252
+ // Step 1: Get list of component codes, but filter by what's actually in formula
253
+ const listCompo = await this.getComponentList(auth);
254
+ // OPTIMIZATION: Only include components that are actually in the formula
255
+ compoCodes = listCompo
256
+ .map(v => v.allowdeductCode)
257
+ .filter(code => uniqueComponents.has(code.toUpperCase()));
258
+ console.log(`>>> Detected ${uniqueComponents.size} unique components in formula, ${compoCodes.length} exist in DB`);
259
+ }
260
+ // Step 2: Fetch all required data in parallel
261
+ // Only fetch components if they are actually in the formula
262
+ const currencyCode = (0, helpers_1.getCurrencyCode)(auth.userData.taxCountry);
263
+ const [allCompoRes, empCompoRes, empData, getWordList] = await Promise.all([
264
+ hasComponents ? this.getAllComponents(auth, compoCodes, currencyCode) : Promise.resolve([]),
265
+ hasComponents ? this.getEmployeeComponents(auth, empId, compoCodes, currencyCode) : Promise.resolve([]),
266
+ this.getEmployeeData(auth, empId),
267
+ this.getReservedWords(auth),
268
+ ]);
269
+ allCompo = allCompoRes;
270
+ empCompo = empCompoRes;
271
+ if (!empData) {
272
+ return {
273
+ hasil: 0,
274
+ message: `Employee not found: ${empId}`,
275
+ };
276
+ }
277
+ // Step 3: Build object values from components
278
+ const objectVal = {};
279
+ if (hasComponents) {
280
+ // Initialize all components to 0
281
+ for (const compo of allCompo) {
282
+ objectVal[compo.allowdeductCode] = '0';
283
+ }
284
+ // Initialize employee components to 0 first
285
+ for (const empCompoItem of empCompo) {
286
+ objectVal[empCompoItem.allowdeductCode] = '0';
287
+ }
288
+ // OPTIMIZATION: Chunked parallel decryption for employee components
289
+ // Process in chunks of 20 to prevent overwhelming the system
290
+ const itemsToDecrypt = empCompo.filter(item => item.allowdeductValue);
291
+ if (itemsToDecrypt.length > 0) {
292
+ const DECRYPT_CHUNK_SIZE = 20;
293
+ const decryptChunks = this.chunkArray(itemsToDecrypt, DECRYPT_CHUNK_SIZE);
294
+ for (const chunk of decryptChunks) {
295
+ const decryptPromises = chunk.map(async (item) => ({
296
+ code: item.allowdeductCode,
297
+ value: await (0, helpers_1.decryptValue)(item.allowdeductValue, auth, empId)
298
+ }));
299
+ const decryptedValues = await Promise.all(decryptPromises);
300
+ for (const { code, value } of decryptedValues) {
301
+ objectVal[code] = value.toString();
302
+ }
303
+ }
304
+ }
305
+ }
306
+ // Step 4: Extract required words from formula
307
+ const requiredWords = this.extractRequiredWordsFromFormula(formula, getWordList.words2);
308
+ // Step 5: Batch load organizational data
309
+ const dataCache = await this.batchLoadFormulaData(auth, empData, requiredWords);
310
+ // Step 6: Process all words using cached data
311
+ const employeeDataConverted = this.convertToEmployeeData(empData);
312
+ const orgDataConverted = this.convertToOrganizationalData(dataCache);
313
+ for (const word of requiredWords) {
314
+ // Process personal data
315
+ (0, formula_parser_1.processPersonalData)(word, objectVal, employeeDataConverted);
316
+ // Process organizational data
317
+ (0, formula_parser_1.processOrganizationalData)(word, objectVal, orgDataConverted, formula);
318
+ // Process custom fields
319
+ (0, formula_parser_1.processCustomFields)(word, objectVal, orgDataConverted);
320
+ }
321
+ // Step 7: Handle formula replacement (same as original logic)
322
+ let processedFormula = formula;
323
+ // Handle quoted strings
324
+ const regexKutip = /\"(.*?)\"/g;
325
+ let match;
326
+ const objectX = {};
327
+ let arr = 0;
328
+ const replacements = {};
329
+ while ((match = regexKutip.exec(processedFormula))) {
330
+ replacements[`${match[1]}`] = 'PART' + arr;
331
+ objectX['PART' + arr] = `${match[1]}`;
332
+ arr++;
333
+ }
334
+ const hasReplacements = Object.values(replacements).some(value => value !== undefined && value !== '');
335
+ if (hasReplacements) {
336
+ const regex = new RegExp(Object.keys(replacements).join('|'), 'g');
337
+ processedFormula = processedFormula.replace(regex, m => replacements[m]);
338
+ }
339
+ // Extract and replace keywords
340
+ const formula2 = processedFormula.match(/[a-zA-Z0-9_]+|[+\-*/]/g) || [];
341
+ let keys = Object.keys(objectVal);
342
+ keys = (0, formula_parser_1.sortKeyByLengthDesc)(keys);
343
+ // Build replacement map
344
+ const replacementMap = new Map();
345
+ for (const wordnya of formula2) {
346
+ if (keys.includes(wordnya) && wordnya !== 'LENGTHOFSERVICE' && !replacementMap.has(wordnya)) {
347
+ replacementMap.set(wordnya, objectVal[wordnya].toString());
348
+ }
349
+ }
350
+ // Single-pass replacement
351
+ if (replacementMap.size > 0) {
352
+ const pattern = Array.from(replacementMap.keys())
353
+ .map(word => `\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`)
354
+ .join('|');
355
+ const regex = new RegExp(pattern, 'g');
356
+ processedFormula = processedFormula.replace(regex, m => replacementMap.get(m) || m);
357
+ }
358
+ // Restore quoted strings
359
+ const keys2 = Object.keys(objectX);
360
+ if (keys2.length > 0) {
361
+ keys2.forEach(key => {
362
+ processedFormula = processedFormula.replace(new RegExp(key, 'g'), `${objectX[key]}`);
363
+ });
364
+ }
365
+ // Step 8: Parse the formula
366
+ const result = (0, formula_engine_1.parseFormula)(processedFormula);
367
+ // console.log('\n=== DEBUG PAYROLL-FORMULA-PARSER ===');
368
+ // console.log('Emp ID:', empId);
369
+ // console.log('Raw Formula:', rawFormula);
370
+ // console.log('Processed Formula:', processedFormula);
371
+ // console.log('Result:', result);
372
+ // console.log('Resolved Variables:', JSON.stringify(objectVal, null, 2));
373
+ // console.log('====================================\n');
374
+ return {
375
+ hasil: result,
376
+ message: 'success',
377
+ };
378
+ }
379
+ catch (error) {
380
+ console.log(error);
381
+ throw error;
382
+ }
383
+ }
384
+ /**
385
+ * Parse formula for multiple employees
386
+ */
387
+ async parseForMultipleEmployees(formula, empIds, auth) {
388
+ const results = {};
389
+ for (const empId of empIds) {
390
+ results[empId] = await this.parseForSingleEmployee(formula, empId, auth);
391
+ }
392
+ return results;
393
+ }
394
+ // ==================== Private Methods ====================
395
+ /**
396
+ * Get list of component codes
397
+ */
398
+ async getComponentList(auth) {
399
+ // OPTIMIZATION: Cache component list (rarely changes)
400
+ const cacheKey = `${auth.userData.companyId}_${auth.userData.taxCountry}`;
401
+ const cached = this.componentListCache.get(cacheKey);
402
+ if (cached && (Date.now() - cached.timestamp < this.CACHE_TTL)) {
403
+ return cached.data;
404
+ }
405
+ let query = `
406
+ SELECT allowdeduct_code as allowdeductCode
407
+ FROM tpympayallowdeduct
408
+ WHERE company_id = ?
409
+ AND (
410
+ (allowdeduct_code LIKE 'AL_%' AND allowdeducttype = 'A' AND allowdeduct_code REGEXP 'AL_[0-9]+')
411
+ OR (allowdeduct_code LIKE 'DE_%' AND allowdeducttype = 'D' AND allowdeduct_code REGEXP 'DE_[0-9]+')
412
+ OR (allowdeduct_code LIKE 'NE_%' AND allowdeducttype = 'N' AND allowdeduct_code REGEXP 'NE_[0-9]+')
413
+ OR allowdeduct_code = 'SALARY'
414
+ )
415
+ `;
416
+ if (auth.userData.taxCountry === 'TH') {
417
+ query += ` AND allowdeduct_code NOT IN ('NE_002', 'NE_003')`;
418
+ }
419
+ const result = await auth.connFin.query(query, [auth.userData.companyId]);
420
+ // Cache the result
421
+ this.componentListCache.set(cacheKey, { data: result, timestamp: Date.now() });
422
+ return result;
423
+ }
424
+ /**
425
+ * Get all components for company
426
+ */
427
+ async getAllComponents(auth, compoCodes, currencyCode) {
428
+ if (compoCodes.length === 0)
429
+ return [];
430
+ const placeholders = compoCodes.map(() => '?').join(',');
431
+ const query = `
432
+ SELECT allowdeduct_code as allowdeductCode
433
+ FROM tpympayallowdeduct
434
+ WHERE company_id = ?
435
+ AND allowdeduct_code IN (${placeholders})
436
+ AND currency_code = ?
437
+ `;
438
+ return auth.connFin.query(query, [auth.userData.companyId, ...compoCodes, currencyCode]);
439
+ }
440
+ /**
441
+ * Get employee component values
442
+ */
443
+ async getEmployeeComponents(auth, empId, compoCodes, currencyCode) {
444
+ if (compoCodes.length === 0)
445
+ return [];
446
+ const placeholders = compoCodes.map(() => '?').join(',');
447
+ const query = `
448
+ SELECT allowdeduct_code as allowdeductCode, allowdeduct_value as allowdeductValue
449
+ FROM tpydempallowdeduct
450
+ WHERE emp_id = ?
451
+ AND company_id = ?
452
+ AND allowdeduct_code IN (${placeholders})
453
+ AND currency_code = ?
454
+ `;
455
+ return auth.connFin.query(query, [empId, auth.userData.companyId, ...compoCodes, currencyCode]);
456
+ }
457
+ /**
458
+ * OPTIMIZATION: Batch get employee components for multiple employees
459
+ * This reduces database roundtrips when processing multiple employees
460
+ */
461
+ async getBatchEmployeeComponents(auth, empIds, compoCodes, currencyCode) {
462
+ if (empIds.length === 0 || compoCodes.length === 0)
463
+ return new Map();
464
+ const empPlaceholders = empIds.map(() => '?').join(',');
465
+ const compoPlaceholders = compoCodes.map(() => '?').join(',');
466
+ const query = `
467
+ SELECT emp_id as empId, allowdeduct_code as allowdeductCode, allowdeduct_value as allowdeductValue
468
+ FROM tpydempallowdeduct
469
+ WHERE emp_id IN (${empPlaceholders})
470
+ AND company_id = ?
471
+ AND allowdeduct_code IN (${compoPlaceholders})
472
+ AND currency_code = ?
473
+ `;
474
+ const results = await auth.connFin.query(query, [...empIds, auth.userData.companyId, ...compoCodes, currencyCode]);
475
+ // Group by employee ID
476
+ const grouped = new Map();
477
+ for (const row of results) {
478
+ if (!grouped.has(row.empId)) {
479
+ grouped.set(row.empId, []);
480
+ }
481
+ grouped.get(row.empId).push({
482
+ allowdeductCode: row.allowdeductCode,
483
+ allowdeductValue: row.allowdeductValue,
484
+ });
485
+ }
486
+ return grouped;
487
+ }
488
+ /**
489
+ * Get employee data with relations
490
+ */
491
+ async getEmployeeData(auth, empId) {
492
+ const query = `
493
+ SELECT
494
+ ec.emp_id as empId,
495
+ ec.emp_no as empNo,
496
+ ec.start_date as startDate,
497
+ ec.end_date as endDate,
498
+ ec.position_id as positionId,
499
+ ec.cost_code as costCode,
500
+ ec.grade_code as gradeCode,
501
+ ec.work_location_code as workLocationCode,
502
+ ec.job_status_code as jobStatusCode,
503
+ ecg.join_date as joinDate,
504
+ ecg.fulljoin_date as fulljoinDate,
505
+ ecg.permanent_date as permanentDate,
506
+ ecg.pension_date as pensionDate,
507
+ ecg.terminate_date as terminateDate,
508
+ ep.gender as gender,
509
+ edp.birthdate as birthdate,
510
+ edp.maritalstatus as maritalstatus,
511
+ edp.religion_code as religionCode,
512
+ ejs.jobstatusname_en as jobstatusnameEn
513
+ FROM teodempcompany ec
514
+ LEFT JOIN teodempcompanygroup ecg ON ec.emp_id = ecg.emp_id
515
+ LEFT JOIN teomemppersonal ep ON ec.emp_id = ep.emp_id
516
+ LEFT JOIN teodemppersonal edp ON ec.emp_id = edp.emp_id
517
+ LEFT JOIN teomjobstatus ejs ON ec.job_status_code = ejs.jobstatuscode AND ec.company_id = ejs.company_id
518
+ WHERE ec.emp_id = ? AND ec.company_id = ?
519
+ `;
520
+ const results = await auth.connFin.query(query, [empId, auth.userData.companyId]);
521
+ return results[0] || null;
522
+ }
523
+ /**
524
+ * OPTIMIZATION: Batch get employee data for multiple employees
525
+ * This reduces database roundtrips significantly
526
+ */
527
+ async getBatchEmployeeData(auth, empIds) {
528
+ if (empIds.length === 0)
529
+ return new Map();
530
+ const placeholders = empIds.map(() => '?').join(',');
531
+ const query = `
532
+ SELECT
533
+ ec.emp_id as empId,
534
+ ec.emp_no as empNo,
535
+ ec.start_date as startDate,
536
+ ec.end_date as endDate,
537
+ ec.position_id as positionId,
538
+ ec.cost_code as costCode,
539
+ ec.grade_code as gradeCode,
540
+ ec.work_location_code as workLocationCode,
541
+ ec.job_status_code as jobStatusCode,
542
+ ecg.join_date as joinDate,
543
+ ecg.fulljoin_date as fulljoinDate,
544
+ ecg.permanent_date as permanentDate,
545
+ ecg.pension_date as pensionDate,
546
+ ecg.terminate_date as terminateDate,
547
+ ep.gender as gender,
548
+ edp.birthdate as birthdate,
549
+ edp.maritalstatus as maritalstatus,
550
+ edp.religion_code as religionCode,
551
+ ejs.jobstatusname_en as jobstatusnameEn
552
+ FROM teodempcompany ec
553
+ LEFT JOIN teodempcompanygroup ecg ON ec.emp_id = ecg.emp_id
554
+ LEFT JOIN teomemppersonal ep ON ec.emp_id = ep.emp_id
555
+ LEFT JOIN teodemppersonal edp ON ec.emp_id = edp.emp_id
556
+ LEFT JOIN teomjobstatus ejs ON ec.job_status_code = ejs.jobstatuscode AND ec.company_id = ejs.company_id
557
+ WHERE ec.emp_id IN (${placeholders}) AND ec.company_id = ?
558
+ `;
559
+ const results = await auth.connFin.query(query, [...empIds, auth.userData.companyId]);
560
+ // Map by employee ID
561
+ const mapped = new Map();
562
+ for (const row of results) {
563
+ mapped.set(row.empId, row);
564
+ }
565
+ return mapped;
566
+ }
567
+ /**
568
+ * Get reserved words list for formula processing
569
+ */
570
+ async getReservedWords(auth) {
571
+ // OPTIMIZATION: Cache reserved words (rarely changes, huge performance boost!)
572
+ const cacheKey = `${auth.userData.companyId}_${auth.userData.taxCountry}`;
573
+ const cached = this.reservedWordsCache.get(cacheKey);
574
+ if (cached && (Date.now() - cached.timestamp < this.CACHE_TTL)) {
575
+ return cached.data;
576
+ }
577
+ const query = `
578
+ SELECT word
579
+ FROM tsfmreserveword
580
+ WHERE (ISNULL(taxcountry) OR taxcountry = '' OR taxcountry = ?)
581
+ AND (company_id = 1 OR company_id = ?)
582
+ AND module IN ('GENERAL', 'PAYROLL')
583
+ AND (category = 'EMPLOYEE' OR category = 'PAYFORMULA' OR category = 'CLAIMFORMULA')
584
+ GROUP BY word
585
+ `;
586
+ const results = await auth.connFin.query(query, [auth.userData.taxCountry, auth.userData.companyId]);
587
+ const words2 = results.map((r) => r.word);
588
+ const result = {
589
+ words2,
590
+ arrayAcr: [],
591
+ claimTypes2: [],
592
+ attStatuses: [],
593
+ leaveTypes: [],
594
+ payvarMaster: [],
595
+ arrayDiscp: [],
596
+ attintfWord: [],
597
+ };
598
+ // Cache the result
599
+ this.reservedWordsCache.set(cacheKey, { data: result, timestamp: Date.now() });
600
+ return result;
601
+ }
602
+ /**
603
+ * Extract required words from formula
604
+ */
605
+ extractRequiredWordsFromFormula(formula, wordList) {
606
+ const required = new Set();
607
+ if (!formula)
608
+ return required;
609
+ for (const word of wordList) {
610
+ if (formula.includes(word)) {
611
+ required.add(word);
612
+ }
613
+ }
614
+ return required;
615
+ }
616
+ /**
617
+ * Batch load all required data for formula parsing
618
+ */
619
+ async batchLoadFormulaData(auth, empData, requiredWords) {
620
+ const cache = {};
621
+ const queries = [];
622
+ const queryMap = [];
623
+ // Position data
624
+ if ((0, helpers_1.needsPositionData)(requiredWords) && empData.positionId) {
625
+ queryMap.push('position');
626
+ queries.push(auth.conn.query(`
627
+ SELECT p.pos_name_en as posNameEn, p.pos_code as posCode,
628
+ d.pos_name_en as deptNameEn, d.pos_code as deptCode
629
+ FROM teomposition p
630
+ LEFT JOIN teomposition d ON p.dept_id = d.position_id AND p.company_id = d.company_id
631
+ WHERE p.position_id = ? AND p.company_id = ?
632
+ `, [empData.positionId, auth.userData.companyId]));
633
+ }
634
+ // Cost Center data
635
+ if ((0, helpers_1.needsCostCenterData)(requiredWords) && empData.costCode) {
636
+ queryMap.push('costCenter');
637
+ queries.push(auth.conn.query(`
638
+ SELECT costcenter_code as costcenterCode, costcenter_name_en as costcenterNameEn
639
+ FROM teomcostcenter
640
+ WHERE costcenter_code = ? AND company_id = ?
641
+ `, [empData.costCode, auth.userData.companyId]));
642
+ }
643
+ // Grade data
644
+ if ((0, helpers_1.needsGradeData)(requiredWords) && empData.gradeCode) {
645
+ queryMap.push('grade');
646
+ queries.push(auth.conn.query(`
647
+ SELECT grade_code as gradeCode, grade_name as gradeName
648
+ FROM teomjobgrade
649
+ WHERE grade_code = ? AND company_id = ?
650
+ `, [empData.gradeCode, auth.userData.companyId]));
651
+ }
652
+ // Work Location data
653
+ if ((0, helpers_1.needsWorkLocationData)(requiredWords) && empData.workLocationCode) {
654
+ queryMap.push('workLocation');
655
+ queries.push(auth.conn.query(`
656
+ SELECT worklocation_code as worklocationCode, worklocation_name as worklocationName
657
+ FROM teomworklocation
658
+ WHERE worklocation_code = ?
659
+ `, [empData.workLocationCode]));
660
+ }
661
+ // Custom Field data
662
+ if ((0, helpers_1.needsCustomFieldData)(requiredWords)) {
663
+ queryMap.push('customField');
664
+ queries.push(auth.conn.query(`
665
+ SELECT customfield1, customfield2, customfield3, customfield4, customfield5,
666
+ customfield6, customfield7, customfield8, customfield9, customfield10
667
+ FROM teodempcustomfield
668
+ WHERE emp_id = ? AND company_code = ?
669
+ `, [empData.empId, auth.userData.companyCode]));
670
+ }
671
+ // Employment Status
672
+ if (requiredWords.has('EMPLOYMENTSTATUS')) {
673
+ queryMap.push('employmentStatus');
674
+ queries.push(auth.connFin.query(`
675
+ SELECT employmentstatus_code as employmentStatusCode
676
+ FROM teodemploymenthistory
677
+ WHERE emp_id = ?
678
+ AND effectivedt = (
679
+ SELECT MAX(effectivedt)
680
+ FROM teodemploymenthistory
681
+ WHERE emp_id = ? AND company_id = ?
682
+ )
683
+ `, [empData.empId, empData.empId, auth.userData.companyId]));
684
+ }
685
+ // Child Dependents
686
+ if (requiredWords.has('CHILDDEPENDENTS')) {
687
+ queryMap.push('childDependents');
688
+ queries.push(auth.conn.query(`
689
+ SELECT COUNT(*) as count
690
+ FROM teodempfamily f
691
+ INNER JOIN teomfamilyrelation r ON f.relationship = r.relationship_code AND r.ischild = 1
692
+ WHERE f.emp_id = ? AND f.dependentsts = 1
693
+ `, [empData.empId]));
694
+ }
695
+ // Execute all queries in parallel
696
+ const results = await Promise.all(queries);
697
+ // Map results back to cache
698
+ results.forEach((result, index) => {
699
+ const key = queryMap[index];
700
+ if (key === 'position' && result[0]) {
701
+ cache.position = {
702
+ posNameEn: result[0].posNameEn,
703
+ posCode: result[0].posCode,
704
+ dept: {
705
+ posNameEn: result[0].deptNameEn,
706
+ posCode: result[0].deptCode,
707
+ },
708
+ };
709
+ }
710
+ else if (key === 'costCenter' && result[0]) {
711
+ cache.costCenter = result[0];
712
+ }
713
+ else if (key === 'grade' && result[0]) {
714
+ cache.grade = result[0];
715
+ }
716
+ else if (key === 'workLocation' && result[0]) {
717
+ cache.workLocation = result[0];
718
+ }
719
+ else if (key === 'customField' && result[0]) {
720
+ cache.customField = result[0];
721
+ }
722
+ else if (key === 'employmentStatus' && result[0]) {
723
+ cache.employmentStatus = result[0].employmentStatusCode;
724
+ }
725
+ else if (key === 'childDependents' && result[0]) {
726
+ cache.childDependents = result[0].count;
727
+ }
728
+ });
729
+ return cache;
730
+ }
731
+ /**
732
+ * Convert raw employee data to EmployeeData interface
733
+ */
734
+ convertToEmployeeData(empData) {
735
+ return {
736
+ empId: empData.empId,
737
+ empNo: empData.empNo,
738
+ startDate: empData.startDate,
739
+ endDate: empData.endDate,
740
+ positionId: empData.positionId,
741
+ costCode: empData.costCode,
742
+ gradeCode: empData.gradeCode,
743
+ workLocationCode: empData.workLocationCode,
744
+ jobStatusCode: empData.jobStatusCode,
745
+ personal: {
746
+ birthdate: empData.birthdate,
747
+ maritalstatus: empData.maritalstatus,
748
+ religionCode: empData.religionCode,
749
+ gender: empData.gender,
750
+ },
751
+ companyGroup: {
752
+ joinDate: empData.joinDate,
753
+ fulljoinDate: empData.fulljoinDate,
754
+ permanentDate: empData.permanentDate,
755
+ pensionDate: empData.pensionDate,
756
+ terminateDate: empData.terminateDate,
757
+ },
758
+ jobStatus: {
759
+ jobstatusnameEn: empData.jobstatusnameEn,
760
+ },
761
+ };
762
+ }
763
+ /**
764
+ * Convert cache to OrganizationalData interface
765
+ */
766
+ convertToOrganizationalData(cache) {
767
+ return {
768
+ position: cache.position ? {
769
+ posNameEn: cache.position.posNameEn,
770
+ posCode: cache.position.posCode,
771
+ dept: cache.position.dept,
772
+ } : undefined,
773
+ costCenter: cache.costCenter,
774
+ grade: cache.grade,
775
+ workLocation: cache.workLocation,
776
+ customField: cache.customField,
777
+ employmentStatus: cache.employmentStatus,
778
+ childDependents: cache.childDependents,
779
+ };
780
+ }
781
+ /**
782
+ * OPTIMIZATION: Utility to chunk array for batched processing
783
+ * This prevents memory overflow when processing thousands of items
784
+ */
785
+ chunkArray(array, chunkSize) {
786
+ const chunks = [];
787
+ for (let i = 0; i < array.length; i += chunkSize) {
788
+ chunks.push(array.slice(i, i + chunkSize));
789
+ }
790
+ return chunks;
791
+ }
792
+ }
793
+ exports.PayrollFormulaService = PayrollFormulaService;
794
+ //# sourceMappingURL=payroll-formula.service.js.map