[SYNC] feat: Sincronizar módulos de erp-core (parcial)
Módulos copiados: - partners/ (20 archivos) - sales/ (19 archivos) - crm/ (11 archivos) - inventory/ (32 archivos nuevos) - financial/taxes.service.ts Infraestructura copiada: - shared/errors/ - shared/middleware/ - shared/types/ - shared/utils/ Entidades core copiadas: - country, currency, discount-rule, payment-term - product-category, sequence, state, uom Dependencias instaladas: - zod - winston Estado: PARCIAL - Build no pasa por incompatibilidades de imports. Ver SYNC-ERPC-CORE-STATUS.md para detalles. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5bea1fc7d
commit
f3515d4f38
259
package-lock.json
generated
259
package-lock.json
generated
@ -23,7 +23,9 @@
|
|||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.17",
|
||||||
"yamljs": "^0.3.0"
|
"winston": "^3.19.0",
|
||||||
|
"yamljs": "^0.3.0",
|
||||||
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@ -565,6 +567,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@colors/colors": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.90"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@cspotcode/source-map-support": {
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||||
@ -589,6 +600,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dabh/diagnostics": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@so-ric/colorspace": "^1.1.6",
|
||||||
|
"enabled": "2.0.x",
|
||||||
|
"kuler": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||||
@ -1375,6 +1397,16 @@
|
|||||||
"@sinonjs/commons": "^3.0.0"
|
"@sinonjs/commons": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@so-ric/colorspace": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color": "^5.0.2",
|
||||||
|
"text-hex": "1.0.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sqltools/formatter": {
|
"node_modules/@sqltools/formatter": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
||||||
@ -1712,6 +1744,12 @@
|
|||||||
"@types/serve-static": "*"
|
"@types/serve-static": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/triple-beam": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validator": {
|
"node_modules/@types/validator": {
|
||||||
"version": "13.15.10",
|
"version": "13.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||||
@ -2123,6 +2161,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@ -2727,6 +2771,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^3.1.3",
|
||||||
|
"color-string": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -2745,6 +2802,48 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-string/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color/node_modules/color-convert": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@ -3079,6 +3178,12 @@
|
|||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/enabled": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@ -3553,6 +3658,12 @@
|
|||||||
"bser": "2.1.1"
|
"bser": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fecha": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@ -3651,6 +3762,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/fn.name": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@ -4298,7 +4415,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -5141,6 +5257,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kuler": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@ -5250,6 +5372,23 @@
|
|||||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/logform": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@colors/colors": "1.6.0",
|
||||||
|
"@types/triple-beam": "^1.3.2",
|
||||||
|
"fecha": "^4.2.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"triple-beam": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@ -5611,6 +5750,15 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/one-time": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fn.name": "1.x.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||||
@ -6203,6 +6351,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@ -6367,6 +6529,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@ -6749,6 +6920,15 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stack-trace": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-utils": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@ -6781,6 +6961,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-length": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||||
@ -6971,6 +7160,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-hex": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/text-table": {
|
"node_modules/text-table": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
@ -7031,6 +7226,15 @@
|
|||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/triple-beam": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||||
@ -7535,6 +7739,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@ -7643,6 +7853,42 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/winston": {
|
||||||
|
"version": "3.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||||
|
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@colors/colors": "^1.6.0",
|
||||||
|
"@dabh/diagnostics": "^2.0.8",
|
||||||
|
"async": "^3.2.3",
|
||||||
|
"is-stream": "^2.0.0",
|
||||||
|
"logform": "^2.7.0",
|
||||||
|
"one-time": "^1.0.0",
|
||||||
|
"readable-stream": "^3.4.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"stack-trace": "0.0.x",
|
||||||
|
"triple-beam": "^1.3.0",
|
||||||
|
"winston-transport": "^4.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/winston-transport": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"logform": "^2.7.0",
|
||||||
|
"readable-stream": "^3.6.2",
|
||||||
|
"triple-beam": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@ -7812,6 +8058,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||||
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
package.json
36
package.json
@ -32,31 +32,33 @@
|
|||||||
"author": "Tu Empresa",
|
"author": "Tu Empresa",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"typeorm": "^0.3.17",
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"class-validator": "^0.14.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"helmet": "^7.1.0",
|
|
||||||
"morgan": "^1.10.0",
|
|
||||||
"express-rate-limit": "^7.1.5",
|
|
||||||
"bcryptjs": "^2.4.3",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"yamljs": "^0.3.0"
|
"typeorm": "^0.3.17",
|
||||||
|
"winston": "^3.19.0",
|
||||||
|
"yamljs": "^0.3.0",
|
||||||
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/node": "^20.10.5",
|
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/morgan": "^1.9.9",
|
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/swagger-ui-express": "^4.1.6",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"@types/swagger-ui-express": "^4.1.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||||
"@typescript-eslint/parser": "^6.15.0",
|
"@typescript-eslint/parser": "^6.15.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
|
|||||||
491
src/modules/auth/apiKeys.service.ts
Normal file
491
src/modules/auth/apiKeys.service.ts
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
key_index: string;
|
||||||
|
key_hash: string;
|
||||||
|
scope: string | null;
|
||||||
|
allowed_ips: string[] | null;
|
||||||
|
expiration_date: Date | null;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyDto {
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
scope?: string;
|
||||||
|
allowed_ips?: string[];
|
||||||
|
expiration_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateApiKeyDto {
|
||||||
|
name?: string;
|
||||||
|
scope?: string;
|
||||||
|
allowed_ips?: string[];
|
||||||
|
expiration_date?: Date | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyWithPlainKey {
|
||||||
|
apiKey: Omit<ApiKey, 'key_hash'>;
|
||||||
|
plainKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
apiKey?: ApiKey;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyFilters {
|
||||||
|
user_id?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const API_KEY_PREFIX = 'mgn_';
|
||||||
|
const KEY_LENGTH = 32; // 32 bytes = 256 bits
|
||||||
|
const HASH_ITERATIONS = 100000;
|
||||||
|
const HASH_KEYLEN = 64;
|
||||||
|
const HASH_DIGEST = 'sha512';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ApiKeysService {
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure API key
|
||||||
|
*/
|
||||||
|
private generatePlainKey(): string {
|
||||||
|
const randomBytes = crypto.randomBytes(KEY_LENGTH);
|
||||||
|
const key = randomBytes.toString('base64url');
|
||||||
|
return `${API_KEY_PREFIX}${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the key index (first 16 chars after prefix) for lookup
|
||||||
|
*/
|
||||||
|
private getKeyIndex(plainKey: string): string {
|
||||||
|
const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, '');
|
||||||
|
return keyWithoutPrefix.substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash the API key using PBKDF2
|
||||||
|
*/
|
||||||
|
private async hashKey(plainKey: string): Promise<string> {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(
|
||||||
|
plainKey,
|
||||||
|
salt,
|
||||||
|
HASH_ITERATIONS,
|
||||||
|
HASH_KEYLEN,
|
||||||
|
HASH_DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(`${salt}:${derivedKey.toString('hex')}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a plain key against a stored hash
|
||||||
|
*/
|
||||||
|
private async verifyKey(plainKey: string, storedHash: string): Promise<boolean> {
|
||||||
|
const [salt, hash] = storedHash.split(':');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(
|
||||||
|
plainKey,
|
||||||
|
salt,
|
||||||
|
HASH_ITERATIONS,
|
||||||
|
HASH_KEYLEN,
|
||||||
|
HASH_DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(derivedKey.toString('hex') === hash);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* Returns the plain key only once - it cannot be retrieved later
|
||||||
|
*/
|
||||||
|
async create(dto: CreateApiKeyDto): Promise<ApiKeyWithPlainKey> {
|
||||||
|
// Validate user exists
|
||||||
|
const user = await queryOne<{ id: string }>(
|
||||||
|
'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[dto.user_id, dto.tenant_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ValidationError('Usuario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2',
|
||||||
|
[dto.user_id, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError('Ya existe una API key con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate key
|
||||||
|
const plainKey = this.generatePlainKey();
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
const keyHash = await this.hashKey(plainKey);
|
||||||
|
|
||||||
|
// Calculate expiration date
|
||||||
|
let expirationDate: Date | null = null;
|
||||||
|
if (dto.expiration_days) {
|
||||||
|
expirationDate = new Date();
|
||||||
|
expirationDate.setDate(expirationDate.getDate() + dto.expiration_days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert API key
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`INSERT INTO auth.api_keys (
|
||||||
|
user_id, tenant_id, name, key_index, key_hash,
|
||||||
|
scope, allowed_ips, expiration_date, is_active
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, is_active, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
dto.user_id,
|
||||||
|
dto.tenant_id,
|
||||||
|
dto.name,
|
||||||
|
keyIndex,
|
||||||
|
keyHash,
|
||||||
|
dto.scope || null,
|
||||||
|
dto.allowed_ips || null,
|
||||||
|
expirationDate,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('Error al crear API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key created', {
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
userId: dto.user_id,
|
||||||
|
name: dto.name
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
plainKey, // Only returned once!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all API keys for a user/tenant
|
||||||
|
*/
|
||||||
|
async findAll(filters: ApiKeyFilters): Promise<Omit<ApiKey, 'key_hash'>[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.user_id) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tenant_id) {
|
||||||
|
conditions.push(`tenant_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.is_active !== undefined) {
|
||||||
|
conditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(filters.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.scope) {
|
||||||
|
conditions.push(`scope = $${paramIndex++}`);
|
||||||
|
params.push(filters.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0
|
||||||
|
? `WHERE ${conditions.join(' AND ')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const apiKeys = await query<ApiKey>(
|
||||||
|
`SELECT id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM auth.api_keys
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific API key by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string, tenantId: string): Promise<Omit<ApiKey, 'key_hash'> | null> {
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`SELECT id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM auth.api_keys
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
*/
|
||||||
|
async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise<Omit<ApiKey, 'key_hash'>> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = ['updated_at = NOW()'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updates.push(`name = $${paramIndex++}`);
|
||||||
|
params.push(dto.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.scope !== undefined) {
|
||||||
|
updates.push(`scope = $${paramIndex++}`);
|
||||||
|
params.push(dto.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.allowed_ips !== undefined) {
|
||||||
|
updates.push(`allowed_ips = $${paramIndex++}`);
|
||||||
|
params.push(dto.allowed_ips);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.expiration_date !== undefined) {
|
||||||
|
updates.push(`expiration_date = $${paramIndex++}`);
|
||||||
|
params.push(dto.expiration_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.is_active !== undefined) {
|
||||||
|
updates.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(dto.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
params.push(tenantId);
|
||||||
|
|
||||||
|
const updated = await queryOne<ApiKey>(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Error al actualizar API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key updated', { apiKeyId: id });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (soft delete) an API key
|
||||||
|
*/
|
||||||
|
async revoke(id: string, tenantId: string): Promise<void> {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET is_active = false, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key revoked', { apiKeyId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
*/
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const result = await query(
|
||||||
|
'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('API key deleted', { apiKeyId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key and return the associated user info
|
||||||
|
* This is the main method used by the authentication middleware
|
||||||
|
*/
|
||||||
|
async validate(plainKey: string, clientIp?: string): Promise<ApiKeyValidationResult> {
|
||||||
|
// Check prefix
|
||||||
|
if (!plainKey.startsWith(API_KEY_PREFIX)) {
|
||||||
|
return { valid: false, error: 'Formato de API key inválido' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key index for lookup
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
|
||||||
|
// Find API key by index
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`SELECT * FROM auth.api_keys
|
||||||
|
WHERE key_index = $1 AND is_active = true`,
|
||||||
|
[keyIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return { valid: false, error: 'API key no encontrada o inactiva' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hash
|
||||||
|
const isValid = await this.verifyKey(plainKey, apiKey.key_hash);
|
||||||
|
if (!isValid) {
|
||||||
|
return { valid: false, error: 'API key inválida' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) {
|
||||||
|
return { valid: false, error: 'API key expirada' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP whitelist
|
||||||
|
if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) {
|
||||||
|
if (!apiKey.allowed_ips.includes(clientIp)) {
|
||||||
|
logger.warn('API key IP not allowed', {
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
clientIp,
|
||||||
|
allowedIps: apiKey.allowed_ips
|
||||||
|
});
|
||||||
|
return { valid: false, error: 'IP no autorizada' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info with roles
|
||||||
|
const user = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
role_codes: string[];
|
||||||
|
}>(
|
||||||
|
`SELECT u.id, u.tenant_id, u.email, array_agg(r.code) as role_codes
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN auth.roles r ON ur.role_id = r.id
|
||||||
|
WHERE u.id = $1 AND u.status = 'active'
|
||||||
|
GROUP BY u.id`,
|
||||||
|
[apiKey.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { valid: false, error: 'Usuario asociado no encontrado o inactivo' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp (async, don't wait)
|
||||||
|
query(
|
||||||
|
'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1',
|
||||||
|
[apiKey.id]
|
||||||
|
).catch(err => logger.error('Error updating last_used_at', { error: err }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
apiKey,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.role_codes?.filter(Boolean) || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key (creates new key, invalidates old)
|
||||||
|
*/
|
||||||
|
async regenerate(id: string, tenantId: string): Promise<ApiKeyWithPlainKey> {
|
||||||
|
const existing = await queryOne<ApiKey>(
|
||||||
|
'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new key
|
||||||
|
const plainKey = this.generatePlainKey();
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
const keyHash = await this.hashKey(plainKey);
|
||||||
|
|
||||||
|
// Update with new key
|
||||||
|
const updated = await queryOne<ApiKey>(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET key_index = $1, key_hash = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3 AND tenant_id = $4
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, is_active, created_at, updated_at`,
|
||||||
|
[keyIndex, keyHash, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Error al regenerar API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key regenerated', { apiKeyId: id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: updated,
|
||||||
|
plainKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeysService = new ApiKeysService();
|
||||||
87
src/modules/auth/entities/api-key.entity.ts
Normal file
87
src/modules/auth/entities/api-key.entity.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'api_keys' })
|
||||||
|
@Index('idx_api_keys_lookup', ['keyIndex', 'isActive'], {
|
||||||
|
where: 'is_active = TRUE',
|
||||||
|
})
|
||||||
|
@Index('idx_api_keys_expiration', ['expirationDate'], {
|
||||||
|
where: 'expiration_date IS NOT NULL',
|
||||||
|
})
|
||||||
|
@Index('idx_api_keys_user', ['userId'])
|
||||||
|
@Index('idx_api_keys_tenant', ['tenantId'])
|
||||||
|
export class ApiKey {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Descripción
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
@Column({ type: 'varchar', length: 16, nullable: false, name: 'key_index' })
|
||||||
|
keyIndex: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'key_hash' })
|
||||||
|
keyHash: string;
|
||||||
|
|
||||||
|
// Scope y restricciones
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
scope: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', array: true, nullable: true, name: 'allowed_ips' })
|
||||||
|
allowedIps: string[] | null;
|
||||||
|
|
||||||
|
// Expiración
|
||||||
|
@Column({
|
||||||
|
type: 'timestamptz',
|
||||||
|
nullable: true,
|
||||||
|
name: 'expiration_date',
|
||||||
|
})
|
||||||
|
expirationDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'revoked_by' })
|
||||||
|
revokedByUser: User | null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
|
||||||
|
revokedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'revoked_by' })
|
||||||
|
revokedBy: string | null;
|
||||||
|
}
|
||||||
35
src/modules/core/entities/country.entity.ts
Normal file
35
src/modules/core/entities/country.entity.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'countries' })
|
||||||
|
@Index('idx_countries_code', ['code'], { unique: true })
|
||||||
|
export class Country {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 2, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: true, name: 'phone_code' })
|
||||||
|
phoneCode: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 3,
|
||||||
|
nullable: true,
|
||||||
|
name: 'currency_code',
|
||||||
|
})
|
||||||
|
currencyCode: string | null;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
55
src/modules/core/entities/currency-rate.entity.ts
Normal file
55
src/modules/core/entities/currency-rate.entity.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Currency } from './currency.entity.js';
|
||||||
|
|
||||||
|
export type RateSource = 'manual' | 'banxico' | 'xe' | 'openexchange';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'currency_rates' })
|
||||||
|
@Index('idx_currency_rates_tenant', ['tenantId'])
|
||||||
|
@Index('idx_currency_rates_from', ['fromCurrencyId'])
|
||||||
|
@Index('idx_currency_rates_to', ['toCurrencyId'])
|
||||||
|
@Index('idx_currency_rates_date', ['rateDate'])
|
||||||
|
@Index('idx_currency_rates_lookup', ['fromCurrencyId', 'toCurrencyId', 'rateDate'])
|
||||||
|
export class CurrencyRate {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'tenant_id', nullable: true })
|
||||||
|
tenantId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'from_currency_id', nullable: false })
|
||||||
|
fromCurrencyId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Currency)
|
||||||
|
@JoinColumn({ name: 'from_currency_id' })
|
||||||
|
fromCurrency: Currency;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'to_currency_id', nullable: false })
|
||||||
|
toCurrencyId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Currency)
|
||||||
|
@JoinColumn({ name: 'to_currency_id' })
|
||||||
|
toCurrency: Currency;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 18, scale: 8, nullable: false })
|
||||||
|
rate: number;
|
||||||
|
|
||||||
|
@Column({ type: 'date', name: 'rate_date', nullable: false })
|
||||||
|
rateDate: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'manual' })
|
||||||
|
source: RateSource;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'created_by', nullable: true })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
43
src/modules/core/entities/currency.entity.ts
Normal file
43
src/modules/core/entities/currency.entity.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'currencies' })
|
||||||
|
@Index('idx_currencies_code', ['code'], { unique: true })
|
||||||
|
@Index('idx_currencies_active', ['active'])
|
||||||
|
export class Currency {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: false })
|
||||||
|
symbol: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 2, name: 'decimals' })
|
||||||
|
decimals: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
default: 0.01,
|
||||||
|
})
|
||||||
|
rounding: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de descuento
|
||||||
|
*/
|
||||||
|
export enum DiscountType {
|
||||||
|
PERCENTAGE = 'percentage', // Porcentaje del total
|
||||||
|
FIXED = 'fixed', // Monto fijo
|
||||||
|
PRICE_OVERRIDE = 'price_override', // Precio especial
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplicación del descuento
|
||||||
|
*/
|
||||||
|
export enum DiscountAppliesTo {
|
||||||
|
ALL = 'all', // Todos los productos
|
||||||
|
CATEGORY = 'category', // Categoría específica
|
||||||
|
PRODUCT = 'product', // Producto específico
|
||||||
|
CUSTOMER = 'customer', // Cliente específico
|
||||||
|
CUSTOMER_GROUP = 'customer_group', // Grupo de clientes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condición de activación
|
||||||
|
*/
|
||||||
|
export enum DiscountCondition {
|
||||||
|
NONE = 'none', // Sin condición
|
||||||
|
MIN_QUANTITY = 'min_quantity', // Cantidad mínima
|
||||||
|
MIN_AMOUNT = 'min_amount', // Monto mínimo
|
||||||
|
DATE_RANGE = 'date_range', // Rango de fechas
|
||||||
|
FIRST_PURCHASE = 'first_purchase', // Primera compra
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regla de descuento
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'core', name: 'discount_rules' })
|
||||||
|
@Index('idx_discount_rules_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_discount_rules_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_discount_rules_active', ['tenantId', 'isActive'])
|
||||||
|
@Index('idx_discount_rules_dates', ['tenantId', 'startDate', 'endDate'])
|
||||||
|
@Index('idx_discount_rules_priority', ['tenantId', 'priority'])
|
||||||
|
export class DiscountRule {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||||
|
companyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DiscountType,
|
||||||
|
default: DiscountType.PERCENTAGE,
|
||||||
|
name: 'discount_type',
|
||||||
|
})
|
||||||
|
discountType: DiscountType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
name: 'discount_value',
|
||||||
|
})
|
||||||
|
discountValue: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
name: 'max_discount_amount',
|
||||||
|
})
|
||||||
|
maxDiscountAmount: number | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DiscountAppliesTo,
|
||||||
|
default: DiscountAppliesTo.ALL,
|
||||||
|
name: 'applies_to',
|
||||||
|
})
|
||||||
|
appliesTo: DiscountAppliesTo;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'applies_to_id' })
|
||||||
|
appliesToId: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DiscountCondition,
|
||||||
|
default: DiscountCondition.NONE,
|
||||||
|
name: 'condition_type',
|
||||||
|
})
|
||||||
|
conditionType: DiscountCondition;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 4,
|
||||||
|
nullable: true,
|
||||||
|
name: 'condition_value',
|
||||||
|
})
|
||||||
|
conditionValue: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
|
||||||
|
startDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
|
||||||
|
endDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 10 })
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'combinable' })
|
||||||
|
combinable: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true, name: 'usage_limit' })
|
||||||
|
usageLimit: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0, name: 'usage_count' })
|
||||||
|
usageCount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
/**
|
export { Currency } from './currency.entity.js';
|
||||||
* Core Entities Index
|
export { Country } from './country.entity.js';
|
||||||
*/
|
export { State } from './state.entity.js';
|
||||||
|
export { CurrencyRate, RateSource } from './currency-rate.entity.js';
|
||||||
export { Tenant } from './tenant.entity';
|
export { UomCategory } from './uom-category.entity.js';
|
||||||
export { User } from './user.entity';
|
export { Uom, UomType } from './uom.entity.js';
|
||||||
|
export { ProductCategory } from './product-category.entity.js';
|
||||||
|
export { Sequence, ResetPeriod } from './sequence.entity.js';
|
||||||
|
export { PaymentTerm, PaymentTermLine, PaymentTermLineType } from './payment-term.entity.js';
|
||||||
|
export { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition } from './discount-rule.entity.js';
|
||||||
|
|||||||
144
src/modules/core/entities/payment-term.entity.ts
Normal file
144
src/modules/core/entities/payment-term.entity.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de cálculo para la línea del término de pago
|
||||||
|
*/
|
||||||
|
export enum PaymentTermLineType {
|
||||||
|
BALANCE = 'balance', // Saldo restante
|
||||||
|
PERCENT = 'percent', // Porcentaje del total
|
||||||
|
FIXED = 'fixed', // Monto fijo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Línea de término de pago (para términos con múltiples vencimientos)
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'core', name: 'payment_term_lines' })
|
||||||
|
@Index('idx_payment_term_lines_term', ['paymentTermId'])
|
||||||
|
export class PaymentTermLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'payment_term_id' })
|
||||||
|
paymentTermId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 1 })
|
||||||
|
sequence: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: PaymentTermLineType,
|
||||||
|
default: PaymentTermLineType.BALANCE,
|
||||||
|
name: 'line_type',
|
||||||
|
})
|
||||||
|
lineType: PaymentTermLineType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
name: 'value_percent',
|
||||||
|
})
|
||||||
|
valuePercent: number | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
name: 'value_amount',
|
||||||
|
})
|
||||||
|
valueAmount: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0 })
|
||||||
|
days: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true, name: 'day_of_month' })
|
||||||
|
dayOfMonth: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: false, name: 'end_of_month' })
|
||||||
|
endOfMonth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Término de pago (Net 30, 50% advance + 50% on delivery, etc.)
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'core', name: 'payment_terms' })
|
||||||
|
@Index('idx_payment_terms_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_payment_terms_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_payment_terms_active', ['tenantId', 'isActive'])
|
||||||
|
export class PaymentTerm {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||||
|
companyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0, name: 'due_days' })
|
||||||
|
dueDays: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
default: 0,
|
||||||
|
name: 'discount_percent',
|
||||||
|
})
|
||||||
|
discountPercent: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true, default: 0, name: 'discount_days' })
|
||||||
|
discountDays: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: false, name: 'is_immediate' })
|
||||||
|
isImmediate: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0 })
|
||||||
|
sequence: number;
|
||||||
|
|
||||||
|
@OneToMany(() => PaymentTermLine, (line) => line.paymentTermId, { eager: true })
|
||||||
|
lines: PaymentTermLine[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
79
src/modules/core/entities/product-category.entity.ts
Normal file
79
src/modules/core/entities/product-category.entity.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'product_categories' })
|
||||||
|
@Index('idx_product_categories_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_product_categories_parent_id', ['parentId'])
|
||||||
|
@Index('idx_product_categories_code_tenant', ['tenantId', 'code'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
@Index('idx_product_categories_active', ['tenantId', 'active'], {
|
||||||
|
where: 'deleted_at IS NULL',
|
||||||
|
})
|
||||||
|
export class ProductCategory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
code: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'parent_id' })
|
||||||
|
parentId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'full_path' })
|
||||||
|
fullPath: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => ProductCategory, (category) => category.children, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: ProductCategory | null;
|
||||||
|
|
||||||
|
@OneToMany(() => ProductCategory, (category) => category.parent)
|
||||||
|
children: ProductCategory[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
83
src/modules/core/entities/sequence.entity.ts
Normal file
83
src/modules/core/entities/sequence.entity.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export enum ResetPeriod {
|
||||||
|
NONE = 'none',
|
||||||
|
YEAR = 'year',
|
||||||
|
MONTH = 'month',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'sequences' })
|
||||||
|
@Index('idx_sequences_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_sequences_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_sequences_active', ['tenantId', 'isActive'])
|
||||||
|
export class Sequence {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||||
|
companyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
prefix: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
suffix: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 1, name: 'next_number' })
|
||||||
|
nextNumber: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 4 })
|
||||||
|
padding: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ResetPeriod,
|
||||||
|
nullable: true,
|
||||||
|
default: ResetPeriod.NONE,
|
||||||
|
name: 'reset_period',
|
||||||
|
})
|
||||||
|
resetPeriod: ResetPeriod | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
name: 'last_reset_date',
|
||||||
|
})
|
||||||
|
lastResetDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
45
src/modules/core/entities/state.entity.ts
Normal file
45
src/modules/core/entities/state.entity.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Country } from './country.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'states' })
|
||||||
|
@Index('idx_states_country', ['countryId'])
|
||||||
|
@Index('idx_states_code', ['code'])
|
||||||
|
@Index('idx_states_country_code', ['countryId', 'code'], { unique: true })
|
||||||
|
export class State {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'country_id', nullable: false })
|
||||||
|
countryId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Country, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'country_id' })
|
||||||
|
country: Country;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
timezone: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', name: 'is_active', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
30
src/modules/core/entities/uom-category.entity.ts
Normal file
30
src/modules/core/entities/uom-category.entity.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Uom } from './uom.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'uom_categories' })
|
||||||
|
@Index('idx_uom_categories_name', ['name'], { unique: true })
|
||||||
|
export class UomCategory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false, unique: true })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@OneToMany(() => Uom, (uom) => uom.category)
|
||||||
|
uoms: Uom[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
76
src/modules/core/entities/uom.entity.ts
Normal file
76
src/modules/core/entities/uom.entity.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UomCategory } from './uom-category.entity.js';
|
||||||
|
|
||||||
|
export enum UomType {
|
||||||
|
REFERENCE = 'reference',
|
||||||
|
BIGGER = 'bigger',
|
||||||
|
SMALLER = 'smaller',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'uom' })
|
||||||
|
@Index('idx_uom_category_id', ['categoryId'])
|
||||||
|
@Index('idx_uom_code', ['code'])
|
||||||
|
@Index('idx_uom_active', ['active'])
|
||||||
|
@Index('idx_uom_name_category', ['categoryId', 'name'], { unique: true })
|
||||||
|
export class Uom {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'category_id' })
|
||||||
|
categoryId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
code: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: UomType,
|
||||||
|
nullable: false,
|
||||||
|
default: UomType.REFERENCE,
|
||||||
|
name: 'uom_type',
|
||||||
|
})
|
||||||
|
uomType: UomType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: false,
|
||||||
|
default: 1.0,
|
||||||
|
})
|
||||||
|
factor: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
default: 0.01,
|
||||||
|
})
|
||||||
|
rounding: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => UomCategory, (category) => category.uoms, {
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'category_id' })
|
||||||
|
category: UomCategory;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
466
src/modules/core/sequences.service.ts
Normal file
466
src/modules/core/sequences.service.ts
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Sequence, ResetPeriod } from './entities/sequence.entity.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CreateSequenceDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
start_number?: number;
|
||||||
|
startNumber?: number; // Accept camelCase too
|
||||||
|
padding?: number;
|
||||||
|
reset_period?: 'none' | 'year' | 'month';
|
||||||
|
resetPeriod?: 'none' | 'year' | 'month'; // Accept camelCase too
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSequenceDto {
|
||||||
|
name?: string;
|
||||||
|
prefix?: string | null;
|
||||||
|
suffix?: string | null;
|
||||||
|
padding?: number;
|
||||||
|
reset_period?: 'none' | 'year' | 'month';
|
||||||
|
resetPeriod?: 'none' | 'year' | 'month'; // Accept camelCase too
|
||||||
|
is_active?: boolean;
|
||||||
|
isActive?: boolean; // Accept camelCase too
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PREDEFINED SEQUENCE CODES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SEQUENCE_CODES = {
|
||||||
|
// Sales
|
||||||
|
SALES_ORDER: 'SO',
|
||||||
|
QUOTATION: 'QT',
|
||||||
|
|
||||||
|
// Purchases
|
||||||
|
PURCHASE_ORDER: 'PO',
|
||||||
|
RFQ: 'RFQ',
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
PICKING_IN: 'WH/IN',
|
||||||
|
PICKING_OUT: 'WH/OUT',
|
||||||
|
PICKING_INT: 'WH/INT',
|
||||||
|
INVENTORY_ADJ: 'INV/ADJ',
|
||||||
|
|
||||||
|
// Financial
|
||||||
|
INVOICE_CUSTOMER: 'INV',
|
||||||
|
INVOICE_SUPPLIER: 'BILL',
|
||||||
|
PAYMENT: 'PAY',
|
||||||
|
JOURNAL_ENTRY: 'JE',
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
LEAD: 'LEAD',
|
||||||
|
OPPORTUNITY: 'OPP',
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
PROJECT: 'PRJ',
|
||||||
|
TASK: 'TASK',
|
||||||
|
|
||||||
|
// HR
|
||||||
|
EMPLOYEE: 'EMP',
|
||||||
|
CONTRACT: 'CTR',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class SequencesService {
|
||||||
|
private repository: Repository<Sequence>;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(Sequence);
|
||||||
|
this.dataSource = AppDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next number in a sequence using the database function
|
||||||
|
* This is atomic and handles concurrent requests safely
|
||||||
|
*/
|
||||||
|
async getNextNumber(
|
||||||
|
sequenceCode: string,
|
||||||
|
tenantId: string,
|
||||||
|
queryRunner?: any
|
||||||
|
): Promise<string> {
|
||||||
|
logger.debug('Generating next sequence number', { sequenceCode, tenantId });
|
||||||
|
|
||||||
|
const executeQuery = queryRunner
|
||||||
|
? (sql: string, params: any[]) => queryRunner.query(sql, params)
|
||||||
|
: (sql: string, params: any[]) => this.dataSource.query(sql, params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the database function for atomic sequence generation
|
||||||
|
const result = await executeQuery(
|
||||||
|
`SELECT core.generate_next_sequence($1, $2) as sequence_number`,
|
||||||
|
[sequenceCode, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.[0]?.sequence_number) {
|
||||||
|
// Sequence doesn't exist, try to create it with default settings
|
||||||
|
logger.warn('Sequence not found, creating default', {
|
||||||
|
sequenceCode,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ensureSequenceExists(sequenceCode, tenantId, queryRunner);
|
||||||
|
|
||||||
|
// Try again
|
||||||
|
const retryResult = await executeQuery(
|
||||||
|
`SELECT core.generate_next_sequence($1, $2) as sequence_number`,
|
||||||
|
[sequenceCode, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!retryResult?.[0]?.sequence_number) {
|
||||||
|
throw new NotFoundError(`Secuencia ${sequenceCode} no encontrada`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Generated sequence number after creating default', {
|
||||||
|
sequenceCode,
|
||||||
|
number: retryResult[0].sequence_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
return retryResult[0].sequence_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Generated sequence number', {
|
||||||
|
sequenceCode,
|
||||||
|
number: result[0].sequence_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result[0].sequence_number;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating sequence number', {
|
||||||
|
sequenceCode,
|
||||||
|
tenantId,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a sequence exists, creating it with defaults if not
|
||||||
|
*/
|
||||||
|
async ensureSequenceExists(
|
||||||
|
sequenceCode: string,
|
||||||
|
tenantId: string,
|
||||||
|
queryRunner?: any
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Ensuring sequence exists', { sequenceCode, tenantId });
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: { code: sequenceCode, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
logger.debug('Sequence already exists', { sequenceCode, tenantId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create with defaults based on code
|
||||||
|
const defaults = this.getDefaultsForCode(sequenceCode);
|
||||||
|
|
||||||
|
const sequence = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
code: sequenceCode,
|
||||||
|
name: defaults.name,
|
||||||
|
prefix: defaults.prefix,
|
||||||
|
padding: defaults.padding,
|
||||||
|
nextNumber: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.repository.save(sequence);
|
||||||
|
|
||||||
|
logger.info('Created default sequence', { sequenceCode, tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default settings for a sequence code
|
||||||
|
*/
|
||||||
|
private getDefaultsForCode(code: string): {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
padding: number;
|
||||||
|
} {
|
||||||
|
const defaults: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; prefix: string; padding: number }
|
||||||
|
> = {
|
||||||
|
[SEQUENCE_CODES.SALES_ORDER]: {
|
||||||
|
name: 'Órdenes de Venta',
|
||||||
|
prefix: 'SO-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.QUOTATION]: {
|
||||||
|
name: 'Cotizaciones',
|
||||||
|
prefix: 'QT-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PURCHASE_ORDER]: {
|
||||||
|
name: 'Órdenes de Compra',
|
||||||
|
prefix: 'PO-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.RFQ]: {
|
||||||
|
name: 'Solicitudes de Cotización',
|
||||||
|
prefix: 'RFQ-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PICKING_IN]: {
|
||||||
|
name: 'Recepciones',
|
||||||
|
prefix: 'WH/IN/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PICKING_OUT]: {
|
||||||
|
name: 'Entregas',
|
||||||
|
prefix: 'WH/OUT/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PICKING_INT]: {
|
||||||
|
name: 'Transferencias',
|
||||||
|
prefix: 'WH/INT/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.INVENTORY_ADJ]: {
|
||||||
|
name: 'Ajustes de Inventario',
|
||||||
|
prefix: 'ADJ/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.INVOICE_CUSTOMER]: {
|
||||||
|
name: 'Facturas de Cliente',
|
||||||
|
prefix: 'INV/',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.INVOICE_SUPPLIER]: {
|
||||||
|
name: 'Facturas de Proveedor',
|
||||||
|
prefix: 'BILL/',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PAYMENT]: { name: 'Pagos', prefix: 'PAY/', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.JOURNAL_ENTRY]: {
|
||||||
|
name: 'Asientos Contables',
|
||||||
|
prefix: 'JE/',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.LEAD]: { name: 'Prospectos', prefix: 'LEAD-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.OPPORTUNITY]: {
|
||||||
|
name: 'Oportunidades',
|
||||||
|
prefix: 'OPP-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PROJECT]: {
|
||||||
|
name: 'Proyectos',
|
||||||
|
prefix: 'PRJ-',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.TASK]: { name: 'Tareas', prefix: 'TASK-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.EMPLOYEE]: {
|
||||||
|
name: 'Empleados',
|
||||||
|
prefix: 'EMP-',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.CONTRACT]: {
|
||||||
|
name: 'Contratos',
|
||||||
|
prefix: 'CTR-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return defaults[code] || { name: code, prefix: `${code}-`, padding: 5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sequences for a tenant
|
||||||
|
*/
|
||||||
|
async findAll(tenantId: string): Promise<Sequence[]> {
|
||||||
|
logger.debug('Finding all sequences', { tenantId });
|
||||||
|
|
||||||
|
return this.repository.find({
|
||||||
|
where: { tenantId },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific sequence by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string, tenantId: string): Promise<Sequence | null> {
|
||||||
|
logger.debug('Finding sequence by code', { code, tenantId });
|
||||||
|
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { code, tenantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new sequence
|
||||||
|
*/
|
||||||
|
async create(dto: CreateSequenceDto, tenantId: string): Promise<Sequence> {
|
||||||
|
logger.debug('Creating sequence', { dto, tenantId });
|
||||||
|
|
||||||
|
// Check for existing
|
||||||
|
const existing = await this.findByCode(dto.code, tenantId);
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Ya existe una secuencia con código ${dto.code}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const startNumber = dto.start_number ?? dto.startNumber ?? 1;
|
||||||
|
const resetPeriod = dto.reset_period ?? dto.resetPeriod ?? 'none';
|
||||||
|
|
||||||
|
const sequence = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
prefix: dto.prefix || null,
|
||||||
|
suffix: dto.suffix || null,
|
||||||
|
nextNumber: startNumber,
|
||||||
|
padding: dto.padding || 5,
|
||||||
|
resetPeriod: resetPeriod as ResetPeriod,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.repository.save(sequence);
|
||||||
|
|
||||||
|
logger.info('Sequence created', { code: dto.code, tenantId });
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a sequence
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
code: string,
|
||||||
|
dto: UpdateSequenceDto,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<Sequence> {
|
||||||
|
logger.debug('Updating sequence', { code, dto, tenantId });
|
||||||
|
|
||||||
|
const existing = await this.findByCode(code, tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const resetPeriod = dto.reset_period ?? dto.resetPeriod;
|
||||||
|
const isActive = dto.is_active ?? dto.isActive;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
existing.name = dto.name;
|
||||||
|
}
|
||||||
|
if (dto.prefix !== undefined) {
|
||||||
|
existing.prefix = dto.prefix;
|
||||||
|
}
|
||||||
|
if (dto.suffix !== undefined) {
|
||||||
|
existing.suffix = dto.suffix;
|
||||||
|
}
|
||||||
|
if (dto.padding !== undefined) {
|
||||||
|
existing.padding = dto.padding;
|
||||||
|
}
|
||||||
|
if (resetPeriod !== undefined) {
|
||||||
|
existing.resetPeriod = resetPeriod as ResetPeriod;
|
||||||
|
}
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
existing.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.repository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Sequence updated', { code, tenantId });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset a sequence to a specific number
|
||||||
|
*/
|
||||||
|
async reset(
|
||||||
|
code: string,
|
||||||
|
tenantId: string,
|
||||||
|
newNumber: number = 1
|
||||||
|
): Promise<Sequence> {
|
||||||
|
logger.debug('Resetting sequence', { code, tenantId, newNumber });
|
||||||
|
|
||||||
|
const sequence = await this.findByCode(code, tenantId);
|
||||||
|
if (!sequence) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence.nextNumber = newNumber;
|
||||||
|
sequence.lastResetDate = new Date();
|
||||||
|
|
||||||
|
const updated = await this.repository.save(sequence);
|
||||||
|
|
||||||
|
logger.info('Sequence reset', { code, tenantId, newNumber });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview what the next number would be (without incrementing)
|
||||||
|
*/
|
||||||
|
async preview(code: string, tenantId: string): Promise<string> {
|
||||||
|
logger.debug('Previewing next sequence number', { code, tenantId });
|
||||||
|
|
||||||
|
const sequence = await this.findByCode(code, tenantId);
|
||||||
|
if (!sequence) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddedNumber = String(sequence.nextNumber).padStart(
|
||||||
|
sequence.padding,
|
||||||
|
'0'
|
||||||
|
);
|
||||||
|
const prefix = sequence.prefix || '';
|
||||||
|
const suffix = sequence.suffix || '';
|
||||||
|
|
||||||
|
return `${prefix}${paddedNumber}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all standard sequences for a new tenant
|
||||||
|
*/
|
||||||
|
async initializeForTenant(tenantId: string): Promise<void> {
|
||||||
|
logger.debug('Initializing sequences for tenant', { tenantId });
|
||||||
|
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [key, code] of Object.entries(SEQUENCE_CODES)) {
|
||||||
|
await this.ensureSequenceExists(code, tenantId, queryRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
logger.info('Initialized sequences for tenant', {
|
||||||
|
tenantId,
|
||||||
|
count: Object.keys(SEQUENCE_CODES).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
logger.error('Error initializing sequences for tenant', {
|
||||||
|
tenantId,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sequencesService = new SequencesService();
|
||||||
309
src/modules/crm/__tests__/leads.service.test.ts
Normal file
309
src/modules/crm/__tests__/leads.service.test.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockLead } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock query functions
|
||||||
|
const mockQuery = jest.fn();
|
||||||
|
const mockQueryOne = jest.fn();
|
||||||
|
const mockGetClient = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../config/database.js', () => ({
|
||||||
|
query: (...args: any[]) => mockQuery(...args),
|
||||||
|
queryOne: (...args: any[]) => mockQueryOne(...args),
|
||||||
|
getClient: () => mockGetClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { leadsService } from '../leads.service.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
|
describe('LeadsService', () => {
|
||||||
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
const userId = 'test-user-uuid';
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGetClient.mockResolvedValue(mockClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return leads with pagination', async () => {
|
||||||
|
const mockLeads = [
|
||||||
|
createMockLead({ id: '1', name: 'Lead 1' }),
|
||||||
|
createMockLead({ id: '2', name: 'Lead 2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '2' });
|
||||||
|
mockQuery.mockResolvedValue(mockLeads);
|
||||||
|
|
||||||
|
const result = await leadsService.findAll(tenantId, { page: 1, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by status', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.findAll(tenantId, { status: 'new' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('l.status = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'new'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by stage_id', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.findAll(tenantId, { stage_id: 'stage-uuid' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('l.stage_id = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'stage-uuid'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by source', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.findAll(tenantId, { source: 'website' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('l.source = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'website'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by search term', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.findAll(tenantId, { search: 'John' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('l.name ILIKE'),
|
||||||
|
expect.arrayContaining([tenantId, '%John%'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should return lead when found', async () => {
|
||||||
|
const mockLead = createMockLead();
|
||||||
|
mockQueryOne.mockResolvedValue(mockLead);
|
||||||
|
|
||||||
|
const result = await leadsService.findById('lead-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockLead);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when lead not found', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.findById('nonexistent-id', tenantId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const createDto = {
|
||||||
|
company_id: 'company-uuid',
|
||||||
|
name: 'New Lead',
|
||||||
|
contact_name: 'Jane Doe',
|
||||||
|
email: 'jane@test.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create lead successfully', async () => {
|
||||||
|
const createdLead = createMockLead({ ...createDto });
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(createdLead) // INSERT
|
||||||
|
.mockResolvedValueOnce(createdLead); // findById
|
||||||
|
|
||||||
|
const result = await leadsService.create(createDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.name).toBe(createDto.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update lead successfully', async () => {
|
||||||
|
const existingLead = createMockLead({ status: 'new' });
|
||||||
|
mockQueryOne.mockResolvedValue(existingLead);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.update(
|
||||||
|
'lead-uuid-1',
|
||||||
|
{ name: 'Updated Lead' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE crm.leads SET'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when lead is converted', async () => {
|
||||||
|
const convertedLead = createMockLead({ status: 'converted' });
|
||||||
|
mockQueryOne.mockResolvedValue(convertedLead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.update('lead-uuid-1', { name: 'Test' }, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when lead is lost', async () => {
|
||||||
|
const lostLead = createMockLead({ status: 'lost' });
|
||||||
|
mockQueryOne.mockResolvedValue(lostLead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.update('lead-uuid-1', { name: 'Test' }, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveStage', () => {
|
||||||
|
it('should move lead to new stage', async () => {
|
||||||
|
const lead = createMockLead({ status: 'new' });
|
||||||
|
mockQueryOne.mockResolvedValue(lead);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.moveStage('lead-uuid-1', 'new-stage-uuid', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('stage_id = $1'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when lead is converted', async () => {
|
||||||
|
const convertedLead = createMockLead({ status: 'converted' });
|
||||||
|
mockQueryOne.mockResolvedValue(convertedLead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.moveStage('lead-uuid-1', 'new-stage-uuid', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convert', () => {
|
||||||
|
it('should convert lead to opportunity', async () => {
|
||||||
|
const lead = createMockLead({ status: 'qualified', email: 'test@example.com' });
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(lead);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // existing partner check
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'new-partner-uuid' }] }) // create partner
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'stage-uuid' }] }) // get default stage
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'opportunity-uuid' }] }) // create opportunity
|
||||||
|
.mockResolvedValueOnce(undefined) // update lead
|
||||||
|
.mockResolvedValueOnce(undefined); // COMMIT
|
||||||
|
|
||||||
|
const result = await leadsService.convert('lead-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.opportunity_id).toBe('opportunity-uuid');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when lead is already converted', async () => {
|
||||||
|
const convertedLead = createMockLead({ status: 'converted' });
|
||||||
|
mockQueryOne.mockResolvedValue(convertedLead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.convert('lead-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when lead is lost', async () => {
|
||||||
|
const lostLead = createMockLead({ status: 'lost' });
|
||||||
|
mockQueryOne.mockResolvedValue(lostLead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.convert('lead-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback on error', async () => {
|
||||||
|
const lead = createMockLead({ status: 'qualified', email: 'test@example.com' });
|
||||||
|
mockQueryOne.mockResolvedValue(lead);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockRejectedValueOnce(new Error('DB Error'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.convert('lead-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow('DB Error');
|
||||||
|
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markLost', () => {
|
||||||
|
it('should mark lead as lost', async () => {
|
||||||
|
const lead = createMockLead({ status: 'qualified' });
|
||||||
|
mockQueryOne.mockResolvedValue(lead);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Too expensive', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("status = 'lost'"),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when lead is converted', async () => {
|
||||||
|
const convertedLead = createMockLead({ status: 'converted' });
|
||||||
|
mockQueryOne.mockResolvedValue(convertedLead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when lead is already lost', async () => {
|
||||||
|
const lostLead = createMockLead({ status: 'lost' });
|
||||||
|
mockQueryOne.mockResolvedValue(lostLead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete lead without opportunity', async () => {
|
||||||
|
const lead = createMockLead({ opportunity_id: null });
|
||||||
|
mockQueryOne.mockResolvedValue(lead);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await leadsService.delete('lead-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM crm.leads'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when lead has opportunity', async () => {
|
||||||
|
const lead = createMockLead({ opportunity_id: 'opportunity-uuid' });
|
||||||
|
mockQueryOne.mockResolvedValue(lead);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
leadsService.delete('lead-uuid-1', tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
361
src/modules/crm/__tests__/opportunities.service.test.ts
Normal file
361
src/modules/crm/__tests__/opportunities.service.test.ts
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockOpportunity, createMockStage } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock query functions
|
||||||
|
const mockQuery = jest.fn();
|
||||||
|
const mockQueryOne = jest.fn();
|
||||||
|
const mockGetClient = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../config/database.js', () => ({
|
||||||
|
query: (...args: any[]) => mockQuery(...args),
|
||||||
|
queryOne: (...args: any[]) => mockQueryOne(...args),
|
||||||
|
getClient: () => mockGetClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { opportunitiesService } from '../opportunities.service.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
|
describe('OpportunitiesService', () => {
|
||||||
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
const userId = 'test-user-uuid';
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGetClient.mockResolvedValue(mockClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return opportunities with pagination', async () => {
|
||||||
|
const mockOpportunities = [
|
||||||
|
createMockOpportunity({ id: '1', name: 'Opp 1' }),
|
||||||
|
createMockOpportunity({ id: '2', name: 'Opp 2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '2' });
|
||||||
|
mockQuery.mockResolvedValue(mockOpportunities);
|
||||||
|
|
||||||
|
const result = await opportunitiesService.findAll(tenantId, { page: 1, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by status', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.findAll(tenantId, { status: 'open' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('o.status = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'open'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by partner_id', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.findAll(tenantId, { partner_id: 'partner-uuid' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('o.partner_id = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'partner-uuid'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by search term', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.findAll(tenantId, { search: 'Test' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('o.name ILIKE'),
|
||||||
|
expect.arrayContaining([tenantId, '%Test%'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should return opportunity when found', async () => {
|
||||||
|
const mockOpp = createMockOpportunity();
|
||||||
|
mockQueryOne.mockResolvedValue(mockOpp);
|
||||||
|
|
||||||
|
const result = await opportunitiesService.findById('opp-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockOpp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when not found', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.findById('nonexistent-id', tenantId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const createDto = {
|
||||||
|
company_id: 'company-uuid',
|
||||||
|
name: 'New Opportunity',
|
||||||
|
partner_id: 'partner-uuid',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create opportunity successfully', async () => {
|
||||||
|
const createdOpp = createMockOpportunity({ ...createDto });
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(createdOpp) // INSERT
|
||||||
|
.mockResolvedValueOnce(createdOpp); // findById
|
||||||
|
|
||||||
|
const result = await opportunitiesService.create(createDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.name).toBe(createDto.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update opportunity successfully', async () => {
|
||||||
|
const existingOpp = createMockOpportunity({ status: 'open' });
|
||||||
|
mockQueryOne.mockResolvedValue(existingOpp);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.update(
|
||||||
|
'opp-uuid-1',
|
||||||
|
{ name: 'Updated Opportunity' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE crm.opportunities SET'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when opportunity is not open', async () => {
|
||||||
|
const wonOpp = createMockOpportunity({ status: 'won' });
|
||||||
|
mockQueryOne.mockResolvedValue(wonOpp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.update('opp-uuid-1', { name: 'Test' }, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveStage', () => {
|
||||||
|
it('should move opportunity to new stage', async () => {
|
||||||
|
const opp = createMockOpportunity({ status: 'open' });
|
||||||
|
const stage = createMockStage({ id: 'new-stage-uuid', probability: 50 });
|
||||||
|
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(opp) // findById
|
||||||
|
.mockResolvedValueOnce(stage); // get stage
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.moveStage('opp-uuid-1', 'new-stage-uuid', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('stage_id = $1'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when opportunity is not open', async () => {
|
||||||
|
const wonOpp = createMockOpportunity({ status: 'won' });
|
||||||
|
mockQueryOne.mockResolvedValue(wonOpp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.moveStage('opp-uuid-1', 'stage-uuid', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when stage not found', async () => {
|
||||||
|
const opp = createMockOpportunity({ status: 'open' });
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(opp) // findById
|
||||||
|
.mockResolvedValueOnce(null); // stage not found
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.moveStage('opp-uuid-1', 'nonexistent-stage', tenantId, userId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markWon', () => {
|
||||||
|
it('should mark opportunity as won', async () => {
|
||||||
|
const opp = createMockOpportunity({ status: 'open' });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.markWon('opp-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("status = 'won'"),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when opportunity is not open', async () => {
|
||||||
|
const lostOpp = createMockOpportunity({ status: 'lost' });
|
||||||
|
mockQueryOne.mockResolvedValue(lostOpp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.markWon('opp-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markLost', () => {
|
||||||
|
it('should mark opportunity as lost', async () => {
|
||||||
|
const opp = createMockOpportunity({ status: 'open' });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.markLost('opp-uuid-1', 'reason-uuid', 'Notes', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("status = 'lost'"),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when opportunity is not open', async () => {
|
||||||
|
const wonOpp = createMockOpportunity({ status: 'won' });
|
||||||
|
mockQueryOne.mockResolvedValue(wonOpp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.markLost('opp-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createQuotation', () => {
|
||||||
|
it('should create quotation from opportunity', async () => {
|
||||||
|
const opp = createMockOpportunity({ status: 'open', quotation_id: null });
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'currency-uuid' }] }) // currency
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'quotation-uuid' }] }) // create quotation
|
||||||
|
.mockResolvedValueOnce(undefined) // update opportunity
|
||||||
|
.mockResolvedValueOnce(undefined); // COMMIT
|
||||||
|
|
||||||
|
const result = await opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.quotation_id).toBe('quotation-uuid');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when opportunity is not open', async () => {
|
||||||
|
const wonOpp = createMockOpportunity({ status: 'won' });
|
||||||
|
mockQueryOne.mockResolvedValue(wonOpp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation already exists', async () => {
|
||||||
|
const opp = createMockOpportunity({ status: 'open', quotation_id: 'existing-quotation' });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback on error', async () => {
|
||||||
|
const opp = createMockOpportunity({ status: 'open', quotation_id: null });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockRejectedValueOnce(new Error('DB Error'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow('DB Error');
|
||||||
|
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete opportunity without quotation or order', async () => {
|
||||||
|
const opp = createMockOpportunity({ quotation_id: null, order_id: null, lead_id: null });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.delete('opp-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM crm.opportunities'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when has quotation', async () => {
|
||||||
|
const opp = createMockOpportunity({ quotation_id: 'quotation-uuid' });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.delete('opp-uuid-1', tenantId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when has order', async () => {
|
||||||
|
const opp = createMockOpportunity({ order_id: 'order-uuid' });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
opportunitiesService.delete('opp-uuid-1', tenantId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update lead when deleting opportunity with lead', async () => {
|
||||||
|
const opp = createMockOpportunity({ quotation_id: null, order_id: null, lead_id: 'lead-uuid' });
|
||||||
|
mockQueryOne.mockResolvedValue(opp);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await opportunitiesService.delete('opp-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE crm.leads SET opportunity_id = NULL'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPipeline', () => {
|
||||||
|
it('should return pipeline with stages and opportunities', async () => {
|
||||||
|
const mockStages = [
|
||||||
|
createMockStage({ id: '1', name: 'Qualification', sequence: 1 }),
|
||||||
|
createMockStage({ id: '2', name: 'Proposal', sequence: 2 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockOpps = [
|
||||||
|
createMockOpportunity({ id: '1', stage_id: '1', expected_revenue: 5000 }),
|
||||||
|
createMockOpportunity({ id: '2', stage_id: '2', expected_revenue: 10000 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce(mockStages) // stages
|
||||||
|
.mockResolvedValueOnce(mockOpps); // opportunities
|
||||||
|
|
||||||
|
const result = await opportunitiesService.getPipeline(tenantId);
|
||||||
|
|
||||||
|
expect(result.stages).toHaveLength(2);
|
||||||
|
expect(result.totals.total_opportunities).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
286
src/modules/crm/__tests__/stages.service.test.ts
Normal file
286
src/modules/crm/__tests__/stages.service.test.ts
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockStage, createMockLostReason } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock query functions
|
||||||
|
const mockQuery = jest.fn();
|
||||||
|
const mockQueryOne = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../config/database.js', () => ({
|
||||||
|
query: (...args: any[]) => mockQuery(...args),
|
||||||
|
queryOne: (...args: any[]) => mockQueryOne(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { stagesService } from '../stages.service.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
|
describe('StagesService', () => {
|
||||||
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Lead Stages', () => {
|
||||||
|
describe('getLeadStages', () => {
|
||||||
|
it('should return active lead stages', async () => {
|
||||||
|
const mockStages = [
|
||||||
|
createMockStage({ id: '1', name: 'New' }),
|
||||||
|
createMockStage({ id: '2', name: 'Qualified' }),
|
||||||
|
];
|
||||||
|
mockQuery.mockResolvedValue(mockStages);
|
||||||
|
|
||||||
|
const result = await stagesService.getLeadStages(tenantId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('active = TRUE'),
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include inactive stages when requested', async () => {
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await stagesService.getLeadStages(tenantId, true);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.not.stringContaining('active = TRUE'),
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeadStageById', () => {
|
||||||
|
it('should return stage when found', async () => {
|
||||||
|
const mockStage = createMockStage();
|
||||||
|
mockQueryOne.mockResolvedValue(mockStage);
|
||||||
|
|
||||||
|
const result = await stagesService.getLeadStageById('stage-uuid', tenantId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockStage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when not found', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.getLeadStageById('nonexistent-id', tenantId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createLeadStage', () => {
|
||||||
|
it('should create lead stage successfully', async () => {
|
||||||
|
const newStage = createMockStage({ name: 'New Stage' });
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(null) // unique check
|
||||||
|
.mockResolvedValueOnce(newStage); // INSERT
|
||||||
|
|
||||||
|
const result = await stagesService.createLeadStage({ name: 'New Stage' }, tenantId);
|
||||||
|
|
||||||
|
expect(result.name).toBe('New Stage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when name exists', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.createLeadStage({ name: 'Existing Stage' }, tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLeadStage', () => {
|
||||||
|
it('should update lead stage successfully', async () => {
|
||||||
|
const existingStage = createMockStage();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(existingStage) // getById
|
||||||
|
.mockResolvedValueOnce(null) // unique name check
|
||||||
|
.mockResolvedValueOnce({ ...existingStage, name: 'Updated' }); // getById after update
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await stagesService.updateLeadStage('stage-uuid', { name: 'Updated' }, tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE crm.lead_stages SET'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when name exists for another stage', async () => {
|
||||||
|
const existingStage = createMockStage();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(existingStage) // getById
|
||||||
|
.mockResolvedValueOnce({ id: 'other-uuid' }); // name exists
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.updateLeadStage('stage-uuid', { name: 'Duplicate' }, tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteLeadStage', () => {
|
||||||
|
it('should delete stage without leads', async () => {
|
||||||
|
const stage = createMockStage();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(stage) // getById
|
||||||
|
.mockResolvedValueOnce({ count: '0' }); // in use check
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await stagesService.deleteLeadStage('stage-uuid', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM crm.lead_stages'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when stage has leads', async () => {
|
||||||
|
const stage = createMockStage();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(stage) // getById
|
||||||
|
.mockResolvedValueOnce({ count: '5' }); // in use
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.deleteLeadStage('stage-uuid', tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Opportunity Stages', () => {
|
||||||
|
describe('getOpportunityStages', () => {
|
||||||
|
it('should return active opportunity stages', async () => {
|
||||||
|
const mockStages = [
|
||||||
|
createMockStage({ id: '1', name: 'Qualification' }),
|
||||||
|
createMockStage({ id: '2', name: 'Proposal' }),
|
||||||
|
];
|
||||||
|
mockQuery.mockResolvedValue(mockStages);
|
||||||
|
|
||||||
|
const result = await stagesService.getOpportunityStages(tenantId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createOpportunityStage', () => {
|
||||||
|
it('should create opportunity stage successfully', async () => {
|
||||||
|
const newStage = createMockStage({ name: 'New Stage' });
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(null) // unique check
|
||||||
|
.mockResolvedValueOnce(newStage); // INSERT
|
||||||
|
|
||||||
|
const result = await stagesService.createOpportunityStage({ name: 'New Stage' }, tenantId);
|
||||||
|
|
||||||
|
expect(result.name).toBe('New Stage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteOpportunityStage', () => {
|
||||||
|
it('should delete stage without opportunities', async () => {
|
||||||
|
const stage = createMockStage();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(stage) // getById
|
||||||
|
.mockResolvedValueOnce({ count: '0' }); // in use check
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await stagesService.deleteOpportunityStage('stage-uuid', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM crm.opportunity_stages'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when stage has opportunities', async () => {
|
||||||
|
const stage = createMockStage();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(stage) // getById
|
||||||
|
.mockResolvedValueOnce({ count: '3' }); // in use
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.deleteOpportunityStage('stage-uuid', tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Lost Reasons', () => {
|
||||||
|
describe('getLostReasons', () => {
|
||||||
|
it('should return active lost reasons', async () => {
|
||||||
|
const mockReasons = [
|
||||||
|
createMockLostReason({ id: '1', name: 'Too expensive' }),
|
||||||
|
createMockLostReason({ id: '2', name: 'Competitor' }),
|
||||||
|
];
|
||||||
|
mockQuery.mockResolvedValue(mockReasons);
|
||||||
|
|
||||||
|
const result = await stagesService.getLostReasons(tenantId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createLostReason', () => {
|
||||||
|
it('should create lost reason successfully', async () => {
|
||||||
|
const newReason = createMockLostReason({ name: 'New Reason' });
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(null) // unique check
|
||||||
|
.mockResolvedValueOnce(newReason); // INSERT
|
||||||
|
|
||||||
|
const result = await stagesService.createLostReason({ name: 'New Reason' }, tenantId);
|
||||||
|
|
||||||
|
expect(result.name).toBe('New Reason');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when name exists', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.createLostReason({ name: 'Existing' }, tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteLostReason', () => {
|
||||||
|
it('should delete reason not in use', async () => {
|
||||||
|
const reason = createMockLostReason();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(reason) // getById
|
||||||
|
.mockResolvedValueOnce({ count: '0' }) // leads check
|
||||||
|
.mockResolvedValueOnce({ count: '0' }); // opportunities check
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await stagesService.deleteLostReason('reason-uuid', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM crm.lost_reasons'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when reason is in use by leads', async () => {
|
||||||
|
const reason = createMockLostReason();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(reason) // getById
|
||||||
|
.mockResolvedValueOnce({ count: '2' }); // leads check
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.deleteLostReason('reason-uuid', tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictError when reason is in use by opportunities', async () => {
|
||||||
|
const reason = createMockLostReason();
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(reason) // getById
|
||||||
|
.mockResolvedValueOnce({ count: '0' }) // leads check
|
||||||
|
.mockResolvedValueOnce({ count: '3' }); // opportunities check
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
stagesService.deleteLostReason('reason-uuid', tenantId)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
571
src/modules/crm/activities.service.ts
Normal file
571
src/modules/crm/activities.service.ts
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ActivityType = 'call' | 'meeting' | 'email' | 'task' | 'note' | 'other';
|
||||||
|
export type ActivityStatus = 'scheduled' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
activity_type: ActivityType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
// Polymorphic relations
|
||||||
|
res_model?: string; // 'opportunity', 'lead', 'partner'
|
||||||
|
res_id?: string;
|
||||||
|
res_name?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
partner_name?: string;
|
||||||
|
scheduled_date?: Date;
|
||||||
|
date_done?: Date;
|
||||||
|
duration_hours?: number;
|
||||||
|
status: ActivityStatus;
|
||||||
|
priority: number;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateActivityDto {
|
||||||
|
company_id: string;
|
||||||
|
activity_type: ActivityType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
user_id?: string;
|
||||||
|
res_model?: string;
|
||||||
|
res_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
scheduled_date?: string;
|
||||||
|
duration_hours?: number;
|
||||||
|
priority?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateActivityDto {
|
||||||
|
activity_type?: ActivityType;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
partner_id?: string | null;
|
||||||
|
scheduled_date?: string | null;
|
||||||
|
duration_hours?: number | null;
|
||||||
|
priority?: number;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityFilters {
|
||||||
|
company_id?: string;
|
||||||
|
activity_type?: ActivityType;
|
||||||
|
status?: ActivityStatus;
|
||||||
|
user_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
res_model?: string;
|
||||||
|
res_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivitySummary {
|
||||||
|
total_activities: number;
|
||||||
|
scheduled: number;
|
||||||
|
done: number;
|
||||||
|
cancelled: number;
|
||||||
|
overdue: number;
|
||||||
|
by_type: Record<ActivityType, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ActivitiesService {
|
||||||
|
async findAll(tenantId: string, filters: ActivityFilters = {}): Promise<{ data: Activity[]; total: number }> {
|
||||||
|
const { company_id, activity_type, status, user_id, partner_id, res_model, res_id, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE a.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND a.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity_type) {
|
||||||
|
whereClause += ` AND a.activity_type = $${paramIndex++}`;
|
||||||
|
params.push(activity_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND a.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND a.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND a.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res_model) {
|
||||||
|
whereClause += ` AND a.res_model = $${paramIndex++}`;
|
||||||
|
params.push(res_model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res_id) {
|
||||||
|
whereClause += ` AND a.res_id = $${paramIndex++}`;
|
||||||
|
params.push(res_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND a.scheduled_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND a.scheduled_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (a.name ILIKE $${paramIndex} OR a.description ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.activities a ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Activity>(
|
||||||
|
`SELECT a.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.name as user_name,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM crm.activities a
|
||||||
|
LEFT JOIN auth.companies c ON a.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN core.partners p ON a.partner_id = p.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN a.status = 'scheduled' THEN 0 ELSE 1 END,
|
||||||
|
a.scheduled_date ASC NULLS LAST,
|
||||||
|
a.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Activity> {
|
||||||
|
const activity = await queryOne<Activity>(
|
||||||
|
`SELECT a.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.name as user_name,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM crm.activities a
|
||||||
|
LEFT JOIN auth.companies c ON a.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN core.partners p ON a.partner_id = p.id
|
||||||
|
WHERE a.id = $1 AND a.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activity) {
|
||||||
|
throw new NotFoundError('Actividad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get resource name if linked
|
||||||
|
if (activity.res_model && activity.res_id) {
|
||||||
|
activity.res_name = await this.getResourceName(activity.res_model, activity.res_id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateActivityDto, tenantId: string, userId: string): Promise<Activity> {
|
||||||
|
const activity = await queryOne<Activity>(
|
||||||
|
`INSERT INTO crm.activities (
|
||||||
|
tenant_id, company_id, activity_type, name, description,
|
||||||
|
user_id, res_model, res_id, partner_id, scheduled_date,
|
||||||
|
duration_hours, priority, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.activity_type, dto.name, dto.description,
|
||||||
|
dto.user_id, dto.res_model, dto.res_id, dto.partner_id, dto.scheduled_date,
|
||||||
|
dto.duration_hours, dto.priority || 1, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Activity created', {
|
||||||
|
activityId: activity?.id,
|
||||||
|
activityType: dto.activity_type,
|
||||||
|
resModel: dto.res_model,
|
||||||
|
resId: dto.res_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update date_last_activity on related opportunity/lead
|
||||||
|
if (dto.res_model && dto.res_id) {
|
||||||
|
await this.updateLastActivityDate(dto.res_model, dto.res_id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(activity!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateActivityDto, tenantId: string, userId: string): Promise<Activity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status === 'done') {
|
||||||
|
throw new ValidationError('No se pueden editar actividades completadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.activity_type !== undefined) {
|
||||||
|
updateFields.push(`activity_type = $${paramIndex++}`);
|
||||||
|
values.push(dto.activity_type);
|
||||||
|
}
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.user_id !== undefined) {
|
||||||
|
updateFields.push(`user_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.user_id);
|
||||||
|
}
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.scheduled_date !== undefined) {
|
||||||
|
updateFields.push(`scheduled_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.scheduled_date);
|
||||||
|
}
|
||||||
|
if (dto.duration_hours !== undefined) {
|
||||||
|
updateFields.push(`duration_hours = $${paramIndex++}`);
|
||||||
|
values.push(dto.duration_hours);
|
||||||
|
}
|
||||||
|
if (dto.priority !== undefined) {
|
||||||
|
updateFields.push(`priority = $${paramIndex++}`);
|
||||||
|
values.push(dto.priority);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.activities SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markDone(id: string, tenantId: string, userId: string, notes?: string): Promise<Activity> {
|
||||||
|
const activity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (activity.status === 'done') {
|
||||||
|
throw new ValidationError('La actividad ya está completada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.status === 'cancelled') {
|
||||||
|
throw new ValidationError('No se puede completar una actividad cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.activities SET
|
||||||
|
status = 'done',
|
||||||
|
date_done = CURRENT_TIMESTAMP,
|
||||||
|
notes = COALESCE($1, notes),
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[notes, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update date_last_activity on related opportunity/lead
|
||||||
|
if (activity.res_model && activity.res_id) {
|
||||||
|
await this.updateLastActivityDate(activity.res_model, activity.res_id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Activity marked as done', {
|
||||||
|
activityId: id,
|
||||||
|
activityType: activity.activity_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Activity> {
|
||||||
|
const activity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (activity.status === 'done') {
|
||||||
|
throw new ValidationError('No se puede cancelar una actividad completada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La actividad ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.activities SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const activity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (activity.status === 'done') {
|
||||||
|
throw new ValidationError('No se pueden eliminar actividades completadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM crm.activities WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activities for a specific resource (opportunity, lead, partner)
|
||||||
|
*/
|
||||||
|
async getResourceActivities(
|
||||||
|
resModel: string,
|
||||||
|
resId: string,
|
||||||
|
tenantId: string,
|
||||||
|
status?: ActivityStatus
|
||||||
|
): Promise<Activity[]> {
|
||||||
|
let whereClause = 'WHERE a.res_model = $1 AND a.res_id = $2 AND a.tenant_id = $3';
|
||||||
|
const params: any[] = [resModel, resId, tenantId];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ' AND a.status = $4';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<Activity>(
|
||||||
|
`SELECT a.*,
|
||||||
|
u.name as user_name,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM crm.activities a
|
||||||
|
LEFT JOIN auth.users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN core.partners p ON a.partner_id = p.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.scheduled_date ASC NULLS LAST, a.created_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activity summary for dashboard
|
||||||
|
*/
|
||||||
|
async getActivitySummary(
|
||||||
|
tenantId: string,
|
||||||
|
userId?: string,
|
||||||
|
dateFrom?: string,
|
||||||
|
dateTo?: string
|
||||||
|
): Promise<ActivitySummary> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
whereClause += ` AND user_id = $${paramIndex++}`;
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClause += ` AND scheduled_date >= $${paramIndex++}`;
|
||||||
|
params.push(dateFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo) {
|
||||||
|
whereClause += ` AND scheduled_date <= $${paramIndex++}`;
|
||||||
|
params.push(dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{
|
||||||
|
total: string;
|
||||||
|
scheduled: string;
|
||||||
|
done: string;
|
||||||
|
cancelled: string;
|
||||||
|
overdue: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'scheduled') as scheduled,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'done') as done,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'scheduled' AND scheduled_date < CURRENT_DATE) as overdue
|
||||||
|
FROM crm.activities
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const byTypeResult = await query<{ activity_type: ActivityType; count: string }>(
|
||||||
|
`SELECT activity_type, COUNT(*) as count
|
||||||
|
FROM crm.activities
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY activity_type`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const byType: Record<ActivityType, number> = {
|
||||||
|
call: 0,
|
||||||
|
meeting: 0,
|
||||||
|
email: 0,
|
||||||
|
task: 0,
|
||||||
|
note: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of byTypeResult) {
|
||||||
|
byType[row.activity_type] = parseInt(row.count, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_activities: parseInt(result?.total || '0', 10),
|
||||||
|
scheduled: parseInt(result?.scheduled || '0', 10),
|
||||||
|
done: parseInt(result?.done || '0', 10),
|
||||||
|
cancelled: parseInt(result?.cancelled || '0', 10),
|
||||||
|
overdue: parseInt(result?.overdue || '0', 10),
|
||||||
|
by_type: byType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a follow-up activity after completing one
|
||||||
|
*/
|
||||||
|
async scheduleFollowUp(
|
||||||
|
completedActivityId: string,
|
||||||
|
followUpDto: CreateActivityDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Activity> {
|
||||||
|
const completedActivity = await this.findById(completedActivityId, tenantId);
|
||||||
|
|
||||||
|
// Inherit resource info from completed activity if not specified
|
||||||
|
const dto = {
|
||||||
|
...followUpDto,
|
||||||
|
res_model: followUpDto.res_model || completedActivity.res_model,
|
||||||
|
res_id: followUpDto.res_id || completedActivity.res_id,
|
||||||
|
partner_id: followUpDto.partner_id || completedActivity.partner_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.create(dto, tenantId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overdue activities count for notifications
|
||||||
|
*/
|
||||||
|
async getOverdueCount(tenantId: string, userId?: string): Promise<number> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1 AND status = \'scheduled\' AND scheduled_date < CURRENT_DATE';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
whereClause += ' AND user_id = $2';
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.activities ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseInt(result?.count || '0', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getResourceName(resModel: string, resId: string, tenantId: string): Promise<string> {
|
||||||
|
let tableName: string;
|
||||||
|
switch (resModel) {
|
||||||
|
case 'opportunity':
|
||||||
|
tableName = 'crm.opportunities';
|
||||||
|
break;
|
||||||
|
case 'lead':
|
||||||
|
tableName = 'crm.leads';
|
||||||
|
break;
|
||||||
|
case 'partner':
|
||||||
|
tableName = 'core.partners';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{ name: string }>(
|
||||||
|
`SELECT name FROM ${tableName} WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[resId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result?.name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateLastActivityDate(resModel: string, resId: string, tenantId: string): Promise<void> {
|
||||||
|
let tableName: string;
|
||||||
|
switch (resModel) {
|
||||||
|
case 'opportunity':
|
||||||
|
tableName = 'crm.opportunities';
|
||||||
|
break;
|
||||||
|
case 'lead':
|
||||||
|
tableName = 'crm.leads';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE ${tableName} SET date_last_activity = CURRENT_TIMESTAMP WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[resId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activitiesService = new ActivitiesService();
|
||||||
682
src/modules/crm/crm.controller.ts
Normal file
682
src/modules/crm/crm.controller.ts
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './leads.service.js';
|
||||||
|
import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './opportunities.service.js';
|
||||||
|
import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './stages.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Lead schemas
|
||||||
|
const createLeadSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
contact_name: z.string().max(255).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
mobile: z.string().max(50).optional(),
|
||||||
|
website: z.string().url().max(255).optional(),
|
||||||
|
company_prospect_name: z.string().max(255).optional(),
|
||||||
|
job_position: z.string().max(100).optional(),
|
||||||
|
industry: z.string().max(100).optional(),
|
||||||
|
employee_count: z.string().max(50).optional(),
|
||||||
|
annual_revenue: z.number().min(0).optional(),
|
||||||
|
street: z.string().max(255).optional(),
|
||||||
|
city: z.string().max(100).optional(),
|
||||||
|
state: z.string().max(100).optional(),
|
||||||
|
zip: z.string().max(20).optional(),
|
||||||
|
country: z.string().max(100).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeadSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
contact_name: z.string().max(255).optional().nullable(),
|
||||||
|
email: z.string().email().max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(50).optional().nullable(),
|
||||||
|
mobile: z.string().max(50).optional().nullable(),
|
||||||
|
website: z.string().url().max(255).optional().nullable(),
|
||||||
|
company_prospect_name: z.string().max(255).optional().nullable(),
|
||||||
|
job_position: z.string().max(100).optional().nullable(),
|
||||||
|
industry: z.string().max(100).optional().nullable(),
|
||||||
|
employee_count: z.string().max(50).optional().nullable(),
|
||||||
|
annual_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
street: z.string().max(255).optional().nullable(),
|
||||||
|
city: z.string().max(100).optional().nullable(),
|
||||||
|
state: z.string().max(100).optional().nullable(),
|
||||||
|
zip: z.string().max(20).optional().nullable(),
|
||||||
|
country: z.string().max(100).optional().nullable(),
|
||||||
|
stage_id: z.string().uuid().optional().nullable(),
|
||||||
|
user_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const leadQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['new', 'contacted', 'qualified', 'converted', 'lost']).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
priority: z.coerce.number().int().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lostSchema = z.object({
|
||||||
|
lost_reason_id: z.string().uuid(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveStageSchema = z.object({
|
||||||
|
stage_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opportunity schemas
|
||||||
|
const createOpportunitySchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
contact_name: z.string().max(255).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional(),
|
||||||
|
recurring_revenue: z.number().min(0).optional(),
|
||||||
|
recurring_plan: z.string().max(50).optional(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOpportunitySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
contact_name: z.string().max(255).optional().nullable(),
|
||||||
|
email: z.string().email().max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(50).optional().nullable(),
|
||||||
|
stage_id: z.string().uuid().optional().nullable(),
|
||||||
|
user_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
recurring_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
recurring_plan: z.string().max(50).optional().nullable(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const opportunityQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['open', 'won', 'lost']).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
priority: z.coerce.number().int().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stage schemas
|
||||||
|
const createStageSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
sequence: z.number().int().optional(),
|
||||||
|
is_won: z.boolean().optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
requirements: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStageSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
sequence: z.number().int().optional(),
|
||||||
|
is_won: z.boolean().optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
requirements: z.string().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lost reason schemas
|
||||||
|
const createLostReasonSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLostReasonSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
class CrmController {
|
||||||
|
// ========== LEADS ==========
|
||||||
|
|
||||||
|
async getLeads(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = leadQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LeadFilters = queryResult.data;
|
||||||
|
const result = await leadsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: lead });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLeadSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lead invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeadDto = parseResult.data;
|
||||||
|
const lead = await leadsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLeadSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lead invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeadDto = parseResult.data;
|
||||||
|
const lead = await leadsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = moveStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = await leadsService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead movido a nueva etapa',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await leadsService.convert(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.lead,
|
||||||
|
opportunity_id: result.opportunity_id,
|
||||||
|
message: 'Lead convertido a oportunidad exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLeadLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = lostSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = await leadsService.markLost(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.lost_reason_id,
|
||||||
|
parseResult.data.notes,
|
||||||
|
req.tenantId!,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead marcado como perdido',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await leadsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Lead eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITIES ==========
|
||||||
|
|
||||||
|
async getOpportunities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = opportunityQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: OpportunityFilters = queryResult.data;
|
||||||
|
const result = await opportunitiesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const opportunity = await opportunitiesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: opportunity });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createOpportunitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateOpportunityDto = parseResult.data;
|
||||||
|
const opportunity = await opportunitiesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateOpportunitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateOpportunityDto = parseResult.data;
|
||||||
|
const opportunity = await opportunitiesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = moveStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await opportunitiesService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad movida a nueva etapa',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOpportunityWon(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const opportunity = await opportunitiesService.markWon(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad marcada como ganada',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOpportunityLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = lostSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await opportunitiesService.markLost(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.lost_reason_id,
|
||||||
|
parseResult.data.notes,
|
||||||
|
req.tenantId!,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad marcada como perdida',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await opportunitiesService.createQuotation(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result.opportunity,
|
||||||
|
quotation_id: result.quotation_id,
|
||||||
|
message: 'Cotizacion creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await opportunitiesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Oportunidad eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPipeline(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyId = req.query.company_id as string | undefined;
|
||||||
|
const pipeline = await opportunitiesService.getPipeline(req.tenantId!, companyId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: pipeline,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
async getLeadStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const stages = await stagesService.getLeadStages(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: stages });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeadStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.createLeadStage(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de lead creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeadStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.updateLeadStage(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de lead actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteLeadStage(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Etapa de lead eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
async getOpportunityStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const stages = await stagesService.getOpportunityStages(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: stages });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateOpportunityStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.createOpportunityStage(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de oportunidad creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateOpportunityStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.updateOpportunityStage(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de oportunidad actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteOpportunityStage(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Etapa de oportunidad eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
async getLostReasons(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const reasons = await stagesService.getLostReasons(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: reasons });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLostReasonSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de razon invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLostReasonDto = parseResult.data;
|
||||||
|
const reason = await stagesService.createLostReason(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: reason,
|
||||||
|
message: 'Razon de perdida creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLostReasonSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de razon invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLostReasonDto = parseResult.data;
|
||||||
|
const reason = await stagesService.updateLostReason(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: reason,
|
||||||
|
message: 'Razon de perdida actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteLostReason(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Razon de perdida eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const crmController = new CrmController();
|
||||||
126
src/modules/crm/crm.routes.ts
Normal file
126
src/modules/crm/crm.routes.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { crmController } from './crm.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== LEADS ==========
|
||||||
|
|
||||||
|
router.get('/leads', (req, res, next) => crmController.getLeads(req, res, next));
|
||||||
|
|
||||||
|
router.get('/leads/:id', (req, res, next) => crmController.getLead(req, res, next));
|
||||||
|
|
||||||
|
router.post('/leads', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/leads/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.moveLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/convert', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.convertLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markLeadLost(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/leads/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== OPPORTUNITIES ==========
|
||||||
|
|
||||||
|
router.get('/opportunities', (req, res, next) => crmController.getOpportunities(req, res, next));
|
||||||
|
|
||||||
|
router.get('/opportunities/:id', (req, res, next) => crmController.getOpportunity(req, res, next));
|
||||||
|
|
||||||
|
router.post('/opportunities', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/opportunities/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.moveOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/won', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markOpportunityWon(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markOpportunityLost(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/quote', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunityQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/opportunities/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PIPELINE ==========
|
||||||
|
|
||||||
|
router.get('/pipeline', (req, res, next) => crmController.getPipeline(req, res, next));
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
router.get('/lead-stages', (req, res, next) => crmController.getLeadStages(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lead-stages', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
router.get('/opportunity-stages', (req, res, next) => crmController.getOpportunityStages(req, res, next));
|
||||||
|
|
||||||
|
router.post('/opportunity-stages', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
router.get('/lost-reasons', (req, res, next) => crmController.getLostReasons(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lost-reasons', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
452
src/modules/crm/forecasting.service.ts
Normal file
452
src/modules/crm/forecasting.service.ts
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ForecastPeriod {
|
||||||
|
period: string; // YYYY-MM or YYYY-QN
|
||||||
|
expected_revenue: number;
|
||||||
|
weighted_revenue: number;
|
||||||
|
opportunity_count: number;
|
||||||
|
avg_probability: number;
|
||||||
|
won_revenue?: number;
|
||||||
|
won_count?: number;
|
||||||
|
lost_revenue?: number;
|
||||||
|
lost_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesForecast {
|
||||||
|
total_pipeline: number;
|
||||||
|
weighted_pipeline: number;
|
||||||
|
expected_close_this_month: number;
|
||||||
|
expected_close_this_quarter: number;
|
||||||
|
opportunities_count: number;
|
||||||
|
avg_deal_size: number;
|
||||||
|
avg_probability: number;
|
||||||
|
periods: ForecastPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinLossAnalysis {
|
||||||
|
period: string;
|
||||||
|
won_count: number;
|
||||||
|
won_revenue: number;
|
||||||
|
lost_count: number;
|
||||||
|
lost_revenue: number;
|
||||||
|
win_rate: number;
|
||||||
|
avg_won_deal_size: number;
|
||||||
|
avg_lost_deal_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineMetrics {
|
||||||
|
total_opportunities: number;
|
||||||
|
total_value: number;
|
||||||
|
by_stage: {
|
||||||
|
stage_id: string;
|
||||||
|
stage_name: string;
|
||||||
|
sequence: number;
|
||||||
|
count: number;
|
||||||
|
value: number;
|
||||||
|
weighted_value: number;
|
||||||
|
avg_probability: number;
|
||||||
|
}[];
|
||||||
|
by_user: {
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
count: number;
|
||||||
|
value: number;
|
||||||
|
weighted_value: number;
|
||||||
|
}[];
|
||||||
|
avg_days_in_stage: number;
|
||||||
|
avg_sales_cycle_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastFilters {
|
||||||
|
company_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ForecastingService {
|
||||||
|
/**
|
||||||
|
* Get sales forecast for the pipeline
|
||||||
|
*/
|
||||||
|
async getSalesForecast(
|
||||||
|
tenantId: string,
|
||||||
|
filters: ForecastFilters = {}
|
||||||
|
): Promise<SalesForecast> {
|
||||||
|
const { company_id, user_id, sales_team_id } = filters;
|
||||||
|
|
||||||
|
let whereClause = `WHERE o.tenant_id = $1 AND o.status = 'open'`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND o.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND o.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sales_team_id) {
|
||||||
|
whereClause += ` AND o.sales_team_id = $${paramIndex++}`;
|
||||||
|
params.push(sales_team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get overall metrics
|
||||||
|
const metrics = await queryOne<{
|
||||||
|
total_pipeline: string;
|
||||||
|
weighted_pipeline: string;
|
||||||
|
count: string;
|
||||||
|
avg_probability: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(expected_revenue), 0) as total_pipeline,
|
||||||
|
COALESCE(SUM(expected_revenue * probability / 100), 0) as weighted_pipeline,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(AVG(probability), 0) as avg_probability
|
||||||
|
FROM crm.opportunities o
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get expected close this month
|
||||||
|
const thisMonthParams = [...params];
|
||||||
|
const thisMonth = await queryOne<{ expected: string }>(
|
||||||
|
`SELECT COALESCE(SUM(expected_revenue * probability / 100), 0) as expected
|
||||||
|
FROM crm.opportunities o
|
||||||
|
${whereClause}
|
||||||
|
AND date_deadline >= DATE_TRUNC('month', CURRENT_DATE)
|
||||||
|
AND date_deadline < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'`,
|
||||||
|
thisMonthParams
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get expected close this quarter
|
||||||
|
const thisQuarterParams = [...params];
|
||||||
|
const thisQuarter = await queryOne<{ expected: string }>(
|
||||||
|
`SELECT COALESCE(SUM(expected_revenue * probability / 100), 0) as expected
|
||||||
|
FROM crm.opportunities o
|
||||||
|
${whereClause}
|
||||||
|
AND date_deadline >= DATE_TRUNC('quarter', CURRENT_DATE)
|
||||||
|
AND date_deadline < DATE_TRUNC('quarter', CURRENT_DATE) + INTERVAL '3 months'`,
|
||||||
|
thisQuarterParams
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get periods (next 6 months)
|
||||||
|
const periods = await query<ForecastPeriod>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', COALESCE(date_deadline, CURRENT_DATE + INTERVAL '1 month')), 'YYYY-MM') as period,
|
||||||
|
COALESCE(SUM(expected_revenue), 0) as expected_revenue,
|
||||||
|
COALESCE(SUM(expected_revenue * probability / 100), 0) as weighted_revenue,
|
||||||
|
COUNT(*) as opportunity_count,
|
||||||
|
COALESCE(AVG(probability), 0) as avg_probability
|
||||||
|
FROM crm.opportunities o
|
||||||
|
${whereClause}
|
||||||
|
AND (date_deadline IS NULL OR date_deadline >= CURRENT_DATE)
|
||||||
|
AND (date_deadline IS NULL OR date_deadline < CURRENT_DATE + INTERVAL '6 months')
|
||||||
|
GROUP BY DATE_TRUNC('month', COALESCE(date_deadline, CURRENT_DATE + INTERVAL '1 month'))
|
||||||
|
ORDER BY period`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPipeline = parseFloat(metrics?.total_pipeline || '0');
|
||||||
|
const count = parseInt(metrics?.count || '0', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_pipeline: totalPipeline,
|
||||||
|
weighted_pipeline: parseFloat(metrics?.weighted_pipeline || '0'),
|
||||||
|
expected_close_this_month: parseFloat(thisMonth?.expected || '0'),
|
||||||
|
expected_close_this_quarter: parseFloat(thisQuarter?.expected || '0'),
|
||||||
|
opportunities_count: count,
|
||||||
|
avg_deal_size: count > 0 ? totalPipeline / count : 0,
|
||||||
|
avg_probability: parseFloat(metrics?.avg_probability || '0'),
|
||||||
|
periods,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get win/loss analysis for reporting
|
||||||
|
*/
|
||||||
|
async getWinLossAnalysis(
|
||||||
|
tenantId: string,
|
||||||
|
filters: ForecastFilters = {},
|
||||||
|
periodType: 'month' | 'quarter' | 'year' = 'month'
|
||||||
|
): Promise<WinLossAnalysis[]> {
|
||||||
|
const { company_id, user_id, sales_team_id, date_from, date_to } = filters;
|
||||||
|
|
||||||
|
let whereClause = `WHERE o.tenant_id = $1 AND o.status IN ('won', 'lost')`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND o.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND o.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sales_team_id) {
|
||||||
|
whereClause += ` AND o.sales_team_id = $${paramIndex++}`;
|
||||||
|
params.push(sales_team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND o.date_closed >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND o.date_closed <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodTrunc = periodType === 'year' ? 'year' : periodType === 'quarter' ? 'quarter' : 'month';
|
||||||
|
const periodFormat = periodType === 'year' ? 'YYYY' : periodType === 'quarter' ? 'YYYY-"Q"Q' : 'YYYY-MM';
|
||||||
|
|
||||||
|
return query<WinLossAnalysis>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('${periodTrunc}', date_closed), '${periodFormat}') as period,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'won') as won_count,
|
||||||
|
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) as won_revenue,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'lost') as lost_count,
|
||||||
|
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'lost'), 0) as lost_revenue,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) > 0
|
||||||
|
THEN ROUND(COUNT(*) FILTER (WHERE status = 'won')::numeric / COUNT(*) * 100, 2)
|
||||||
|
ELSE 0
|
||||||
|
END as win_rate,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) FILTER (WHERE status = 'won') > 0
|
||||||
|
THEN COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) / COUNT(*) FILTER (WHERE status = 'won')
|
||||||
|
ELSE 0
|
||||||
|
END as avg_won_deal_size,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) FILTER (WHERE status = 'lost') > 0
|
||||||
|
THEN COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'lost'), 0) / COUNT(*) FILTER (WHERE status = 'lost')
|
||||||
|
ELSE 0
|
||||||
|
END as avg_lost_deal_size
|
||||||
|
FROM crm.opportunities o
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY DATE_TRUNC('${periodTrunc}', date_closed)
|
||||||
|
ORDER BY period DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pipeline metrics for dashboard
|
||||||
|
*/
|
||||||
|
async getPipelineMetrics(
|
||||||
|
tenantId: string,
|
||||||
|
filters: ForecastFilters = {}
|
||||||
|
): Promise<PipelineMetrics> {
|
||||||
|
const { company_id, user_id, sales_team_id } = filters;
|
||||||
|
|
||||||
|
let whereClause = `WHERE o.tenant_id = $1 AND o.status = 'open'`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND o.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND o.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sales_team_id) {
|
||||||
|
whereClause += ` AND o.sales_team_id = $${paramIndex++}`;
|
||||||
|
params.push(sales_team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get totals
|
||||||
|
const totals = await queryOne<{ count: string; total: string }>(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(expected_revenue), 0) as total
|
||||||
|
FROM crm.opportunities o ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get by stage
|
||||||
|
const byStage = await query<{
|
||||||
|
stage_id: string;
|
||||||
|
stage_name: string;
|
||||||
|
sequence: number;
|
||||||
|
count: string;
|
||||||
|
value: string;
|
||||||
|
weighted_value: string;
|
||||||
|
avg_probability: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
s.id as stage_id,
|
||||||
|
s.name as stage_name,
|
||||||
|
s.sequence,
|
||||||
|
COUNT(o.id) as count,
|
||||||
|
COALESCE(SUM(o.expected_revenue), 0) as value,
|
||||||
|
COALESCE(SUM(o.expected_revenue * o.probability / 100), 0) as weighted_value,
|
||||||
|
COALESCE(AVG(o.probability), 0) as avg_probability
|
||||||
|
FROM crm.stages s
|
||||||
|
LEFT JOIN crm.opportunities o ON o.stage_id = s.id AND o.status = 'open' AND o.tenant_id = $1
|
||||||
|
WHERE s.tenant_id = $1 AND s.active = true
|
||||||
|
GROUP BY s.id, s.name, s.sequence
|
||||||
|
ORDER BY s.sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get by user
|
||||||
|
const byUser = await query<{
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
count: string;
|
||||||
|
value: string;
|
||||||
|
weighted_value: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
u.id as user_id,
|
||||||
|
u.name as user_name,
|
||||||
|
COUNT(o.id) as count,
|
||||||
|
COALESCE(SUM(o.expected_revenue), 0) as value,
|
||||||
|
COALESCE(SUM(o.expected_revenue * o.probability / 100), 0) as weighted_value
|
||||||
|
FROM crm.opportunities o
|
||||||
|
JOIN auth.users u ON o.user_id = u.id
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY u.id, u.name
|
||||||
|
ORDER BY weighted_value DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get average sales cycle
|
||||||
|
const cycleStats = await queryOne<{ avg_days: string }>(
|
||||||
|
`SELECT AVG(EXTRACT(EPOCH FROM (date_closed - created_at)) / 86400) as avg_days
|
||||||
|
FROM crm.opportunities o
|
||||||
|
WHERE o.tenant_id = $1 AND o.status = 'won' AND date_closed IS NOT NULL`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_opportunities: parseInt(totals?.count || '0', 10),
|
||||||
|
total_value: parseFloat(totals?.total || '0'),
|
||||||
|
by_stage: byStage.map(s => ({
|
||||||
|
stage_id: s.stage_id,
|
||||||
|
stage_name: s.stage_name,
|
||||||
|
sequence: s.sequence,
|
||||||
|
count: parseInt(s.count, 10),
|
||||||
|
value: parseFloat(s.value),
|
||||||
|
weighted_value: parseFloat(s.weighted_value),
|
||||||
|
avg_probability: parseFloat(s.avg_probability),
|
||||||
|
})),
|
||||||
|
by_user: byUser.map(u => ({
|
||||||
|
user_id: u.user_id,
|
||||||
|
user_name: u.user_name,
|
||||||
|
count: parseInt(u.count, 10),
|
||||||
|
value: parseFloat(u.value),
|
||||||
|
weighted_value: parseFloat(u.weighted_value),
|
||||||
|
})),
|
||||||
|
avg_days_in_stage: 0, // Would need stage history tracking
|
||||||
|
avg_sales_cycle_days: parseFloat(cycleStats?.avg_days || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user performance metrics
|
||||||
|
*/
|
||||||
|
async getUserPerformance(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
dateFrom?: string,
|
||||||
|
dateTo?: string
|
||||||
|
): Promise<{
|
||||||
|
open_opportunities: number;
|
||||||
|
pipeline_value: number;
|
||||||
|
won_deals: number;
|
||||||
|
won_revenue: number;
|
||||||
|
lost_deals: number;
|
||||||
|
win_rate: number;
|
||||||
|
activities_done: number;
|
||||||
|
avg_deal_size: number;
|
||||||
|
}> {
|
||||||
|
let whereClause = `WHERE o.tenant_id = $1 AND o.user_id = $2`;
|
||||||
|
const params: any[] = [tenantId, userId];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClause += ` AND o.created_at >= $${paramIndex++}`;
|
||||||
|
params.push(dateFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo) {
|
||||||
|
whereClause += ` AND o.created_at <= $${paramIndex++}`;
|
||||||
|
params.push(dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await queryOne<{
|
||||||
|
open_count: string;
|
||||||
|
pipeline: string;
|
||||||
|
won_count: string;
|
||||||
|
won_revenue: string;
|
||||||
|
lost_count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'open') as open_count,
|
||||||
|
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'open'), 0) as pipeline,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'won') as won_count,
|
||||||
|
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) as won_revenue,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'lost') as lost_count
|
||||||
|
FROM crm.opportunities o
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get activities count
|
||||||
|
let activityWhere = `WHERE tenant_id = $1 AND user_id = $2 AND status = 'done'`;
|
||||||
|
const activityParams: any[] = [tenantId, userId];
|
||||||
|
let actParamIndex = 3;
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
activityWhere += ` AND date_done >= $${actParamIndex++}`;
|
||||||
|
activityParams.push(dateFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo) {
|
||||||
|
activityWhere += ` AND date_done <= $${actParamIndex++}`;
|
||||||
|
activityParams.push(dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityCount = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.activities ${activityWhere}`,
|
||||||
|
activityParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const wonCount = parseInt(metrics?.won_count || '0', 10);
|
||||||
|
const lostCount = parseInt(metrics?.lost_count || '0', 10);
|
||||||
|
const wonRevenue = parseFloat(metrics?.won_revenue || '0');
|
||||||
|
const totalDeals = wonCount + lostCount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
open_opportunities: parseInt(metrics?.open_count || '0', 10),
|
||||||
|
pipeline_value: parseFloat(metrics?.pipeline || '0'),
|
||||||
|
won_deals: wonCount,
|
||||||
|
won_revenue: wonRevenue,
|
||||||
|
lost_deals: lostCount,
|
||||||
|
win_rate: totalDeals > 0 ? (wonCount / totalDeals) * 100 : 0,
|
||||||
|
activities_done: parseInt(activityCount?.count || '0', 10),
|
||||||
|
avg_deal_size: wonCount > 0 ? wonRevenue / wonCount : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const forecastingService = new ForecastingService();
|
||||||
7
src/modules/crm/index.ts
Normal file
7
src/modules/crm/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export * from './leads.service.js';
|
||||||
|
export * from './opportunities.service.js';
|
||||||
|
export * from './stages.service.js';
|
||||||
|
export * from './activities.service.js';
|
||||||
|
export * from './forecasting.service.js';
|
||||||
|
export * from './crm.controller.js';
|
||||||
|
export { default as crmRoutes } from './crm.routes.js';
|
||||||
449
src/modules/crm/leads.service.ts
Normal file
449
src/modules/crm/leads.service.ts
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
|
||||||
|
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
company_prospect_name?: string;
|
||||||
|
job_position?: string;
|
||||||
|
industry?: string;
|
||||||
|
employee_count?: string;
|
||||||
|
annual_revenue?: number;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
stage_name?: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
date_open?: Date;
|
||||||
|
date_closed?: Date;
|
||||||
|
date_deadline?: Date;
|
||||||
|
date_last_activity?: Date;
|
||||||
|
partner_id?: string;
|
||||||
|
opportunity_id?: string;
|
||||||
|
lost_reason_id?: string;
|
||||||
|
lost_reason_name?: string;
|
||||||
|
lost_notes?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
company_prospect_name?: string;
|
||||||
|
job_position?: string;
|
||||||
|
industry?: string;
|
||||||
|
employee_count?: string;
|
||||||
|
annual_revenue?: number;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
date_deadline?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeadDto {
|
||||||
|
name?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
contact_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
company_prospect_name?: string | null;
|
||||||
|
job_position?: string | null;
|
||||||
|
industry?: string | null;
|
||||||
|
employee_count?: string | null;
|
||||||
|
annual_revenue?: number | null;
|
||||||
|
street?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
stage_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
source?: LeadSource | null;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number | null;
|
||||||
|
date_deadline?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadFilters {
|
||||||
|
company_id?: string;
|
||||||
|
status?: LeadStatus;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeadsService {
|
||||||
|
async findAll(tenantId: string, filters: LeadFilters = {}): Promise<{ data: Lead[]; total: number }> {
|
||||||
|
const { company_id, status, stage_id, user_id, source, priority, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND l.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND l.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage_id) {
|
||||||
|
whereClause += ` AND l.stage_id = $${paramIndex++}`;
|
||||||
|
params.push(stage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND l.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
whereClause += ` AND l.source = $${paramIndex++}`;
|
||||||
|
params.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority !== undefined) {
|
||||||
|
whereClause += ` AND l.priority = $${paramIndex++}`;
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.contact_name ILIKE $${paramIndex} OR l.email ILIKE $${paramIndex} OR l.company_name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads l ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Lead>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
ls.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.leads l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
|
||||||
|
LEFT JOIN auth.users u ON l.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.priority DESC, l.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Lead> {
|
||||||
|
const lead = await queryOne<Lead>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
ls.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.leads l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
|
||||||
|
LEFT JOIN auth.users u ON l.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lead) {
|
||||||
|
throw new NotFoundError('Lead no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lead;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLeadDto, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await queryOne<Lead>(
|
||||||
|
`INSERT INTO crm.leads (
|
||||||
|
tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website,
|
||||||
|
company_name, job_position, industry, employee_count, annual_revenue,
|
||||||
|
street, city, state, zip, country, stage_id, user_id, sales_team_id, source,
|
||||||
|
priority, probability, expected_revenue, date_deadline, description, notes, tags,
|
||||||
|
date_open, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
|
||||||
|
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, CURRENT_TIMESTAMP, $31)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.ref, dto.contact_name, dto.email, dto.phone,
|
||||||
|
dto.mobile, dto.website, dto.company_prospect_name, dto.job_position, dto.industry,
|
||||||
|
dto.employee_count, dto.annual_revenue, dto.street, dto.city, dto.state, dto.zip,
|
||||||
|
dto.country, dto.stage_id, dto.user_id, dto.sales_team_id, dto.source,
|
||||||
|
dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.date_deadline,
|
||||||
|
dto.description, dto.notes, dto.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(lead!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLeadDto, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status === 'converted' || existing.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede editar un lead convertido o perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'name', 'ref', 'contact_name', 'email', 'phone', 'mobile', 'website',
|
||||||
|
'company_prospect_name', 'job_position', 'industry', 'employee_count', 'annual_revenue',
|
||||||
|
'street', 'city', 'state', 'zip', 'country', 'stage_id', 'user_id', 'sales_team_id',
|
||||||
|
'source', 'priority', 'probability', 'expected_revenue', 'date_deadline',
|
||||||
|
'description', 'notes', 'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
const key = field === 'company_prospect_name' ? 'company_name' : field;
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${key} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`);
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted' || lead.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede mover un lead convertido o perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
stage_id = $1,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[stageId, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async convert(id: string, tenantId: string, userId: string): Promise<{ lead: Lead; opportunity_id: string }> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted') {
|
||||||
|
throw new ValidationError('El lead ya fue convertido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede convertir un lead perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Create or get partner
|
||||||
|
let partnerId = lead.partner_id;
|
||||||
|
|
||||||
|
if (!partnerId && lead.email) {
|
||||||
|
// Check if partner exists with same email
|
||||||
|
const existingPartner = await client.query(
|
||||||
|
`SELECT id FROM core.partners WHERE email = $1 AND tenant_id = $2`,
|
||||||
|
[lead.email, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingPartner.rows.length > 0) {
|
||||||
|
partnerId = existingPartner.rows[0].id;
|
||||||
|
} else {
|
||||||
|
// Create new partner
|
||||||
|
const partnerResult = await client.query(
|
||||||
|
`INSERT INTO core.partners (tenant_id, name, email, phone, mobile, is_customer, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId, lead.contact_name || lead.name, lead.email, lead.phone, lead.mobile, userId]
|
||||||
|
);
|
||||||
|
partnerId = partnerResult.rows[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partnerId) {
|
||||||
|
throw new ValidationError('El lead debe tener un email o partner asociado para convertirse');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default opportunity stage
|
||||||
|
const stageResult = await client.query(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE tenant_id = $1 ORDER BY sequence LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageId = stageResult.rows[0]?.id || null;
|
||||||
|
|
||||||
|
// Create opportunity
|
||||||
|
const opportunityResult = await client.query(
|
||||||
|
`INSERT INTO crm.opportunities (
|
||||||
|
tenant_id, company_id, name, partner_id, contact_name, email, phone,
|
||||||
|
stage_id, user_id, sales_team_id, source, priority, probability,
|
||||||
|
expected_revenue, lead_id, description, notes, tags, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId, lead.company_id, lead.name, partnerId, lead.contact_name, lead.email,
|
||||||
|
lead.phone, stageId, lead.user_id, lead.sales_team_id, lead.source, lead.priority,
|
||||||
|
lead.probability, lead.expected_revenue, id, lead.description, lead.notes, lead.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const opportunityId = opportunityResult.rows[0].id;
|
||||||
|
|
||||||
|
// Update lead
|
||||||
|
await client.query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
status = 'converted',
|
||||||
|
partner_id = $1,
|
||||||
|
opportunity_id = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4`,
|
||||||
|
[partnerId, opportunityId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const updatedLead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return { lead: updatedLead, opportunity_id: opportunityId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted') {
|
||||||
|
throw new ValidationError('No se puede marcar como perdido un lead convertido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.status === 'lost') {
|
||||||
|
throw new ValidationError('El lead ya esta marcado como perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
status = 'lost',
|
||||||
|
lost_reason_id = $1,
|
||||||
|
lost_notes = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[lostReasonId, notes, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.opportunity_id) {
|
||||||
|
throw new ConflictError('No se puede eliminar un lead que tiene una oportunidad asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.leads WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const leadsService = new LeadsService();
|
||||||
503
src/modules/crm/opportunities.service.ts
Normal file
503
src/modules/crm/opportunities.service.ts
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { LeadSource } from './leads.service.js';
|
||||||
|
|
||||||
|
export type OpportunityStatus = 'open' | 'won' | 'lost';
|
||||||
|
|
||||||
|
export interface Opportunity {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
stage_name?: string;
|
||||||
|
status: OpportunityStatus;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
recurring_revenue?: number;
|
||||||
|
recurring_plan?: string;
|
||||||
|
date_deadline?: Date;
|
||||||
|
date_closed?: Date;
|
||||||
|
date_last_activity?: Date;
|
||||||
|
lead_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
lost_reason_id?: string;
|
||||||
|
lost_reason_name?: string;
|
||||||
|
lost_notes?: string;
|
||||||
|
quotation_id?: string;
|
||||||
|
order_id?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOpportunityDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
recurring_revenue?: number;
|
||||||
|
recurring_plan?: string;
|
||||||
|
date_deadline?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOpportunityDto {
|
||||||
|
name?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
partner_id?: string;
|
||||||
|
contact_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
stage_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number | null;
|
||||||
|
recurring_revenue?: number | null;
|
||||||
|
recurring_plan?: string | null;
|
||||||
|
date_deadline?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityFilters {
|
||||||
|
company_id?: string;
|
||||||
|
status?: OpportunityStatus;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpportunitiesService {
|
||||||
|
async findAll(tenantId: string, filters: OpportunityFilters = {}): Promise<{ data: Opportunity[]; total: number }> {
|
||||||
|
const { company_id, status, stage_id, user_id, partner_id, priority, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE o.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND o.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND o.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage_id) {
|
||||||
|
whereClause += ` AND o.stage_id = $${paramIndex++}`;
|
||||||
|
params.push(stage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND o.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND o.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority !== undefined) {
|
||||||
|
whereClause += ` AND o.priority = $${paramIndex++}`;
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (o.name ILIKE $${paramIndex} OR o.contact_name ILIKE $${paramIndex} OR o.email ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities o
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Opportunity>(
|
||||||
|
`SELECT o.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
os.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN auth.companies c ON o.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id
|
||||||
|
LEFT JOIN auth.users u ON o.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.priority DESC, o.expected_revenue DESC NULLS LAST, o.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await queryOne<Opportunity>(
|
||||||
|
`SELECT o.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
os.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN auth.companies c ON o.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id
|
||||||
|
LEFT JOIN auth.users u ON o.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id
|
||||||
|
WHERE o.id = $1 AND o.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!opportunity) {
|
||||||
|
throw new NotFoundError('Oportunidad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return opportunity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateOpportunityDto, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await queryOne<Opportunity>(
|
||||||
|
`INSERT INTO crm.opportunities (
|
||||||
|
tenant_id, company_id, name, ref, partner_id, contact_name, email, phone,
|
||||||
|
stage_id, user_id, sales_team_id, priority, probability, expected_revenue,
|
||||||
|
recurring_revenue, recurring_plan, date_deadline, source, description, notes, tags, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.ref, dto.partner_id, dto.contact_name,
|
||||||
|
dto.email, dto.phone, dto.stage_id, dto.user_id, dto.sales_team_id,
|
||||||
|
dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.recurring_revenue,
|
||||||
|
dto.recurring_plan, dto.date_deadline, dto.source, dto.description, dto.notes, dto.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(opportunity!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateOpportunityDto, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden editar oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'name', 'ref', 'partner_id', 'contact_name', 'email', 'phone', 'stage_id',
|
||||||
|
'user_id', 'sales_team_id', 'priority', 'probability', 'expected_revenue',
|
||||||
|
'recurring_revenue', 'recurring_plan', 'date_deadline', 'description', 'notes', 'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`);
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden mover oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stage probability
|
||||||
|
const stage = await queryOne<{ probability: number; is_won: boolean }>(
|
||||||
|
`SELECT probability, is_won FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[stageId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
stage_id = $1,
|
||||||
|
probability = $2,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[stageId, stage.probability, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markWon(id: string, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden marcar como ganadas oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
status = 'won',
|
||||||
|
probability = 100,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden marcar como perdidas oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
status = 'lost',
|
||||||
|
probability = 0,
|
||||||
|
lost_reason_id = $1,
|
||||||
|
lost_notes = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[lostReasonId, notes, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createQuotation(id: string, tenantId: string, userId: string): Promise<{ opportunity: Opportunity; quotation_id: string }> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden crear cotizaciones de oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opportunity.quotation_id) {
|
||||||
|
throw new ValidationError('Esta oportunidad ya tiene una cotizacion asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate quotation name
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 3) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'SO%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const nextNum = seqResult.rows[0]?.next_num || 1;
|
||||||
|
const quotationName = `SO${String(nextNum).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// Get default currency
|
||||||
|
const currencyResult = await client.query(
|
||||||
|
`SELECT id FROM core.currencies WHERE code = 'MXN' AND tenant_id = $1 LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const currencyId = currencyResult.rows[0]?.id;
|
||||||
|
|
||||||
|
if (!currencyId) {
|
||||||
|
throw new ValidationError('No se encontro una moneda configurada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create quotation
|
||||||
|
const quotationResult = await client.query(
|
||||||
|
`INSERT INTO sales.quotations (
|
||||||
|
tenant_id, company_id, name, partner_id, quotation_date, validity_date,
|
||||||
|
currency_id, user_id, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_DATE, CURRENT_DATE + INTERVAL '30 days', $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId, opportunity.company_id, quotationName, opportunity.partner_id,
|
||||||
|
currencyId, userId, opportunity.description, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const quotationId = quotationResult.rows[0].id;
|
||||||
|
|
||||||
|
// Update opportunity
|
||||||
|
await client.query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
quotation_id = $1,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3`,
|
||||||
|
[quotationId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const updatedOpportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return { opportunity: updatedOpportunity, quotation_id: quotationId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.quotation_id || opportunity.order_id) {
|
||||||
|
throw new ValidationError('No se puede eliminar una oportunidad con cotizacion u orden asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lead if exists
|
||||||
|
if (opportunity.lead_id) {
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET opportunity_id = NULL WHERE id = $1`,
|
||||||
|
[opportunity.lead_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.opportunities WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline view - grouped by stage
|
||||||
|
async getPipeline(tenantId: string, companyId?: string): Promise<{ stages: any[]; totals: any }> {
|
||||||
|
let whereClause = 'WHERE o.tenant_id = $1 AND o.status = $2';
|
||||||
|
const params: any[] = [tenantId, 'open'];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
whereClause += ` AND o.company_id = $3`;
|
||||||
|
params.push(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = await query<{ id: string; name: string; sequence: number; probability: number }>(
|
||||||
|
`SELECT id, name, sequence, probability
|
||||||
|
FROM crm.opportunity_stages
|
||||||
|
WHERE tenant_id = $1 AND active = TRUE
|
||||||
|
ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const opportunities = await query<any>(
|
||||||
|
`SELECT o.id, o.name, o.partner_id, p.name as partner_name,
|
||||||
|
o.stage_id, o.expected_revenue, o.probability, o.priority,
|
||||||
|
o.date_deadline, o.user_id
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.priority DESC, o.expected_revenue DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group opportunities by stage
|
||||||
|
const pipelineStages = stages.map(stage => ({
|
||||||
|
...stage,
|
||||||
|
opportunities: opportunities.filter((opp: any) => opp.stage_id === stage.id),
|
||||||
|
count: opportunities.filter((opp: any) => opp.stage_id === stage.id).length,
|
||||||
|
total_revenue: opportunities
|
||||||
|
.filter((opp: any) => opp.stage_id === stage.id)
|
||||||
|
.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add "No stage" for opportunities without stage
|
||||||
|
const noStageOpps = opportunities.filter((opp: any) => !opp.stage_id);
|
||||||
|
if (noStageOpps.length > 0) {
|
||||||
|
pipelineStages.unshift({
|
||||||
|
id: null as unknown as string,
|
||||||
|
name: 'Sin etapa',
|
||||||
|
sequence: 0,
|
||||||
|
probability: 0,
|
||||||
|
opportunities: noStageOpps,
|
||||||
|
count: noStageOpps.length,
|
||||||
|
total_revenue: noStageOpps.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = {
|
||||||
|
total_opportunities: opportunities.length,
|
||||||
|
total_expected_revenue: opportunities.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0),
|
||||||
|
weighted_revenue: opportunities.reduce((sum: number, opp: any) => {
|
||||||
|
const revenue = parseFloat(opp.expected_revenue) || 0;
|
||||||
|
const probability = parseFloat(opp.probability) || 0;
|
||||||
|
return sum + (revenue * probability / 100);
|
||||||
|
}, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stages: pipelineStages, totals };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opportunitiesService = new OpportunitiesService();
|
||||||
435
src/modules/crm/stages.service.ts
Normal file
435
src/modules/crm/stages.service.ts
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
export interface LeadStage {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
is_won: boolean;
|
||||||
|
probability: number;
|
||||||
|
requirements?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadStageDto {
|
||||||
|
name: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeadStageDto {
|
||||||
|
name?: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
export interface OpportunityStage {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
is_won: boolean;
|
||||||
|
probability: number;
|
||||||
|
requirements?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOpportunityStageDto {
|
||||||
|
name: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOpportunityStageDto {
|
||||||
|
name?: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
export interface LostReason {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLostReasonDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLostReasonDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StagesService {
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
async getLeadStages(tenantId: string, includeInactive = false): Promise<LeadStage[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LeadStage>(
|
||||||
|
`SELECT * FROM crm.lead_stages ${whereClause} ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeadStageById(id: string, tenantId: string): Promise<LeadStage> {
|
||||||
|
const stage = await queryOne<LeadStage>(
|
||||||
|
`SELECT * FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa de lead no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeadStage(dto: CreateLeadStageDto, tenantId: string): Promise<LeadStage> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await queryOne<LeadStage>(
|
||||||
|
`INSERT INTO crm.lead_stages (tenant_id, name, sequence, is_won, probability, requirements)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements]
|
||||||
|
);
|
||||||
|
|
||||||
|
return stage!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeadStage(id: string, dto: UpdateLeadStageDto, tenantId: string): Promise<LeadStage> {
|
||||||
|
await this.getLeadStageById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.sequence !== undefined) {
|
||||||
|
updateFields.push(`sequence = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence);
|
||||||
|
}
|
||||||
|
if (dto.is_won !== undefined) {
|
||||||
|
updateFields.push(`is_won = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_won);
|
||||||
|
}
|
||||||
|
if (dto.probability !== undefined) {
|
||||||
|
updateFields.push(`probability = $${paramIndex++}`);
|
||||||
|
values.push(dto.probability);
|
||||||
|
}
|
||||||
|
if (dto.requirements !== undefined) {
|
||||||
|
updateFields.push(`requirements = $${paramIndex++}`);
|
||||||
|
values.push(dto.requirements);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getLeadStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.lead_stages SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLeadStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeadStage(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLeadStageById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if stage is in use
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads WHERE stage_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una etapa que tiene leads asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
async getOpportunityStages(tenantId: string, includeInactive = false): Promise<OpportunityStage[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<OpportunityStage>(
|
||||||
|
`SELECT * FROM crm.opportunity_stages ${whereClause} ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpportunityStageById(id: string, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
const stage = await queryOne<OpportunityStage>(
|
||||||
|
`SELECT * FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa de oportunidad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityStage(dto: CreateOpportunityStageDto, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await queryOne<OpportunityStage>(
|
||||||
|
`INSERT INTO crm.opportunity_stages (tenant_id, name, sequence, is_won, probability, requirements)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements]
|
||||||
|
);
|
||||||
|
|
||||||
|
return stage!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunityStage(id: string, dto: UpdateOpportunityStageDto, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
await this.getOpportunityStageById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.sequence !== undefined) {
|
||||||
|
updateFields.push(`sequence = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence);
|
||||||
|
}
|
||||||
|
if (dto.is_won !== undefined) {
|
||||||
|
updateFields.push(`is_won = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_won);
|
||||||
|
}
|
||||||
|
if (dto.probability !== undefined) {
|
||||||
|
updateFields.push(`probability = $${paramIndex++}`);
|
||||||
|
values.push(dto.probability);
|
||||||
|
}
|
||||||
|
if (dto.requirements !== undefined) {
|
||||||
|
updateFields.push(`requirements = $${paramIndex++}`);
|
||||||
|
values.push(dto.requirements);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getOpportunityStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunity_stages SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getOpportunityStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunityStage(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getOpportunityStageById(id, tenantId);
|
||||||
|
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities WHERE stage_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una etapa que tiene oportunidades asociadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
async getLostReasons(tenantId: string, includeInactive = false): Promise<LostReason[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LostReason>(
|
||||||
|
`SELECT * FROM crm.lost_reasons ${whereClause} ORDER BY name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLostReasonById(id: string, tenantId: string): Promise<LostReason> {
|
||||||
|
const reason = await queryOne<LostReason>(
|
||||||
|
`SELECT * FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
throw new NotFoundError('Razon de perdida no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLostReason(dto: CreateLostReasonDto, tenantId: string): Promise<LostReason> {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una razon con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = await queryOne<LostReason>(
|
||||||
|
`INSERT INTO crm.lost_reasons (tenant_id, name, description)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.description]
|
||||||
|
);
|
||||||
|
|
||||||
|
return reason!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLostReason(id: string, dto: UpdateLostReasonDto, tenantId: string): Promise<LostReason> {
|
||||||
|
await this.getLostReasonById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una razon con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getLostReasonById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.lost_reasons SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLostReasonById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLostReason(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLostReasonById(id, tenantId);
|
||||||
|
|
||||||
|
const inUseLeads = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads WHERE lost_reason_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inUseOpps = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities WHERE lost_reason_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUseLeads?.count || '0') > 0 || parseInt(inUseOpps?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una razon que esta en uso');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stagesService = new StagesService();
|
||||||
1
src/modules/financial/index.ts
Normal file
1
src/modules/financial/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './taxes.service.js';
|
||||||
382
src/modules/financial/taxes.service.ts
Normal file
382
src/modules/financial/taxes.service.ts
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Tax {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaxDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
tax_type?: 'sales' | 'purchase' | 'all';
|
||||||
|
amount?: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxFilters {
|
||||||
|
company_id?: string;
|
||||||
|
tax_type?: string;
|
||||||
|
active?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaxesService {
|
||||||
|
async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> {
|
||||||
|
const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE t.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND t.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tax_type) {
|
||||||
|
whereClause += ` AND t.tax_type = $${paramIndex++}`;
|
||||||
|
params.push(tax_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND t.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Tax> {
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
WHERE t.id = $1 AND t.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tax) {
|
||||||
|
throw new NotFoundError('Impuesto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tax;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
// Check unique code
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`,
|
||||||
|
[tenantId, dto.code]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`INSERT INTO financial.taxes (
|
||||||
|
tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.code, dto.tax_type,
|
||||||
|
dto.amount, dto.included_in_price ?? false, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return tax!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.code !== undefined) {
|
||||||
|
// Check unique code
|
||||||
|
const existingCode = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.code, id]
|
||||||
|
);
|
||||||
|
if (existingCode) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
updateFields.push(`code = $${paramIndex++}`);
|
||||||
|
values.push(dto.code);
|
||||||
|
}
|
||||||
|
if (dto.tax_type !== undefined) {
|
||||||
|
updateFields.push(`tax_type = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_type);
|
||||||
|
}
|
||||||
|
if (dto.amount !== undefined) {
|
||||||
|
updateFields.push(`amount = $${paramIndex++}`);
|
||||||
|
values.push(dto.amount);
|
||||||
|
}
|
||||||
|
if (dto.included_in_price !== undefined) {
|
||||||
|
updateFields.push(`included_in_price = $${paramIndex++}`);
|
||||||
|
values.push(dto.included_in_price);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.taxes SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if tax is used in any invoice lines
|
||||||
|
const usageCheck = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.invoice_lines
|
||||||
|
WHERE $1 = ANY(tax_ids)`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(usageCheck?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para una linea de documento
|
||||||
|
* Sigue la logica de Odoo para calculos de IVA
|
||||||
|
*/
|
||||||
|
async calculateTaxes(
|
||||||
|
lineData: TaxCalculationInput,
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
// Validar inputs
|
||||||
|
if (lineData.quantity <= 0 || lineData.priceUnit < 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed: 0,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: 0,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular subtotal antes de impuestos
|
||||||
|
const subtotal = lineData.quantity * lineData.priceUnit;
|
||||||
|
const discountAmount = subtotal * (lineData.discount || 0) / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
|
||||||
|
// Si no hay impuestos, retornar solo el monto sin impuestos
|
||||||
|
if (!lineData.taxIds || lineData.taxIds.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener impuestos de la BD
|
||||||
|
const taxResults = await query<Tax>(
|
||||||
|
`SELECT * FROM financial.taxes
|
||||||
|
WHERE id = ANY($1) AND tenant_id = $2 AND active = true
|
||||||
|
AND (tax_type = $3 OR tax_type = 'all')`,
|
||||||
|
[lineData.taxIds, tenantId, transactionType]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taxResults.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular impuestos
|
||||||
|
const taxBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
let totalTax = 0;
|
||||||
|
|
||||||
|
for (const tax of taxResults) {
|
||||||
|
let taxBase = amountUntaxed;
|
||||||
|
let taxAmount: number;
|
||||||
|
|
||||||
|
if (tax.included_in_price) {
|
||||||
|
// Precio incluye impuesto (IVA incluido)
|
||||||
|
// Base = Precio / (1 + tasa)
|
||||||
|
// Impuesto = Precio - Base
|
||||||
|
taxBase = amountUntaxed / (1 + tax.amount / 100);
|
||||||
|
taxAmount = amountUntaxed - taxBase;
|
||||||
|
} else {
|
||||||
|
// Precio sin impuesto (IVA añadido)
|
||||||
|
// Impuesto = Base * tasa
|
||||||
|
taxAmount = amountUntaxed * tax.amount / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
taxBreakdown.push({
|
||||||
|
taxId: tax.id,
|
||||||
|
taxName: tax.name,
|
||||||
|
taxCode: tax.code,
|
||||||
|
taxRate: tax.amount,
|
||||||
|
includedInPrice: tax.included_in_price,
|
||||||
|
base: Math.round(taxBase * 100) / 100,
|
||||||
|
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalTax += taxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redondear a 2 decimales
|
||||||
|
const finalAmountTax = Math.round(totalTax * 100) / 100;
|
||||||
|
const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100;
|
||||||
|
const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: finalAmountUntaxed,
|
||||||
|
amountTax: finalAmountTax,
|
||||||
|
amountTotal: finalAmountTotal,
|
||||||
|
taxBreakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para multiples lineas (ej: para totales de documento)
|
||||||
|
*/
|
||||||
|
async calculateDocumentTaxes(
|
||||||
|
lines: TaxCalculationInput[],
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
let totalUntaxed = 0;
|
||||||
|
let totalTax = 0;
|
||||||
|
const allBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const result = await this.calculateTaxes(line, tenantId, transactionType);
|
||||||
|
totalUntaxed += result.amountUntaxed;
|
||||||
|
totalTax += result.amountTax;
|
||||||
|
allBreakdown.push(...result.taxBreakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidar breakdown por impuesto
|
||||||
|
const consolidatedBreakdown = new Map<string, TaxBreakdownItem>();
|
||||||
|
for (const item of allBreakdown) {
|
||||||
|
const existing = consolidatedBreakdown.get(item.taxId);
|
||||||
|
if (existing) {
|
||||||
|
existing.base += item.base;
|
||||||
|
existing.taxAmount += item.taxAmount;
|
||||||
|
} else {
|
||||||
|
consolidatedBreakdown.set(item.taxId, { ...item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: Math.round(totalUntaxed * 100) / 100,
|
||||||
|
amountTax: Math.round(totalTax * 100) / 100,
|
||||||
|
amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100,
|
||||||
|
taxBreakdown: Array.from(consolidatedBreakdown.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces para calculo de impuestos
|
||||||
|
export interface TaxCalculationInput {
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
discount: number;
|
||||||
|
taxIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxBreakdownItem {
|
||||||
|
taxId: string;
|
||||||
|
taxName: string;
|
||||||
|
taxCode: string;
|
||||||
|
taxRate: number;
|
||||||
|
includedInPrice: boolean;
|
||||||
|
base: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxCalculationResult {
|
||||||
|
amountUntaxed: number;
|
||||||
|
amountTax: number;
|
||||||
|
amountTotal: number;
|
||||||
|
taxBreakdown: TaxBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taxesService = new TaxesService();
|
||||||
177
src/modules/inventory/MIGRATION_STATUS.md
Normal file
177
src/modules/inventory/MIGRATION_STATUS.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# Inventory Module TypeORM Migration Status
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
|
||||||
|
### 1. Entity Creation (100% Complete)
|
||||||
|
All entity files have been successfully created in `/src/modules/inventory/entities/`:
|
||||||
|
|
||||||
|
- ✅ `product.entity.ts` - Product entity with types, tracking, and valuation methods
|
||||||
|
- ✅ `warehouse.entity.ts` - Warehouse entity with company relation
|
||||||
|
- ✅ `location.entity.ts` - Location entity with hierarchy support
|
||||||
|
- ✅ `stock-quant.entity.ts` - Stock quantities per location
|
||||||
|
- ✅ `lot.entity.ts` - Lot/batch tracking
|
||||||
|
- ✅ `picking.entity.ts` - Picking/fulfillment operations
|
||||||
|
- ✅ `stock-move.entity.ts` - Stock movement lines
|
||||||
|
- ✅ `inventory-adjustment.entity.ts` - Stock adjustments header
|
||||||
|
- ✅ `inventory-adjustment-line.entity.ts` - Stock adjustment lines
|
||||||
|
- ✅ `stock-valuation-layer.entity.ts` - FIFO/Average cost valuation
|
||||||
|
|
||||||
|
All entities include:
|
||||||
|
- Proper schema specification (`schema: 'inventory'`)
|
||||||
|
- Indexes on key fields
|
||||||
|
- Relations using TypeORM decorators
|
||||||
|
- Audit fields (created_at, created_by, updated_at, updated_by, deleted_at, deleted_by)
|
||||||
|
- Enums for type-safe status fields
|
||||||
|
|
||||||
|
### 2. Service Refactoring (Partial - 2/8 Complete)
|
||||||
|
|
||||||
|
#### ✅ Completed Services:
|
||||||
|
1. **products.service.ts** - Fully migrated to TypeORM
|
||||||
|
- Uses Repository pattern
|
||||||
|
- All CRUD operations converted
|
||||||
|
- Proper error handling and logging
|
||||||
|
- Stock validation before deletion
|
||||||
|
|
||||||
|
2. **warehouses.service.ts** - Fully migrated to TypeORM
|
||||||
|
- Company relations properly loaded
|
||||||
|
- Default warehouse handling
|
||||||
|
- Stock validation
|
||||||
|
- Location and stock retrieval
|
||||||
|
|
||||||
|
#### ⏳ Remaining Services to Migrate:
|
||||||
|
3. **locations.service.ts** - Needs TypeORM migration
|
||||||
|
- Current: Uses raw SQL queries
|
||||||
|
- Todo: Convert to Repository pattern with QueryBuilder
|
||||||
|
- Key features: Hierarchical locations, parent-child relationships
|
||||||
|
|
||||||
|
4. **lots.service.ts** - Needs TypeORM migration
|
||||||
|
- Current: Uses raw SQL queries
|
||||||
|
- Todo: Convert to Repository pattern
|
||||||
|
- Key features: Expiration tracking, stock quantity aggregation
|
||||||
|
|
||||||
|
5. **pickings.service.ts** - Needs TypeORM migration (COMPLEX)
|
||||||
|
- Current: Uses raw SQL with transactions
|
||||||
|
- Todo: Convert to TypeORM with QueryRunner for transactions
|
||||||
|
- Key features: Multi-line operations, status workflows, stock updates
|
||||||
|
|
||||||
|
6. **adjustments.service.ts** - Needs TypeORM migration (COMPLEX)
|
||||||
|
- Current: Uses raw SQL with transactions
|
||||||
|
- Todo: Convert to TypeORM with QueryRunner
|
||||||
|
- Key features: Multi-line operations, theoretical vs counted quantities
|
||||||
|
|
||||||
|
7. **valuation.service.ts** - Needs TypeORM migration (COMPLEX)
|
||||||
|
- Current: Uses raw SQL with client transactions
|
||||||
|
- Todo: Convert to TypeORM while maintaining FIFO logic
|
||||||
|
- Key features: Valuation layer management, FIFO consumption
|
||||||
|
|
||||||
|
8. **stock-quants.service.ts** - NEW SERVICE NEEDED
|
||||||
|
- Currently no dedicated service (operations are in other services)
|
||||||
|
- Should handle: Stock queries, reservations, availability checks
|
||||||
|
|
||||||
|
### 3. TypeORM Configuration
|
||||||
|
- ✅ Entities imported in `/src/config/typeorm.ts`
|
||||||
|
- ⚠️ **ACTION REQUIRED**: Add entities to the `entities` array in AppDataSource configuration
|
||||||
|
|
||||||
|
Add these lines after `FiscalPeriod,` in the entities array:
|
||||||
|
```typescript
|
||||||
|
// Inventory Entities
|
||||||
|
Product,
|
||||||
|
Warehouse,
|
||||||
|
Location,
|
||||||
|
StockQuant,
|
||||||
|
Lot,
|
||||||
|
Picking,
|
||||||
|
StockMove,
|
||||||
|
InventoryAdjustment,
|
||||||
|
InventoryAdjustmentLine,
|
||||||
|
StockValuationLayer,
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Controller Updates
|
||||||
|
- ⏳ **inventory.controller.ts** - Needs snake_case/camelCase handling
|
||||||
|
- Current: Only accepts snake_case from frontend
|
||||||
|
- Todo: Add transformers or accept both formats
|
||||||
|
- Pattern: Use class-transformer decorators or manual mapping
|
||||||
|
|
||||||
|
### 5. Index File
|
||||||
|
- ✅ Created `/src/modules/inventory/entities/index.ts` - Exports all entities
|
||||||
|
|
||||||
|
## Migration Patterns Used
|
||||||
|
|
||||||
|
### Repository Pattern
|
||||||
|
```typescript
|
||||||
|
class ProductsService {
|
||||||
|
private productRepository: Repository<Product>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.productRepository = AppDataSource.getRepository(Product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### QueryBuilder for Complex Queries
|
||||||
|
```typescript
|
||||||
|
const products = await this.productRepository
|
||||||
|
.createQueryBuilder('product')
|
||||||
|
.where('product.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('product.deletedAt IS NULL')
|
||||||
|
.getMany();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relations Loading
|
||||||
|
```typescript
|
||||||
|
.leftJoinAndSelect('warehouse.company', 'company')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// operations
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error message', { error, context });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
1. **Add entities to typeorm.ts entities array** (Manual edit required)
|
||||||
|
2. **Migrate locations.service.ts** - Simple, good next step
|
||||||
|
3. **Migrate lots.service.ts** - Simple, includes aggregations
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
4. **Create stock-quants.service.ts** - New service for stock operations
|
||||||
|
5. **Migrate pickings.service.ts** - Complex transactions
|
||||||
|
6. **Migrate adjustments.service.ts** - Complex transactions
|
||||||
|
|
||||||
|
### Lower Priority
|
||||||
|
7. **Migrate valuation.service.ts** - Most complex, FIFO logic
|
||||||
|
8. **Update controller for case handling** - Nice to have
|
||||||
|
9. **Add integration tests** - Verify TypeORM migration works correctly
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
After completing migration:
|
||||||
|
- [ ] Test product CRUD operations
|
||||||
|
- [ ] Test warehouse operations with company relations
|
||||||
|
- [ ] Test stock queries with filters
|
||||||
|
- [ ] Test multi-level location hierarchies
|
||||||
|
- [ ] Test lot expiration tracking
|
||||||
|
- [ ] Test picking workflows (draft → confirmed → done)
|
||||||
|
- [ ] Test inventory adjustments with stock updates
|
||||||
|
- [ ] Test FIFO valuation consumption
|
||||||
|
- [ ] Test transaction rollbacks on errors
|
||||||
|
- [ ] Performance test: Compare query performance vs raw SQL
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All entities use the `inventory` schema
|
||||||
|
- Soft deletes are implemented for products (deletedAt field)
|
||||||
|
- Hard deletes are used for other entities where appropriate
|
||||||
|
- Audit trails are maintained (created_by, updated_by, etc.)
|
||||||
|
- Foreign keys properly set up with @JoinColumn decorators
|
||||||
|
- Indexes added on frequently queried fields
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
None - The migration maintains API compatibility. All DTOs use camelCase internally but accept snake_case from the original queries.
|
||||||
594
src/modules/inventory/adjustments.service.ts
Normal file
594
src/modules/inventory/adjustments.service.ts
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
import { valuationService } from './valuation.service.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface AdjustmentLine {
|
||||||
|
id: string;
|
||||||
|
adjustment_id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
lot_id?: string;
|
||||||
|
lot_name?: string;
|
||||||
|
theoretical_qty: number;
|
||||||
|
counted_qty: number;
|
||||||
|
difference_qty: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adjustment {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
date: Date;
|
||||||
|
status: AdjustmentStatus;
|
||||||
|
notes?: string;
|
||||||
|
lines?: AdjustmentLine[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAdjustmentLineDto {
|
||||||
|
product_id: string;
|
||||||
|
location_id: string;
|
||||||
|
lot_id?: string;
|
||||||
|
counted_qty: number;
|
||||||
|
uom_id: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAdjustmentDto {
|
||||||
|
company_id: string;
|
||||||
|
location_id: string;
|
||||||
|
date?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines: CreateAdjustmentLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAdjustmentDto {
|
||||||
|
location_id?: string;
|
||||||
|
date?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAdjustmentLineDto {
|
||||||
|
counted_qty?: number;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdjustmentFilters {
|
||||||
|
company_id?: string;
|
||||||
|
location_id?: string;
|
||||||
|
status?: AdjustmentStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdjustmentsService {
|
||||||
|
async findAll(tenantId: string, filters: AdjustmentFilters = {}): Promise<{ data: Adjustment[]; total: number }> {
|
||||||
|
const { company_id, location_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE a.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND a.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location_id) {
|
||||||
|
whereClause += ` AND a.location_id = $${paramIndex++}`;
|
||||||
|
params.push(location_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND a.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND a.date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND a.date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (a.name ILIKE $${paramIndex} OR a.notes ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.inventory_adjustments a ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Adjustment>(
|
||||||
|
`SELECT a.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name
|
||||||
|
FROM inventory.inventory_adjustments a
|
||||||
|
LEFT JOIN auth.companies c ON a.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON a.location_id = l.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.date DESC, a.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await queryOne<Adjustment>(
|
||||||
|
`SELECT a.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name
|
||||||
|
FROM inventory.inventory_adjustments a
|
||||||
|
LEFT JOIN auth.companies c ON a.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON a.location_id = l.id
|
||||||
|
WHERE a.id = $1 AND a.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!adjustment) {
|
||||||
|
throw new NotFoundError('Ajuste de inventario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<AdjustmentLine>(
|
||||||
|
`SELECT al.*,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
l.name as location_name,
|
||||||
|
lot.name as lot_name,
|
||||||
|
u.name as uom_name
|
||||||
|
FROM inventory.inventory_adjustment_lines al
|
||||||
|
LEFT JOIN inventory.products p ON al.product_id = p.id
|
||||||
|
LEFT JOIN inventory.locations l ON al.location_id = l.id
|
||||||
|
LEFT JOIN inventory.lots lot ON al.lot_id = lot.id
|
||||||
|
LEFT JOIN core.uom u ON al.uom_id = u.id
|
||||||
|
WHERE al.adjustment_id = $1
|
||||||
|
ORDER BY al.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
adjustment.lines = lines;
|
||||||
|
|
||||||
|
return adjustment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAdjustmentDto, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
if (dto.lines.length === 0) {
|
||||||
|
throw new ValidationError('El ajuste debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate adjustment name
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM inventory.inventory_adjustments WHERE tenant_id = $1 AND name LIKE 'ADJ-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const nextNum = seqResult.rows[0]?.next_num || 1;
|
||||||
|
const adjustmentName = `ADJ-${String(nextNum).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
const adjustmentDate = dto.date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Create adjustment
|
||||||
|
const adjustmentResult = await client.query(
|
||||||
|
`INSERT INTO inventory.inventory_adjustments (
|
||||||
|
tenant_id, company_id, name, location_id, date, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, adjustmentName, dto.location_id, adjustmentDate, dto.notes, userId]
|
||||||
|
);
|
||||||
|
const adjustment = adjustmentResult.rows[0];
|
||||||
|
|
||||||
|
// Create lines with theoretical qty from stock_quants
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
// Get theoretical quantity from stock_quants
|
||||||
|
const stockResult = await client.query(
|
||||||
|
`SELECT COALESCE(SUM(quantity), 0) as qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
WHERE product_id = $1 AND location_id = $2
|
||||||
|
AND ($3::uuid IS NULL OR lot_id = $3)`,
|
||||||
|
[line.product_id, line.location_id, line.lot_id || null]
|
||||||
|
);
|
||||||
|
const theoreticalQty = parseFloat(stockResult.rows[0]?.qty || '0');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.inventory_adjustment_lines (
|
||||||
|
adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty,
|
||||||
|
counted_qty
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[
|
||||||
|
adjustment.id, tenantId, line.product_id, line.location_id, line.lot_id,
|
||||||
|
theoreticalQty, line.counted_qty
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(adjustment.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateAdjustmentDto, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.location_id !== undefined) {
|
||||||
|
updateFields.push(`location_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.location_id);
|
||||||
|
}
|
||||||
|
if (dto.date !== undefined) {
|
||||||
|
updateFields.push(`date = $${paramIndex++}`);
|
||||||
|
values.push(dto.date);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(adjustmentId: string, dto: CreateAdjustmentLineDto, tenantId: string): Promise<AdjustmentLine> {
|
||||||
|
const adjustment = await this.findById(adjustmentId, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get theoretical quantity
|
||||||
|
const stockResult = await queryOne<{ qty: string }>(
|
||||||
|
`SELECT COALESCE(SUM(quantity), 0) as qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
WHERE product_id = $1 AND location_id = $2
|
||||||
|
AND ($3::uuid IS NULL OR lot_id = $3)`,
|
||||||
|
[dto.product_id, dto.location_id, dto.lot_id || null]
|
||||||
|
);
|
||||||
|
const theoreticalQty = parseFloat(stockResult?.qty || '0');
|
||||||
|
|
||||||
|
const line = await queryOne<AdjustmentLine>(
|
||||||
|
`INSERT INTO inventory.inventory_adjustment_lines (
|
||||||
|
adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty,
|
||||||
|
counted_qty
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
adjustmentId, tenantId, dto.product_id, dto.location_id, dto.lot_id,
|
||||||
|
theoreticalQty, dto.counted_qty
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(adjustmentId: string, lineId: string, dto: UpdateAdjustmentLineDto, tenantId: string): Promise<AdjustmentLine> {
|
||||||
|
const adjustment = await this.findById(adjustmentId, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas en ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = adjustment.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.counted_qty !== undefined) {
|
||||||
|
updateFields.push(`counted_qty = $${paramIndex++}`);
|
||||||
|
values.push(dto.counted_qty);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existingLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(lineId);
|
||||||
|
|
||||||
|
const line = await queryOne<AdjustmentLine>(
|
||||||
|
`UPDATE inventory.inventory_adjustment_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(adjustmentId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const adjustment = await this.findById(adjustmentId, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas en ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = adjustment.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustment.lines && adjustment.lines.length <= 1) {
|
||||||
|
throw new ValidationError('El ajuste debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.inventory_adjustment_lines WHERE id = $1`, [lineId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden confirmar ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adjustment.lines || adjustment.lines.length === 0) {
|
||||||
|
throw new ValidationError('El ajuste debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET
|
||||||
|
status = 'confirmed',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(id: string, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'confirmed') {
|
||||||
|
throw new ValidationError('Solo se pueden validar ajustes confirmados');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Validating inventory adjustment', {
|
||||||
|
adjustmentId: id,
|
||||||
|
adjustmentName: adjustment.name,
|
||||||
|
linesCount: adjustment.lines?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Update status to done
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET
|
||||||
|
status = 'done',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply stock adjustments
|
||||||
|
for (const line of adjustment.lines!) {
|
||||||
|
const difference = line.counted_qty - line.theoretical_qty;
|
||||||
|
|
||||||
|
if (difference !== 0) {
|
||||||
|
// Check if quant exists
|
||||||
|
const existingQuant = await client.query(
|
||||||
|
`SELECT id, quantity FROM inventory.stock_quants
|
||||||
|
WHERE product_id = $1 AND location_id = $2
|
||||||
|
AND ($3::uuid IS NULL OR lot_id = $3)`,
|
||||||
|
[line.product_id, line.location_id, line.lot_id || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingQuant.rows.length > 0) {
|
||||||
|
// Update existing quant
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_quants SET
|
||||||
|
quantity = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[line.counted_qty, existingQuant.rows[0].id]
|
||||||
|
);
|
||||||
|
} else if (line.counted_qty > 0) {
|
||||||
|
// Create new quant if counted > 0
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_quants (
|
||||||
|
tenant_id, product_id, location_id, lot_id, quantity
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[tenantId, line.product_id, line.location_id, line.lot_id, line.counted_qty]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK-006-06: Create/consume valuation layers for adjustments
|
||||||
|
// Get product valuation info
|
||||||
|
const productInfo = await client.query(
|
||||||
|
`SELECT valuation_method, cost_price FROM inventory.products WHERE id = $1`,
|
||||||
|
[line.product_id]
|
||||||
|
);
|
||||||
|
const product = productInfo.rows[0];
|
||||||
|
|
||||||
|
if (product && product.valuation_method !== 'standard') {
|
||||||
|
try {
|
||||||
|
if (difference > 0) {
|
||||||
|
// Positive adjustment = Create valuation layer (like receiving stock)
|
||||||
|
await valuationService.createLayer(
|
||||||
|
{
|
||||||
|
product_id: line.product_id,
|
||||||
|
company_id: adjustment.company_id,
|
||||||
|
quantity: difference,
|
||||||
|
unit_cost: Number(product.cost_price) || 0,
|
||||||
|
description: `Ajuste inventario positivo - ${adjustment.name}`,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
logger.debug('Valuation layer created for positive adjustment', {
|
||||||
|
adjustmentId: id,
|
||||||
|
productId: line.product_id,
|
||||||
|
quantity: difference,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Negative adjustment = Consume valuation layers (FIFO)
|
||||||
|
const consumeResult = await valuationService.consumeFifo(
|
||||||
|
line.product_id,
|
||||||
|
adjustment.company_id,
|
||||||
|
Math.abs(difference),
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
logger.debug('Valuation layers consumed for negative adjustment', {
|
||||||
|
adjustmentId: id,
|
||||||
|
productId: line.product_id,
|
||||||
|
quantity: Math.abs(difference),
|
||||||
|
totalCost: consumeResult.total_cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update average cost if using that method
|
||||||
|
if (product.valuation_method === 'average') {
|
||||||
|
await valuationService.updateProductAverageCost(
|
||||||
|
line.product_id,
|
||||||
|
adjustment.company_id,
|
||||||
|
tenantId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (valErr) {
|
||||||
|
logger.warn('Failed to process valuation for adjustment', {
|
||||||
|
adjustmentId: id,
|
||||||
|
productId: line.product_id,
|
||||||
|
error: (valErr as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info('Inventory adjustment validated', {
|
||||||
|
adjustmentId: id,
|
||||||
|
adjustmentName: adjustment.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('Error validating inventory adjustment', {
|
||||||
|
adjustmentId: id,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Adjustment> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status === 'done') {
|
||||||
|
throw new ValidationError('No se puede cancelar un ajuste validado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustment.status === 'cancelled') {
|
||||||
|
throw new ValidationError('El ajuste ya está cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.inventory_adjustments SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const adjustment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (adjustment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar ajustes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.inventory_adjustments WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustmentsService = new AdjustmentsService();
|
||||||
342
src/modules/inventory/controllers/inventory.controller.ts
Normal file
342
src/modules/inventory/controllers/inventory.controller.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { InventoryService } from '../services/inventory.service';
|
||||||
|
import {
|
||||||
|
CreateStockMovementDto,
|
||||||
|
AdjustStockDto,
|
||||||
|
TransferStockDto,
|
||||||
|
ReserveStockDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class InventoryController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly inventoryService: InventoryService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Stock Levels
|
||||||
|
this.router.get('/stock', this.getStockLevels.bind(this));
|
||||||
|
this.router.get('/stock/product/:productId', this.getStockByProduct.bind(this));
|
||||||
|
this.router.get('/stock/warehouse/:warehouseId', this.getStockByWarehouse.bind(this));
|
||||||
|
this.router.get(
|
||||||
|
'/stock/available/:productId/:warehouseId',
|
||||||
|
this.getAvailableStock.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Movements
|
||||||
|
this.router.get('/movements', this.getMovements.bind(this));
|
||||||
|
this.router.get('/movements/:id', this.getMovement.bind(this));
|
||||||
|
this.router.post('/movements', this.createMovement.bind(this));
|
||||||
|
this.router.post('/movements/:id/confirm', this.confirmMovement.bind(this));
|
||||||
|
this.router.post('/movements/:id/cancel', this.cancelMovement.bind(this));
|
||||||
|
|
||||||
|
// Operations
|
||||||
|
this.router.post('/adjust', this.adjustStock.bind(this));
|
||||||
|
this.router.post('/transfer', this.transferStock.bind(this));
|
||||||
|
this.router.post('/reserve', this.reserveStock.bind(this));
|
||||||
|
this.router.post('/release', this.releaseReservation.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Stock Levels ====================
|
||||||
|
|
||||||
|
private async getStockLevels(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
locationId,
|
||||||
|
lotNumber,
|
||||||
|
hasStock,
|
||||||
|
lowStock,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await this.inventoryService.getStockLevels({
|
||||||
|
tenantId,
|
||||||
|
productId: productId as string,
|
||||||
|
warehouseId: warehouseId as string,
|
||||||
|
locationId: locationId as string,
|
||||||
|
lotNumber: lotNumber as string,
|
||||||
|
hasStock: hasStock ? hasStock === 'true' : undefined,
|
||||||
|
lowStock: lowStock ? lowStock === 'true' : undefined,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||||
|
offset: offset ? parseInt(offset as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStockByProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId } = req.params;
|
||||||
|
const stock = await this.inventoryService.getStockByProduct(productId, tenantId);
|
||||||
|
res.json({ data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStockByWarehouse(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { warehouseId } = req.params;
|
||||||
|
const stock = await this.inventoryService.getStockByWarehouse(warehouseId, tenantId);
|
||||||
|
res.json({ data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId, warehouseId } = req.params;
|
||||||
|
const available = await this.inventoryService.getAvailableStock(
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
res.json({ data: { available } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Movements ====================
|
||||||
|
|
||||||
|
private async getMovements(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
movementType,
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
status,
|
||||||
|
referenceType,
|
||||||
|
referenceId,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await this.inventoryService.getMovements({
|
||||||
|
tenantId,
|
||||||
|
movementType: movementType as string,
|
||||||
|
productId: productId as string,
|
||||||
|
warehouseId: warehouseId as string,
|
||||||
|
status: status as 'draft' | 'confirmed' | 'cancelled',
|
||||||
|
referenceType: referenceType as string,
|
||||||
|
referenceId: referenceId as string,
|
||||||
|
fromDate: fromDate ? new Date(fromDate as string) : undefined,
|
||||||
|
toDate: toDate ? new Date(toDate as string) : undefined,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||||
|
offset: offset ? parseInt(offset as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const movement = await this.inventoryService.getMovement(id, tenantId);
|
||||||
|
|
||||||
|
if (!movement) {
|
||||||
|
res.status(404).json({ error: 'Movement not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateStockMovementDto = req.body;
|
||||||
|
const movement = await this.inventoryService.createMovement(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async confirmMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const movement = await this.inventoryService.confirmMovement(id, tenantId, userId);
|
||||||
|
|
||||||
|
if (!movement) {
|
||||||
|
res.status(404).json({ error: 'Movement not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancelMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const movement = await this.inventoryService.cancelMovement(id, tenantId);
|
||||||
|
|
||||||
|
if (!movement) {
|
||||||
|
res.status(404).json({ error: 'Movement not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Operations ====================
|
||||||
|
|
||||||
|
private async adjustStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: AdjustStockDto = req.body;
|
||||||
|
const movement = await this.inventoryService.adjustStock(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transferStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: TransferStockDto = req.body;
|
||||||
|
const movement = await this.inventoryService.transferStock(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reserveStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: ReserveStockDto = req.body;
|
||||||
|
await this.inventoryService.reserveStock(tenantId, dto);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseReservation(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId, warehouseId, quantity } = req.body;
|
||||||
|
await this.inventoryService.releaseReservation(productId, warehouseId, quantity, tenantId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/modules/inventory/dto/create-inventory.dto.ts
Normal file
192
src/modules/inventory/dto/create-inventory.dto.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsUUID,
|
||||||
|
IsDateString,
|
||||||
|
MaxLength,
|
||||||
|
IsEnum,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateStockMovementDto {
|
||||||
|
@IsEnum(['receipt', 'shipment', 'transfer', 'adjustment', 'return', 'production', 'consumption'])
|
||||||
|
movementType:
|
||||||
|
| 'receipt'
|
||||||
|
| 'shipment'
|
||||||
|
| 'transfer'
|
||||||
|
| 'adjustment'
|
||||||
|
| 'return'
|
||||||
|
| 'production'
|
||||||
|
| 'consumption';
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sourceWarehouseId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sourceLocationId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destWarehouseId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destLocationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
uom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiryDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
unitCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
referenceType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
referenceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
referenceNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdjustStockDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
locationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
newQuantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransferStockDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
sourceWarehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sourceLocationId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
destWarehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destLocationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReserveStockDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
locationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
referenceType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
referenceId?: string;
|
||||||
|
}
|
||||||
6
src/modules/inventory/dto/index.ts
Normal file
6
src/modules/inventory/dto/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
CreateStockMovementDto,
|
||||||
|
AdjustStockDto,
|
||||||
|
TransferStockDto,
|
||||||
|
ReserveStockDto,
|
||||||
|
} from './create-inventory.dto';
|
||||||
@ -2,9 +2,46 @@
|
|||||||
* Inventory Entities Index
|
* Inventory Entities Index
|
||||||
* @module Inventory
|
* @module Inventory
|
||||||
*
|
*
|
||||||
* Extensiones de inventario para construcción (MAI-004)
|
* Entidades de inventario para construcción
|
||||||
|
* - Entidades core sincronizadas desde erp-core
|
||||||
|
* - Extensiones específicas para construcción (MAI-004)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// CORE ENTITIES (from erp-core)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Core Inventory Entities
|
||||||
|
export { Product } from './product.entity';
|
||||||
|
// Re-export Warehouse from canonical location in warehouses module
|
||||||
|
export { Warehouse } from '../../warehouses/entities/warehouse.entity';
|
||||||
|
export { Location } from './location.entity';
|
||||||
|
export { StockQuant } from './stock-quant.entity';
|
||||||
|
export { Lot } from './lot.entity';
|
||||||
|
|
||||||
|
// Stock Operations
|
||||||
|
export { Picking } from './picking.entity';
|
||||||
|
export { StockMove } from './stock-move.entity';
|
||||||
|
export { StockLevel } from './stock-level.entity';
|
||||||
|
export { StockMovement } from './stock-movement.entity';
|
||||||
|
|
||||||
|
// Inventory Management
|
||||||
|
export { InventoryCount } from './inventory-count.entity';
|
||||||
|
export { InventoryCountLine } from './inventory-count-line.entity';
|
||||||
|
export { InventoryAdjustment } from './inventory-adjustment.entity';
|
||||||
|
export { InventoryAdjustmentLine } from './inventory-adjustment-line.entity';
|
||||||
|
|
||||||
|
// Transfers
|
||||||
|
export { TransferOrder } from './transfer-order.entity';
|
||||||
|
export { TransferOrderLine } from './transfer-order-line.entity';
|
||||||
|
|
||||||
|
// Valuation
|
||||||
|
export { StockValuationLayer } from './stock-valuation-layer.entity';
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// CONSTRUCCION-SPECIFIC ENTITIES (MAI-004)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
export * from './almacen-proyecto.entity';
|
export * from './almacen-proyecto.entity';
|
||||||
export * from './requisicion-obra.entity';
|
export * from './requisicion-obra.entity';
|
||||||
export * from './requisicion-linea.entity';
|
export * from './requisicion-linea.entity';
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { InventoryAdjustment } from './inventory-adjustment.entity.js';
|
||||||
|
import { Product } from './product.entity.js';
|
||||||
|
import { Location } from './location.entity.js';
|
||||||
|
import { Lot } from './lot.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'inventory_adjustment_lines' })
|
||||||
|
@Index('idx_adjustment_lines_adjustment_id', ['adjustmentId'])
|
||||||
|
@Index('idx_adjustment_lines_product_id', ['productId'])
|
||||||
|
export class InventoryAdjustmentLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'adjustment_id' })
|
||||||
|
adjustmentId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'product_id' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'location_id' })
|
||||||
|
locationId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'lot_id' })
|
||||||
|
lotId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'theoretical_qty' })
|
||||||
|
theoreticalQty: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'counted_qty' })
|
||||||
|
countedQty: number;
|
||||||
|
|
||||||
|
// Computed field: difference_qty = counted_qty - theoretical_qty
|
||||||
|
// This should be handled at database level or computed on read
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 16,
|
||||||
|
scale: 4,
|
||||||
|
nullable: true,
|
||||||
|
name: 'difference_qty',
|
||||||
|
})
|
||||||
|
differenceQty: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'uom_id' })
|
||||||
|
uomId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => InventoryAdjustment, (adjustment) => adjustment.lines, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'adjustment_id' })
|
||||||
|
adjustment: InventoryAdjustment;
|
||||||
|
|
||||||
|
@ManyToOne(() => Product)
|
||||||
|
@JoinColumn({ name: 'product_id' })
|
||||||
|
product: Product;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location)
|
||||||
|
@JoinColumn({ name: 'location_id' })
|
||||||
|
location: Location;
|
||||||
|
|
||||||
|
@ManyToOne(() => Lot, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'lot_id' })
|
||||||
|
lot: Lot | null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
import { Location } from './location.entity.js';
|
||||||
|
import { InventoryAdjustmentLine } from './inventory-adjustment-line.entity.js';
|
||||||
|
|
||||||
|
export enum AdjustmentStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
CONFIRMED = 'confirmed',
|
||||||
|
DONE = 'done',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'inventory_adjustments' })
|
||||||
|
@Index('idx_adjustments_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_adjustments_company_id', ['companyId'])
|
||||||
|
@Index('idx_adjustments_status', ['status'])
|
||||||
|
@Index('idx_adjustments_date', ['date'])
|
||||||
|
export class InventoryAdjustment {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'location_id' })
|
||||||
|
locationId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false })
|
||||||
|
date: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: AdjustmentStatus,
|
||||||
|
default: AdjustmentStatus.DRAFT,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: AdjustmentStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location)
|
||||||
|
@JoinColumn({ name: 'location_id' })
|
||||||
|
location: Location;
|
||||||
|
|
||||||
|
@OneToMany(() => InventoryAdjustmentLine, (line) => line.adjustment)
|
||||||
|
lines: InventoryAdjustmentLine[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { InventoryCount } from './inventory-count.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'inventory_count_lines', schema: 'inventory' })
|
||||||
|
export class InventoryCountLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'count_id', type: 'uuid' })
|
||||||
|
countId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => InventoryCount, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'count_id' })
|
||||||
|
count: InventoryCount;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'product_id', type: 'uuid' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||||
|
locationId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
systemQuantity?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
countedQuantity?: number;
|
||||||
|
|
||||||
|
// Note: difference is GENERATED in DDL, but we calculate it in app layer
|
||||||
|
@Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_counted', type: 'boolean', default: false })
|
||||||
|
isCounted: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'counted_at', type: 'timestamptz', nullable: true })
|
||||||
|
countedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'counted_by', type: 'uuid', nullable: true })
|
||||||
|
countedBy?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
53
src/modules/inventory/entities/inventory-count.entity.ts
Normal file
53
src/modules/inventory/entities/inventory-count.entity.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'inventory_counts', schema: 'inventory' })
|
||||||
|
export class InventoryCount {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'count_number', type: 'varchar', length: 30 })
|
||||||
|
countNumber: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'count_type', type: 'varchar', length: 20, default: 'full' })
|
||||||
|
countType: 'full' | 'partial' | 'cycle' | 'spot';
|
||||||
|
|
||||||
|
@Column({ name: 'scheduled_date', type: 'date', nullable: true })
|
||||||
|
scheduledDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'started_at', type: 'timestamptz', nullable: true })
|
||||||
|
startedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||||
|
completedAt?: Date;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||||
|
status: 'draft' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
|
||||||
|
assignedTo?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
96
src/modules/inventory/entities/location.entity.ts
Normal file
96
src/modules/inventory/entities/location.entity.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
|
||||||
|
import { StockQuant } from './stock-quant.entity.js';
|
||||||
|
|
||||||
|
export enum LocationType {
|
||||||
|
INTERNAL = 'internal',
|
||||||
|
SUPPLIER = 'supplier',
|
||||||
|
CUSTOMER = 'customer',
|
||||||
|
INVENTORY = 'inventory',
|
||||||
|
PRODUCTION = 'production',
|
||||||
|
TRANSIT = 'transit',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'locations' })
|
||||||
|
@Index('idx_locations_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_locations_warehouse_id', ['warehouseId'])
|
||||||
|
@Index('idx_locations_parent_id', ['parentId'])
|
||||||
|
@Index('idx_locations_type', ['locationType'])
|
||||||
|
export class Location {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'warehouse_id' })
|
||||||
|
warehouseId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true, name: 'complete_name' })
|
||||||
|
completeName: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: LocationType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'location_type',
|
||||||
|
})
|
||||||
|
locationType: LocationType;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'parent_id' })
|
||||||
|
parentId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_scrap_location' })
|
||||||
|
isScrapLocation: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_return_location' })
|
||||||
|
isReturnLocation: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Warehouse, (warehouse) => warehouse.locations)
|
||||||
|
@JoinColumn({ name: 'warehouse_id' })
|
||||||
|
warehouse: Warehouse;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location, (location) => location.children)
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: Location;
|
||||||
|
|
||||||
|
@OneToMany(() => Location, (location) => location.parent)
|
||||||
|
children: Location[];
|
||||||
|
|
||||||
|
@OneToMany(() => StockQuant, (stockQuant) => stockQuant.location)
|
||||||
|
stockQuants: StockQuant[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
64
src/modules/inventory/entities/lot.entity.ts
Normal file
64
src/modules/inventory/entities/lot.entity.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Product } from './product.entity.js';
|
||||||
|
import { StockQuant } from './stock-quant.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'lots' })
|
||||||
|
@Index('idx_lots_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_lots_product_id', ['productId'])
|
||||||
|
@Index('idx_lots_name_product', ['productId', 'name'], { unique: true })
|
||||||
|
@Index('idx_lots_expiration_date', ['expirationDate'])
|
||||||
|
export class Lot {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'product_id' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
ref: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true, name: 'manufacture_date' })
|
||||||
|
manufactureDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true, name: 'expiration_date' })
|
||||||
|
expirationDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true, name: 'removal_date' })
|
||||||
|
removalDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true, name: 'alert_date' })
|
||||||
|
alertDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Product, (product) => product.lots)
|
||||||
|
@JoinColumn({ name: 'product_id' })
|
||||||
|
product: Product;
|
||||||
|
|
||||||
|
@OneToMany(() => StockQuant, (stockQuant) => stockQuant.lot)
|
||||||
|
stockQuants: StockQuant[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
}
|
||||||
125
src/modules/inventory/entities/picking.entity.ts
Normal file
125
src/modules/inventory/entities/picking.entity.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
import { Location } from './location.entity.js';
|
||||||
|
import { StockMove } from './stock-move.entity.js';
|
||||||
|
|
||||||
|
export enum PickingType {
|
||||||
|
INCOMING = 'incoming',
|
||||||
|
OUTGOING = 'outgoing',
|
||||||
|
INTERNAL = 'internal',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MoveStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
WAITING = 'waiting',
|
||||||
|
CONFIRMED = 'confirmed',
|
||||||
|
ASSIGNED = 'assigned',
|
||||||
|
DONE = 'done',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'pickings' })
|
||||||
|
@Index('idx_pickings_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_pickings_company_id', ['companyId'])
|
||||||
|
@Index('idx_pickings_status', ['status'])
|
||||||
|
@Index('idx_pickings_partner_id', ['partnerId'])
|
||||||
|
@Index('idx_pickings_scheduled_date', ['scheduledDate'])
|
||||||
|
export class Picking {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: PickingType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'picking_type',
|
||||||
|
})
|
||||||
|
pickingType: PickingType;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'location_id' })
|
||||||
|
locationId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'location_dest_id' })
|
||||||
|
locationDestId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
|
||||||
|
partnerId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'scheduled_date' })
|
||||||
|
scheduledDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'date_done' })
|
||||||
|
dateDone: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
origin: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: MoveStatus,
|
||||||
|
default: MoveStatus.DRAFT,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: MoveStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'validated_at' })
|
||||||
|
validatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'validated_by' })
|
||||||
|
validatedBy: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location)
|
||||||
|
@JoinColumn({ name: 'location_id' })
|
||||||
|
location: Location;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location)
|
||||||
|
@JoinColumn({ name: 'location_dest_id' })
|
||||||
|
locationDest: Location;
|
||||||
|
|
||||||
|
@OneToMany(() => StockMove, (stockMove) => stockMove.picking)
|
||||||
|
moves: StockMove[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
171
src/modules/inventory/entities/product.entity.ts
Normal file
171
src/modules/inventory/entities/product.entity.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { StockQuant } from './stock-quant.entity.js';
|
||||||
|
import { Lot } from './lot.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory Product Entity (schema: inventory.products)
|
||||||
|
*
|
||||||
|
* NOTE: This is NOT a duplicate of products/entities/product.entity.ts
|
||||||
|
*
|
||||||
|
* Key differences:
|
||||||
|
* - This entity: inventory.products - Warehouse/stock management focused (Odoo-style)
|
||||||
|
* - Has: valuationMethod, tracking (lot/serial), isStorable, StockQuant/Lot relations
|
||||||
|
* - Used by: Inventory module for stock tracking, valuation, picking operations
|
||||||
|
*
|
||||||
|
* - Products entity: products.products - Commerce/retail focused
|
||||||
|
* - Has: SAT codes, tax rates, detailed dimensions, min/max stock, reorder points
|
||||||
|
* - Used by: Sales, purchases, invoicing
|
||||||
|
*
|
||||||
|
* These are intentionally separate by domain. A product in the products schema
|
||||||
|
* may reference an inventory product for stock tracking purposes.
|
||||||
|
*/
|
||||||
|
export enum ProductType {
|
||||||
|
STORABLE = 'storable',
|
||||||
|
CONSUMABLE = 'consumable',
|
||||||
|
SERVICE = 'service',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TrackingType {
|
||||||
|
NONE = 'none',
|
||||||
|
LOT = 'lot',
|
||||||
|
SERIAL = 'serial',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ValuationMethod {
|
||||||
|
STANDARD = 'standard',
|
||||||
|
FIFO = 'fifo',
|
||||||
|
AVERAGE = 'average',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'products' })
|
||||||
|
@Index('idx_products_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_products_code', ['code'], { where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_products_barcode', ['barcode'], { where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_products_category_id', ['categoryId'])
|
||||||
|
@Index('idx_products_active', ['active'], { where: 'deleted_at IS NULL' })
|
||||||
|
export class Product {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true, unique: true })
|
||||||
|
code: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
barcode: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ProductType,
|
||||||
|
default: ProductType.STORABLE,
|
||||||
|
nullable: false,
|
||||||
|
name: 'product_type',
|
||||||
|
})
|
||||||
|
productType: ProductType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: TrackingType,
|
||||||
|
default: TrackingType.NONE,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
tracking: TrackingType;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'category_id' })
|
||||||
|
categoryId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'uom_id' })
|
||||||
|
uomId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'purchase_uom_id' })
|
||||||
|
purchaseUomId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0, name: 'cost_price' })
|
||||||
|
costPrice: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0, name: 'list_price' })
|
||||||
|
listPrice: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ValuationMethod,
|
||||||
|
default: ValuationMethod.FIFO,
|
||||||
|
nullable: false,
|
||||||
|
name: 'valuation_method',
|
||||||
|
})
|
||||||
|
valuationMethod: ValuationMethod;
|
||||||
|
|
||||||
|
// Computed field: is_storable is derived from product_type = 'storable'
|
||||||
|
// This should be handled at database level or computed on read
|
||||||
|
@Column({
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
nullable: false,
|
||||||
|
name: 'is_storable',
|
||||||
|
})
|
||||||
|
isStorable: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 4, nullable: true })
|
||||||
|
weight: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 4, nullable: true })
|
||||||
|
volume: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false, name: 'can_be_sold' })
|
||||||
|
canBeSold: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false, name: 'can_be_purchased' })
|
||||||
|
canBePurchased: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true, name: 'image_url' })
|
||||||
|
imageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@OneToMany(() => StockQuant, (stockQuant) => stockQuant.product)
|
||||||
|
stockQuants: StockQuant[];
|
||||||
|
|
||||||
|
@OneToMany(() => Lot, (lot) => lot.product)
|
||||||
|
lots: Lot[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
87
src/modules/inventory/entities/stock-level.entity.ts
Normal file
87
src/modules/inventory/entities/stock-level.entity.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'stock_levels', schema: 'inventory' })
|
||||||
|
export class StockLevel {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'product_id', type: 'uuid' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||||
|
locationId: string;
|
||||||
|
|
||||||
|
// Cantidades
|
||||||
|
@Column({ name: 'quantity_on_hand', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityOnHand: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_reserved', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityReserved: number;
|
||||||
|
|
||||||
|
// quantity_available es calculado en DDL como GENERATED COLUMN, lo leemos aquí
|
||||||
|
@Column({
|
||||||
|
name: 'quantity_available',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 4,
|
||||||
|
insert: false,
|
||||||
|
update: false,
|
||||||
|
})
|
||||||
|
quantityAvailable: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_incoming', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityIncoming: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_outgoing', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityOutgoing: number;
|
||||||
|
|
||||||
|
// Lote y serie
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
lotNumber: string;
|
||||||
|
|
||||||
|
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
serialNumber: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'expiry_date', type: 'date', nullable: true })
|
||||||
|
expiryDate: Date;
|
||||||
|
|
||||||
|
// Costo
|
||||||
|
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
unitCost: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
totalCost: number;
|
||||||
|
|
||||||
|
// Ultima actividad
|
||||||
|
@Column({ name: 'last_movement_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastMovementAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_count_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastCountAt: Date;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
104
src/modules/inventory/entities/stock-move.entity.ts
Normal file
104
src/modules/inventory/entities/stock-move.entity.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Picking, MoveStatus } from './picking.entity.js';
|
||||||
|
import { Product } from './product.entity.js';
|
||||||
|
import { Location } from './location.entity.js';
|
||||||
|
import { Lot } from './lot.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'stock_moves' })
|
||||||
|
@Index('idx_stock_moves_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_stock_moves_picking_id', ['pickingId'])
|
||||||
|
@Index('idx_stock_moves_product_id', ['productId'])
|
||||||
|
@Index('idx_stock_moves_status', ['status'])
|
||||||
|
@Index('idx_stock_moves_date', ['date'])
|
||||||
|
export class StockMove {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'picking_id' })
|
||||||
|
pickingId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'product_id' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'product_uom_id' })
|
||||||
|
productUomId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'location_id' })
|
||||||
|
locationId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'location_dest_id' })
|
||||||
|
locationDestId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, nullable: false, name: 'product_qty' })
|
||||||
|
productQty: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'quantity_done' })
|
||||||
|
quantityDone: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'lot_id' })
|
||||||
|
lotId: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: MoveStatus,
|
||||||
|
default: MoveStatus.DRAFT,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: MoveStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
date: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
origin: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Picking, (picking) => picking.moves, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'picking_id' })
|
||||||
|
picking: Picking;
|
||||||
|
|
||||||
|
@ManyToOne(() => Product)
|
||||||
|
@JoinColumn({ name: 'product_id' })
|
||||||
|
product: Product;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location)
|
||||||
|
@JoinColumn({ name: 'location_id' })
|
||||||
|
location: Location;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location)
|
||||||
|
@JoinColumn({ name: 'location_dest_id' })
|
||||||
|
locationDest: Location;
|
||||||
|
|
||||||
|
@ManyToOne(() => Lot, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'lot_id' })
|
||||||
|
lot: Lot | null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
122
src/modules/inventory/entities/stock-movement.entity.ts
Normal file
122
src/modules/inventory/entities/stock-movement.entity.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'stock_movements', schema: 'inventory' })
|
||||||
|
export class StockMovement {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Tipo de movimiento
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'movement_type', type: 'varchar', length: 20 })
|
||||||
|
movementType:
|
||||||
|
| 'receipt'
|
||||||
|
| 'shipment'
|
||||||
|
| 'transfer'
|
||||||
|
| 'adjustment'
|
||||||
|
| 'return'
|
||||||
|
| 'production'
|
||||||
|
| 'consumption';
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'movement_number', type: 'varchar', length: 30 })
|
||||||
|
movementNumber: string;
|
||||||
|
|
||||||
|
// Producto
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'product_id', type: 'uuid' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
// Origen y destino
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'source_warehouse_id', type: 'uuid', nullable: true })
|
||||||
|
sourceWarehouseId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'source_location_id', type: 'uuid', nullable: true })
|
||||||
|
sourceLocationId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'dest_warehouse_id', type: 'uuid', nullable: true })
|
||||||
|
destWarehouseId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'dest_location_id', type: 'uuid', nullable: true })
|
||||||
|
destLocationId: string;
|
||||||
|
|
||||||
|
// Cantidad
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 4 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'PZA' })
|
||||||
|
uom: string;
|
||||||
|
|
||||||
|
// Lote y serie
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
lotNumber: string;
|
||||||
|
|
||||||
|
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
serialNumber: string;
|
||||||
|
|
||||||
|
@Column({ name: 'expiry_date', type: 'date', nullable: true })
|
||||||
|
expiryDate: Date;
|
||||||
|
|
||||||
|
// Costo
|
||||||
|
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
unitCost: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
totalCost: number;
|
||||||
|
|
||||||
|
// Referencia
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'reference_type', type: 'varchar', length: 30, nullable: true })
|
||||||
|
referenceType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'reference_id', type: 'uuid', nullable: true })
|
||||||
|
referenceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'reference_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
referenceNumber: string;
|
||||||
|
|
||||||
|
// Razon (para ajustes)
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||||
|
status: 'draft' | 'confirmed' | 'cancelled';
|
||||||
|
|
||||||
|
@Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true })
|
||||||
|
confirmedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'confirmed_by', type: 'uuid', nullable: true })
|
||||||
|
confirmedBy: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
66
src/modules/inventory/entities/stock-quant.entity.ts
Normal file
66
src/modules/inventory/entities/stock-quant.entity.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Product } from './product.entity.js';
|
||||||
|
import { Location } from './location.entity.js';
|
||||||
|
import { Lot } from './lot.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'stock_quants' })
|
||||||
|
@Index('idx_stock_quants_product_id', ['productId'])
|
||||||
|
@Index('idx_stock_quants_location_id', ['locationId'])
|
||||||
|
@Index('idx_stock_quants_lot_id', ['lotId'])
|
||||||
|
@Unique('uq_stock_quants_product_location_lot', ['productId', 'locationId', 'lotId'])
|
||||||
|
export class StockQuant {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'product_id' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'location_id' })
|
||||||
|
locationId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'lot_id' })
|
||||||
|
lotId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, default: 0 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'reserved_quantity' })
|
||||||
|
reservedQuantity: number;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Product, (product) => product.stockQuants)
|
||||||
|
@JoinColumn({ name: 'product_id' })
|
||||||
|
product: Product;
|
||||||
|
|
||||||
|
@ManyToOne(() => Location, (location) => location.stockQuants)
|
||||||
|
@JoinColumn({ name: 'location_id' })
|
||||||
|
location: Location;
|
||||||
|
|
||||||
|
@ManyToOne(() => Lot, (lot) => lot.stockQuants, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'lot_id' })
|
||||||
|
lot: Lot | null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Product } from './product.entity.js';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'inventory', name: 'stock_valuation_layers' })
|
||||||
|
@Index('idx_valuation_layers_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_valuation_layers_product_id', ['productId'])
|
||||||
|
@Index('idx_valuation_layers_company_id', ['companyId'])
|
||||||
|
@Index('idx_valuation_layers_stock_move_id', ['stockMoveId'])
|
||||||
|
@Index('idx_valuation_layers_remaining_qty', ['remainingQty'])
|
||||||
|
export class StockValuationLayer {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'product_id' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, nullable: false })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: false, name: 'unit_cost' })
|
||||||
|
unitCost: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 2, nullable: false })
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 4, nullable: false, name: 'remaining_qty' })
|
||||||
|
remainingQty: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 16, scale: 2, nullable: false, name: 'remaining_value' })
|
||||||
|
remainingValue: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'stock_move_id' })
|
||||||
|
stockMoveId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'account_move_id' })
|
||||||
|
accountMoveId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' })
|
||||||
|
journalEntryId: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Product)
|
||||||
|
@JoinColumn({ name: 'product_id' })
|
||||||
|
product: Product;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
50
src/modules/inventory/entities/transfer-order-line.entity.ts
Normal file
50
src/modules/inventory/entities/transfer-order-line.entity.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { TransferOrder } from './transfer-order.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'transfer_order_lines', schema: 'inventory' })
|
||||||
|
export class TransferOrderLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'transfer_id', type: 'uuid' })
|
||||||
|
transferId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => TransferOrder, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'transfer_id' })
|
||||||
|
transfer: TransferOrder;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'product_id', type: 'uuid' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'source_location_id', type: 'uuid', nullable: true })
|
||||||
|
sourceLocationId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'dest_location_id', type: 'uuid', nullable: true })
|
||||||
|
destLocationId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_requested', type: 'decimal', precision: 15, scale: 4 })
|
||||||
|
quantityRequested: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityShipped: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityReceived: number;
|
||||||
|
|
||||||
|
@Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
50
src/modules/inventory/entities/transfer-order.entity.ts
Normal file
50
src/modules/inventory/entities/transfer-order.entity.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'transfer_orders', schema: 'inventory' })
|
||||||
|
export class TransferOrder {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'transfer_number', type: 'varchar', length: 30 })
|
||||||
|
transferNumber: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'source_warehouse_id', type: 'uuid' })
|
||||||
|
sourceWarehouseId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'dest_warehouse_id', type: 'uuid' })
|
||||||
|
destWarehouseId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'scheduled_date', type: 'date', nullable: true })
|
||||||
|
scheduledDate?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'shipped_at', type: 'timestamptz', nullable: true })
|
||||||
|
shippedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'received_at', type: 'timestamptz', nullable: true })
|
||||||
|
receivedAt?: Date;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||||
|
status: 'draft' | 'confirmed' | 'shipped' | 'in_transit' | 'received' | 'cancelled';
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
5
src/modules/inventory/index.ts
Normal file
5
src/modules/inventory/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { InventoryModule, InventoryModuleOptions } from './inventory.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
875
src/modules/inventory/inventory.controller.ts
Normal file
875
src/modules/inventory/inventory.controller.ts
Normal file
@ -0,0 +1,875 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { productsService, CreateProductDto, UpdateProductDto, ProductFilters } from './products.service.js';
|
||||||
|
import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, WarehouseFilters } from './warehouses.service.js';
|
||||||
|
import { locationsService, CreateLocationDto, UpdateLocationDto, LocationFilters } from './locations.service.js';
|
||||||
|
import { pickingsService, CreatePickingDto, PickingFilters } from './pickings.service.js';
|
||||||
|
import { lotsService, CreateLotDto, UpdateLotDto, LotFilters } from './lots.service.js';
|
||||||
|
import { adjustmentsService, CreateAdjustmentDto, UpdateAdjustmentDto, CreateAdjustmentLineDto, UpdateAdjustmentLineDto, AdjustmentFilters } from './adjustments.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Product schemas
|
||||||
|
const createProductSchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
code: z.string().max(100).optional(),
|
||||||
|
barcode: z.string().max(100).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
productType: z.enum(['storable', 'consumable', 'service']).default('storable'),
|
||||||
|
tracking: z.enum(['none', 'lot', 'serial']).default('none'),
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
uomId: z.string().uuid({ message: 'La unidad de medida es requerida' }),
|
||||||
|
purchaseUomId: z.string().uuid().optional(),
|
||||||
|
costPrice: z.number().min(0).default(0),
|
||||||
|
listPrice: z.number().min(0).default(0),
|
||||||
|
valuationMethod: z.enum(['standard', 'fifo', 'average']).default('fifo'),
|
||||||
|
weight: z.number().min(0).optional(),
|
||||||
|
volume: z.number().min(0).optional(),
|
||||||
|
canBeSold: z.boolean().default(true),
|
||||||
|
canBePurchased: z.boolean().default(true),
|
||||||
|
imageUrl: z.string().url().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProductSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
barcode: z.string().max(100).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
tracking: z.enum(['none', 'lot', 'serial']).optional(),
|
||||||
|
categoryId: z.string().uuid().optional().nullable(),
|
||||||
|
uomId: z.string().uuid().optional(),
|
||||||
|
purchaseUomId: z.string().uuid().optional().nullable(),
|
||||||
|
costPrice: z.number().min(0).optional(),
|
||||||
|
listPrice: z.number().min(0).optional(),
|
||||||
|
valuationMethod: z.enum(['standard', 'fifo', 'average']).optional(),
|
||||||
|
weight: z.number().min(0).optional().nullable(),
|
||||||
|
volume: z.number().min(0).optional().nullable(),
|
||||||
|
canBeSold: z.boolean().optional(),
|
||||||
|
canBePurchased: z.boolean().optional(),
|
||||||
|
imageUrl: z.string().url().max(500).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const productQuerySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
productType: z.enum(['storable', 'consumable', 'service']).optional(),
|
||||||
|
canBeSold: z.coerce.boolean().optional(),
|
||||||
|
canBePurchased: z.coerce.boolean().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warehouse schemas
|
||||||
|
const createWarehouseSchema = z.object({
|
||||||
|
companyId: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
addressId: z.string().uuid().optional(),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWarehouseSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
addressId: z.string().uuid().optional().nullable(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const warehouseQuerySchema = z.object({
|
||||||
|
companyId: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Location schemas
|
||||||
|
const createLocationSchema = z.object({
|
||||||
|
warehouse_id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
location_type: z.enum(['internal', 'supplier', 'customer', 'inventory', 'production', 'transit']),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
is_scrap_location: z.boolean().default(false),
|
||||||
|
is_return_location: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLocationSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
is_scrap_location: z.boolean().optional(),
|
||||||
|
is_return_location: z.boolean().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationQuerySchema = z.object({
|
||||||
|
warehouse_id: z.string().uuid().optional(),
|
||||||
|
location_type: z.enum(['internal', 'supplier', 'customer', 'inventory', 'production', 'transit']).optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Picking schemas
|
||||||
|
const stockMoveLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
product_uom_id: z.string().uuid({ message: 'La UdM es requerida' }),
|
||||||
|
product_qty: z.number().positive({ message: 'La cantidad debe ser mayor a 0' }),
|
||||||
|
lot_id: z.string().uuid().optional(),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación origen es requerida' }),
|
||||||
|
location_dest_id: z.string().uuid({ message: 'La ubicación destino es requerida' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPickingSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
picking_type: z.enum(['incoming', 'outgoing', 'internal']),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación origen es requerida' }),
|
||||||
|
location_dest_id: z.string().uuid({ message: 'La ubicación destino es requerida' }),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
scheduled_date: z.string().optional(),
|
||||||
|
origin: z.string().max(255).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
moves: z.array(stockMoveLineSchema).min(1, 'Debe incluir al menos un movimiento'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickingQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
picking_type: z.enum(['incoming', 'outgoing', 'internal']).optional(),
|
||||||
|
status: z.enum(['draft', 'waiting', 'confirmed', 'assigned', 'done', 'cancelled']).optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lot schemas
|
||||||
|
const createLotSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
name: z.string().min(1, 'El nombre del lote es requerido').max(100),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
manufacture_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
expiration_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
removal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
alert_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLotSchema = z.object({
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
manufacture_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
expiration_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
removal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
alert_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lotQuerySchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
expiring_soon: z.coerce.boolean().optional(),
|
||||||
|
expired: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjustment schemas
|
||||||
|
const adjustmentLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación es requerida' }),
|
||||||
|
lot_id: z.string().uuid().optional(),
|
||||||
|
counted_qty: z.number().min(0),
|
||||||
|
uom_id: z.string().uuid({ message: 'La UdM es requerida' }),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAdjustmentSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación es requerida' }),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
lines: z.array(adjustmentLineSchema).min(1, 'Debe incluir al menos una línea'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAdjustmentSchema = z.object({
|
||||||
|
location_id: z.string().uuid().optional(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAdjustmentLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
location_id: z.string().uuid({ message: 'La ubicación es requerida' }),
|
||||||
|
lot_id: z.string().uuid().optional(),
|
||||||
|
counted_qty: z.number().min(0),
|
||||||
|
uom_id: z.string().uuid({ message: 'La UdM es requerida' }),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAdjustmentLineSchema = z.object({
|
||||||
|
counted_qty: z.number().min(0).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjustmentQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
location_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'confirmed', 'done', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class InventoryController {
|
||||||
|
// ========== PRODUCTS ==========
|
||||||
|
async getProducts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = productQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = queryResult.data as ProductFilters;
|
||||||
|
const result = await productsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const product = await productsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: product });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createProductSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de producto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = parseResult.data as CreateProductDto;
|
||||||
|
const product = await productsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: product,
|
||||||
|
message: 'Producto creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateProductSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de producto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = parseResult.data as UpdateProductDto;
|
||||||
|
const product = await productsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: product,
|
||||||
|
message: 'Producto actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await productsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Producto eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stock = await productsService.getStock(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== WAREHOUSES ==========
|
||||||
|
async getWarehouses(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = warehouseQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: WarehouseFilters = queryResult.data;
|
||||||
|
const result = await warehousesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 50)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const warehouse = await warehousesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: warehouse });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createWarehouseSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de almacén inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateWarehouseDto = parseResult.data;
|
||||||
|
const warehouse = await warehousesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: warehouse,
|
||||||
|
message: 'Almacén creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateWarehouseSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de almacén inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateWarehouseDto = parseResult.data;
|
||||||
|
const warehouse = await warehousesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: warehouse,
|
||||||
|
message: 'Almacén actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await warehousesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Almacén eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWarehouseLocations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const locations = await warehousesService.getLocations(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: locations });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWarehouseStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stock = await warehousesService.getStock(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOCATIONS ==========
|
||||||
|
async getLocations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = locationQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LocationFilters = queryResult.data;
|
||||||
|
const result = await locationsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 50)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const location = await locationsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: location });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLocationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ubicación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLocationDto = parseResult.data;
|
||||||
|
const location = await locationsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: location,
|
||||||
|
message: 'Ubicación creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLocationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ubicación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLocationDto = parseResult.data;
|
||||||
|
const location = await locationsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: location,
|
||||||
|
message: 'Ubicación actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocationStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stock = await locationsService.getStock(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PICKINGS ==========
|
||||||
|
async getPickings(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = pickingQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: PickingFilters = queryResult.data;
|
||||||
|
const result = await pickingsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: picking });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPickingSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de picking inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePickingDto = parseResult.data;
|
||||||
|
const picking = await pickingsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking confirmado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validatePicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.validate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking validado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const picking = await pickingsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: picking,
|
||||||
|
message: 'Picking cancelado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pickingsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Picking eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOTS ==========
|
||||||
|
async getLots(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = lotQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LotFilters = queryResult.data;
|
||||||
|
const result = await lotsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 50)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lot = await lotsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: lot });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLotSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lote inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLotDto = parseResult.data;
|
||||||
|
const lot = await lotsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: lot,
|
||||||
|
message: 'Lote creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLotSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lote inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLotDto = parseResult.data;
|
||||||
|
const lot = await lotsService.update(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lot,
|
||||||
|
message: 'Lote actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLotMovements(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const movements = await lotsService.getMovements(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: movements });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await lotsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Lote eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ADJUSTMENTS ==========
|
||||||
|
async getAdjustments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = adjustmentQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: AdjustmentFilters = queryResult.data;
|
||||||
|
const result = await adjustmentsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: adjustment });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createAdjustmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ajuste inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateAdjustmentDto = parseResult.data;
|
||||||
|
const adjustment = await adjustmentsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste de inventario creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateAdjustmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ajuste inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateAdjustmentDto = parseResult.data;
|
||||||
|
const adjustment = await adjustmentsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste de inventario actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createAdjustmentLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateAdjustmentLineDto = parseResult.data;
|
||||||
|
const line = await adjustmentsService.addLine(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea agregada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateAdjustmentLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateAdjustmentLineDto = parseResult.data;
|
||||||
|
const line = await adjustmentsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await adjustmentsService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste confirmado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.validate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste validado exitosamente. Stock actualizado.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const adjustment = await adjustmentsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: adjustment,
|
||||||
|
message: 'Ajuste cancelado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await adjustmentsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Ajuste eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inventoryController = new InventoryController();
|
||||||
45
src/modules/inventory/inventory.module.ts
Normal file
45
src/modules/inventory/inventory.module.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { InventoryService } from './services';
|
||||||
|
import { InventoryController } from './controllers';
|
||||||
|
import { StockLevel, StockMovement } from './entities';
|
||||||
|
|
||||||
|
export interface InventoryModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InventoryModule {
|
||||||
|
public router: Router;
|
||||||
|
public inventoryService: InventoryService;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor(options: InventoryModuleOptions) {
|
||||||
|
this.dataSource = options.dataSource;
|
||||||
|
this.basePath = options.basePath || '';
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeServices();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
const stockLevelRepository = this.dataSource.getRepository(StockLevel);
|
||||||
|
const movementRepository = this.dataSource.getRepository(StockMovement);
|
||||||
|
|
||||||
|
this.inventoryService = new InventoryService(
|
||||||
|
stockLevelRepository,
|
||||||
|
movementRepository,
|
||||||
|
this.dataSource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
const inventoryController = new InventoryController(this.inventoryService);
|
||||||
|
this.router.use(`${this.basePath}/inventory`, inventoryController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getEntities(): Function[] {
|
||||||
|
return [StockLevel, StockMovement];
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/modules/inventory/inventory.routes.ts
Normal file
174
src/modules/inventory/inventory.routes.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { inventoryController } from './inventory.controller.js';
|
||||||
|
import { valuationController } from './valuation.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== PRODUCTS ==========
|
||||||
|
router.get('/products', (req, res, next) => inventoryController.getProducts(req, res, next));
|
||||||
|
|
||||||
|
router.get('/products/:id', (req, res, next) => inventoryController.getProduct(req, res, next));
|
||||||
|
|
||||||
|
router.get('/products/:id/stock', (req, res, next) => inventoryController.getProductStock(req, res, next));
|
||||||
|
|
||||||
|
router.post('/products', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createProduct(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/products/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateProduct(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/products/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteProduct(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== WAREHOUSES ==========
|
||||||
|
router.get('/warehouses', (req, res, next) => inventoryController.getWarehouses(req, res, next));
|
||||||
|
|
||||||
|
router.get('/warehouses/:id', (req, res, next) => inventoryController.getWarehouse(req, res, next));
|
||||||
|
|
||||||
|
router.get('/warehouses/:id/locations', (req, res, next) => inventoryController.getWarehouseLocations(req, res, next));
|
||||||
|
|
||||||
|
router.get('/warehouses/:id/stock', (req, res, next) => inventoryController.getWarehouseStock(req, res, next));
|
||||||
|
|
||||||
|
router.post('/warehouses', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createWarehouse(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/warehouses/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateWarehouse(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/warehouses/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteWarehouse(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LOCATIONS ==========
|
||||||
|
router.get('/locations', (req, res, next) => inventoryController.getLocations(req, res, next));
|
||||||
|
|
||||||
|
router.get('/locations/:id', (req, res, next) => inventoryController.getLocation(req, res, next));
|
||||||
|
|
||||||
|
router.get('/locations/:id/stock', (req, res, next) => inventoryController.getLocationStock(req, res, next));
|
||||||
|
|
||||||
|
router.post('/locations', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createLocation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/locations/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateLocation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PICKINGS ==========
|
||||||
|
router.get('/pickings', (req, res, next) => inventoryController.getPickings(req, res, next));
|
||||||
|
|
||||||
|
router.get('/pickings/:id', (req, res, next) => inventoryController.getPicking(req, res, next));
|
||||||
|
|
||||||
|
router.post('/pickings', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createPicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/pickings/:id/confirm', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.confirmPicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/pickings/:id/validate', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.validatePicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/pickings/:id/cancel', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.cancelPicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/pickings/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deletePicking(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LOTS ==========
|
||||||
|
router.get('/lots', (req, res, next) => inventoryController.getLots(req, res, next));
|
||||||
|
|
||||||
|
router.get('/lots/:id', (req, res, next) => inventoryController.getLot(req, res, next));
|
||||||
|
|
||||||
|
router.get('/lots/:id/movements', (req, res, next) => inventoryController.getLotMovements(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lots', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createLot(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lots/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateLot(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lots/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteLot(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== ADJUSTMENTS ==========
|
||||||
|
router.get('/adjustments', (req, res, next) => inventoryController.getAdjustments(req, res, next));
|
||||||
|
|
||||||
|
router.get('/adjustments/:id', (req, res, next) => inventoryController.getAdjustment(req, res, next));
|
||||||
|
|
||||||
|
router.post('/adjustments', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.createAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/adjustments/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adjustment lines
|
||||||
|
router.post('/adjustments/:id/lines', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.addAdjustmentLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/adjustments/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.updateAdjustmentLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/adjustments/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.removeAdjustmentLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adjustment workflow
|
||||||
|
router.post('/adjustments/:id/confirm', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.confirmAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/adjustments/:id/validate', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.validateAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/adjustments/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.cancelAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/adjustments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
inventoryController.deleteAdjustment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== VALUATION ==========
|
||||||
|
router.get('/valuation/cost', (req, res, next) => valuationController.getProductCost(req, res, next));
|
||||||
|
|
||||||
|
router.get('/valuation/report', (req, res, next) => valuationController.getCompanyReport(req, res, next));
|
||||||
|
|
||||||
|
router.get('/valuation/products/:productId/summary', (req, res, next) =>
|
||||||
|
valuationController.getProductSummary(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/valuation/products/:productId/layers', (req, res, next) =>
|
||||||
|
valuationController.getProductLayers(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/valuation/layers', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
valuationController.createLayer(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/valuation/consume', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
valuationController.consumeFifo(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
212
src/modules/inventory/locations.service.ts
Normal file
212
src/modules/inventory/locations.service.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type LocationType = 'internal' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
warehouse_id?: string;
|
||||||
|
warehouse_name?: string;
|
||||||
|
name: string;
|
||||||
|
complete_name?: string;
|
||||||
|
location_type: LocationType;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
is_scrap_location: boolean;
|
||||||
|
is_return_location: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLocationDto {
|
||||||
|
warehouse_id?: string;
|
||||||
|
name: string;
|
||||||
|
location_type: LocationType;
|
||||||
|
parent_id?: string;
|
||||||
|
is_scrap_location?: boolean;
|
||||||
|
is_return_location?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLocationDto {
|
||||||
|
name?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
is_scrap_location?: boolean;
|
||||||
|
is_return_location?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationFilters {
|
||||||
|
warehouse_id?: string;
|
||||||
|
location_type?: LocationType;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocationsService {
|
||||||
|
async findAll(tenantId: string, filters: LocationFilters = {}): Promise<{ data: Location[]; total: number }> {
|
||||||
|
const { warehouse_id, location_type, active, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (warehouse_id) {
|
||||||
|
whereClause += ` AND l.warehouse_id = $${paramIndex++}`;
|
||||||
|
params.push(warehouse_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location_type) {
|
||||||
|
whereClause += ` AND l.location_type = $${paramIndex++}`;
|
||||||
|
params.push(location_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND l.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.locations l ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Location>(
|
||||||
|
`SELECT l.*,
|
||||||
|
w.name as warehouse_name,
|
||||||
|
lp.name as parent_name
|
||||||
|
FROM inventory.locations l
|
||||||
|
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
LEFT JOIN inventory.locations lp ON l.parent_id = lp.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.complete_name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Location> {
|
||||||
|
const location = await queryOne<Location>(
|
||||||
|
`SELECT l.*,
|
||||||
|
w.name as warehouse_name,
|
||||||
|
lp.name as parent_name
|
||||||
|
FROM inventory.locations l
|
||||||
|
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
LEFT JOIN inventory.locations lp ON l.parent_id = lp.id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
throw new NotFoundError('Ubicación no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLocationDto, tenantId: string, userId: string): Promise<Location> {
|
||||||
|
// Validate parent location if specified
|
||||||
|
if (dto.parent_id) {
|
||||||
|
const parent = await queryOne<Location>(
|
||||||
|
`SELECT id FROM inventory.locations WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[dto.parent_id, tenantId]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Ubicación padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await queryOne<Location>(
|
||||||
|
`INSERT INTO inventory.locations (tenant_id, warehouse_id, name, location_type, parent_id, is_scrap_location, is_return_location, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.warehouse_id,
|
||||||
|
dto.name,
|
||||||
|
dto.location_type,
|
||||||
|
dto.parent_id,
|
||||||
|
dto.is_scrap_location || false,
|
||||||
|
dto.is_return_location || false,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return location!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLocationDto, tenantId: string, userId: string): Promise<Location> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference)
|
||||||
|
if (dto.parent_id) {
|
||||||
|
if (dto.parent_id === id) {
|
||||||
|
throw new ConflictError('Una ubicación no puede ser su propia ubicación padre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.parent_id !== undefined) {
|
||||||
|
updateFields.push(`parent_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_id);
|
||||||
|
}
|
||||||
|
if (dto.is_scrap_location !== undefined) {
|
||||||
|
updateFields.push(`is_scrap_location = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_scrap_location);
|
||||||
|
}
|
||||||
|
if (dto.is_return_location !== undefined) {
|
||||||
|
updateFields.push(`is_return_location = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_return_location);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const location = await queryOne<Location>(
|
||||||
|
`UPDATE inventory.locations SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return location!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStock(locationId: string, tenantId: string): Promise<any[]> {
|
||||||
|
await this.findById(locationId, tenantId);
|
||||||
|
|
||||||
|
return query(
|
||||||
|
`SELECT sq.*, p.name as product_name, p.code as product_code, u.name as uom_name
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
INNER JOIN inventory.products p ON sq.product_id = p.id
|
||||||
|
LEFT JOIN core.uom u ON p.uom_id = u.id
|
||||||
|
WHERE sq.location_id = $1 AND sq.quantity > 0
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[locationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locationsService = new LocationsService();
|
||||||
263
src/modules/inventory/lots.service.ts
Normal file
263
src/modules/inventory/lots.service.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Lot {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
manufacture_date?: Date;
|
||||||
|
expiration_date?: Date;
|
||||||
|
removal_date?: Date;
|
||||||
|
alert_date?: Date;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
quantity_on_hand?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLotDto {
|
||||||
|
product_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
manufacture_date?: string;
|
||||||
|
expiration_date?: string;
|
||||||
|
removal_date?: string;
|
||||||
|
alert_date?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLotDto {
|
||||||
|
ref?: string | null;
|
||||||
|
manufacture_date?: string | null;
|
||||||
|
expiration_date?: string | null;
|
||||||
|
removal_date?: string | null;
|
||||||
|
alert_date?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LotFilters {
|
||||||
|
product_id?: string;
|
||||||
|
expiring_soon?: boolean;
|
||||||
|
expired?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LotMovement {
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
origin: string;
|
||||||
|
location_from: string;
|
||||||
|
location_to: string;
|
||||||
|
quantity: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LotsService {
|
||||||
|
async findAll(tenantId: string, filters: LotFilters = {}): Promise<{ data: Lot[]; total: number }> {
|
||||||
|
const { product_id, expiring_soon, expired, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (product_id) {
|
||||||
|
whereClause += ` AND l.product_id = $${paramIndex++}`;
|
||||||
|
params.push(product_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiring_soon) {
|
||||||
|
whereClause += ` AND l.expiration_date IS NOT NULL AND l.expiration_date <= CURRENT_DATE + INTERVAL '30 days' AND l.expiration_date > CURRENT_DATE`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
whereClause += ` AND l.expiration_date IS NOT NULL AND l.expiration_date < CURRENT_DATE`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM inventory.lots l
|
||||||
|
LEFT JOIN inventory.products p ON l.product_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Lot>(
|
||||||
|
`SELECT l.*,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
COALESCE(sq.total_qty, 0) as quantity_on_hand
|
||||||
|
FROM inventory.lots l
|
||||||
|
LEFT JOIN inventory.products p ON l.product_id = p.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT lot_id, SUM(quantity) as total_qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
GROUP BY lot_id
|
||||||
|
) sq ON l.id = sq.lot_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.expiration_date ASC NULLS LAST, l.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Lot> {
|
||||||
|
const lot = await queryOne<Lot>(
|
||||||
|
`SELECT l.*,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
COALESCE(sq.total_qty, 0) as quantity_on_hand
|
||||||
|
FROM inventory.lots l
|
||||||
|
LEFT JOIN inventory.products p ON l.product_id = p.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT lot_id, SUM(quantity) as total_qty
|
||||||
|
FROM inventory.stock_quants
|
||||||
|
GROUP BY lot_id
|
||||||
|
) sq ON l.id = sq.lot_id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lot) {
|
||||||
|
throw new NotFoundError('Lote no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLotDto, tenantId: string, userId: string): Promise<Lot> {
|
||||||
|
// Check for unique lot name for product
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM inventory.lots WHERE product_id = $1 AND name = $2`,
|
||||||
|
[dto.product_id, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un lote con ese nombre para este producto');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lot = await queryOne<Lot>(
|
||||||
|
`INSERT INTO inventory.lots (
|
||||||
|
tenant_id, product_id, name, ref, manufacture_date, expiration_date,
|
||||||
|
removal_date, alert_date, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.product_id, dto.name, dto.ref, dto.manufacture_date,
|
||||||
|
dto.expiration_date, dto.removal_date, dto.alert_date, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(lot!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLotDto, tenantId: string): Promise<Lot> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.manufacture_date !== undefined) {
|
||||||
|
updateFields.push(`manufacture_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.manufacture_date);
|
||||||
|
}
|
||||||
|
if (dto.expiration_date !== undefined) {
|
||||||
|
updateFields.push(`expiration_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.expiration_date);
|
||||||
|
}
|
||||||
|
if (dto.removal_date !== undefined) {
|
||||||
|
updateFields.push(`removal_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.removal_date);
|
||||||
|
}
|
||||||
|
if (dto.alert_date !== undefined) {
|
||||||
|
updateFields.push(`alert_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.alert_date);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.lots SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMovements(id: string, tenantId: string): Promise<LotMovement[]> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const movements = await query<LotMovement>(
|
||||||
|
`SELECT sm.id,
|
||||||
|
sm.date,
|
||||||
|
sm.origin,
|
||||||
|
lo.name as location_from,
|
||||||
|
ld.name as location_to,
|
||||||
|
sm.quantity_done as quantity,
|
||||||
|
sm.status
|
||||||
|
FROM inventory.stock_moves sm
|
||||||
|
LEFT JOIN inventory.locations lo ON sm.location_id = lo.id
|
||||||
|
LEFT JOIN inventory.locations ld ON sm.location_dest_id = ld.id
|
||||||
|
WHERE sm.lot_id = $1 AND sm.status = 'done'
|
||||||
|
ORDER BY sm.date DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return movements;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const lot = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if lot has stock
|
||||||
|
if (lot.quantity_on_hand && lot.quantity_on_hand > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un lote con stock');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if lot is used in moves
|
||||||
|
const movesCheck = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.stock_moves WHERE lot_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(movesCheck?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar: el lote tiene movimientos asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.lots WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lotsService = new LotsService();
|
||||||
607
src/modules/inventory/pickings.service.ts
Normal file
607
src/modules/inventory/pickings.service.ts
Normal file
@ -0,0 +1,607 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { stockReservationService, ReservationLine } from './stock-reservation.service.js';
|
||||||
|
import { valuationService } from './valuation.service.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export type PickingType = 'incoming' | 'outgoing' | 'internal';
|
||||||
|
export type MoveStatus = 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface StockMoveLine {
|
||||||
|
id?: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_code?: string;
|
||||||
|
product_uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
product_qty: number;
|
||||||
|
quantity_done?: number;
|
||||||
|
lot_id?: string;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
location_dest_name?: string;
|
||||||
|
status?: MoveStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Picking {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
picking_type: PickingType;
|
||||||
|
location_id: string;
|
||||||
|
location_name?: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
location_dest_name?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
partner_name?: string;
|
||||||
|
scheduled_date?: Date;
|
||||||
|
date_done?: Date;
|
||||||
|
origin?: string;
|
||||||
|
status: MoveStatus;
|
||||||
|
notes?: string;
|
||||||
|
moves?: StockMoveLine[];
|
||||||
|
created_at: Date;
|
||||||
|
validated_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePickingDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
picking_type: PickingType;
|
||||||
|
location_id: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
partner_id?: string;
|
||||||
|
scheduled_date?: string;
|
||||||
|
origin?: string;
|
||||||
|
notes?: string;
|
||||||
|
moves: Omit<StockMoveLine, 'id' | 'product_name' | 'product_code' | 'uom_name' | 'location_name' | 'location_dest_name' | 'quantity_done' | 'status'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePickingDto {
|
||||||
|
partner_id?: string | null;
|
||||||
|
scheduled_date?: string | null;
|
||||||
|
origin?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
moves?: Omit<StockMoveLine, 'id' | 'product_name' | 'product_code' | 'uom_name' | 'location_name' | 'location_dest_name' | 'quantity_done' | 'status'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PickingFilters {
|
||||||
|
company_id?: string;
|
||||||
|
picking_type?: PickingType;
|
||||||
|
status?: MoveStatus;
|
||||||
|
partner_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PickingsService {
|
||||||
|
async findAll(tenantId: string, filters: PickingFilters = {}): Promise<{ data: Picking[]; total: number }> {
|
||||||
|
const { company_id, picking_type, status, partner_id, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picking_type) {
|
||||||
|
whereClause += ` AND p.picking_type = $${paramIndex++}`;
|
||||||
|
params.push(picking_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND p.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND p.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND p.scheduled_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND p.scheduled_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.origin ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM inventory.pickings p ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Picking>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name,
|
||||||
|
ld.name as location_dest_name,
|
||||||
|
pa.name as partner_name
|
||||||
|
FROM inventory.pickings p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON p.location_id = l.id
|
||||||
|
LEFT JOIN inventory.locations ld ON p.location_dest_id = ld.id
|
||||||
|
LEFT JOIN core.partners pa ON p.partner_id = pa.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.scheduled_date DESC NULLS LAST, p.name DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Picking> {
|
||||||
|
const picking = await queryOne<Picking>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
l.name as location_name,
|
||||||
|
ld.name as location_dest_name,
|
||||||
|
pa.name as partner_name
|
||||||
|
FROM inventory.pickings p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN inventory.locations l ON p.location_id = l.id
|
||||||
|
LEFT JOIN inventory.locations ld ON p.location_dest_id = ld.id
|
||||||
|
LEFT JOIN core.partners pa ON p.partner_id = pa.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!picking) {
|
||||||
|
throw new NotFoundError('Picking no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get moves
|
||||||
|
const moves = await query<StockMoveLine>(
|
||||||
|
`SELECT sm.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
pr.code as product_code,
|
||||||
|
u.name as uom_name,
|
||||||
|
l.name as location_name,
|
||||||
|
ld.name as location_dest_name
|
||||||
|
FROM inventory.stock_moves sm
|
||||||
|
LEFT JOIN inventory.products pr ON sm.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom u ON sm.product_uom_id = u.id
|
||||||
|
LEFT JOIN inventory.locations l ON sm.location_id = l.id
|
||||||
|
LEFT JOIN inventory.locations ld ON sm.location_dest_id = ld.id
|
||||||
|
WHERE sm.picking_id = $1
|
||||||
|
ORDER BY sm.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
picking.moves = moves;
|
||||||
|
|
||||||
|
return picking;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePickingDto, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
if (dto.moves.length === 0) {
|
||||||
|
throw new ValidationError('El picking debe tener al menos un movimiento');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Create picking
|
||||||
|
const pickingResult = await client.query(
|
||||||
|
`INSERT INTO inventory.pickings (tenant_id, company_id, name, picking_type, location_id, location_dest_id, partner_id, scheduled_date, origin, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.picking_type, dto.location_id, dto.location_dest_id, dto.partner_id, dto.scheduled_date, dto.origin, dto.notes, userId]
|
||||||
|
);
|
||||||
|
const picking = pickingResult.rows[0] as Picking;
|
||||||
|
|
||||||
|
// Create moves
|
||||||
|
for (const move of dto.moves) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_moves (tenant_id, picking_id, product_id, product_uom_id, location_id, location_dest_id, product_qty, lot_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[tenantId, picking.id, move.product_id, move.product_uom_id, move.location_id, move.location_dest_id, move.product_qty, move.lot_id, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(picking.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden confirmar pickings en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.pickings SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.stock_moves SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE picking_id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(id: string, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status === 'done') {
|
||||||
|
throw new ConflictError('El picking ya está validado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picking.status === 'cancelled') {
|
||||||
|
throw new ConflictError('No se puede validar un picking cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK-006-05: Validate lots for tracked products
|
||||||
|
if (picking.moves && picking.moves.length > 0) {
|
||||||
|
for (const move of picking.moves) {
|
||||||
|
// Check if product requires lot tracking
|
||||||
|
const productResult = await queryOne<{ tracking: string; name: string }>(
|
||||||
|
`SELECT tracking, name FROM inventory.products WHERE id = $1`,
|
||||||
|
[move.product_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productResult && productResult.tracking !== 'none' && !move.lot_id) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`El producto "${productResult.name || move.product_name}" requiere número de lote/serie para ser movido`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Release reserved stock before moving (for outgoing pickings)
|
||||||
|
if (picking.picking_type === 'outgoing' && picking.moves) {
|
||||||
|
const releaseLines: ReservationLine[] = picking.moves.map(move => ({
|
||||||
|
productId: move.product_id,
|
||||||
|
locationId: move.location_id,
|
||||||
|
quantity: move.product_qty,
|
||||||
|
lotId: move.lot_id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await stockReservationService.releaseWithClient(
|
||||||
|
client,
|
||||||
|
releaseLines,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stock quants for each move
|
||||||
|
for (const move of picking.moves || []) {
|
||||||
|
const qty = move.product_qty;
|
||||||
|
|
||||||
|
// Decrease from source location
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_quants (product_id, location_id, quantity, tenant_id)
|
||||||
|
VALUES ($1, $2, -$3, $4)
|
||||||
|
ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'))
|
||||||
|
DO UPDATE SET quantity = stock_quants.quantity - $3, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[move.product_id, move.location_id, qty, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Increase in destination location
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_quants (product_id, location_id, quantity, tenant_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'))
|
||||||
|
DO UPDATE SET quantity = stock_quants.quantity + $3, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[move.product_id, move.location_dest_id, qty, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update move
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_moves
|
||||||
|
SET quantity_done = $1, status = 'done', date = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP, updated_by = $2
|
||||||
|
WHERE id = $3`,
|
||||||
|
[qty, userId, move.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TASK-006-01/02: Process stock valuation for the move
|
||||||
|
// Get location types to determine if it's incoming or outgoing
|
||||||
|
const [srcLoc, destLoc] = await Promise.all([
|
||||||
|
client.query('SELECT location_type FROM inventory.locations WHERE id = $1', [move.location_id]),
|
||||||
|
client.query('SELECT location_type FROM inventory.locations WHERE id = $1', [move.location_dest_id]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const srcIsInternal = srcLoc.rows[0]?.location_type === 'internal';
|
||||||
|
const destIsInternal = destLoc.rows[0]?.location_type === 'internal';
|
||||||
|
|
||||||
|
// Get product cost info for valuation
|
||||||
|
const productInfo = await client.query(
|
||||||
|
`SELECT cost_price, valuation_method FROM inventory.products WHERE id = $1`,
|
||||||
|
[move.product_id]
|
||||||
|
);
|
||||||
|
const product = productInfo.rows[0];
|
||||||
|
|
||||||
|
if (product && product.valuation_method !== 'standard') {
|
||||||
|
// Incoming to internal location (create valuation layer)
|
||||||
|
if (!srcIsInternal && destIsInternal) {
|
||||||
|
try {
|
||||||
|
await valuationService.createLayer(
|
||||||
|
{
|
||||||
|
product_id: move.product_id,
|
||||||
|
company_id: picking.company_id,
|
||||||
|
quantity: qty,
|
||||||
|
unit_cost: Number(product.cost_price) || 0,
|
||||||
|
stock_move_id: move.id,
|
||||||
|
description: `Recepción - ${picking.name}`,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
logger.debug('Valuation layer created for incoming move', {
|
||||||
|
pickingId: id,
|
||||||
|
moveId: move.id,
|
||||||
|
productId: move.product_id,
|
||||||
|
quantity: qty,
|
||||||
|
});
|
||||||
|
} catch (valErr) {
|
||||||
|
logger.warn('Failed to create valuation layer', {
|
||||||
|
moveId: move.id,
|
||||||
|
error: (valErr as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outgoing from internal location (consume valuation layers with FIFO)
|
||||||
|
if (srcIsInternal && !destIsInternal) {
|
||||||
|
try {
|
||||||
|
const consumeResult = await valuationService.consumeFifo(
|
||||||
|
move.product_id,
|
||||||
|
picking.company_id,
|
||||||
|
qty,
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
logger.debug('Valuation layers consumed for outgoing move', {
|
||||||
|
pickingId: id,
|
||||||
|
moveId: move.id,
|
||||||
|
productId: move.product_id,
|
||||||
|
quantity: qty,
|
||||||
|
totalCost: consumeResult.total_cost,
|
||||||
|
layersConsumed: consumeResult.layers_consumed.length,
|
||||||
|
});
|
||||||
|
} catch (valErr) {
|
||||||
|
logger.warn('Failed to consume valuation layers', {
|
||||||
|
moveId: move.id,
|
||||||
|
error: (valErr as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update average cost if using that method
|
||||||
|
if (product.valuation_method === 'average') {
|
||||||
|
await valuationService.updateProductAverageCost(
|
||||||
|
move.product_id,
|
||||||
|
picking.company_id,
|
||||||
|
tenantId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update picking
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.pickings
|
||||||
|
SET status = 'done', date_done = CURRENT_TIMESTAMP, validated_at = CURRENT_TIMESTAMP, validated_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TASK-003-07: Update sales order delivery_status if this is a sales order picking
|
||||||
|
if (picking.origin && picking.picking_type === 'outgoing') {
|
||||||
|
// Check if this picking is from a sales order (origin starts with 'SO-')
|
||||||
|
const orderResult = await client.query(
|
||||||
|
`SELECT so.id, so.name
|
||||||
|
FROM sales.sales_orders so
|
||||||
|
WHERE so.picking_id = $1 AND so.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderResult.rows.length > 0) {
|
||||||
|
const orderId = orderResult.rows[0].id;
|
||||||
|
const orderName = orderResult.rows[0].name;
|
||||||
|
|
||||||
|
// Update qty_delivered on order lines based on moves
|
||||||
|
for (const move of picking.moves || []) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_order_lines
|
||||||
|
SET qty_delivered = qty_delivered + $1
|
||||||
|
WHERE order_id = $2 AND product_id = $3`,
|
||||||
|
[move.product_qty, orderId, move.product_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new delivery_status based on delivered quantities
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
delivery_status = CASE
|
||||||
|
WHEN (SELECT SUM(qty_delivered) FROM sales.sales_order_lines WHERE order_id = $1) >=
|
||||||
|
(SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1)
|
||||||
|
THEN 'delivered'::varchar
|
||||||
|
WHEN (SELECT SUM(qty_delivered) FROM sales.sales_order_lines WHERE order_id = $1) > 0
|
||||||
|
THEN 'partial'::varchar
|
||||||
|
ELSE 'pending'::varchar
|
||||||
|
END,
|
||||||
|
status = CASE
|
||||||
|
WHEN (SELECT SUM(qty_delivered) FROM sales.sales_order_lines WHERE order_id = $1) >=
|
||||||
|
(SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1)
|
||||||
|
THEN 'sale'::varchar
|
||||||
|
ELSE status
|
||||||
|
END,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1`,
|
||||||
|
[orderId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Sales order delivery status updated', {
|
||||||
|
pickingId: id,
|
||||||
|
orderId,
|
||||||
|
orderName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK-004-04: Update purchase order receipt_status if this is a purchase order picking
|
||||||
|
if (picking.origin && picking.picking_type === 'incoming') {
|
||||||
|
// Check if this picking is from a purchase order
|
||||||
|
const poResult = await client.query(
|
||||||
|
`SELECT po.id, po.name
|
||||||
|
FROM purchase.purchase_orders po
|
||||||
|
WHERE po.picking_id = $1 AND po.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (poResult.rows.length > 0) {
|
||||||
|
const poId = poResult.rows[0].id;
|
||||||
|
const poName = poResult.rows[0].name;
|
||||||
|
|
||||||
|
// Update qty_received on order lines based on moves
|
||||||
|
for (const move of picking.moves || []) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE purchase.purchase_order_lines
|
||||||
|
SET qty_received = COALESCE(qty_received, 0) + $1
|
||||||
|
WHERE order_id = $2 AND product_id = $3`,
|
||||||
|
[move.product_qty, poId, move.product_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new receipt_status based on received quantities
|
||||||
|
await client.query(
|
||||||
|
`UPDATE purchase.purchase_orders SET
|
||||||
|
receipt_status = CASE
|
||||||
|
WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) >=
|
||||||
|
(SELECT COALESCE(SUM(quantity), 0) FROM purchase.purchase_order_lines WHERE order_id = $1)
|
||||||
|
THEN 'received'
|
||||||
|
WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) > 0
|
||||||
|
THEN 'partial'
|
||||||
|
ELSE 'pending'
|
||||||
|
END,
|
||||||
|
status = CASE
|
||||||
|
WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) >=
|
||||||
|
(SELECT COALESCE(SUM(quantity), 0) FROM purchase.purchase_order_lines WHERE order_id = $1)
|
||||||
|
THEN 'done'
|
||||||
|
ELSE status
|
||||||
|
END,
|
||||||
|
effective_date = CASE
|
||||||
|
WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) >=
|
||||||
|
(SELECT COALESCE(SUM(quantity), 0) FROM purchase.purchase_order_lines WHERE order_id = $1)
|
||||||
|
THEN CURRENT_DATE
|
||||||
|
ELSE effective_date
|
||||||
|
END,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1`,
|
||||||
|
[poId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Purchase order receipt status updated', {
|
||||||
|
pickingId: id,
|
||||||
|
purchaseOrderId: poId,
|
||||||
|
purchaseOrderName: poName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info('Picking validated', {
|
||||||
|
pickingId: id,
|
||||||
|
pickingName: picking.name,
|
||||||
|
movesCount: picking.moves?.length || 0,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('Error validating picking', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
pickingId: id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Picking> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status === 'done') {
|
||||||
|
throw new ConflictError('No se puede cancelar un picking ya validado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picking.status === 'cancelled') {
|
||||||
|
throw new ConflictError('El picking ya está cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.pickings SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE inventory.stock_moves SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE picking_id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const picking = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (picking.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden eliminar pickings en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM inventory.pickings WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pickingsService = new PickingsService();
|
||||||
410
src/modules/inventory/products.service.ts
Normal file
410
src/modules/inventory/products.service.ts
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
import { Repository, IsNull, ILike } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Product, ProductType, TrackingType, ValuationMethod } from './entities/product.entity.js';
|
||||||
|
import { StockQuant } from './entities/stock-quant.entity.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ===== Interfaces =====
|
||||||
|
|
||||||
|
export interface CreateProductDto {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
barcode?: string;
|
||||||
|
description?: string;
|
||||||
|
productType?: ProductType;
|
||||||
|
tracking?: TrackingType;
|
||||||
|
categoryId?: string;
|
||||||
|
uomId: string;
|
||||||
|
purchaseUomId?: string;
|
||||||
|
costPrice?: number;
|
||||||
|
listPrice?: number;
|
||||||
|
valuationMethod?: ValuationMethod;
|
||||||
|
weight?: number;
|
||||||
|
volume?: number;
|
||||||
|
canBeSold?: boolean;
|
||||||
|
canBePurchased?: boolean;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductDto {
|
||||||
|
name?: string;
|
||||||
|
barcode?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
tracking?: TrackingType;
|
||||||
|
categoryId?: string | null;
|
||||||
|
uomId?: string;
|
||||||
|
purchaseUomId?: string | null;
|
||||||
|
costPrice?: number;
|
||||||
|
listPrice?: number;
|
||||||
|
valuationMethod?: ValuationMethod;
|
||||||
|
weight?: number | null;
|
||||||
|
volume?: number | null;
|
||||||
|
canBeSold?: boolean;
|
||||||
|
canBePurchased?: boolean;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductFilters {
|
||||||
|
search?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
productType?: ProductType;
|
||||||
|
canBeSold?: boolean;
|
||||||
|
canBePurchased?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductWithRelations extends Product {
|
||||||
|
categoryName?: string;
|
||||||
|
uomName?: string;
|
||||||
|
purchaseUomName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Service Class =====
|
||||||
|
|
||||||
|
class ProductsService {
|
||||||
|
private productRepository: Repository<Product>;
|
||||||
|
private stockQuantRepository: Repository<StockQuant>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.productRepository = AppDataSource.getRepository(Product);
|
||||||
|
this.stockQuantRepository = AppDataSource.getRepository(StockQuant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all products with filters and pagination
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: ProductFilters = {}
|
||||||
|
): Promise<{ data: ProductWithRelations[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const { search, categoryId, productType, canBeSold, canBePurchased, active, page = 1, limit = 20 } = filters;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const queryBuilder = this.productRepository
|
||||||
|
.createQueryBuilder('product')
|
||||||
|
.where('product.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('product.deletedAt IS NULL');
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (search) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(product.name ILIKE :search OR product.code ILIKE :search OR product.barcode ILIKE :search)',
|
||||||
|
{ search: `%${search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (categoryId) {
|
||||||
|
queryBuilder.andWhere('product.categoryId = :categoryId', { categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by product type
|
||||||
|
if (productType) {
|
||||||
|
queryBuilder.andWhere('product.productType = :productType', { productType });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by can be sold
|
||||||
|
if (canBeSold !== undefined) {
|
||||||
|
queryBuilder.andWhere('product.canBeSold = :canBeSold', { canBeSold });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by can be purchased
|
||||||
|
if (canBePurchased !== undefined) {
|
||||||
|
queryBuilder.andWhere('product.canBePurchased = :canBePurchased', { canBePurchased });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by active status
|
||||||
|
if (active !== undefined) {
|
||||||
|
queryBuilder.andWhere('product.active = :active', { active });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await queryBuilder.getCount();
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
const products = await queryBuilder
|
||||||
|
.orderBy('product.name', 'ASC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Note: categoryName, uomName, purchaseUomName would need joins to core schema tables
|
||||||
|
// For now, we return the products as-is. If needed, these can be fetched with raw queries.
|
||||||
|
const data: ProductWithRelations[] = products;
|
||||||
|
|
||||||
|
logger.debug('Products retrieved', { tenantId, count: data.length, total });
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving products', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string, tenantId: string): Promise<ProductWithRelations> {
|
||||||
|
try {
|
||||||
|
const product = await this.productRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundError('Producto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return product;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error finding product', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string, tenantId: string): Promise<Product | null> {
|
||||||
|
return this.productRepository.findOne({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new product
|
||||||
|
*/
|
||||||
|
async create(dto: CreateProductDto, tenantId: string, userId: string): Promise<Product> {
|
||||||
|
try {
|
||||||
|
// Check unique code
|
||||||
|
if (dto.code) {
|
||||||
|
const existing = await this.findByCode(dto.code, tenantId);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe un producto con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check unique barcode
|
||||||
|
if (dto.barcode) {
|
||||||
|
const existingBarcode = await this.productRepository.findOne({
|
||||||
|
where: {
|
||||||
|
barcode: dto.barcode,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existingBarcode) {
|
||||||
|
throw new ConflictError(`Ya existe un producto con código de barras ${dto.barcode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
const product = this.productRepository.create({
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code || null,
|
||||||
|
barcode: dto.barcode || null,
|
||||||
|
description: dto.description || null,
|
||||||
|
productType: dto.productType || ProductType.STORABLE,
|
||||||
|
tracking: dto.tracking || TrackingType.NONE,
|
||||||
|
categoryId: dto.categoryId || null,
|
||||||
|
uomId: dto.uomId,
|
||||||
|
purchaseUomId: dto.purchaseUomId || null,
|
||||||
|
costPrice: dto.costPrice || 0,
|
||||||
|
listPrice: dto.listPrice || 0,
|
||||||
|
valuationMethod: dto.valuationMethod || ValuationMethod.FIFO,
|
||||||
|
weight: dto.weight || null,
|
||||||
|
volume: dto.volume || null,
|
||||||
|
canBeSold: dto.canBeSold !== false,
|
||||||
|
canBePurchased: dto.canBePurchased !== false,
|
||||||
|
imageUrl: dto.imageUrl || null,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.productRepository.save(product);
|
||||||
|
|
||||||
|
logger.info('Product created', {
|
||||||
|
productId: product.id,
|
||||||
|
tenantId,
|
||||||
|
name: product.name,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return product;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating product', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
dto,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a product
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateProductDto, tenantId: string, userId: string): Promise<Product> {
|
||||||
|
try {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check unique barcode if changing
|
||||||
|
if (dto.barcode !== undefined && dto.barcode !== existing.barcode) {
|
||||||
|
if (dto.barcode) {
|
||||||
|
const duplicate = await this.productRepository.findOne({
|
||||||
|
where: {
|
||||||
|
barcode: dto.barcode,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicate && duplicate.id !== id) {
|
||||||
|
throw new ConflictError(`Ya existe un producto con código de barras ${dto.barcode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update allowed fields
|
||||||
|
if (dto.name !== undefined) existing.name = dto.name;
|
||||||
|
if (dto.barcode !== undefined) existing.barcode = dto.barcode;
|
||||||
|
if (dto.description !== undefined) existing.description = dto.description;
|
||||||
|
if (dto.tracking !== undefined) existing.tracking = dto.tracking;
|
||||||
|
if (dto.categoryId !== undefined) existing.categoryId = dto.categoryId;
|
||||||
|
if (dto.uomId !== undefined) existing.uomId = dto.uomId;
|
||||||
|
if (dto.purchaseUomId !== undefined) existing.purchaseUomId = dto.purchaseUomId;
|
||||||
|
if (dto.costPrice !== undefined) existing.costPrice = dto.costPrice;
|
||||||
|
if (dto.listPrice !== undefined) existing.listPrice = dto.listPrice;
|
||||||
|
if (dto.valuationMethod !== undefined) existing.valuationMethod = dto.valuationMethod;
|
||||||
|
if (dto.weight !== undefined) existing.weight = dto.weight;
|
||||||
|
if (dto.volume !== undefined) existing.volume = dto.volume;
|
||||||
|
if (dto.canBeSold !== undefined) existing.canBeSold = dto.canBeSold;
|
||||||
|
if (dto.canBePurchased !== undefined) existing.canBePurchased = dto.canBePurchased;
|
||||||
|
if (dto.imageUrl !== undefined) existing.imageUrl = dto.imageUrl;
|
||||||
|
if (dto.active !== undefined) existing.active = dto.active;
|
||||||
|
|
||||||
|
existing.updatedBy = userId;
|
||||||
|
existing.updatedAt = new Date();
|
||||||
|
|
||||||
|
await this.productRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Product updated', {
|
||||||
|
productId: id,
|
||||||
|
tenantId,
|
||||||
|
updatedBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating product', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete a product
|
||||||
|
*/
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if product has stock
|
||||||
|
const stockQuantCount = await this.stockQuantRepository
|
||||||
|
.createQueryBuilder('sq')
|
||||||
|
.where('sq.productId = :productId', { productId: id })
|
||||||
|
.andWhere('sq.quantity > 0')
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
if (stockQuantCount > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un producto que tiene stock');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await this.productRepository.update(
|
||||||
|
{ id, tenantId },
|
||||||
|
{
|
||||||
|
deletedAt: new Date(),
|
||||||
|
deletedBy: userId,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Product deleted', {
|
||||||
|
productId: id,
|
||||||
|
tenantId,
|
||||||
|
deletedBy: userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting product', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stock for a product
|
||||||
|
*/
|
||||||
|
async getStock(productId: string, tenantId: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
await this.findById(productId, tenantId);
|
||||||
|
|
||||||
|
const stock = await this.stockQuantRepository
|
||||||
|
.createQueryBuilder('sq')
|
||||||
|
.leftJoinAndSelect('sq.location', 'location')
|
||||||
|
.leftJoinAndSelect('location.warehouse', 'warehouse')
|
||||||
|
.where('sq.productId = :productId', { productId })
|
||||||
|
.orderBy('warehouse.name', 'ASC')
|
||||||
|
.addOrderBy('location.name', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Map to include relation names
|
||||||
|
return stock.map((sq) => ({
|
||||||
|
id: sq.id,
|
||||||
|
productId: sq.productId,
|
||||||
|
locationId: sq.locationId,
|
||||||
|
locationName: sq.location?.name,
|
||||||
|
warehouseName: sq.location?.warehouse?.name,
|
||||||
|
lotId: sq.lotId,
|
||||||
|
quantity: sq.quantity,
|
||||||
|
reservedQuantity: sq.reservedQuantity,
|
||||||
|
createdAt: sq.createdAt,
|
||||||
|
updatedAt: sq.updatedAt,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting product stock', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
productId,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Export Singleton Instance =====
|
||||||
|
|
||||||
|
export const productsService = new ProductsService();
|
||||||
376
src/modules/inventory/reorder-alerts.service.ts
Normal file
376
src/modules/inventory/reorder-alerts.service.ts
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ReorderAlert {
|
||||||
|
product_id: string;
|
||||||
|
product_code: string;
|
||||||
|
product_name: string;
|
||||||
|
warehouse_id?: string;
|
||||||
|
warehouse_name?: string;
|
||||||
|
current_quantity: number;
|
||||||
|
reserved_quantity: number;
|
||||||
|
available_quantity: number;
|
||||||
|
reorder_point: number;
|
||||||
|
reorder_quantity: number;
|
||||||
|
min_stock: number;
|
||||||
|
max_stock?: number;
|
||||||
|
shortage: number;
|
||||||
|
suggested_order_qty: number;
|
||||||
|
alert_level: 'critical' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockLevelReport {
|
||||||
|
product_id: string;
|
||||||
|
product_code: string;
|
||||||
|
product_name: string;
|
||||||
|
warehouse_id: string;
|
||||||
|
warehouse_name: string;
|
||||||
|
location_id: string;
|
||||||
|
location_name: string;
|
||||||
|
quantity: number;
|
||||||
|
reserved_quantity: number;
|
||||||
|
available_quantity: number;
|
||||||
|
lot_id?: string;
|
||||||
|
lot_number?: string;
|
||||||
|
uom_name: string;
|
||||||
|
valuation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockSummary {
|
||||||
|
product_id: string;
|
||||||
|
product_code: string;
|
||||||
|
product_name: string;
|
||||||
|
total_quantity: number;
|
||||||
|
total_reserved: number;
|
||||||
|
total_available: number;
|
||||||
|
warehouse_count: number;
|
||||||
|
location_count: number;
|
||||||
|
total_valuation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReorderAlertFilters {
|
||||||
|
warehouse_id?: string;
|
||||||
|
category_id?: string;
|
||||||
|
alert_level?: 'critical' | 'warning' | 'info' | 'all';
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockLevelFilters {
|
||||||
|
product_id?: string;
|
||||||
|
warehouse_id?: string;
|
||||||
|
location_id?: string;
|
||||||
|
include_zero?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ReorderAlertsService {
|
||||||
|
/**
|
||||||
|
* Get all products below their reorder point
|
||||||
|
* Checks inventory.stock_quants against products.products reorder settings
|
||||||
|
*/
|
||||||
|
async getReorderAlerts(
|
||||||
|
tenantId: string,
|
||||||
|
companyId: string,
|
||||||
|
filters: ReorderAlertFilters = {}
|
||||||
|
): Promise<{ data: ReorderAlert[]; total: number }> {
|
||||||
|
const { warehouse_id, category_id, alert_level = 'all', page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = `WHERE p.tenant_id = $1 AND p.active = true`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (warehouse_id) {
|
||||||
|
whereClause += ` AND l.warehouse_id = $${paramIndex++}`;
|
||||||
|
params.push(warehouse_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category_id) {
|
||||||
|
whereClause += ` AND p.category_id = $${paramIndex++}`;
|
||||||
|
params.push(category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total alerts
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(DISTINCT p.id) as count
|
||||||
|
FROM products.products p
|
||||||
|
LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id
|
||||||
|
LEFT JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
${whereClause}
|
||||||
|
AND p.reorder_point IS NOT NULL
|
||||||
|
AND COALESCE(sq.quantity, 0) - COALESCE(sq.reserved_quantity, 0) < p.reorder_point`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get alerts with stock details
|
||||||
|
params.push(limit, offset);
|
||||||
|
const alerts = await query<ReorderAlert>(
|
||||||
|
`SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.code as product_code,
|
||||||
|
p.name as product_name,
|
||||||
|
w.id as warehouse_id,
|
||||||
|
w.name as warehouse_name,
|
||||||
|
COALESCE(SUM(sq.quantity), 0)::numeric as current_quantity,
|
||||||
|
COALESCE(SUM(sq.reserved_quantity), 0)::numeric as reserved_quantity,
|
||||||
|
COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) as available_quantity,
|
||||||
|
p.reorder_point,
|
||||||
|
p.reorder_quantity,
|
||||||
|
p.min_stock,
|
||||||
|
p.max_stock,
|
||||||
|
p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0)) as shortage,
|
||||||
|
COALESCE(p.reorder_quantity, p.reorder_point * 2) as suggested_order_qty,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 'critical'
|
||||||
|
WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 'warning'
|
||||||
|
ELSE 'info'
|
||||||
|
END as alert_level
|
||||||
|
FROM products.products p
|
||||||
|
LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id
|
||||||
|
LEFT JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal'
|
||||||
|
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
${whereClause}
|
||||||
|
AND p.reorder_point IS NOT NULL
|
||||||
|
GROUP BY p.id, p.code, p.name, w.id, w.name, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock
|
||||||
|
HAVING COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 1
|
||||||
|
WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
(p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0))) DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by alert level if specified
|
||||||
|
const filteredAlerts = alert_level === 'all'
|
||||||
|
? alerts
|
||||||
|
: alerts.filter(a => a.alert_level === alert_level);
|
||||||
|
|
||||||
|
logger.info('Reorder alerts retrieved', {
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
totalAlerts: parseInt(countResult?.count || '0', 10),
|
||||||
|
returnedAlerts: filteredAlerts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: filteredAlerts,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stock levels by product, warehouse, and location
|
||||||
|
* TASK-006-03: Vista niveles de stock
|
||||||
|
*/
|
||||||
|
async getStockLevels(
|
||||||
|
tenantId: string,
|
||||||
|
filters: StockLevelFilters = {}
|
||||||
|
): Promise<{ data: StockLevelReport[]; total: number }> {
|
||||||
|
const { product_id, warehouse_id, location_id, include_zero = false, page = 1, limit = 100 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = `WHERE sq.tenant_id = $1`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (product_id) {
|
||||||
|
whereClause += ` AND sq.product_id = $${paramIndex++}`;
|
||||||
|
params.push(product_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warehouse_id) {
|
||||||
|
whereClause += ` AND l.warehouse_id = $${paramIndex++}`;
|
||||||
|
params.push(warehouse_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location_id) {
|
||||||
|
whereClause += ` AND sq.location_id = $${paramIndex++}`;
|
||||||
|
params.push(location_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!include_zero) {
|
||||||
|
whereClause += ` AND (sq.quantity != 0 OR sq.reserved_quantity != 0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<StockLevelReport>(
|
||||||
|
`SELECT
|
||||||
|
sq.product_id,
|
||||||
|
p.code as product_code,
|
||||||
|
p.name as product_name,
|
||||||
|
w.id as warehouse_id,
|
||||||
|
w.name as warehouse_name,
|
||||||
|
l.id as location_id,
|
||||||
|
l.name as location_name,
|
||||||
|
sq.quantity,
|
||||||
|
sq.reserved_quantity,
|
||||||
|
sq.quantity - sq.reserved_quantity as available_quantity,
|
||||||
|
sq.lot_id,
|
||||||
|
lot.name as lot_number,
|
||||||
|
uom.name as uom_name,
|
||||||
|
COALESCE(sq.quantity * p.cost_price, 0) as valuation
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
JOIN inventory.products p ON sq.product_id = p.id
|
||||||
|
JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
LEFT JOIN inventory.lots lot ON sq.lot_id = lot.id
|
||||||
|
LEFT JOIN core.uom uom ON p.uom_id = uom.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.name, w.name, l.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stock summary grouped by product
|
||||||
|
*/
|
||||||
|
async getStockSummary(
|
||||||
|
tenantId: string,
|
||||||
|
productId?: string
|
||||||
|
): Promise<StockSummary[]> {
|
||||||
|
let whereClause = `WHERE sq.tenant_id = $1`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
whereClause += ` AND sq.product_id = $2`;
|
||||||
|
params.push(productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<StockSummary>(
|
||||||
|
`SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.code as product_code,
|
||||||
|
p.name as product_name,
|
||||||
|
SUM(sq.quantity) as total_quantity,
|
||||||
|
SUM(sq.reserved_quantity) as total_reserved,
|
||||||
|
SUM(sq.quantity - sq.reserved_quantity) as total_available,
|
||||||
|
COUNT(DISTINCT l.warehouse_id) as warehouse_count,
|
||||||
|
COUNT(DISTINCT sq.location_id) as location_count,
|
||||||
|
COALESCE(SUM(sq.quantity * p.cost_price), 0) as total_valuation
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
JOIN inventory.products p ON sq.product_id = p.id
|
||||||
|
JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY p.id, p.code, p.name
|
||||||
|
ORDER BY p.name`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific product needs reorder
|
||||||
|
*/
|
||||||
|
async checkProductReorder(
|
||||||
|
productId: string,
|
||||||
|
tenantId: string,
|
||||||
|
warehouseId?: string
|
||||||
|
): Promise<ReorderAlert | null> {
|
||||||
|
let whereClause = `WHERE p.id = $1 AND p.tenant_id = $2`;
|
||||||
|
const params: any[] = [productId, tenantId];
|
||||||
|
|
||||||
|
if (warehouseId) {
|
||||||
|
whereClause += ` AND l.warehouse_id = $3`;
|
||||||
|
params.push(warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<ReorderAlert>(
|
||||||
|
`SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.code as product_code,
|
||||||
|
p.name as product_name,
|
||||||
|
w.id as warehouse_id,
|
||||||
|
w.name as warehouse_name,
|
||||||
|
COALESCE(SUM(sq.quantity), 0)::numeric as current_quantity,
|
||||||
|
COALESCE(SUM(sq.reserved_quantity), 0)::numeric as reserved_quantity,
|
||||||
|
COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) as available_quantity,
|
||||||
|
p.reorder_point,
|
||||||
|
p.reorder_quantity,
|
||||||
|
p.min_stock,
|
||||||
|
p.max_stock,
|
||||||
|
GREATEST(0, p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0))) as shortage,
|
||||||
|
COALESCE(p.reorder_quantity, p.reorder_point * 2) as suggested_order_qty,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 'critical'
|
||||||
|
WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 'warning'
|
||||||
|
ELSE 'info'
|
||||||
|
END as alert_level
|
||||||
|
FROM products.products p
|
||||||
|
LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id
|
||||||
|
LEFT JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal'
|
||||||
|
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY p.id, p.code, p.name, w.id, w.name, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only return if below reorder point
|
||||||
|
if (result && Number(result.available_quantity) < Number(result.reorder_point)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get products with low stock for dashboard/notifications
|
||||||
|
*/
|
||||||
|
async getLowStockProductsCount(
|
||||||
|
tenantId: string,
|
||||||
|
companyId: string
|
||||||
|
): Promise<{ critical: number; warning: number; total: number }> {
|
||||||
|
const result = await queryOne<{ critical: string; warning: string }>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT CASE WHEN available <= p.min_stock THEN p.id END) as critical,
|
||||||
|
COUNT(DISTINCT CASE WHEN available > p.min_stock AND available < p.reorder_point THEN p.id END) as warning
|
||||||
|
FROM products.products p
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT product_id, SUM(quantity) - SUM(reserved_quantity) as available
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal'
|
||||||
|
WHERE sq.tenant_id = $1
|
||||||
|
GROUP BY product_id
|
||||||
|
) stock ON stock.product_id = p.inventory_product_id
|
||||||
|
WHERE p.tenant_id = $1 AND p.active = true AND p.reorder_point IS NOT NULL`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const critical = parseInt(result?.critical || '0', 10);
|
||||||
|
const warning = parseInt(result?.warning || '0', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
critical,
|
||||||
|
warning,
|
||||||
|
total: critical + warning,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reorderAlertsService = new ReorderAlertsService();
|
||||||
470
src/modules/inventory/services/inventory.service.ts
Normal file
470
src/modules/inventory/services/inventory.service.ts
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
import { Repository, FindOptionsWhere, ILike, DataSource } from 'typeorm';
|
||||||
|
import { StockLevel, StockMovement } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateStockMovementDto,
|
||||||
|
AdjustStockDto,
|
||||||
|
TransferStockDto,
|
||||||
|
ReserveStockDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export interface StockSearchParams {
|
||||||
|
tenantId: string;
|
||||||
|
productId?: string;
|
||||||
|
warehouseId?: string;
|
||||||
|
locationId?: string;
|
||||||
|
lotNumber?: string;
|
||||||
|
hasStock?: boolean;
|
||||||
|
lowStock?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MovementSearchParams {
|
||||||
|
tenantId: string;
|
||||||
|
movementType?: string;
|
||||||
|
productId?: string;
|
||||||
|
warehouseId?: string;
|
||||||
|
status?: 'draft' | 'confirmed' | 'cancelled';
|
||||||
|
referenceType?: string;
|
||||||
|
referenceId?: string;
|
||||||
|
fromDate?: Date;
|
||||||
|
toDate?: Date;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InventoryService {
|
||||||
|
constructor(
|
||||||
|
private readonly stockLevelRepository: Repository<StockLevel>,
|
||||||
|
private readonly movementRepository: Repository<StockMovement>,
|
||||||
|
private readonly dataSource: DataSource
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ==================== Stock Levels ====================
|
||||||
|
|
||||||
|
async getStockLevels(
|
||||||
|
params: StockSearchParams
|
||||||
|
): Promise<{ data: StockLevel[]; total: number }> {
|
||||||
|
const {
|
||||||
|
tenantId,
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
locationId,
|
||||||
|
lotNumber,
|
||||||
|
hasStock,
|
||||||
|
lowStock,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const qb = this.stockLevelRepository
|
||||||
|
.createQueryBuilder('stock')
|
||||||
|
.where('stock.tenant_id = :tenantId', { tenantId });
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
qb.andWhere('stock.product_id = :productId', { productId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warehouseId) {
|
||||||
|
qb.andWhere('stock.warehouse_id = :warehouseId', { warehouseId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationId) {
|
||||||
|
qb.andWhere('stock.location_id = :locationId', { locationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lotNumber) {
|
||||||
|
qb.andWhere('stock.lot_number = :lotNumber', { lotNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStock) {
|
||||||
|
qb.andWhere('stock.quantity_on_hand > 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowStock) {
|
||||||
|
qb.andWhere('stock.quantity_on_hand <= 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await qb
|
||||||
|
.orderBy('stock.product_id', 'ASC')
|
||||||
|
.addOrderBy('stock.warehouse_id', 'ASC')
|
||||||
|
.take(limit)
|
||||||
|
.skip(offset)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStockByProduct(
|
||||||
|
productId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<StockLevel[]> {
|
||||||
|
return this.stockLevelRepository.find({
|
||||||
|
where: { productId, tenantId },
|
||||||
|
order: { warehouseId: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStockByWarehouse(
|
||||||
|
warehouseId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<StockLevel[]> {
|
||||||
|
return this.stockLevelRepository.find({
|
||||||
|
where: { warehouseId, tenantId },
|
||||||
|
order: { productId: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableStock(
|
||||||
|
productId: string,
|
||||||
|
warehouseId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const stock = await this.stockLevelRepository.findOne({
|
||||||
|
where: { productId, warehouseId, tenantId },
|
||||||
|
});
|
||||||
|
return stock?.quantityAvailable ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Stock Movements ====================
|
||||||
|
|
||||||
|
async getMovements(
|
||||||
|
params: MovementSearchParams
|
||||||
|
): Promise<{ data: StockMovement[]; total: number }> {
|
||||||
|
const {
|
||||||
|
tenantId,
|
||||||
|
movementType,
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
status,
|
||||||
|
referenceType,
|
||||||
|
referenceId,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const qb = this.movementRepository
|
||||||
|
.createQueryBuilder('movement')
|
||||||
|
.where('movement.tenant_id = :tenantId', { tenantId });
|
||||||
|
|
||||||
|
if (movementType) {
|
||||||
|
qb.andWhere('movement.movement_type = :movementType', { movementType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
qb.andWhere('movement.product_id = :productId', { productId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warehouseId) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(movement.source_warehouse_id = :warehouseId OR movement.dest_warehouse_id = :warehouseId)',
|
||||||
|
{ warehouseId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
qb.andWhere('movement.status = :status', { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (referenceType) {
|
||||||
|
qb.andWhere('movement.reference_type = :referenceType', { referenceType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (referenceId) {
|
||||||
|
qb.andWhere('movement.reference_id = :referenceId', { referenceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromDate) {
|
||||||
|
qb.andWhere('movement.created_at >= :fromDate', { fromDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate) {
|
||||||
|
qb.andWhere('movement.created_at <= :toDate', { toDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await qb
|
||||||
|
.orderBy('movement.created_at', 'DESC')
|
||||||
|
.take(limit)
|
||||||
|
.skip(offset)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMovement(id: string, tenantId: string): Promise<StockMovement | null> {
|
||||||
|
return this.movementRepository.findOne({ where: { id, tenantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMovement(
|
||||||
|
tenantId: string,
|
||||||
|
dto: CreateStockMovementDto,
|
||||||
|
createdBy?: string
|
||||||
|
): Promise<StockMovement> {
|
||||||
|
// Generate movement number
|
||||||
|
const count = await this.movementRepository.count({ where: { tenantId } });
|
||||||
|
const movementNumber = `MOV-${String(count + 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
const totalCost = dto.unitCost ? dto.unitCost * dto.quantity : undefined;
|
||||||
|
|
||||||
|
const movement = this.movementRepository.create({
|
||||||
|
...dto,
|
||||||
|
tenantId,
|
||||||
|
movementNumber,
|
||||||
|
totalCost,
|
||||||
|
expiryDate: dto.expiryDate ? new Date(dto.expiryDate) : undefined,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.movementRepository.save(movement);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmMovement(
|
||||||
|
id: string,
|
||||||
|
tenantId: string,
|
||||||
|
confirmedBy: string
|
||||||
|
): Promise<StockMovement | null> {
|
||||||
|
const movement = await this.getMovement(id, tenantId);
|
||||||
|
if (!movement) return null;
|
||||||
|
|
||||||
|
if (movement.status !== 'draft') {
|
||||||
|
throw new Error('Only draft movements can be confirmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stock levels based on movement type
|
||||||
|
await this.applyMovementToStock(movement);
|
||||||
|
|
||||||
|
movement.status = 'confirmed';
|
||||||
|
movement.confirmedAt = new Date();
|
||||||
|
movement.confirmedBy = confirmedBy;
|
||||||
|
|
||||||
|
return this.movementRepository.save(movement);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelMovement(id: string, tenantId: string): Promise<StockMovement | null> {
|
||||||
|
const movement = await this.getMovement(id, tenantId);
|
||||||
|
if (!movement) return null;
|
||||||
|
|
||||||
|
if (movement.status === 'confirmed') {
|
||||||
|
throw new Error('Cannot cancel confirmed movement');
|
||||||
|
}
|
||||||
|
|
||||||
|
movement.status = 'cancelled';
|
||||||
|
return this.movementRepository.save(movement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Stock Operations ====================
|
||||||
|
|
||||||
|
async adjustStock(
|
||||||
|
tenantId: string,
|
||||||
|
dto: AdjustStockDto,
|
||||||
|
userId?: string
|
||||||
|
): Promise<StockMovement> {
|
||||||
|
const currentStock = await this.getStockLevel(
|
||||||
|
dto.productId,
|
||||||
|
dto.warehouseId,
|
||||||
|
dto.locationId,
|
||||||
|
dto.lotNumber,
|
||||||
|
dto.serialNumber,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentQuantity = currentStock?.quantityOnHand ?? 0;
|
||||||
|
const difference = dto.newQuantity - currentQuantity;
|
||||||
|
|
||||||
|
const movement = await this.createMovement(
|
||||||
|
tenantId,
|
||||||
|
{
|
||||||
|
movementType: 'adjustment',
|
||||||
|
productId: dto.productId,
|
||||||
|
destWarehouseId: dto.warehouseId,
|
||||||
|
destLocationId: dto.locationId,
|
||||||
|
quantity: Math.abs(difference),
|
||||||
|
lotNumber: dto.lotNumber,
|
||||||
|
serialNumber: dto.serialNumber,
|
||||||
|
reason: dto.reason,
|
||||||
|
notes: dto.notes,
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.confirmMovement(movement.id, tenantId, userId || '') as Promise<StockMovement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async transferStock(
|
||||||
|
tenantId: string,
|
||||||
|
dto: TransferStockDto,
|
||||||
|
userId?: string
|
||||||
|
): Promise<StockMovement> {
|
||||||
|
// Verify available stock
|
||||||
|
const available = await this.getAvailableStock(
|
||||||
|
dto.productId,
|
||||||
|
dto.sourceWarehouseId,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (available < dto.quantity) {
|
||||||
|
throw new Error('Insufficient stock for transfer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const movement = await this.createMovement(
|
||||||
|
tenantId,
|
||||||
|
{
|
||||||
|
movementType: 'transfer',
|
||||||
|
productId: dto.productId,
|
||||||
|
sourceWarehouseId: dto.sourceWarehouseId,
|
||||||
|
sourceLocationId: dto.sourceLocationId,
|
||||||
|
destWarehouseId: dto.destWarehouseId,
|
||||||
|
destLocationId: dto.destLocationId,
|
||||||
|
quantity: dto.quantity,
|
||||||
|
lotNumber: dto.lotNumber,
|
||||||
|
serialNumber: dto.serialNumber,
|
||||||
|
notes: dto.notes,
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.confirmMovement(movement.id, tenantId, userId || '') as Promise<StockMovement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reserveStock(tenantId: string, dto: ReserveStockDto): Promise<boolean> {
|
||||||
|
const stock = await this.getStockLevel(
|
||||||
|
dto.productId,
|
||||||
|
dto.warehouseId,
|
||||||
|
dto.locationId,
|
||||||
|
dto.lotNumber,
|
||||||
|
undefined,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stock || stock.quantityAvailable < dto.quantity) {
|
||||||
|
throw new Error('Insufficient available stock for reservation');
|
||||||
|
}
|
||||||
|
|
||||||
|
stock.quantityReserved = Number(stock.quantityReserved) + dto.quantity;
|
||||||
|
await this.stockLevelRepository.save(stock);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseReservation(
|
||||||
|
productId: string,
|
||||||
|
warehouseId: string,
|
||||||
|
quantity: number,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const stock = await this.stockLevelRepository.findOne({
|
||||||
|
where: { productId, warehouseId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stock) return false;
|
||||||
|
|
||||||
|
stock.quantityReserved = Math.max(0, Number(stock.quantityReserved) - quantity);
|
||||||
|
await this.stockLevelRepository.save(stock);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
private async getStockLevel(
|
||||||
|
productId: string,
|
||||||
|
warehouseId: string,
|
||||||
|
locationId: string | undefined,
|
||||||
|
lotNumber: string | undefined,
|
||||||
|
serialNumber: string | undefined,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<StockLevel | null> {
|
||||||
|
const where: FindOptionsWhere<StockLevel> = {
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
tenantId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (locationId) where.locationId = locationId;
|
||||||
|
if (lotNumber) where.lotNumber = lotNumber;
|
||||||
|
if (serialNumber) where.serialNumber = serialNumber;
|
||||||
|
|
||||||
|
return this.stockLevelRepository.findOne({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyMovementToStock(movement: StockMovement): Promise<void> {
|
||||||
|
const { movementType, productId, quantity, sourceWarehouseId, destWarehouseId, lotNumber } =
|
||||||
|
movement;
|
||||||
|
|
||||||
|
// Decrease source stock
|
||||||
|
if (sourceWarehouseId && ['shipment', 'transfer', 'consumption'].includes(movementType)) {
|
||||||
|
await this.updateStockLevel(
|
||||||
|
productId,
|
||||||
|
sourceWarehouseId,
|
||||||
|
movement.sourceLocationId,
|
||||||
|
lotNumber,
|
||||||
|
movement.serialNumber,
|
||||||
|
movement.tenantId,
|
||||||
|
-quantity
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase destination stock
|
||||||
|
if (destWarehouseId && ['receipt', 'transfer', 'adjustment', 'return', 'production'].includes(movementType)) {
|
||||||
|
await this.updateStockLevel(
|
||||||
|
productId,
|
||||||
|
destWarehouseId,
|
||||||
|
movement.destLocationId,
|
||||||
|
lotNumber,
|
||||||
|
movement.serialNumber,
|
||||||
|
movement.tenantId,
|
||||||
|
quantity,
|
||||||
|
movement.unitCost
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateStockLevel(
|
||||||
|
productId: string,
|
||||||
|
warehouseId: string,
|
||||||
|
locationId: string | null,
|
||||||
|
lotNumber: string | null,
|
||||||
|
serialNumber: string | null,
|
||||||
|
tenantId: string,
|
||||||
|
quantityChange: number,
|
||||||
|
unitCost?: number
|
||||||
|
): Promise<void> {
|
||||||
|
let stock = await this.stockLevelRepository.findOne({
|
||||||
|
where: {
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
locationId: locationId || undefined,
|
||||||
|
lotNumber: lotNumber || undefined,
|
||||||
|
serialNumber: serialNumber || undefined,
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stock) {
|
||||||
|
stock = this.stockLevelRepository.create({
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
locationId: locationId || undefined,
|
||||||
|
lotNumber: lotNumber || undefined,
|
||||||
|
serialNumber: serialNumber || undefined,
|
||||||
|
tenantId,
|
||||||
|
quantityOnHand: 0,
|
||||||
|
quantityReserved: 0,
|
||||||
|
quantityIncoming: 0,
|
||||||
|
quantityOutgoing: 0,
|
||||||
|
} as Partial<StockLevel>);
|
||||||
|
}
|
||||||
|
|
||||||
|
stock.quantityOnHand = Number(stock.quantityOnHand) + quantityChange;
|
||||||
|
stock.lastMovementAt = new Date();
|
||||||
|
|
||||||
|
if (unitCost !== undefined) {
|
||||||
|
stock.unitCost = unitCost;
|
||||||
|
stock.totalCost = stock.quantityOnHand * unitCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.stockLevelRepository.save(stock);
|
||||||
|
}
|
||||||
|
}
|
||||||
473
src/modules/inventory/stock-reservation.service.ts
Normal file
473
src/modules/inventory/stock-reservation.service.ts
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock Reservation Service
|
||||||
|
*
|
||||||
|
* Manages soft reservations for stock. Reservations don't move stock,
|
||||||
|
* they mark quantities as committed to specific orders/documents.
|
||||||
|
*
|
||||||
|
* Key concepts:
|
||||||
|
* - quantity: Total physical stock at location
|
||||||
|
* - reserved_quantity: Stock committed to orders but not yet picked
|
||||||
|
* - available = quantity - reserved_quantity
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - Sales Orders: Reserve on confirm, release on cancel
|
||||||
|
* - Transfers: Reserve on confirm, release on complete/cancel
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReservationLine {
|
||||||
|
productId: string;
|
||||||
|
locationId: string;
|
||||||
|
quantity: number;
|
||||||
|
lotId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservationResult {
|
||||||
|
success: boolean;
|
||||||
|
lines: ReservationLineResult[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservationLineResult {
|
||||||
|
productId: string;
|
||||||
|
locationId: string;
|
||||||
|
lotId?: string;
|
||||||
|
requestedQty: number;
|
||||||
|
reservedQty: number;
|
||||||
|
availableQty: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockAvailability {
|
||||||
|
productId: string;
|
||||||
|
locationId: string;
|
||||||
|
lotId?: string;
|
||||||
|
quantity: number;
|
||||||
|
reservedQuantity: number;
|
||||||
|
availableQuantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StockReservationService {
|
||||||
|
/**
|
||||||
|
* Check stock availability for a list of products at locations
|
||||||
|
*/
|
||||||
|
async checkAvailability(
|
||||||
|
lines: ReservationLine[],
|
||||||
|
tenantId: string
|
||||||
|
): Promise<StockAvailability[]> {
|
||||||
|
const results: StockAvailability[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const lotCondition = line.lotId
|
||||||
|
? 'AND sq.lot_id = $4'
|
||||||
|
: 'AND sq.lot_id IS NULL';
|
||||||
|
|
||||||
|
const params = line.lotId
|
||||||
|
? [tenantId, line.productId, line.locationId, line.lotId]
|
||||||
|
: [tenantId, line.productId, line.locationId];
|
||||||
|
|
||||||
|
const quant = await queryOne<{
|
||||||
|
quantity: string;
|
||||||
|
reserved_quantity: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(sq.quantity), 0) as quantity,
|
||||||
|
COALESCE(SUM(sq.reserved_quantity), 0) as reserved_quantity
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
WHERE sq.tenant_id = $1
|
||||||
|
AND sq.product_id = $2
|
||||||
|
AND sq.location_id = $3
|
||||||
|
${lotCondition}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const quantity = parseFloat(quant?.quantity || '0');
|
||||||
|
const reservedQuantity = parseFloat(quant?.reserved_quantity || '0');
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
productId: line.productId,
|
||||||
|
locationId: line.locationId,
|
||||||
|
lotId: line.lotId,
|
||||||
|
quantity,
|
||||||
|
reservedQuantity,
|
||||||
|
availableQuantity: quantity - reservedQuantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve stock for an order/document
|
||||||
|
*
|
||||||
|
* @param lines - Lines to reserve
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param sourceDocument - Reference to source document (e.g., "SO-000001")
|
||||||
|
* @param allowPartial - If true, reserve what's available even if less than requested
|
||||||
|
* @returns Reservation result with details per line
|
||||||
|
*/
|
||||||
|
async reserve(
|
||||||
|
lines: ReservationLine[],
|
||||||
|
tenantId: string,
|
||||||
|
sourceDocument: string,
|
||||||
|
allowPartial: boolean = false
|
||||||
|
): Promise<ReservationResult> {
|
||||||
|
const results: ReservationLineResult[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// First check availability
|
||||||
|
const availability = await this.checkAvailability(lines, tenantId);
|
||||||
|
|
||||||
|
// Validate all lines have sufficient stock (if partial not allowed)
|
||||||
|
if (!allowPartial) {
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const avail = availability[i];
|
||||||
|
|
||||||
|
if (avail.availableQuantity < line.quantity) {
|
||||||
|
errors.push(
|
||||||
|
`Producto ${line.productId}: disponible ${avail.availableQuantity}, solicitado ${line.quantity}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
lines: [],
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve stock
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const avail = availability[i];
|
||||||
|
const qtyToReserve = allowPartial
|
||||||
|
? Math.min(line.quantity, avail.availableQuantity)
|
||||||
|
: line.quantity;
|
||||||
|
|
||||||
|
if (qtyToReserve <= 0) {
|
||||||
|
results.push({
|
||||||
|
productId: line.productId,
|
||||||
|
locationId: line.locationId,
|
||||||
|
lotId: line.lotId,
|
||||||
|
requestedQty: line.quantity,
|
||||||
|
reservedQty: 0,
|
||||||
|
availableQty: avail.availableQuantity,
|
||||||
|
success: false,
|
||||||
|
error: 'Sin stock disponible',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update reserved_quantity
|
||||||
|
const lotCondition = line.lotId
|
||||||
|
? 'AND lot_id = $5'
|
||||||
|
: 'AND lot_id IS NULL';
|
||||||
|
|
||||||
|
const params = line.lotId
|
||||||
|
? [qtyToReserve, tenantId, line.productId, line.locationId, line.lotId]
|
||||||
|
: [qtyToReserve, tenantId, line.productId, line.locationId];
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_quants
|
||||||
|
SET reserved_quantity = reserved_quantity + $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
AND product_id = $3
|
||||||
|
AND location_id = $4
|
||||||
|
${lotCondition}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
productId: line.productId,
|
||||||
|
locationId: line.locationId,
|
||||||
|
lotId: line.lotId,
|
||||||
|
requestedQty: line.quantity,
|
||||||
|
reservedQty: qtyToReserve,
|
||||||
|
availableQty: avail.availableQuantity - qtyToReserve,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info('Stock reserved', {
|
||||||
|
sourceDocument,
|
||||||
|
tenantId,
|
||||||
|
linesReserved: results.filter(r => r.success).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: results.every(r => r.success),
|
||||||
|
lines: results,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('Error reserving stock', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
sourceDocument,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release previously reserved stock
|
||||||
|
*
|
||||||
|
* @param lines - Lines to release
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param sourceDocument - Reference to source document
|
||||||
|
*/
|
||||||
|
async release(
|
||||||
|
lines: ReservationLine[],
|
||||||
|
tenantId: string,
|
||||||
|
sourceDocument: string
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const lotCondition = line.lotId
|
||||||
|
? 'AND lot_id = $5'
|
||||||
|
: 'AND lot_id IS NULL';
|
||||||
|
|
||||||
|
const params = line.lotId
|
||||||
|
? [line.quantity, tenantId, line.productId, line.locationId, line.lotId]
|
||||||
|
: [line.quantity, tenantId, line.productId, line.locationId];
|
||||||
|
|
||||||
|
// Decrease reserved_quantity (don't go below 0)
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_quants
|
||||||
|
SET reserved_quantity = GREATEST(reserved_quantity - $1, 0),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
AND product_id = $3
|
||||||
|
AND location_id = $4
|
||||||
|
${lotCondition}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info('Stock reservation released', {
|
||||||
|
sourceDocument,
|
||||||
|
tenantId,
|
||||||
|
linesReleased: lines.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('Error releasing stock reservation', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
sourceDocument,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve stock within an existing transaction
|
||||||
|
* Used when reservation is part of a larger transaction (e.g., confirm order)
|
||||||
|
*/
|
||||||
|
async reserveWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
lines: ReservationLine[],
|
||||||
|
tenantId: string,
|
||||||
|
sourceDocument: string,
|
||||||
|
allowPartial: boolean = false
|
||||||
|
): Promise<ReservationResult> {
|
||||||
|
const results: ReservationLineResult[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Check availability
|
||||||
|
for (const line of lines) {
|
||||||
|
const lotCondition = line.lotId
|
||||||
|
? 'AND sq.lot_id = $4'
|
||||||
|
: 'AND sq.lot_id IS NULL';
|
||||||
|
|
||||||
|
const params = line.lotId
|
||||||
|
? [tenantId, line.productId, line.locationId, line.lotId]
|
||||||
|
: [tenantId, line.productId, line.locationId];
|
||||||
|
|
||||||
|
const quantResult = await client.query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(sq.quantity), 0) as quantity,
|
||||||
|
COALESCE(SUM(sq.reserved_quantity), 0) as reserved_quantity
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
WHERE sq.tenant_id = $1
|
||||||
|
AND sq.product_id = $2
|
||||||
|
AND sq.location_id = $3
|
||||||
|
${lotCondition}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const quantity = parseFloat(quantResult.rows[0]?.quantity || '0');
|
||||||
|
const reservedQuantity = parseFloat(quantResult.rows[0]?.reserved_quantity || '0');
|
||||||
|
const availableQuantity = quantity - reservedQuantity;
|
||||||
|
const qtyToReserve = allowPartial
|
||||||
|
? Math.min(line.quantity, availableQuantity)
|
||||||
|
: line.quantity;
|
||||||
|
|
||||||
|
if (!allowPartial && availableQuantity < line.quantity) {
|
||||||
|
errors.push(
|
||||||
|
`Producto ${line.productId}: disponible ${availableQuantity}, solicitado ${line.quantity}`
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
productId: line.productId,
|
||||||
|
locationId: line.locationId,
|
||||||
|
lotId: line.lotId,
|
||||||
|
requestedQty: line.quantity,
|
||||||
|
reservedQty: 0,
|
||||||
|
availableQty: availableQuantity,
|
||||||
|
success: false,
|
||||||
|
error: 'Stock insuficiente',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qtyToReserve > 0) {
|
||||||
|
// Update reserved_quantity
|
||||||
|
const updateLotCondition = line.lotId
|
||||||
|
? 'AND lot_id = $5'
|
||||||
|
: 'AND lot_id IS NULL';
|
||||||
|
|
||||||
|
const updateParams = line.lotId
|
||||||
|
? [qtyToReserve, tenantId, line.productId, line.locationId, line.lotId]
|
||||||
|
: [qtyToReserve, tenantId, line.productId, line.locationId];
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_quants
|
||||||
|
SET reserved_quantity = reserved_quantity + $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
AND product_id = $3
|
||||||
|
AND location_id = $4
|
||||||
|
${updateLotCondition}`,
|
||||||
|
updateParams
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
productId: line.productId,
|
||||||
|
locationId: line.locationId,
|
||||||
|
lotId: line.lotId,
|
||||||
|
requestedQty: line.quantity,
|
||||||
|
reservedQty: qtyToReserve,
|
||||||
|
availableQty: availableQuantity - qtyToReserve,
|
||||||
|
success: qtyToReserve > 0 || line.quantity === 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: errors.length === 0,
|
||||||
|
lines: results,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release stock within an existing transaction
|
||||||
|
*/
|
||||||
|
async releaseWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
lines: ReservationLine[],
|
||||||
|
tenantId: string
|
||||||
|
): Promise<void> {
|
||||||
|
for (const line of lines) {
|
||||||
|
const lotCondition = line.lotId
|
||||||
|
? 'AND lot_id = $5'
|
||||||
|
: 'AND lot_id IS NULL';
|
||||||
|
|
||||||
|
const params = line.lotId
|
||||||
|
? [line.quantity, tenantId, line.productId, line.locationId, line.lotId]
|
||||||
|
: [line.quantity, tenantId, line.productId, line.locationId];
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_quants
|
||||||
|
SET reserved_quantity = GREATEST(reserved_quantity - $1, 0),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
AND product_id = $3
|
||||||
|
AND location_id = $4
|
||||||
|
${lotCondition}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total available stock for a product across all locations
|
||||||
|
*/
|
||||||
|
async getProductAvailability(
|
||||||
|
productId: string,
|
||||||
|
tenantId: string,
|
||||||
|
warehouseId?: string
|
||||||
|
): Promise<{
|
||||||
|
totalQuantity: number;
|
||||||
|
totalReserved: number;
|
||||||
|
totalAvailable: number;
|
||||||
|
byLocation: StockAvailability[];
|
||||||
|
}> {
|
||||||
|
let whereClause = 'WHERE sq.tenant_id = $1 AND sq.product_id = $2';
|
||||||
|
const params: any[] = [tenantId, productId];
|
||||||
|
|
||||||
|
if (warehouseId) {
|
||||||
|
whereClause += ' AND l.warehouse_id = $3';
|
||||||
|
params.push(warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<{
|
||||||
|
location_id: string;
|
||||||
|
lot_id: string | null;
|
||||||
|
quantity: string;
|
||||||
|
reserved_quantity: string;
|
||||||
|
}>(
|
||||||
|
`SELECT sq.location_id, sq.lot_id, sq.quantity, sq.reserved_quantity
|
||||||
|
FROM inventory.stock_quants sq
|
||||||
|
LEFT JOIN inventory.locations l ON sq.location_id = l.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const byLocation: StockAvailability[] = result.map(row => ({
|
||||||
|
productId,
|
||||||
|
locationId: row.location_id,
|
||||||
|
lotId: row.lot_id || undefined,
|
||||||
|
quantity: parseFloat(row.quantity),
|
||||||
|
reservedQuantity: parseFloat(row.reserved_quantity),
|
||||||
|
availableQuantity: parseFloat(row.quantity) - parseFloat(row.reserved_quantity),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalQuantity = byLocation.reduce((sum, l) => sum + l.quantity, 0);
|
||||||
|
const totalReserved = byLocation.reduce((sum, l) => sum + l.reservedQuantity, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalQuantity,
|
||||||
|
totalReserved,
|
||||||
|
totalAvailable: totalQuantity - totalReserved,
|
||||||
|
byLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stockReservationService = new StockReservationService();
|
||||||
230
src/modules/inventory/valuation.controller.ts
Normal file
230
src/modules/inventory/valuation.controller.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { valuationService, CreateValuationLayerDto } from './valuation.service.js';
|
||||||
|
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getProductCostSchema = z.object({
|
||||||
|
product_id: z.string().uuid(),
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLayerSchema = z.object({
|
||||||
|
product_id: z.string().uuid(),
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unit_cost: z.number().nonnegative(),
|
||||||
|
stock_move_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const consumeFifoSchema = z.object({
|
||||||
|
product_id: z.string().uuid(),
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const productLayersSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
include_empty: z.enum(['true', 'false']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTROLLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ValuationController {
|
||||||
|
/**
|
||||||
|
* Get cost for a product based on its valuation method
|
||||||
|
* GET /api/inventory/valuation/cost
|
||||||
|
*/
|
||||||
|
async getProductCost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = getProductCostSchema.safeParse(req.query);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Parámetros inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product_id, company_id } = validation.data;
|
||||||
|
const result = await valuationService.getProductCost(
|
||||||
|
product_id,
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valuation summary for a product
|
||||||
|
* GET /api/inventory/valuation/products/:productId/summary
|
||||||
|
*/
|
||||||
|
async getProductSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const { company_id } = req.query;
|
||||||
|
|
||||||
|
if (!company_id || typeof company_id !== 'string') {
|
||||||
|
throw new ValidationError('company_id es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await valuationService.getProductValuationSummary(
|
||||||
|
productId,
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valuation layers for a product
|
||||||
|
* GET /api/inventory/valuation/products/:productId/layers
|
||||||
|
*/
|
||||||
|
async getProductLayers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { productId } = req.params;
|
||||||
|
const validation = productLayersSchema.safeParse(req.query);
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Parámetros inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { company_id, include_empty } = validation.data;
|
||||||
|
const includeEmpty = include_empty === 'true';
|
||||||
|
|
||||||
|
const result = await valuationService.getProductLayers(
|
||||||
|
productId,
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId,
|
||||||
|
includeEmpty
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get company-wide valuation report
|
||||||
|
* GET /api/inventory/valuation/report
|
||||||
|
*/
|
||||||
|
async getCompanyReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id } = req.query;
|
||||||
|
|
||||||
|
if (!company_id || typeof company_id !== 'string') {
|
||||||
|
throw new ValidationError('company_id es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await valuationService.getCompanyValuationReport(
|
||||||
|
company_id,
|
||||||
|
req.user!.tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
meta: {
|
||||||
|
total: result.length,
|
||||||
|
totalValue: result.reduce((sum, p) => sum + Number(p.total_value), 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a valuation layer manually (for adjustments)
|
||||||
|
* POST /api/inventory/valuation/layers
|
||||||
|
*/
|
||||||
|
async createLayer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = createLayerSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateValuationLayerDto = validation.data;
|
||||||
|
|
||||||
|
const result = await valuationService.createLayer(
|
||||||
|
dto,
|
||||||
|
req.user!.tenantId,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Capa de valoración creada',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume stock using FIFO (for testing/manual adjustments)
|
||||||
|
* POST /api/inventory/valuation/consume
|
||||||
|
*/
|
||||||
|
async consumeFifo(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = consumeFifoSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product_id, company_id, quantity } = validation.data;
|
||||||
|
|
||||||
|
const result = await valuationService.consumeFifo(
|
||||||
|
product_id,
|
||||||
|
company_id,
|
||||||
|
quantity,
|
||||||
|
req.user!.tenantId,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: `Consumidas ${result.layers_consumed.length} capas FIFO`,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const valuationController = new ValuationController();
|
||||||
522
src/modules/inventory/valuation.service.ts
Normal file
522
src/modules/inventory/valuation.service.ts
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ValuationMethod = 'standard' | 'fifo' | 'average';
|
||||||
|
|
||||||
|
export interface StockValuationLayer {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
product_id: string;
|
||||||
|
company_id: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_cost: number;
|
||||||
|
value: number;
|
||||||
|
remaining_qty: number;
|
||||||
|
remaining_value: number;
|
||||||
|
stock_move_id?: string;
|
||||||
|
description?: string;
|
||||||
|
account_move_id?: string;
|
||||||
|
journal_entry_id?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateValuationLayerDto {
|
||||||
|
product_id: string;
|
||||||
|
company_id: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_cost: number;
|
||||||
|
stock_move_id?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuationSummary {
|
||||||
|
product_id: string;
|
||||||
|
product_name: string;
|
||||||
|
product_code?: string;
|
||||||
|
total_quantity: number;
|
||||||
|
total_value: number;
|
||||||
|
average_cost: number;
|
||||||
|
valuation_method: ValuationMethod;
|
||||||
|
layer_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FifoConsumptionResult {
|
||||||
|
layers_consumed: {
|
||||||
|
layer_id: string;
|
||||||
|
quantity_consumed: number;
|
||||||
|
unit_cost: number;
|
||||||
|
value_consumed: number;
|
||||||
|
}[];
|
||||||
|
total_cost: number;
|
||||||
|
weighted_average_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductCostResult {
|
||||||
|
product_id: string;
|
||||||
|
valuation_method: ValuationMethod;
|
||||||
|
standard_cost: number;
|
||||||
|
fifo_cost?: number;
|
||||||
|
average_cost: number;
|
||||||
|
recommended_cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ValuationService {
|
||||||
|
/**
|
||||||
|
* Create a new valuation layer (for incoming stock)
|
||||||
|
* Used when receiving products via purchase orders or inventory adjustments
|
||||||
|
*/
|
||||||
|
async createLayer(
|
||||||
|
dto: CreateValuationLayerDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<StockValuationLayer> {
|
||||||
|
const executeQuery = client
|
||||||
|
? (sql: string, params: any[]) => client.query(sql, params).then(r => r.rows[0])
|
||||||
|
: queryOne;
|
||||||
|
|
||||||
|
const value = dto.quantity * dto.unit_cost;
|
||||||
|
|
||||||
|
const layer = await executeQuery(
|
||||||
|
`INSERT INTO inventory.stock_valuation_layers (
|
||||||
|
tenant_id, product_id, company_id, quantity, unit_cost, value,
|
||||||
|
remaining_qty, remaining_value, stock_move_id, description, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $4, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.product_id,
|
||||||
|
dto.company_id,
|
||||||
|
dto.quantity,
|
||||||
|
dto.unit_cost,
|
||||||
|
value,
|
||||||
|
dto.stock_move_id,
|
||||||
|
dto.description,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Valuation layer created', {
|
||||||
|
layerId: layer?.id,
|
||||||
|
productId: dto.product_id,
|
||||||
|
quantity: dto.quantity,
|
||||||
|
unitCost: dto.unit_cost,
|
||||||
|
});
|
||||||
|
|
||||||
|
return layer as StockValuationLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume stock using FIFO method
|
||||||
|
* Returns the layers consumed and total cost
|
||||||
|
*/
|
||||||
|
async consumeFifo(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
quantity: number,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<FifoConsumptionResult> {
|
||||||
|
const dbClient = client || await getClient();
|
||||||
|
const shouldReleaseClient = !client;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!client) {
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available layers ordered by creation date (FIFO)
|
||||||
|
const layersResult = await dbClient.query(
|
||||||
|
`SELECT * FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
AND remaining_qty > 0
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
FOR UPDATE`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const layers = layersResult.rows as StockValuationLayer[];
|
||||||
|
let remainingToConsume = quantity;
|
||||||
|
const consumedLayers: FifoConsumptionResult['layers_consumed'] = [];
|
||||||
|
let totalCost = 0;
|
||||||
|
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (remainingToConsume <= 0) break;
|
||||||
|
|
||||||
|
const consumeFromLayer = Math.min(remainingToConsume, Number(layer.remaining_qty));
|
||||||
|
const valueConsumed = consumeFromLayer * Number(layer.unit_cost);
|
||||||
|
|
||||||
|
// Update layer
|
||||||
|
await dbClient.query(
|
||||||
|
`UPDATE inventory.stock_valuation_layers
|
||||||
|
SET remaining_qty = remaining_qty - $1,
|
||||||
|
remaining_value = remaining_value - $2,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = $3
|
||||||
|
WHERE id = $4`,
|
||||||
|
[consumeFromLayer, valueConsumed, userId, layer.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
consumedLayers.push({
|
||||||
|
layer_id: layer.id,
|
||||||
|
quantity_consumed: consumeFromLayer,
|
||||||
|
unit_cost: Number(layer.unit_cost),
|
||||||
|
value_consumed: valueConsumed,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalCost += valueConsumed;
|
||||||
|
remainingToConsume -= consumeFromLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingToConsume > 0) {
|
||||||
|
// Not enough stock in layers - this is a warning, not an error
|
||||||
|
// The stock might exist without valuation layers (e.g., initial data)
|
||||||
|
logger.warn('Insufficient valuation layers for FIFO consumption', {
|
||||||
|
productId,
|
||||||
|
requestedQty: quantity,
|
||||||
|
availableQty: quantity - remainingToConsume,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightedAvgCost = quantity > 0 ? totalCost / (quantity - remainingToConsume) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
layers_consumed: consumedLayers,
|
||||||
|
total_cost: totalCost,
|
||||||
|
weighted_average_cost: weightedAvgCost,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!client) {
|
||||||
|
await dbClient.query('ROLLBACK');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (shouldReleaseClient) {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the current cost of a product based on its valuation method
|
||||||
|
*/
|
||||||
|
async getProductCost(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ProductCostResult> {
|
||||||
|
// Get product with its valuation method and standard cost
|
||||||
|
const product = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
valuation_method: ValuationMethod;
|
||||||
|
cost_price: number;
|
||||||
|
}>(
|
||||||
|
`SELECT id, valuation_method, cost_price
|
||||||
|
FROM inventory.products
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[productId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundError('Producto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get FIFO cost (oldest layer's unit cost)
|
||||||
|
const oldestLayer = await queryOne<{ unit_cost: number }>(
|
||||||
|
`SELECT unit_cost FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
AND remaining_qty > 0
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get average cost from all layers
|
||||||
|
const avgResult = await queryOne<{ avg_cost: number; total_qty: number }>(
|
||||||
|
`SELECT
|
||||||
|
CASE WHEN SUM(remaining_qty) > 0
|
||||||
|
THEN SUM(remaining_value) / SUM(remaining_qty)
|
||||||
|
ELSE 0
|
||||||
|
END as avg_cost,
|
||||||
|
SUM(remaining_qty) as total_qty
|
||||||
|
FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
AND remaining_qty > 0`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const standardCost = Number(product.cost_price) || 0;
|
||||||
|
const fifoCost = oldestLayer ? Number(oldestLayer.unit_cost) : undefined;
|
||||||
|
const averageCost = Number(avgResult?.avg_cost) || 0;
|
||||||
|
|
||||||
|
// Determine recommended cost based on valuation method
|
||||||
|
let recommendedCost: number;
|
||||||
|
switch (product.valuation_method) {
|
||||||
|
case 'fifo':
|
||||||
|
recommendedCost = fifoCost ?? standardCost;
|
||||||
|
break;
|
||||||
|
case 'average':
|
||||||
|
recommendedCost = averageCost > 0 ? averageCost : standardCost;
|
||||||
|
break;
|
||||||
|
case 'standard':
|
||||||
|
default:
|
||||||
|
recommendedCost = standardCost;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
product_id: productId,
|
||||||
|
valuation_method: product.valuation_method,
|
||||||
|
standard_cost: standardCost,
|
||||||
|
fifo_cost: fifoCost,
|
||||||
|
average_cost: averageCost,
|
||||||
|
recommended_cost: recommendedCost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valuation summary for a product
|
||||||
|
*/
|
||||||
|
async getProductValuationSummary(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ValuationSummary | null> {
|
||||||
|
const result = await queryOne<ValuationSummary>(
|
||||||
|
`SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
p.valuation_method,
|
||||||
|
COALESCE(SUM(svl.remaining_qty), 0) as total_quantity,
|
||||||
|
COALESCE(SUM(svl.remaining_value), 0) as total_value,
|
||||||
|
CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0
|
||||||
|
THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty)
|
||||||
|
ELSE p.cost_price
|
||||||
|
END as average_cost,
|
||||||
|
COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count
|
||||||
|
FROM inventory.products p
|
||||||
|
LEFT JOIN inventory.stock_valuation_layers svl
|
||||||
|
ON p.id = svl.product_id
|
||||||
|
AND svl.company_id = $2
|
||||||
|
AND svl.tenant_id = $3
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $3
|
||||||
|
GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all valuation layers for a product
|
||||||
|
*/
|
||||||
|
async getProductLayers(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string,
|
||||||
|
includeEmpty: boolean = false
|
||||||
|
): Promise<StockValuationLayer[]> {
|
||||||
|
const whereClause = includeEmpty
|
||||||
|
? ''
|
||||||
|
: 'AND remaining_qty > 0';
|
||||||
|
|
||||||
|
return query<StockValuationLayer>(
|
||||||
|
`SELECT * FROM inventory.stock_valuation_layers
|
||||||
|
WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inventory valuation report for a company
|
||||||
|
*/
|
||||||
|
async getCompanyValuationReport(
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ValuationSummary[]> {
|
||||||
|
return query<ValuationSummary>(
|
||||||
|
`SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.name as product_name,
|
||||||
|
p.code as product_code,
|
||||||
|
p.valuation_method,
|
||||||
|
COALESCE(SUM(svl.remaining_qty), 0) as total_quantity,
|
||||||
|
COALESCE(SUM(svl.remaining_value), 0) as total_value,
|
||||||
|
CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0
|
||||||
|
THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty)
|
||||||
|
ELSE p.cost_price
|
||||||
|
END as average_cost,
|
||||||
|
COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count
|
||||||
|
FROM inventory.products p
|
||||||
|
LEFT JOIN inventory.stock_valuation_layers svl
|
||||||
|
ON p.id = svl.product_id
|
||||||
|
AND svl.company_id = $1
|
||||||
|
AND svl.tenant_id = $2
|
||||||
|
WHERE p.tenant_id = $2
|
||||||
|
AND p.product_type = 'storable'
|
||||||
|
AND p.active = true
|
||||||
|
GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price
|
||||||
|
HAVING COALESCE(SUM(svl.remaining_qty), 0) > 0
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[companyId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update average cost on product after valuation changes
|
||||||
|
* Call this after creating layers or consuming stock
|
||||||
|
*/
|
||||||
|
async updateProductAverageCost(
|
||||||
|
productId: string,
|
||||||
|
companyId: string,
|
||||||
|
tenantId: string,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<void> {
|
||||||
|
const executeQuery = client
|
||||||
|
? (sql: string, params: any[]) => client.query(sql, params)
|
||||||
|
: query;
|
||||||
|
|
||||||
|
// Only update products using average cost method
|
||||||
|
await executeQuery(
|
||||||
|
`UPDATE inventory.products p
|
||||||
|
SET cost_price = (
|
||||||
|
SELECT CASE WHEN SUM(svl.remaining_qty) > 0
|
||||||
|
THEN SUM(svl.remaining_value) / SUM(svl.remaining_qty)
|
||||||
|
ELSE p.cost_price
|
||||||
|
END
|
||||||
|
FROM inventory.stock_valuation_layers svl
|
||||||
|
WHERE svl.product_id = p.id
|
||||||
|
AND svl.company_id = $2
|
||||||
|
AND svl.tenant_id = $3
|
||||||
|
AND svl.remaining_qty > 0
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE p.id = $1
|
||||||
|
AND p.tenant_id = $3
|
||||||
|
AND p.valuation_method = 'average'`,
|
||||||
|
[productId, companyId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process stock move for valuation
|
||||||
|
* Creates or consumes valuation layers based on move direction
|
||||||
|
*/
|
||||||
|
async processStockMoveValuation(
|
||||||
|
moveId: string,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const move = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_qty: number;
|
||||||
|
location_id: string;
|
||||||
|
location_dest_id: string;
|
||||||
|
company_id: string;
|
||||||
|
}>(
|
||||||
|
`SELECT sm.id, sm.product_id, sm.product_qty,
|
||||||
|
sm.location_id, sm.location_dest_id,
|
||||||
|
p.company_id
|
||||||
|
FROM inventory.stock_moves sm
|
||||||
|
JOIN inventory.pickings p ON sm.picking_id = p.id
|
||||||
|
WHERE sm.id = $1 AND sm.tenant_id = $2`,
|
||||||
|
[moveId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!move) {
|
||||||
|
throw new NotFoundError('Movimiento no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get location types
|
||||||
|
const [srcLoc, destLoc] = await Promise.all([
|
||||||
|
queryOne<{ location_type: string }>(
|
||||||
|
'SELECT location_type FROM inventory.locations WHERE id = $1',
|
||||||
|
[move.location_id]
|
||||||
|
),
|
||||||
|
queryOne<{ location_type: string }>(
|
||||||
|
'SELECT location_type FROM inventory.locations WHERE id = $1',
|
||||||
|
[move.location_dest_id]
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const srcIsInternal = srcLoc?.location_type === 'internal';
|
||||||
|
const destIsInternal = destLoc?.location_type === 'internal';
|
||||||
|
|
||||||
|
// Get product cost for new layers
|
||||||
|
const product = await queryOne<{ cost_price: number; valuation_method: string }>(
|
||||||
|
'SELECT cost_price, valuation_method FROM inventory.products WHERE id = $1',
|
||||||
|
[move.product_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!product) return;
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Incoming to internal location (create layer)
|
||||||
|
if (!srcIsInternal && destIsInternal) {
|
||||||
|
await this.createLayer({
|
||||||
|
product_id: move.product_id,
|
||||||
|
company_id: move.company_id,
|
||||||
|
quantity: Number(move.product_qty),
|
||||||
|
unit_cost: Number(product.cost_price),
|
||||||
|
stock_move_id: move.id,
|
||||||
|
description: `Recepción - Move ${move.id}`,
|
||||||
|
}, tenantId, userId, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outgoing from internal location (consume layer with FIFO)
|
||||||
|
if (srcIsInternal && !destIsInternal) {
|
||||||
|
if (product.valuation_method === 'fifo' || product.valuation_method === 'average') {
|
||||||
|
await this.consumeFifo(
|
||||||
|
move.product_id,
|
||||||
|
move.company_id,
|
||||||
|
Number(move.product_qty),
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update average cost if using that method
|
||||||
|
if (product.valuation_method === 'average') {
|
||||||
|
await this.updateProductAverageCost(
|
||||||
|
move.product_id,
|
||||||
|
move.company_id,
|
||||||
|
tenantId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const valuationService = new ValuationService();
|
||||||
299
src/modules/inventory/warehouses.service.ts
Normal file
299
src/modules/inventory/warehouses.service.ts
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Warehouse } from '../warehouses/entities/warehouse.entity.js';
|
||||||
|
import { Location } from './entities/location.entity.js';
|
||||||
|
import { StockQuant } from './entities/stock-quant.entity.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ===== Interfaces =====
|
||||||
|
|
||||||
|
export interface CreateWarehouseDto {
|
||||||
|
companyId?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
addressLine1?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWarehouseDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
addressLine1?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseFilters {
|
||||||
|
companyId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseWithRelations extends Warehouse {
|
||||||
|
companyName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Service Class =====
|
||||||
|
|
||||||
|
class WarehousesService {
|
||||||
|
private warehouseRepository: Repository<Warehouse>;
|
||||||
|
private locationRepository: Repository<Location>;
|
||||||
|
private stockQuantRepository: Repository<StockQuant>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.warehouseRepository = AppDataSource.getRepository(Warehouse);
|
||||||
|
this.locationRepository = AppDataSource.getRepository(Location);
|
||||||
|
this.stockQuantRepository = AppDataSource.getRepository(StockQuant);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: WarehouseFilters = {}
|
||||||
|
): Promise<{ data: WarehouseWithRelations[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const { companyId, isActive, page = 1, limit = 50 } = filters;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const queryBuilder = this.warehouseRepository
|
||||||
|
.createQueryBuilder('warehouse')
|
||||||
|
.leftJoinAndSelect('warehouse.company', 'company')
|
||||||
|
.where('warehouse.tenantId = :tenantId', { tenantId });
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
queryBuilder.andWhere('warehouse.companyId = :companyId', { companyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
queryBuilder.andWhere('warehouse.isActive = :isActive', { isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await queryBuilder.getCount();
|
||||||
|
|
||||||
|
const warehouses = await queryBuilder
|
||||||
|
.orderBy('warehouse.name', 'ASC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const data: WarehouseWithRelations[] = warehouses.map(w => ({
|
||||||
|
...w,
|
||||||
|
companyName: w.company?.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug('Warehouses retrieved', { tenantId, count: data.length, total });
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving warehouses', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<WarehouseWithRelations> {
|
||||||
|
try {
|
||||||
|
const warehouse = await this.warehouseRepository
|
||||||
|
.createQueryBuilder('warehouse')
|
||||||
|
.leftJoinAndSelect('warehouse.company', 'company')
|
||||||
|
.where('warehouse.id = :id', { id })
|
||||||
|
.andWhere('warehouse.tenantId = :tenantId', { tenantId })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!warehouse) {
|
||||||
|
throw new NotFoundError('Almacén no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...warehouse,
|
||||||
|
companyName: warehouse.company?.name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error finding warehouse', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateWarehouseDto, tenantId: string, userId: string): Promise<Warehouse> {
|
||||||
|
try {
|
||||||
|
// Check unique code within company
|
||||||
|
const existing = await this.warehouseRepository.findOne({
|
||||||
|
where: {
|
||||||
|
companyId: dto.companyId,
|
||||||
|
code: dto.code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe un almacén con código ${dto.code} en esta empresa`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If is_default, clear other defaults for company
|
||||||
|
if (dto.isDefault) {
|
||||||
|
await this.warehouseRepository.update(
|
||||||
|
{ companyId: dto.companyId, tenantId },
|
||||||
|
{ isDefault: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warehouseData: Partial<Warehouse> = {
|
||||||
|
tenantId,
|
||||||
|
companyId: dto.companyId,
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
description: dto.description,
|
||||||
|
addressLine1: dto.addressLine1,
|
||||||
|
city: dto.city,
|
||||||
|
state: dto.state,
|
||||||
|
postalCode: dto.postalCode,
|
||||||
|
isDefault: dto.isDefault || false,
|
||||||
|
createdBy: userId,
|
||||||
|
};
|
||||||
|
const warehouse = this.warehouseRepository.create(warehouseData as Warehouse);
|
||||||
|
|
||||||
|
await this.warehouseRepository.save(warehouse);
|
||||||
|
|
||||||
|
logger.info('Warehouse created', {
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
tenantId,
|
||||||
|
name: warehouse.name,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return warehouse;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating warehouse', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
dto,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateWarehouseDto, tenantId: string, userId: string): Promise<Warehouse> {
|
||||||
|
try {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// If setting as default, clear other defaults
|
||||||
|
if (dto.isDefault) {
|
||||||
|
await this.warehouseRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(Warehouse)
|
||||||
|
.set({ isDefault: false })
|
||||||
|
.where('companyId = :companyId', { companyId: existing.companyId })
|
||||||
|
.andWhere('tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('id != :id', { id })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.name !== undefined) existing.name = dto.name;
|
||||||
|
if (dto.description !== undefined) existing.description = dto.description;
|
||||||
|
if (dto.addressLine1 !== undefined) existing.addressLine1 = dto.addressLine1;
|
||||||
|
if (dto.city !== undefined) existing.city = dto.city;
|
||||||
|
if (dto.state !== undefined) existing.state = dto.state;
|
||||||
|
if (dto.postalCode !== undefined) existing.postalCode = dto.postalCode;
|
||||||
|
if (dto.isDefault !== undefined) existing.isDefault = dto.isDefault;
|
||||||
|
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
|
||||||
|
|
||||||
|
existing.updatedBy = userId;
|
||||||
|
|
||||||
|
await this.warehouseRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Warehouse updated', {
|
||||||
|
warehouseId: id,
|
||||||
|
tenantId,
|
||||||
|
updatedBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating warehouse', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if warehouse has locations with stock
|
||||||
|
const hasStock = await this.stockQuantRepository
|
||||||
|
.createQueryBuilder('sq')
|
||||||
|
.innerJoin('sq.location', 'location')
|
||||||
|
.where('location.warehouseId = :warehouseId', { warehouseId: id })
|
||||||
|
.andWhere('sq.quantity > 0')
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
if (hasStock > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un almacén que tiene stock');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.warehouseRepository.delete({ id, tenantId });
|
||||||
|
|
||||||
|
logger.info('Warehouse deleted', {
|
||||||
|
warehouseId: id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting warehouse', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocations(warehouseId: string, tenantId: string): Promise<Location[]> {
|
||||||
|
await this.findById(warehouseId, tenantId);
|
||||||
|
|
||||||
|
return this.locationRepository.find({
|
||||||
|
where: {
|
||||||
|
warehouseId,
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStock(warehouseId: string, tenantId: string): Promise<any[]> {
|
||||||
|
await this.findById(warehouseId, tenantId);
|
||||||
|
|
||||||
|
const stock = await this.stockQuantRepository
|
||||||
|
.createQueryBuilder('sq')
|
||||||
|
.innerJoinAndSelect('sq.product', 'product')
|
||||||
|
.innerJoinAndSelect('sq.location', 'location')
|
||||||
|
.where('location.warehouseId = :warehouseId', { warehouseId })
|
||||||
|
.orderBy('product.name', 'ASC')
|
||||||
|
.addOrderBy('location.name', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return stock.map(sq => ({
|
||||||
|
...sq,
|
||||||
|
productName: sq.product?.name,
|
||||||
|
productCode: sq.product?.code,
|
||||||
|
locationName: sq.location?.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const warehousesService = new WarehousesService();
|
||||||
292
src/modules/partners/__tests__/partners.controller.test.ts
Normal file
292
src/modules/partners/__tests__/partners.controller.test.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { createMockPartner } from '../../../__tests__/helpers.js';
|
||||||
|
import { AuthenticatedRequest } from '../../../shared/types/index.js';
|
||||||
|
|
||||||
|
// Mock the service
|
||||||
|
const mockFindAll = jest.fn();
|
||||||
|
const mockFindById = jest.fn();
|
||||||
|
const mockCreate = jest.fn();
|
||||||
|
const mockUpdate = jest.fn();
|
||||||
|
const mockDelete = jest.fn();
|
||||||
|
const mockFindCustomers = jest.fn();
|
||||||
|
const mockFindSuppliers = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../partners.service.js', () => ({
|
||||||
|
partnersService: {
|
||||||
|
findAll: (...args: any[]) => mockFindAll(...args),
|
||||||
|
findById: (...args: any[]) => mockFindById(...args),
|
||||||
|
create: (...args: any[]) => mockCreate(...args),
|
||||||
|
update: (...args: any[]) => mockUpdate(...args),
|
||||||
|
delete: (...args: any[]) => mockDelete(...args),
|
||||||
|
findCustomers: (...args: any[]) => mockFindCustomers(...args),
|
||||||
|
findSuppliers: (...args: any[]) => mockFindSuppliers(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { partnersController } from '../partners.controller.js';
|
||||||
|
|
||||||
|
describe('PartnersController', () => {
|
||||||
|
let mockReq: Partial<AuthenticatedRequest>;
|
||||||
|
let mockRes: Partial<Response>;
|
||||||
|
let mockNext: NextFunction;
|
||||||
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
const userId = 'test-user-uuid';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockReq = {
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
email: 'test@test.com',
|
||||||
|
role: 'admin',
|
||||||
|
} as any,
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
body: {},
|
||||||
|
};
|
||||||
|
mockRes = {
|
||||||
|
status: jest.fn().mockReturnThis() as any,
|
||||||
|
json: jest.fn() as any,
|
||||||
|
};
|
||||||
|
mockNext = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return paginated partners', async () => {
|
||||||
|
const mockPartners = {
|
||||||
|
data: [createMockPartner()],
|
||||||
|
total: 1,
|
||||||
|
};
|
||||||
|
mockFindAll.mockResolvedValue(mockPartners);
|
||||||
|
mockReq.query = { page: '1', limit: '20' };
|
||||||
|
|
||||||
|
await partnersController.findAll(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFindAll).toHaveBeenCalledWith(tenantId, expect.objectContaining({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
}));
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
data: mockPartners.data,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply filters from query params', async () => {
|
||||||
|
mockFindAll.mockResolvedValue({ data: [], total: 0 });
|
||||||
|
mockReq.query = { search: 'test', partnerType: 'customer', isActive: 'true' };
|
||||||
|
|
||||||
|
await partnersController.findAll(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFindAll).toHaveBeenCalledWith(tenantId, expect.objectContaining({
|
||||||
|
search: 'test',
|
||||||
|
partnerType: 'customer',
|
||||||
|
isActive: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next with error on invalid query', async () => {
|
||||||
|
mockReq.query = { page: 'invalid' };
|
||||||
|
|
||||||
|
await partnersController.findAll(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should return partner when found', async () => {
|
||||||
|
const mockPartner = createMockPartner();
|
||||||
|
mockFindById.mockResolvedValue(mockPartner);
|
||||||
|
mockReq.params = { id: 'partner-uuid-1' };
|
||||||
|
|
||||||
|
await partnersController.findById(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFindById).toHaveBeenCalledWith('partner-uuid-1', tenantId);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
data: mockPartner,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next with error when partner not found', async () => {
|
||||||
|
mockFindById.mockRejectedValue(new Error('Not found'));
|
||||||
|
mockReq.params = { id: 'nonexistent' };
|
||||||
|
|
||||||
|
await partnersController.findById(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create partner successfully', async () => {
|
||||||
|
const mockPartner = createMockPartner();
|
||||||
|
mockCreate.mockResolvedValue(mockPartner);
|
||||||
|
mockReq.body = {
|
||||||
|
code: 'PART-001',
|
||||||
|
displayName: 'New Partner',
|
||||||
|
email: 'new@partner.com',
|
||||||
|
partnerType: 'customer',
|
||||||
|
};
|
||||||
|
|
||||||
|
await partnersController.create(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'PART-001',
|
||||||
|
displayName: 'New Partner',
|
||||||
|
email: 'new@partner.com',
|
||||||
|
partnerType: 'customer',
|
||||||
|
}),
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required fields', async () => {
|
||||||
|
mockReq.body = {}; // Missing required code
|
||||||
|
|
||||||
|
await partnersController.create(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalled();
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept snake_case fields', async () => {
|
||||||
|
const mockPartner = createMockPartner();
|
||||||
|
mockCreate.mockResolvedValue(mockPartner);
|
||||||
|
mockReq.body = {
|
||||||
|
code: 'PART-002',
|
||||||
|
display_name: 'Snake Case Partner',
|
||||||
|
partner_type: 'supplier',
|
||||||
|
};
|
||||||
|
|
||||||
|
await partnersController.create(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update partner successfully', async () => {
|
||||||
|
const mockPartner = createMockPartner({ displayName: 'Updated Name' });
|
||||||
|
mockUpdate.mockResolvedValue(mockPartner);
|
||||||
|
mockReq.params = { id: 'partner-uuid-1' };
|
||||||
|
mockReq.body = { displayName: 'Updated Name' };
|
||||||
|
|
||||||
|
await partnersController.update(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
'partner-uuid-1',
|
||||||
|
expect.objectContaining({ displayName: 'Updated Name' }),
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next with error on invalid data', async () => {
|
||||||
|
mockReq.params = { id: 'partner-uuid-1' };
|
||||||
|
mockReq.body = { email: 'not-an-email' };
|
||||||
|
|
||||||
|
await partnersController.update(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete partner successfully', async () => {
|
||||||
|
mockDelete.mockResolvedValue(undefined);
|
||||||
|
mockReq.params = { id: 'partner-uuid-1' };
|
||||||
|
|
||||||
|
await partnersController.delete(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('partner-uuid-1', tenantId, userId);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findCustomers', () => {
|
||||||
|
it('should return only customers', async () => {
|
||||||
|
const mockCustomers = { data: [createMockPartner({ partnerType: 'customer' })], total: 1 };
|
||||||
|
mockFindCustomers.mockResolvedValue(mockCustomers);
|
||||||
|
mockReq.query = {};
|
||||||
|
|
||||||
|
await partnersController.findCustomers(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFindCustomers).toHaveBeenCalledWith(tenantId, expect.any(Object));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findSuppliers', () => {
|
||||||
|
it('should return only suppliers', async () => {
|
||||||
|
const mockSuppliers = { data: [createMockPartner({ partnerType: 'supplier' })], total: 1 };
|
||||||
|
mockFindSuppliers.mockResolvedValue(mockSuppliers);
|
||||||
|
mockReq.query = {};
|
||||||
|
|
||||||
|
await partnersController.findSuppliers(
|
||||||
|
mockReq as AuthenticatedRequest,
|
||||||
|
mockRes as Response,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFindSuppliers).toHaveBeenCalledWith(tenantId, expect.any(Object));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
325
src/modules/partners/__tests__/partners.service.test.ts
Normal file
325
src/modules/partners/__tests__/partners.service.test.ts
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockRepository, createMockQueryBuilder, createMockPartner } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock dependencies before importing service
|
||||||
|
const mockRepository = createMockRepository();
|
||||||
|
const mockQueryBuilder = createMockQueryBuilder();
|
||||||
|
|
||||||
|
jest.mock('../../../config/typeorm.js', () => ({
|
||||||
|
AppDataSource: {
|
||||||
|
getRepository: jest.fn(() => mockRepository),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../shared/utils/logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { partnersService } from '../partners.service.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../../shared/types/index.js';
|
||||||
|
|
||||||
|
describe('PartnersService', () => {
|
||||||
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
const userId = 'test-user-uuid';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return partners with pagination', async () => {
|
||||||
|
const mockPartners = [
|
||||||
|
createMockPartner({ id: '1', displayName: 'Partner A' }),
|
||||||
|
createMockPartner({ id: '2', displayName: 'Partner B' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockQueryBuilder.getCount.mockResolvedValue(2);
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue(mockPartners);
|
||||||
|
|
||||||
|
const result = await partnersService.findAll(tenantId, { page: 1, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
|
||||||
|
'partner.tenantId = :tenantId',
|
||||||
|
{ tenantId }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by search term', async () => {
|
||||||
|
mockQueryBuilder.getCount.mockResolvedValue(1);
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue([createMockPartner()]);
|
||||||
|
|
||||||
|
await partnersService.findAll(tenantId, { search: 'test' });
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('partner.displayName ILIKE :search'),
|
||||||
|
{ search: '%test%' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by partner type', async () => {
|
||||||
|
mockQueryBuilder.getCount.mockResolvedValue(1);
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue([createMockPartner()]);
|
||||||
|
|
||||||
|
await partnersService.findAll(tenantId, { partnerType: 'customer' });
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
'partner.partnerType = :partnerType',
|
||||||
|
{ partnerType: 'customer' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by active status', async () => {
|
||||||
|
mockQueryBuilder.getCount.mockResolvedValue(1);
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue([createMockPartner()]);
|
||||||
|
|
||||||
|
await partnersService.findAll(tenantId, { isActive: true });
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
'partner.isActive = :isActive',
|
||||||
|
{ isActive: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply pagination correctly', async () => {
|
||||||
|
mockQueryBuilder.getCount.mockResolvedValue(50);
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await partnersService.findAll(tenantId, { page: 3, limit: 10 });
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20);
|
||||||
|
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should return partner when found', async () => {
|
||||||
|
const mockPartner = createMockPartner();
|
||||||
|
mockRepository.findOne.mockResolvedValue(mockPartner);
|
||||||
|
|
||||||
|
const result = await partnersService.findById('partner-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockPartner);
|
||||||
|
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: 'partner-uuid-1',
|
||||||
|
tenantId,
|
||||||
|
deletedAt: expect.anything(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when partner not found', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
partnersService.findById('nonexistent-id', tenantId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce tenant isolation', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
partnersService.findById('partner-uuid-1', 'different-tenant')
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const createDto = {
|
||||||
|
code: 'PART-001',
|
||||||
|
displayName: 'New Partner',
|
||||||
|
email: 'new@partner.com',
|
||||||
|
partnerType: 'customer' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create partner successfully', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(null); // No existing partner
|
||||||
|
const savedPartner = createMockPartner({ ...createDto });
|
||||||
|
mockRepository.create.mockReturnValue(savedPartner);
|
||||||
|
mockRepository.save.mockResolvedValue(savedPartner);
|
||||||
|
|
||||||
|
const result = await partnersService.create(createDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(result).toEqual(savedPartner);
|
||||||
|
expect(mockRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tenantId,
|
||||||
|
code: createDto.code,
|
||||||
|
displayName: createDto.displayName,
|
||||||
|
createdBy: userId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when code already exists', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(createMockPartner());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
partnersService.create(createDto, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize email to lowercase', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(null);
|
||||||
|
const savedPartner = createMockPartner();
|
||||||
|
mockRepository.create.mockReturnValue(savedPartner);
|
||||||
|
mockRepository.save.mockResolvedValue(savedPartner);
|
||||||
|
|
||||||
|
await partnersService.create(
|
||||||
|
{ ...createDto, email: 'TEST@PARTNER.COM' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: 'test@partner.com',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set default values correctly', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(null);
|
||||||
|
const savedPartner = createMockPartner();
|
||||||
|
mockRepository.create.mockReturnValue(savedPartner);
|
||||||
|
mockRepository.save.mockResolvedValue(savedPartner);
|
||||||
|
|
||||||
|
await partnersService.create(
|
||||||
|
{ code: 'PART-001', displayName: 'Partner' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
partnerType: 'customer',
|
||||||
|
paymentTermDays: 0,
|
||||||
|
creditLimit: 0,
|
||||||
|
discountPercent: 0,
|
||||||
|
isActive: true,
|
||||||
|
isVerified: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update partner successfully', async () => {
|
||||||
|
const existingPartner = createMockPartner();
|
||||||
|
mockRepository.findOne.mockResolvedValue(existingPartner);
|
||||||
|
mockRepository.save.mockResolvedValue({ ...existingPartner, displayName: 'Updated Name' });
|
||||||
|
|
||||||
|
const result = await partnersService.update(
|
||||||
|
'partner-uuid-1',
|
||||||
|
{ displayName: 'Updated Name' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRepository.save).toHaveBeenCalled();
|
||||||
|
expect(result.displayName).toBe('Updated Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when partner not found', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
partnersService.update('nonexistent-id', { displayName: 'Test' }, tenantId, userId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update credit limit', async () => {
|
||||||
|
const existingPartner = createMockPartner({ creditLimit: 1000 });
|
||||||
|
mockRepository.findOne.mockResolvedValue(existingPartner);
|
||||||
|
mockRepository.save.mockResolvedValue({ ...existingPartner, creditLimit: 5000 });
|
||||||
|
|
||||||
|
await partnersService.update(
|
||||||
|
'partner-uuid-1',
|
||||||
|
{ creditLimit: 5000 },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(existingPartner.creditLimit).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set updatedBy field', async () => {
|
||||||
|
const existingPartner = createMockPartner();
|
||||||
|
mockRepository.findOne.mockResolvedValue(existingPartner);
|
||||||
|
mockRepository.save.mockResolvedValue(existingPartner);
|
||||||
|
|
||||||
|
await partnersService.update(
|
||||||
|
'partner-uuid-1',
|
||||||
|
{ displayName: 'Updated' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(existingPartner.updatedBy).toBe(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should soft delete partner', async () => {
|
||||||
|
const existingPartner = createMockPartner();
|
||||||
|
mockRepository.findOne.mockResolvedValue(existingPartner);
|
||||||
|
mockRepository.save.mockResolvedValue(existingPartner);
|
||||||
|
|
||||||
|
await partnersService.delete('partner-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(existingPartner.deletedAt).toBeInstanceOf(Date);
|
||||||
|
expect(existingPartner.isActive).toBe(false);
|
||||||
|
expect(mockRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when partner not found', async () => {
|
||||||
|
mockRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
partnersService.delete('nonexistent-id', tenantId, userId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findCustomers', () => {
|
||||||
|
it('should return only customers', async () => {
|
||||||
|
const mockCustomers = [createMockPartner({ partnerType: 'customer' })];
|
||||||
|
mockQueryBuilder.getCount.mockResolvedValue(1);
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue(mockCustomers);
|
||||||
|
|
||||||
|
const result = await partnersService.findCustomers(tenantId, {});
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
'partner.partnerType = :partnerType',
|
||||||
|
{ partnerType: 'customer' }
|
||||||
|
);
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findSuppliers', () => {
|
||||||
|
it('should return only suppliers', async () => {
|
||||||
|
const mockSuppliers = [createMockPartner({ partnerType: 'supplier' })];
|
||||||
|
mockQueryBuilder.getCount.mockResolvedValue(1);
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue(mockSuppliers);
|
||||||
|
|
||||||
|
const result = await partnersService.findSuppliers(tenantId, {});
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
'partner.partnerType = :partnerType',
|
||||||
|
{ partnerType: 'supplier' }
|
||||||
|
);
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/modules/partners/controllers/index.ts
Normal file
1
src/modules/partners/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { PartnersController } from './partners.controller';
|
||||||
348
src/modules/partners/controllers/partners.controller.ts
Normal file
348
src/modules/partners/controllers/partners.controller.ts
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { PartnersService } from '../services/partners.service';
|
||||||
|
import {
|
||||||
|
CreatePartnerDto,
|
||||||
|
UpdatePartnerDto,
|
||||||
|
CreatePartnerAddressDto,
|
||||||
|
CreatePartnerContactDto,
|
||||||
|
CreatePartnerBankAccountDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class PartnersController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly partnersService: PartnersService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Partners
|
||||||
|
this.router.get('/', this.findAll.bind(this));
|
||||||
|
this.router.get('/customers', this.getCustomers.bind(this));
|
||||||
|
this.router.get('/suppliers', this.getSuppliers.bind(this));
|
||||||
|
this.router.get('/:id', this.findOne.bind(this));
|
||||||
|
this.router.get('/code/:code', this.findByCode.bind(this));
|
||||||
|
this.router.post('/', this.create.bind(this));
|
||||||
|
this.router.patch('/:id', this.update.bind(this));
|
||||||
|
this.router.delete('/:id', this.delete.bind(this));
|
||||||
|
|
||||||
|
// Addresses
|
||||||
|
this.router.get('/:id/addresses', this.getAddresses.bind(this));
|
||||||
|
this.router.post('/:id/addresses', this.createAddress.bind(this));
|
||||||
|
this.router.delete('/:id/addresses/:addressId', this.deleteAddress.bind(this));
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
this.router.get('/:id/contacts', this.getContacts.bind(this));
|
||||||
|
this.router.post('/:id/contacts', this.createContact.bind(this));
|
||||||
|
this.router.delete('/:id/contacts/:contactId', this.deleteContact.bind(this));
|
||||||
|
|
||||||
|
// Bank Accounts
|
||||||
|
this.router.get('/:id/bank-accounts', this.getBankAccounts.bind(this));
|
||||||
|
this.router.post('/:id/bank-accounts', this.createBankAccount.bind(this));
|
||||||
|
this.router.delete('/:id/bank-accounts/:accountId', this.deleteBankAccount.bind(this));
|
||||||
|
this.router.post('/:id/bank-accounts/:accountId/verify', this.verifyBankAccount.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Partners ====================
|
||||||
|
|
||||||
|
private async findAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { search, partnerType, category, isActive, salesRepId, limit, offset } = req.query;
|
||||||
|
|
||||||
|
const result = await this.partnersService.findAll({
|
||||||
|
tenantId,
|
||||||
|
search: search as string,
|
||||||
|
partnerType: partnerType as 'customer' | 'supplier' | 'both',
|
||||||
|
category: category as string,
|
||||||
|
isActive: isActive ? isActive === 'true' : undefined,
|
||||||
|
salesRepId: salesRepId as string,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||||
|
offset: offset ? parseInt(offset as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOne(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const partner = await this.partnersService.findOne(id, tenantId);
|
||||||
|
|
||||||
|
if (!partner) {
|
||||||
|
res.status(404).json({ error: 'Partner not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: partner });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = req.params;
|
||||||
|
const partner = await this.partnersService.findByCode(code, tenantId);
|
||||||
|
|
||||||
|
if (!partner) {
|
||||||
|
res.status(404).json({ error: 'Partner not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: partner });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePartnerDto = req.body;
|
||||||
|
const partner = await this.partnersService.create(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: partner });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const dto: UpdatePartnerDto = req.body;
|
||||||
|
const partner = await this.partnersService.update(id, tenantId, dto, userId);
|
||||||
|
|
||||||
|
if (!partner) {
|
||||||
|
res.status(404).json({ error: 'Partner not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: partner });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const deleted = await this.partnersService.delete(id, tenantId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Partner not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCustomers(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customers = await this.partnersService.getCustomers(tenantId);
|
||||||
|
res.json({ data: customers });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSuppliers(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppliers = await this.partnersService.getSuppliers(tenantId);
|
||||||
|
res.json({ data: suppliers });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Addresses ====================
|
||||||
|
|
||||||
|
private async getAddresses(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const addresses = await this.partnersService.getAddresses(id);
|
||||||
|
res.json({ data: addresses });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createAddress(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const dto: CreatePartnerAddressDto = { ...req.body, partnerId: id };
|
||||||
|
const address = await this.partnersService.createAddress(dto);
|
||||||
|
res.status(201).json({ data: address });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteAddress(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { addressId } = req.params;
|
||||||
|
const deleted = await this.partnersService.deleteAddress(addressId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Address not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Contacts ====================
|
||||||
|
|
||||||
|
private async getContacts(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const contacts = await this.partnersService.getContacts(id);
|
||||||
|
res.json({ data: contacts });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createContact(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const dto: CreatePartnerContactDto = { ...req.body, partnerId: id };
|
||||||
|
const contact = await this.partnersService.createContact(dto);
|
||||||
|
res.status(201).json({ data: contact });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteContact(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { contactId } = req.params;
|
||||||
|
const deleted = await this.partnersService.deleteContact(contactId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Contact not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Bank Accounts ====================
|
||||||
|
|
||||||
|
private async getBankAccounts(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const bankAccounts = await this.partnersService.getBankAccounts(id);
|
||||||
|
res.json({ data: bankAccounts });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBankAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const dto: CreatePartnerBankAccountDto = { ...req.body, partnerId: id };
|
||||||
|
const bankAccount = await this.partnersService.createBankAccount(dto);
|
||||||
|
res.status(201).json({ data: bankAccount });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteBankAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
const deleted = await this.partnersService.deleteBankAccount(accountId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Bank account not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyBankAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
const bankAccount = await this.partnersService.verifyBankAccount(accountId);
|
||||||
|
|
||||||
|
if (!bankAccount) {
|
||||||
|
res.status(404).json({ error: 'Bank account not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: bankAccount });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
389
src/modules/partners/dto/create-partner.dto.ts
Normal file
389
src/modules/partners/dto/create-partner.dto.ts
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
IsEnum,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePartnerDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
legalName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['customer', 'supplier', 'both'])
|
||||||
|
partnerType?: 'customer' | 'supplier' | 'both';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
taxId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
taxRegime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
cfdiUse?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
mobile?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
paymentTermDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
creditLimit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
priceListId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
discountPercent?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
salesRepId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePartnerDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
legalName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['customer', 'supplier', 'both'])
|
||||||
|
partnerType?: 'customer' | 'supplier' | 'both';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
taxId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
taxRegime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
cfdiUse?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
mobile?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
paymentTermDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
creditLimit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
priceListId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
discountPercent?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
salesRepId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatePartnerAddressDto {
|
||||||
|
@IsUUID()
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['billing', 'shipping', 'both'])
|
||||||
|
addressType?: 'billing' | 'shipping' | 'both';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
street: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
exteriorNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
interiorNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
neighborhood?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
municipality?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
postalCode: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reference?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
longitude?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatePartnerContactDto {
|
||||||
|
@IsUUID()
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
position?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
department?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
mobile?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
extension?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPrimary?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isBillingContact?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isShippingContact?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
receivesNotifications?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatePartnerBankAccountDto {
|
||||||
|
@IsUUID()
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
bankName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
bankCode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
accountNumber: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
clabe?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['checking', 'savings'])
|
||||||
|
accountType?: 'checking' | 'savings';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
beneficiaryName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
beneficiaryTaxId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
swiftCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
7
src/modules/partners/dto/index.ts
Normal file
7
src/modules/partners/dto/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
CreatePartnerDto,
|
||||||
|
UpdatePartnerDto,
|
||||||
|
CreatePartnerAddressDto,
|
||||||
|
CreatePartnerContactDto,
|
||||||
|
CreatePartnerBankAccountDto,
|
||||||
|
} from './create-partner.dto';
|
||||||
7
src/modules/partners/entities/index.ts
Normal file
7
src/modules/partners/entities/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { Partner } from './partner.entity';
|
||||||
|
export { PartnerAddress } from './partner-address.entity';
|
||||||
|
export { PartnerContact } from './partner-contact.entity';
|
||||||
|
export { PartnerBankAccount } from './partner-bank-account.entity';
|
||||||
|
|
||||||
|
// Type aliases
|
||||||
|
export type PartnerType = 'customer' | 'supplier' | 'both';
|
||||||
82
src/modules/partners/entities/partner-address.entity.ts
Normal file
82
src/modules/partners/entities/partner-address.entity.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Partner } from './partner.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'partner_addresses', schema: 'partners' })
|
||||||
|
export class PartnerAddress {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'partner_id', type: 'uuid' })
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Partner, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'partner_id' })
|
||||||
|
partner: Partner;
|
||||||
|
|
||||||
|
// Tipo de direccion
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' })
|
||||||
|
addressType: 'billing' | 'shipping' | 'both';
|
||||||
|
|
||||||
|
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
// Direccion
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 200 })
|
||||||
|
street: string;
|
||||||
|
|
||||||
|
@Column({ name: 'exterior_number', type: 'varchar', length: 20, nullable: true })
|
||||||
|
exteriorNumber: string;
|
||||||
|
|
||||||
|
@Column({ name: 'interior_number', type: 'varchar', length: 20, nullable: true })
|
||||||
|
interiorNumber: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
neighborhood: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
municipality: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
@Column({ name: 'postal_code', type: 'varchar', length: 10 })
|
||||||
|
postalCode: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MEX' })
|
||||||
|
country: string;
|
||||||
|
|
||||||
|
// Referencia
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
reference: string;
|
||||||
|
|
||||||
|
// Geolocalizacion
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
77
src/modules/partners/entities/partner-bank-account.entity.ts
Normal file
77
src/modules/partners/entities/partner-bank-account.entity.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Partner } from './partner.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'partner_bank_accounts', schema: 'partners' })
|
||||||
|
export class PartnerBankAccount {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'partner_id', type: 'uuid' })
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Partner, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'partner_id' })
|
||||||
|
partner: Partner;
|
||||||
|
|
||||||
|
// Banco
|
||||||
|
@Column({ name: 'bank_name', type: 'varchar', length: 100 })
|
||||||
|
bankName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'bank_code', type: 'varchar', length: 10, nullable: true })
|
||||||
|
bankCode: string;
|
||||||
|
|
||||||
|
// Cuenta
|
||||||
|
@Column({ name: 'account_number', type: 'varchar', length: 30 })
|
||||||
|
accountNumber: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
clabe: string;
|
||||||
|
|
||||||
|
@Column({ name: 'account_type', type: 'varchar', length: 20, default: 'checking' })
|
||||||
|
accountType: 'checking' | 'savings';
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
// Titular
|
||||||
|
@Column({ name: 'beneficiary_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
beneficiaryName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'beneficiary_tax_id', type: 'varchar', length: 20, nullable: true })
|
||||||
|
beneficiaryTaxId: string;
|
||||||
|
|
||||||
|
// Swift para transferencias internacionales
|
||||||
|
@Column({ name: 'swift_code', type: 'varchar', length: 20, nullable: true })
|
||||||
|
swiftCode: string;
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_verified', type: 'boolean', default: false })
|
||||||
|
isVerified: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
|
||||||
|
verifiedAt: Date;
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
72
src/modules/partners/entities/partner-contact.entity.ts
Normal file
72
src/modules/partners/entities/partner-contact.entity.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Partner } from './partner.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'partner_contacts', schema: 'partners' })
|
||||||
|
export class PartnerContact {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'partner_id', type: 'uuid' })
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Partner, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'partner_id' })
|
||||||
|
partner: Partner;
|
||||||
|
|
||||||
|
// Datos del contacto
|
||||||
|
@Column({ name: 'full_name', type: 'varchar', length: 200 })
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
position: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
department: string;
|
||||||
|
|
||||||
|
// Contacto
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||||
|
mobile: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||||
|
extension: string;
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
@Column({ name: 'is_primary', type: 'boolean', default: false })
|
||||||
|
isPrimary: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_billing_contact', type: 'boolean', default: false })
|
||||||
|
isBillingContact: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_shipping_contact', type: 'boolean', default: false })
|
||||||
|
isShippingContact: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'receives_notifications', type: 'boolean', default: true })
|
||||||
|
receivesNotifications: boolean;
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
118
src/modules/partners/entities/partner.entity.ts
Normal file
118
src/modules/partners/entities/partner.entity.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'partners', schema: 'partners' })
|
||||||
|
export class Partner {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ name: 'display_name', type: 'varchar', length: 200 })
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'legal_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
legalName: string;
|
||||||
|
|
||||||
|
// Tipo de partner
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'partner_type', type: 'varchar', length: 20, default: 'customer' })
|
||||||
|
partnerType: 'customer' | 'supplier' | 'both';
|
||||||
|
|
||||||
|
// Fiscal
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
|
||||||
|
taxId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_regime', type: 'varchar', length: 100, nullable: true })
|
||||||
|
taxRegime: string;
|
||||||
|
|
||||||
|
@Column({ name: 'cfdi_use', type: 'varchar', length: 10, nullable: true })
|
||||||
|
cfdiUse: string;
|
||||||
|
|
||||||
|
// Contacto principal
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||||
|
mobile: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
website: string;
|
||||||
|
|
||||||
|
// Terminos de pago
|
||||||
|
@Column({ name: 'payment_term_days', type: 'int', default: 0 })
|
||||||
|
paymentTermDays: number;
|
||||||
|
|
||||||
|
@Column({ name: 'credit_limit', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
creditLimit: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_balance', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
currentBalance: number;
|
||||||
|
|
||||||
|
// Lista de precios
|
||||||
|
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
|
||||||
|
priceListId: string;
|
||||||
|
|
||||||
|
// Descuentos
|
||||||
|
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||||
|
discountPercent: number;
|
||||||
|
|
||||||
|
// Categoria
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', array: true, default: '{}' })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
// Notas
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_verified', type: 'boolean', default: false })
|
||||||
|
isVerified: boolean;
|
||||||
|
|
||||||
|
// Vendedor asignado
|
||||||
|
@Column({ name: 'sales_rep_id', type: 'uuid', nullable: true })
|
||||||
|
salesRepId: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy: string;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
5
src/modules/partners/index.ts
Normal file
5
src/modules/partners/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { PartnersModule, PartnersModuleOptions } from './partners.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
363
src/modules/partners/partners.controller.ts
Normal file
363
src/modules/partners/partners.controller.ts
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { partnersService, CreatePartnerDto, UpdatePartnerDto, PartnerFilters, PartnerType } from './partners.service.js';
|
||||||
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// Validation schemas (accept both snake_case and camelCase from frontend)
|
||||||
|
const createPartnerSchema = z.object({
|
||||||
|
code: z.string().min(1, 'El código es requerido').max(20),
|
||||||
|
display_name: z.string().min(1).max(200).optional(),
|
||||||
|
displayName: z.string().min(1, 'El nombre es requerido').max(200).optional(),
|
||||||
|
legal_name: z.string().max(200).optional(),
|
||||||
|
legalName: z.string().max(200).optional(),
|
||||||
|
partner_type: z.enum(['customer', 'supplier', 'both']).default('customer'),
|
||||||
|
partnerType: z.enum(['customer', 'supplier', 'both']).default('customer'),
|
||||||
|
email: z.string().email('Email inválido').max(255).optional(),
|
||||||
|
phone: z.string().max(30).optional(),
|
||||||
|
mobile: z.string().max(30).optional(),
|
||||||
|
website: z.string().max(500).optional(),
|
||||||
|
tax_id: z.string().max(20).optional(),
|
||||||
|
taxId: z.string().max(20).optional(),
|
||||||
|
tax_regime: z.string().max(100).optional(),
|
||||||
|
taxRegime: z.string().max(100).optional(),
|
||||||
|
cfdi_use: z.string().max(10).optional(),
|
||||||
|
cfdiUse: z.string().max(10).optional(),
|
||||||
|
payment_term_days: z.coerce.number().int().default(0),
|
||||||
|
paymentTermDays: z.coerce.number().int().default(0),
|
||||||
|
credit_limit: z.coerce.number().default(0),
|
||||||
|
creditLimit: z.coerce.number().default(0),
|
||||||
|
price_list_id: z.string().uuid().optional(),
|
||||||
|
priceListId: z.string().uuid().optional(),
|
||||||
|
discount_percent: z.coerce.number().default(0),
|
||||||
|
discountPercent: z.coerce.number().default(0),
|
||||||
|
category: z.string().max(50).optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
sales_rep_id: z.string().uuid().optional(),
|
||||||
|
salesRepId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePartnerSchema = z.object({
|
||||||
|
display_name: z.string().min(1).max(200).optional(),
|
||||||
|
displayName: z.string().min(1).max(200).optional(),
|
||||||
|
legal_name: z.string().max(200).optional().nullable(),
|
||||||
|
legalName: z.string().max(200).optional().nullable(),
|
||||||
|
partner_type: z.enum(['customer', 'supplier', 'both']).optional(),
|
||||||
|
partnerType: z.enum(['customer', 'supplier', 'both']).optional(),
|
||||||
|
email: z.string().email('Email inválido').max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(30).optional().nullable(),
|
||||||
|
mobile: z.string().max(30).optional().nullable(),
|
||||||
|
website: z.string().max(500).optional().nullable(),
|
||||||
|
tax_id: z.string().max(20).optional().nullable(),
|
||||||
|
taxId: z.string().max(20).optional().nullable(),
|
||||||
|
tax_regime: z.string().max(100).optional().nullable(),
|
||||||
|
taxRegime: z.string().max(100).optional().nullable(),
|
||||||
|
cfdi_use: z.string().max(10).optional().nullable(),
|
||||||
|
cfdiUse: z.string().max(10).optional().nullable(),
|
||||||
|
payment_term_days: z.coerce.number().int().optional(),
|
||||||
|
paymentTermDays: z.coerce.number().int().optional(),
|
||||||
|
credit_limit: z.coerce.number().optional(),
|
||||||
|
creditLimit: z.coerce.number().optional(),
|
||||||
|
price_list_id: z.string().uuid().optional().nullable(),
|
||||||
|
priceListId: z.string().uuid().optional().nullable(),
|
||||||
|
discount_percent: z.coerce.number().optional(),
|
||||||
|
discountPercent: z.coerce.number().optional(),
|
||||||
|
category: z.string().max(50).optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
is_verified: z.boolean().optional(),
|
||||||
|
isVerified: z.boolean().optional(),
|
||||||
|
sales_rep_id: z.string().uuid().optional().nullable(),
|
||||||
|
salesRepId: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
partner_type: z.enum(['customer', 'supplier', 'both']).optional(),
|
||||||
|
partnerType: z.enum(['customer', 'supplier', 'both']).optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
is_active: z.coerce.boolean().optional(),
|
||||||
|
isActive: z.coerce.boolean().optional(),
|
||||||
|
is_verified: z.coerce.boolean().optional(),
|
||||||
|
isVerified: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class PartnersController {
|
||||||
|
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = queryResult.data;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const filters: PartnerFilters = {
|
||||||
|
search: data.search,
|
||||||
|
partnerType: (data.partnerType || data.partner_type) as PartnerType | undefined,
|
||||||
|
category: data.category,
|
||||||
|
isActive: data.isActive ?? data.is_active,
|
||||||
|
isVerified: data.isVerified ?? data.is_verified,
|
||||||
|
page: data.page,
|
||||||
|
limit: data.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await partnersService.findAll(tenantId, filters);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page || 1,
|
||||||
|
limit: filters.limit || 20,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCustomers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = queryResult.data;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const filters = {
|
||||||
|
search: data.search,
|
||||||
|
category: data.category,
|
||||||
|
isActive: data.isActive ?? data.is_active,
|
||||||
|
isVerified: data.isVerified ?? data.is_verified,
|
||||||
|
page: data.page,
|
||||||
|
limit: data.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await partnersService.findCustomers(tenantId, filters);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page || 1,
|
||||||
|
limit: filters.limit || 20,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSuppliers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = queryResult.data;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const filters = {
|
||||||
|
search: data.search,
|
||||||
|
category: data.category,
|
||||||
|
isActive: data.isActive ?? data.is_active,
|
||||||
|
isVerified: data.isVerified ?? data.is_verified,
|
||||||
|
page: data.page,
|
||||||
|
limit: data.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await partnersService.findSuppliers(tenantId, filters);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page || 1,
|
||||||
|
limit: filters.limit || 20,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const partner = await partnersService.findById(id, tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: partner,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPartnerSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contacto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// Transform to camelCase DTO
|
||||||
|
const dto: CreatePartnerDto = {
|
||||||
|
code: data.code,
|
||||||
|
displayName: data.displayName || data.display_name || data.code,
|
||||||
|
legalName: data.legalName || data.legal_name,
|
||||||
|
partnerType: (data.partnerType || data.partner_type) as PartnerType,
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone,
|
||||||
|
mobile: data.mobile,
|
||||||
|
website: data.website,
|
||||||
|
taxId: data.taxId || data.tax_id,
|
||||||
|
taxRegime: data.taxRegime || data.tax_regime,
|
||||||
|
cfdiUse: data.cfdiUse || data.cfdi_use,
|
||||||
|
paymentTermDays: data.paymentTermDays || data.payment_term_days,
|
||||||
|
creditLimit: data.creditLimit || data.credit_limit,
|
||||||
|
priceListId: data.priceListId || data.price_list_id,
|
||||||
|
discountPercent: data.discountPercent || data.discount_percent,
|
||||||
|
category: data.category,
|
||||||
|
tags: data.tags,
|
||||||
|
notes: data.notes,
|
||||||
|
salesRepId: data.salesRepId || data.sales_rep_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const partner = await partnersService.create(dto, tenantId, userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: partner,
|
||||||
|
message: 'Contacto creado exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const parseResult = updatePartnerSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contacto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// Transform to camelCase DTO
|
||||||
|
const dto: UpdatePartnerDto = {};
|
||||||
|
|
||||||
|
if (data.displayName !== undefined || data.display_name !== undefined) {
|
||||||
|
dto.displayName = data.displayName ?? data.display_name;
|
||||||
|
}
|
||||||
|
if (data.legalName !== undefined || data.legal_name !== undefined) {
|
||||||
|
dto.legalName = data.legalName ?? data.legal_name;
|
||||||
|
}
|
||||||
|
if (data.partnerType !== undefined || data.partner_type !== undefined) {
|
||||||
|
dto.partnerType = (data.partnerType ?? data.partner_type) as PartnerType;
|
||||||
|
}
|
||||||
|
if (data.email !== undefined) dto.email = data.email;
|
||||||
|
if (data.phone !== undefined) dto.phone = data.phone;
|
||||||
|
if (data.mobile !== undefined) dto.mobile = data.mobile;
|
||||||
|
if (data.website !== undefined) dto.website = data.website;
|
||||||
|
if (data.taxId !== undefined || data.tax_id !== undefined) {
|
||||||
|
dto.taxId = data.taxId ?? data.tax_id;
|
||||||
|
}
|
||||||
|
if (data.taxRegime !== undefined || data.tax_regime !== undefined) {
|
||||||
|
dto.taxRegime = data.taxRegime ?? data.tax_regime;
|
||||||
|
}
|
||||||
|
if (data.cfdiUse !== undefined || data.cfdi_use !== undefined) {
|
||||||
|
dto.cfdiUse = data.cfdiUse ?? data.cfdi_use;
|
||||||
|
}
|
||||||
|
if (data.paymentTermDays !== undefined || data.payment_term_days !== undefined) {
|
||||||
|
dto.paymentTermDays = data.paymentTermDays ?? data.payment_term_days;
|
||||||
|
}
|
||||||
|
if (data.creditLimit !== undefined || data.credit_limit !== undefined) {
|
||||||
|
dto.creditLimit = data.creditLimit ?? data.credit_limit;
|
||||||
|
}
|
||||||
|
if (data.priceListId !== undefined || data.price_list_id !== undefined) {
|
||||||
|
dto.priceListId = data.priceListId ?? data.price_list_id;
|
||||||
|
}
|
||||||
|
if (data.discountPercent !== undefined || data.discount_percent !== undefined) {
|
||||||
|
dto.discountPercent = data.discountPercent ?? data.discount_percent;
|
||||||
|
}
|
||||||
|
if (data.category !== undefined) dto.category = data.category;
|
||||||
|
if (data.tags !== undefined) dto.tags = data.tags;
|
||||||
|
if (data.notes !== undefined) dto.notes = data.notes;
|
||||||
|
if (data.isActive !== undefined || data.is_active !== undefined) {
|
||||||
|
dto.isActive = data.isActive ?? data.is_active;
|
||||||
|
}
|
||||||
|
if (data.isVerified !== undefined || data.is_verified !== undefined) {
|
||||||
|
dto.isVerified = data.isVerified ?? data.is_verified;
|
||||||
|
}
|
||||||
|
if (data.salesRepId !== undefined || data.sales_rep_id !== undefined) {
|
||||||
|
dto.salesRepId = data.salesRepId ?? data.sales_rep_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await partnersService.update(id, dto, tenantId, userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: partner,
|
||||||
|
message: 'Contacto actualizado exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
await partnersService.delete(id, tenantId, userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Contacto eliminado exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const partnersController = new PartnersController();
|
||||||
48
src/modules/partners/partners.module.ts
Normal file
48
src/modules/partners/partners.module.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { PartnersService } from './services';
|
||||||
|
import { PartnersController } from './controllers';
|
||||||
|
import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from './entities';
|
||||||
|
|
||||||
|
export interface PartnersModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PartnersModule {
|
||||||
|
public router: Router;
|
||||||
|
public partnersService: PartnersService;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor(options: PartnersModuleOptions) {
|
||||||
|
this.dataSource = options.dataSource;
|
||||||
|
this.basePath = options.basePath || '';
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeServices();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
const partnerRepository = this.dataSource.getRepository(Partner);
|
||||||
|
const addressRepository = this.dataSource.getRepository(PartnerAddress);
|
||||||
|
const contactRepository = this.dataSource.getRepository(PartnerContact);
|
||||||
|
const bankAccountRepository = this.dataSource.getRepository(PartnerBankAccount);
|
||||||
|
|
||||||
|
this.partnersService = new PartnersService(
|
||||||
|
partnerRepository,
|
||||||
|
addressRepository,
|
||||||
|
contactRepository,
|
||||||
|
bankAccountRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
const partnersController = new PartnersController(this.partnersService);
|
||||||
|
this.router.use(`${this.basePath}/partners`, partnersController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getEntities(): Function[] {
|
||||||
|
return [Partner, PartnerAddress, PartnerContact, PartnerBankAccount];
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/modules/partners/partners.routes.ts
Normal file
90
src/modules/partners/partners.routes.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { partnersController } from './partners.controller.js';
|
||||||
|
import { rankingController } from './ranking.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RANKING ROUTES (must be before /:id routes to avoid conflicts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Calculate rankings (admin, manager)
|
||||||
|
router.post('/rankings/calculate', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.calculateRankings(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all rankings
|
||||||
|
router.get('/rankings', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.findRankings(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top partners
|
||||||
|
router.get('/rankings/top/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getTopCustomers(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/top/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getTopSuppliers(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ABC distribution
|
||||||
|
router.get('/rankings/abc/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getCustomerABCDistribution(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/abc/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getSupplierABCDistribution(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Partners by ABC
|
||||||
|
router.get('/rankings/abc/customers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getCustomersByABC(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/abc/suppliers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getSuppliersByABC(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Partner-specific ranking
|
||||||
|
router.get('/rankings/partner/:partnerId', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.findPartnerRanking(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/rankings/partner/:partnerId/history', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
rankingController.getPartnerHistory(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PARTNER ROUTES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Convenience endpoints for customers and suppliers
|
||||||
|
router.get('/customers', (req, res, next) => partnersController.findCustomers(req, res, next));
|
||||||
|
router.get('/suppliers', (req, res, next) => partnersController.findSuppliers(req, res, next));
|
||||||
|
|
||||||
|
// List all partners (admin, manager, sales, accountant)
|
||||||
|
router.get('/', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.findAll(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get partner by ID
|
||||||
|
router.get('/:id', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.findById(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create partner (admin, manager, sales)
|
||||||
|
router.post('/', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.create(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update partner (admin, manager, sales)
|
||||||
|
router.put('/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.update(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete partner (admin only)
|
||||||
|
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
partnersController.delete(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
350
src/modules/partners/partners.service.ts
Normal file
350
src/modules/partners/partners.service.ts
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
import { Repository, IsNull, Like } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Partner, PartnerType } from './entities/index.js';
|
||||||
|
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// Re-export PartnerType for controller use
|
||||||
|
export type { PartnerType };
|
||||||
|
|
||||||
|
// ===== Interfaces =====
|
||||||
|
|
||||||
|
export interface CreatePartnerDto {
|
||||||
|
code: string;
|
||||||
|
displayName: string;
|
||||||
|
legalName?: string;
|
||||||
|
partnerType?: PartnerType;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
taxId?: string;
|
||||||
|
taxRegime?: string;
|
||||||
|
cfdiUse?: string;
|
||||||
|
paymentTermDays?: number;
|
||||||
|
creditLimit?: number;
|
||||||
|
priceListId?: string;
|
||||||
|
discountPercent?: number;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
salesRepId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePartnerDto {
|
||||||
|
displayName?: string;
|
||||||
|
legalName?: string | null;
|
||||||
|
partnerType?: PartnerType;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
taxId?: string | null;
|
||||||
|
taxRegime?: string | null;
|
||||||
|
cfdiUse?: string | null;
|
||||||
|
paymentTermDays?: number;
|
||||||
|
creditLimit?: number;
|
||||||
|
priceListId?: string | null;
|
||||||
|
discountPercent?: number;
|
||||||
|
category?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
isVerified?: boolean;
|
||||||
|
salesRepId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerFilters {
|
||||||
|
search?: string;
|
||||||
|
partnerType?: PartnerType;
|
||||||
|
category?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isVerified?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerWithRelations extends Partner {
|
||||||
|
// Add computed fields if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PartnersService Class =====
|
||||||
|
|
||||||
|
class PartnersService {
|
||||||
|
private partnerRepository: Repository<Partner>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.partnerRepository = AppDataSource.getRepository(Partner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all partners for a tenant with filters and pagination
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: PartnerFilters = {}
|
||||||
|
): Promise<{ data: Partner[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const { search, partnerType, category, isActive, isVerified, page = 1, limit = 20 } = filters;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const queryBuilder = this.partnerRepository
|
||||||
|
.createQueryBuilder('partner')
|
||||||
|
.where('partner.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('partner.deletedAt IS NULL');
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (search) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(partner.displayName ILIKE :search OR partner.legalName ILIKE :search OR partner.email ILIKE :search OR partner.taxId ILIKE :search OR partner.code ILIKE :search)',
|
||||||
|
{ search: `%${search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by partner type
|
||||||
|
if (partnerType !== undefined) {
|
||||||
|
queryBuilder.andWhere('partner.partnerType = :partnerType', { partnerType });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (category) {
|
||||||
|
queryBuilder.andWhere('partner.category = :category', { category });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by active status
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
queryBuilder.andWhere('partner.isActive = :isActive', { isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by verified status
|
||||||
|
if (isVerified !== undefined) {
|
||||||
|
queryBuilder.andWhere('partner.isVerified = :isVerified', { isVerified });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await queryBuilder.getCount();
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
const data = await queryBuilder
|
||||||
|
.orderBy('partner.displayName', 'ASC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
logger.debug('Partners retrieved', { tenantId, count: data.length, total });
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving partners', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get partner by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string, tenantId: string): Promise<Partner> {
|
||||||
|
try {
|
||||||
|
const partner = await this.partnerRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!partner) {
|
||||||
|
throw new NotFoundError('Contacto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return partner;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error finding partner', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new partner
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreatePartnerDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Partner> {
|
||||||
|
try {
|
||||||
|
// Check if code already exists
|
||||||
|
const existing = await this.partnerRepository.findOne({
|
||||||
|
where: { code: dto.code, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError('Ya existe un contacto con este código');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create partner - only include defined fields
|
||||||
|
const partnerData: Partial<Partner> = {
|
||||||
|
tenantId,
|
||||||
|
code: dto.code,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
partnerType: dto.partnerType || 'customer',
|
||||||
|
paymentTermDays: dto.paymentTermDays ?? 0,
|
||||||
|
creditLimit: dto.creditLimit ?? 0,
|
||||||
|
discountPercent: dto.discountPercent ?? 0,
|
||||||
|
tags: dto.tags || [],
|
||||||
|
isActive: true,
|
||||||
|
isVerified: false,
|
||||||
|
createdBy: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add optional fields only if defined
|
||||||
|
if (dto.legalName) partnerData.legalName = dto.legalName;
|
||||||
|
if (dto.email) partnerData.email = dto.email.toLowerCase();
|
||||||
|
if (dto.phone) partnerData.phone = dto.phone;
|
||||||
|
if (dto.mobile) partnerData.mobile = dto.mobile;
|
||||||
|
if (dto.website) partnerData.website = dto.website;
|
||||||
|
if (dto.taxId) partnerData.taxId = dto.taxId;
|
||||||
|
if (dto.taxRegime) partnerData.taxRegime = dto.taxRegime;
|
||||||
|
if (dto.cfdiUse) partnerData.cfdiUse = dto.cfdiUse;
|
||||||
|
if (dto.priceListId) partnerData.priceListId = dto.priceListId;
|
||||||
|
if (dto.category) partnerData.category = dto.category;
|
||||||
|
if (dto.notes) partnerData.notes = dto.notes;
|
||||||
|
if (dto.salesRepId) partnerData.salesRepId = dto.salesRepId;
|
||||||
|
|
||||||
|
const partner = this.partnerRepository.create(partnerData);
|
||||||
|
|
||||||
|
await this.partnerRepository.save(partner);
|
||||||
|
|
||||||
|
logger.info('Partner created', {
|
||||||
|
partnerId: partner.id,
|
||||||
|
tenantId,
|
||||||
|
code: partner.code,
|
||||||
|
displayName: partner.displayName,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return partner;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating partner', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
dto,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a partner
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdatePartnerDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Partner> {
|
||||||
|
try {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Update allowed fields
|
||||||
|
if (dto.displayName !== undefined) existing.displayName = dto.displayName;
|
||||||
|
if (dto.legalName !== undefined) existing.legalName = dto.legalName as string;
|
||||||
|
if (dto.partnerType !== undefined) existing.partnerType = dto.partnerType;
|
||||||
|
if (dto.email !== undefined) existing.email = dto.email?.toLowerCase() || null as any;
|
||||||
|
if (dto.phone !== undefined) existing.phone = dto.phone as string;
|
||||||
|
if (dto.mobile !== undefined) existing.mobile = dto.mobile as string;
|
||||||
|
if (dto.website !== undefined) existing.website = dto.website as string;
|
||||||
|
if (dto.taxId !== undefined) existing.taxId = dto.taxId as string;
|
||||||
|
if (dto.taxRegime !== undefined) existing.taxRegime = dto.taxRegime as string;
|
||||||
|
if (dto.cfdiUse !== undefined) existing.cfdiUse = dto.cfdiUse as string;
|
||||||
|
if (dto.paymentTermDays !== undefined) existing.paymentTermDays = dto.paymentTermDays;
|
||||||
|
if (dto.creditLimit !== undefined) existing.creditLimit = dto.creditLimit;
|
||||||
|
if (dto.priceListId !== undefined) existing.priceListId = dto.priceListId as string;
|
||||||
|
if (dto.discountPercent !== undefined) existing.discountPercent = dto.discountPercent;
|
||||||
|
if (dto.category !== undefined) existing.category = dto.category as string;
|
||||||
|
if (dto.tags !== undefined) existing.tags = dto.tags;
|
||||||
|
if (dto.notes !== undefined) existing.notes = dto.notes as string;
|
||||||
|
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
|
||||||
|
if (dto.isVerified !== undefined) existing.isVerified = dto.isVerified;
|
||||||
|
if (dto.salesRepId !== undefined) existing.salesRepId = dto.salesRepId as string;
|
||||||
|
|
||||||
|
existing.updatedBy = userId;
|
||||||
|
|
||||||
|
await this.partnerRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Partner updated', {
|
||||||
|
partnerId: id,
|
||||||
|
tenantId,
|
||||||
|
updatedBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating partner', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete a partner
|
||||||
|
*/
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const partner = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Soft delete using the deletedAt column
|
||||||
|
partner.deletedAt = new Date();
|
||||||
|
partner.isActive = false;
|
||||||
|
|
||||||
|
await this.partnerRepository.save(partner);
|
||||||
|
|
||||||
|
logger.info('Partner deleted', {
|
||||||
|
partnerId: id,
|
||||||
|
tenantId,
|
||||||
|
deletedBy: userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting partner', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customers only
|
||||||
|
*/
|
||||||
|
async findCustomers(
|
||||||
|
tenantId: string,
|
||||||
|
filters: Omit<PartnerFilters, 'partnerType'>
|
||||||
|
): Promise<{ data: Partner[]; total: number }> {
|
||||||
|
return this.findAll(tenantId, { ...filters, partnerType: 'customer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get suppliers only
|
||||||
|
*/
|
||||||
|
async findSuppliers(
|
||||||
|
tenantId: string,
|
||||||
|
filters: Omit<PartnerFilters, 'partnerType'>
|
||||||
|
): Promise<{ data: Partner[]; total: number }> {
|
||||||
|
return this.findAll(tenantId, { ...filters, partnerType: 'supplier' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Export Singleton Instance =====
|
||||||
|
|
||||||
|
export const partnersService = new PartnersService();
|
||||||
368
src/modules/partners/ranking.controller.ts
Normal file
368
src/modules/partners/ranking.controller.ts
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/types/index.js';
|
||||||
|
import { rankingService, ABCClassification } from './ranking.service.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const calculateRankingsSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
period_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankingFiltersSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
period_start: z.string().optional(),
|
||||||
|
period_end: z.string().optional(),
|
||||||
|
customer_abc: z.enum(['A', 'B', 'C']).optional(),
|
||||||
|
supplier_abc: z.enum(['A', 'B', 'C']).optional(),
|
||||||
|
min_sales: z.coerce.number().min(0).optional(),
|
||||||
|
min_purchases: z.coerce.number().min(0).optional(),
|
||||||
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTROLLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class RankingController {
|
||||||
|
/**
|
||||||
|
* POST /rankings/calculate
|
||||||
|
* Calculate partner rankings
|
||||||
|
*/
|
||||||
|
async calculateRankings(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id, period_start, period_end } = calculateRankingsSchema.parse(req.body);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const result = await rankingService.calculateRankings(
|
||||||
|
tenantId,
|
||||||
|
company_id,
|
||||||
|
period_start,
|
||||||
|
period_end
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Rankings calculados exitosamente',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings
|
||||||
|
* List all rankings with filters
|
||||||
|
*/
|
||||||
|
async findRankings(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const filters = rankingFiltersSchema.parse(req.query);
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const { data, total } = await rankingService.findRankings(tenantId, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / filters.limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/partner/:partnerId
|
||||||
|
* Get ranking for a specific partner
|
||||||
|
*/
|
||||||
|
async findPartnerRanking(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { partnerId } = req.params;
|
||||||
|
const { period_start, period_end } = req.query as {
|
||||||
|
period_start?: string;
|
||||||
|
period_end?: string;
|
||||||
|
};
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const ranking = await rankingService.findPartnerRanking(
|
||||||
|
partnerId,
|
||||||
|
tenantId,
|
||||||
|
period_start,
|
||||||
|
period_end
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ranking) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No se encontró ranking para este contacto',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: ranking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/partner/:partnerId/history
|
||||||
|
* Get ranking history for a partner
|
||||||
|
*/
|
||||||
|
async getPartnerHistory(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { partnerId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 12;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const history = await rankingService.getPartnerRankingHistory(
|
||||||
|
partnerId,
|
||||||
|
tenantId,
|
||||||
|
Math.min(limit, 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: history,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/top/customers
|
||||||
|
* Get top customers
|
||||||
|
*/
|
||||||
|
async getTopCustomers(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const data = await rankingService.getTopPartners(
|
||||||
|
tenantId,
|
||||||
|
'customers',
|
||||||
|
Math.min(limit, 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/top/suppliers
|
||||||
|
* Get top suppliers
|
||||||
|
*/
|
||||||
|
async getTopSuppliers(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const data = await rankingService.getTopPartners(
|
||||||
|
tenantId,
|
||||||
|
'suppliers',
|
||||||
|
Math.min(limit, 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/customers
|
||||||
|
* Get ABC distribution for customers
|
||||||
|
*/
|
||||||
|
async getCustomerABCDistribution(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id } = req.query as { company_id?: string };
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const distribution = await rankingService.getABCDistribution(
|
||||||
|
tenantId,
|
||||||
|
'customers',
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: distribution,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/suppliers
|
||||||
|
* Get ABC distribution for suppliers
|
||||||
|
*/
|
||||||
|
async getSupplierABCDistribution(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { company_id } = req.query as { company_id?: string };
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
const distribution = await rankingService.getABCDistribution(
|
||||||
|
tenantId,
|
||||||
|
'suppliers',
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: distribution,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/customers/:abc
|
||||||
|
* Get customers by ABC classification
|
||||||
|
*/
|
||||||
|
async getCustomersByABC(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const abc = req.params.abc.toUpperCase() as ABCClassification;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
if (!['A', 'B', 'C'].includes(abc || '')) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Clasificación ABC inválida. Use A, B o C.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, total } = await rankingService.findPartnersByABC(
|
||||||
|
tenantId,
|
||||||
|
abc,
|
||||||
|
'customers',
|
||||||
|
page,
|
||||||
|
Math.min(limit, 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rankings/abc/suppliers/:abc
|
||||||
|
* Get suppliers by ABC classification
|
||||||
|
*/
|
||||||
|
async getSuppliersByABC(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const abc = req.params.abc.toUpperCase() as ABCClassification;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
|
||||||
|
if (!['A', 'B', 'C'].includes(abc || '')) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Clasificación ABC inválida. Use A, B o C.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, total } = await rankingService.findPartnersByABC(
|
||||||
|
tenantId,
|
||||||
|
abc,
|
||||||
|
'suppliers',
|
||||||
|
page,
|
||||||
|
Math.min(limit, 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rankingController = new RankingController();
|
||||||
431
src/modules/partners/ranking.service.ts
Normal file
431
src/modules/partners/ranking.service.ts
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Partner } from './entities/index.js';
|
||||||
|
import { NotFoundError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ABCClassification = 'A' | 'B' | 'C' | null;
|
||||||
|
|
||||||
|
export interface PartnerRanking {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
company_id: string | null;
|
||||||
|
period_start: Date;
|
||||||
|
period_end: Date;
|
||||||
|
total_sales: number;
|
||||||
|
sales_order_count: number;
|
||||||
|
avg_order_value: number;
|
||||||
|
total_purchases: number;
|
||||||
|
purchase_order_count: number;
|
||||||
|
avg_purchase_value: number;
|
||||||
|
avg_payment_days: number | null;
|
||||||
|
on_time_payment_rate: number | null;
|
||||||
|
sales_rank: number | null;
|
||||||
|
purchase_rank: number | null;
|
||||||
|
customer_abc: ABCClassification;
|
||||||
|
supplier_abc: ABCClassification;
|
||||||
|
customer_score: number | null;
|
||||||
|
supplier_score: number | null;
|
||||||
|
overall_score: number | null;
|
||||||
|
sales_trend: number | null;
|
||||||
|
purchase_trend: number | null;
|
||||||
|
calculated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankingCalculationResult {
|
||||||
|
partners_processed: number;
|
||||||
|
customers_ranked: number;
|
||||||
|
suppliers_ranked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankingFilters {
|
||||||
|
company_id?: string;
|
||||||
|
period_start?: string;
|
||||||
|
period_end?: string;
|
||||||
|
customer_abc?: ABCClassification;
|
||||||
|
supplier_abc?: ABCClassification;
|
||||||
|
min_sales?: number;
|
||||||
|
min_purchases?: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopPartner {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
is_customer: boolean;
|
||||||
|
is_supplier: boolean;
|
||||||
|
customer_rank: number | null;
|
||||||
|
supplier_rank: number | null;
|
||||||
|
customer_abc: ABCClassification;
|
||||||
|
supplier_abc: ABCClassification;
|
||||||
|
total_sales_ytd: number;
|
||||||
|
total_purchases_ytd: number;
|
||||||
|
last_ranking_date: Date | null;
|
||||||
|
customer_category: string | null;
|
||||||
|
supplier_category: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class RankingService {
|
||||||
|
private partnerRepository: Repository<Partner>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.partnerRepository = AppDataSource.getRepository(Partner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate rankings for all partners in a tenant
|
||||||
|
* Uses the database function for atomic calculation
|
||||||
|
*/
|
||||||
|
async calculateRankings(
|
||||||
|
tenantId: string,
|
||||||
|
companyId?: string,
|
||||||
|
periodStart?: string,
|
||||||
|
periodEnd?: string
|
||||||
|
): Promise<RankingCalculationResult> {
|
||||||
|
try {
|
||||||
|
const result = await this.partnerRepository.query(
|
||||||
|
`SELECT * FROM core.calculate_partner_rankings($1, $2, $3, $4)`,
|
||||||
|
[tenantId, companyId || null, periodStart || null, periodEnd || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = result[0];
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('Error calculando rankings');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Partner rankings calculated', {
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
result: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
partners_processed: parseInt(data.partners_processed, 10),
|
||||||
|
customers_ranked: parseInt(data.customers_ranked, 10),
|
||||||
|
suppliers_ranked: parseInt(data.suppliers_ranked, 10),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error calculating partner rankings', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rankings for a specific period
|
||||||
|
*/
|
||||||
|
async findRankings(
|
||||||
|
tenantId: string,
|
||||||
|
filters: RankingFilters = {}
|
||||||
|
): Promise<{ data: PartnerRanking[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
company_id,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
customer_abc,
|
||||||
|
supplier_abc,
|
||||||
|
min_sales,
|
||||||
|
min_purchases,
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
} = filters;
|
||||||
|
|
||||||
|
const conditions: string[] = ['pr.tenant_id = $1'];
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
conditions.push(`pr.company_id = $${idx++}`);
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period_start) {
|
||||||
|
conditions.push(`pr.period_start >= $${idx++}`);
|
||||||
|
params.push(period_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period_end) {
|
||||||
|
conditions.push(`pr.period_end <= $${idx++}`);
|
||||||
|
params.push(period_end);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customer_abc) {
|
||||||
|
conditions.push(`pr.customer_abc = $${idx++}`);
|
||||||
|
params.push(customer_abc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supplier_abc) {
|
||||||
|
conditions.push(`pr.supplier_abc = $${idx++}`);
|
||||||
|
params.push(supplier_abc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min_sales !== undefined) {
|
||||||
|
conditions.push(`pr.total_sales >= $${idx++}`);
|
||||||
|
params.push(min_sales);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min_purchases !== undefined) {
|
||||||
|
conditions.push(`pr.total_purchases >= $${idx++}`);
|
||||||
|
params.push(min_purchases);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ');
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
const countResult = await this.partnerRepository.query(
|
||||||
|
`SELECT COUNT(*) as count FROM core.partner_rankings pr WHERE ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get data with pagination
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const data = await this.partnerRepository.query(
|
||||||
|
`SELECT pr.*,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM core.partner_rankings pr
|
||||||
|
JOIN core.partners p ON pr.partner_id = p.id
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY pr.overall_score DESC NULLS LAST, pr.total_sales DESC
|
||||||
|
LIMIT $${idx} OFFSET $${idx + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult[0]?.count || '0', 10),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving partner rankings', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ranking for a specific partner
|
||||||
|
*/
|
||||||
|
async findPartnerRanking(
|
||||||
|
partnerId: string,
|
||||||
|
tenantId: string,
|
||||||
|
periodStart?: string,
|
||||||
|
periodEnd?: string
|
||||||
|
): Promise<PartnerRanking | null> {
|
||||||
|
try {
|
||||||
|
let sql = `
|
||||||
|
SELECT pr.*, p.name as partner_name
|
||||||
|
FROM core.partner_rankings pr
|
||||||
|
JOIN core.partners p ON pr.partner_id = p.id
|
||||||
|
WHERE pr.partner_id = $1 AND pr.tenant_id = $2
|
||||||
|
`;
|
||||||
|
const params: any[] = [partnerId, tenantId];
|
||||||
|
|
||||||
|
if (periodStart && periodEnd) {
|
||||||
|
sql += ` AND pr.period_start = $3 AND pr.period_end = $4`;
|
||||||
|
params.push(periodStart, periodEnd);
|
||||||
|
} else {
|
||||||
|
// Get most recent ranking
|
||||||
|
sql += ` ORDER BY pr.calculated_at DESC LIMIT 1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.partnerRepository.query(sql, params);
|
||||||
|
return result[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error finding partner ranking', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
partnerId,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top partners (customers or suppliers)
|
||||||
|
*/
|
||||||
|
async getTopPartners(
|
||||||
|
tenantId: string,
|
||||||
|
type: 'customers' | 'suppliers',
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<TopPartner[]> {
|
||||||
|
try {
|
||||||
|
const orderColumn = type === 'customers' ? 'customer_rank' : 'supplier_rank';
|
||||||
|
|
||||||
|
const result = await this.partnerRepository.query(
|
||||||
|
`SELECT * FROM core.top_partners_view
|
||||||
|
WHERE tenant_id = $1 AND ${orderColumn} IS NOT NULL
|
||||||
|
ORDER BY ${orderColumn} ASC
|
||||||
|
LIMIT $2`,
|
||||||
|
[tenantId, limit]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting top partners', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ABC distribution summary
|
||||||
|
*/
|
||||||
|
async getABCDistribution(
|
||||||
|
tenantId: string,
|
||||||
|
type: 'customers' | 'suppliers',
|
||||||
|
companyId?: string
|
||||||
|
): Promise<{
|
||||||
|
A: { count: number; total_value: number; percentage: number };
|
||||||
|
B: { count: number; total_value: number; percentage: number };
|
||||||
|
C: { count: number; total_value: number; percentage: number };
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc';
|
||||||
|
const valueColumn = type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd';
|
||||||
|
|
||||||
|
const whereClause = `tenant_id = $1 AND ${abcColumn} IS NOT NULL`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
|
||||||
|
const result = await this.partnerRepository.query(
|
||||||
|
`SELECT
|
||||||
|
${abcColumn} as abc,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(${valueColumn}), 0) as total_value
|
||||||
|
FROM core.partners
|
||||||
|
WHERE ${whereClause} AND deleted_at IS NULL
|
||||||
|
GROUP BY ${abcColumn}
|
||||||
|
ORDER BY ${abcColumn}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const grandTotal = result.reduce((sum: number, r: any) => sum + parseFloat(r.total_value), 0);
|
||||||
|
|
||||||
|
const distribution = {
|
||||||
|
A: { count: 0, total_value: 0, percentage: 0 },
|
||||||
|
B: { count: 0, total_value: 0, percentage: 0 },
|
||||||
|
C: { count: 0, total_value: 0, percentage: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const abc = row.abc as 'A' | 'B' | 'C';
|
||||||
|
if (abc in distribution) {
|
||||||
|
distribution[abc] = {
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
total_value: parseFloat(row.total_value),
|
||||||
|
percentage: grandTotal > 0 ? (parseFloat(row.total_value) / grandTotal) * 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return distribution;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting ABC distribution', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ranking history for a partner
|
||||||
|
*/
|
||||||
|
async getPartnerRankingHistory(
|
||||||
|
partnerId: string,
|
||||||
|
tenantId: string,
|
||||||
|
limit: number = 12
|
||||||
|
): Promise<PartnerRanking[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.partnerRepository.query(
|
||||||
|
`SELECT pr.*, p.name as partner_name
|
||||||
|
FROM core.partner_rankings pr
|
||||||
|
JOIN core.partners p ON pr.partner_id = p.id
|
||||||
|
WHERE pr.partner_id = $1 AND pr.tenant_id = $2
|
||||||
|
ORDER BY pr.period_end DESC
|
||||||
|
LIMIT $3`,
|
||||||
|
[partnerId, tenantId, limit]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting partner ranking history', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
partnerId,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get partners by ABC classification
|
||||||
|
*/
|
||||||
|
async findPartnersByABC(
|
||||||
|
tenantId: string,
|
||||||
|
abc: ABCClassification,
|
||||||
|
type: 'customers' | 'suppliers',
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<{ data: TopPartner[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc';
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const countResult = await this.partnerRepository.query(
|
||||||
|
`SELECT COUNT(*) as count FROM core.partners
|
||||||
|
WHERE tenant_id = $1 AND ${abcColumn} = $2 AND deleted_at IS NULL`,
|
||||||
|
[tenantId, abc]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await this.partnerRepository.query(
|
||||||
|
`SELECT * FROM core.top_partners_view
|
||||||
|
WHERE tenant_id = $1 AND ${abcColumn} = $2
|
||||||
|
ORDER BY ${type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'} DESC
|
||||||
|
LIMIT $3 OFFSET $4`,
|
||||||
|
[tenantId, abc, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult[0]?.count || '0', 10),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error finding partners by ABC', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
abc,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rankingService = new RankingService();
|
||||||
1
src/modules/partners/services/index.ts
Normal file
1
src/modules/partners/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { PartnersService, PartnerSearchParams } from './partners.service';
|
||||||
266
src/modules/partners/services/partners.service.ts
Normal file
266
src/modules/partners/services/partners.service.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
|
||||||
|
import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from '../entities';
|
||||||
|
import {
|
||||||
|
CreatePartnerDto,
|
||||||
|
UpdatePartnerDto,
|
||||||
|
CreatePartnerAddressDto,
|
||||||
|
CreatePartnerContactDto,
|
||||||
|
CreatePartnerBankAccountDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export interface PartnerSearchParams {
|
||||||
|
tenantId: string;
|
||||||
|
search?: string;
|
||||||
|
partnerType?: 'customer' | 'supplier' | 'both';
|
||||||
|
category?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
salesRepId?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PartnersService {
|
||||||
|
constructor(
|
||||||
|
private readonly partnerRepository: Repository<Partner>,
|
||||||
|
private readonly addressRepository: Repository<PartnerAddress>,
|
||||||
|
private readonly contactRepository: Repository<PartnerContact>,
|
||||||
|
private readonly bankAccountRepository: Repository<PartnerBankAccount>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ==================== Partners ====================
|
||||||
|
|
||||||
|
async findAll(params: PartnerSearchParams): Promise<{ data: Partner[]; total: number }> {
|
||||||
|
const {
|
||||||
|
tenantId,
|
||||||
|
search,
|
||||||
|
partnerType,
|
||||||
|
category,
|
||||||
|
isActive,
|
||||||
|
salesRepId,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const where: FindOptionsWhere<Partner>[] = [];
|
||||||
|
const baseWhere: FindOptionsWhere<Partner> = { tenantId };
|
||||||
|
|
||||||
|
if (partnerType) {
|
||||||
|
baseWhere.partnerType = partnerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
baseWhere.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
baseWhere.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (salesRepId) {
|
||||||
|
baseWhere.salesRepId = salesRepId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.push(
|
||||||
|
{ ...baseWhere, displayName: ILike(`%${search}%`) },
|
||||||
|
{ ...baseWhere, legalName: ILike(`%${search}%`) },
|
||||||
|
{ ...baseWhere, code: ILike(`%${search}%`) },
|
||||||
|
{ ...baseWhere, taxId: ILike(`%${search}%`) },
|
||||||
|
{ ...baseWhere, email: ILike(`%${search}%`) }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
where.push(baseWhere);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await this.partnerRepository.findAndCount({
|
||||||
|
where,
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
order: { displayName: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, tenantId: string): Promise<Partner | null> {
|
||||||
|
return this.partnerRepository.findOne({ where: { id, tenantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string, tenantId: string): Promise<Partner | null> {
|
||||||
|
return this.partnerRepository.findOne({ where: { code, tenantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTaxId(taxId: string, tenantId: string): Promise<Partner | null> {
|
||||||
|
return this.partnerRepository.findOne({ where: { taxId, tenantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, dto: CreatePartnerDto, createdBy?: string): Promise<Partner> {
|
||||||
|
// Check for existing code
|
||||||
|
const existingCode = await this.findByCode(dto.code, tenantId);
|
||||||
|
if (existingCode) {
|
||||||
|
throw new Error('A partner with this code already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing tax ID
|
||||||
|
if (dto.taxId) {
|
||||||
|
const existingTaxId = await this.findByTaxId(dto.taxId, tenantId);
|
||||||
|
if (existingTaxId) {
|
||||||
|
throw new Error('A partner with this tax ID already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = this.partnerRepository.create({
|
||||||
|
...dto,
|
||||||
|
tenantId,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.partnerRepository.save(partner);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
tenantId: string,
|
||||||
|
dto: UpdatePartnerDto,
|
||||||
|
updatedBy?: string
|
||||||
|
): Promise<Partner | null> {
|
||||||
|
const partner = await this.findOne(id, tenantId);
|
||||||
|
if (!partner) return null;
|
||||||
|
|
||||||
|
// If changing code, check for duplicates
|
||||||
|
if (dto.code && dto.code !== partner.code) {
|
||||||
|
const existing = await this.findByCode(dto.code, tenantId);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('A partner with this code already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If changing tax ID, check for duplicates
|
||||||
|
if (dto.taxId && dto.taxId !== partner.taxId) {
|
||||||
|
const existing = await this.findByTaxId(dto.taxId, tenantId);
|
||||||
|
if (existing && existing.id !== id) {
|
||||||
|
throw new Error('A partner with this tax ID already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(partner, {
|
||||||
|
...dto,
|
||||||
|
updatedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.partnerRepository.save(partner);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<boolean> {
|
||||||
|
const partner = await this.findOne(id, tenantId);
|
||||||
|
if (!partner) return false;
|
||||||
|
|
||||||
|
const result = await this.partnerRepository.softDelete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCustomers(tenantId: string): Promise<Partner[]> {
|
||||||
|
return this.partnerRepository.find({
|
||||||
|
where: [
|
||||||
|
{ tenantId, partnerType: 'customer', isActive: true },
|
||||||
|
{ tenantId, partnerType: 'both', isActive: true },
|
||||||
|
],
|
||||||
|
order: { displayName: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSuppliers(tenantId: string): Promise<Partner[]> {
|
||||||
|
return this.partnerRepository.find({
|
||||||
|
where: [
|
||||||
|
{ tenantId, partnerType: 'supplier', isActive: true },
|
||||||
|
{ tenantId, partnerType: 'both', isActive: true },
|
||||||
|
],
|
||||||
|
order: { displayName: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Addresses ====================
|
||||||
|
|
||||||
|
async getAddresses(partnerId: string): Promise<PartnerAddress[]> {
|
||||||
|
return this.addressRepository.find({
|
||||||
|
where: { partnerId },
|
||||||
|
order: { isDefault: 'DESC', addressType: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAddress(dto: CreatePartnerAddressDto): Promise<PartnerAddress> {
|
||||||
|
// If setting as default, unset other defaults of same type
|
||||||
|
if (dto.isDefault) {
|
||||||
|
await this.addressRepository.update(
|
||||||
|
{ partnerId: dto.partnerId, addressType: dto.addressType },
|
||||||
|
{ isDefault: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.addressRepository.create(dto);
|
||||||
|
return this.addressRepository.save(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAddress(id: string): Promise<boolean> {
|
||||||
|
const result = await this.addressRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Contacts ====================
|
||||||
|
|
||||||
|
async getContacts(partnerId: string): Promise<PartnerContact[]> {
|
||||||
|
return this.contactRepository.find({
|
||||||
|
where: { partnerId },
|
||||||
|
order: { isPrimary: 'DESC', fullName: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContact(dto: CreatePartnerContactDto): Promise<PartnerContact> {
|
||||||
|
// If setting as primary, unset other primaries
|
||||||
|
if (dto.isPrimary) {
|
||||||
|
await this.contactRepository.update({ partnerId: dto.partnerId }, { isPrimary: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = this.contactRepository.create(dto);
|
||||||
|
return this.contactRepository.save(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteContact(id: string): Promise<boolean> {
|
||||||
|
const result = await this.contactRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Bank Accounts ====================
|
||||||
|
|
||||||
|
async getBankAccounts(partnerId: string): Promise<PartnerBankAccount[]> {
|
||||||
|
return this.bankAccountRepository.find({
|
||||||
|
where: { partnerId },
|
||||||
|
order: { isDefault: 'DESC', bankName: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBankAccount(dto: CreatePartnerBankAccountDto): Promise<PartnerBankAccount> {
|
||||||
|
// If setting as default, unset other defaults
|
||||||
|
if (dto.isDefault) {
|
||||||
|
await this.bankAccountRepository.update({ partnerId: dto.partnerId }, { isDefault: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bankAccount = this.bankAccountRepository.create(dto);
|
||||||
|
return this.bankAccountRepository.save(bankAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBankAccount(id: string): Promise<boolean> {
|
||||||
|
const result = await this.bankAccountRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyBankAccount(id: string): Promise<PartnerBankAccount | null> {
|
||||||
|
const bankAccount = await this.bankAccountRepository.findOne({ where: { id } });
|
||||||
|
if (!bankAccount) return null;
|
||||||
|
|
||||||
|
bankAccount.isVerified = true;
|
||||||
|
bankAccount.verifiedAt = new Date();
|
||||||
|
|
||||||
|
return this.bankAccountRepository.save(bankAccount);
|
||||||
|
}
|
||||||
|
}
|
||||||
624
src/modules/sales/__tests__/orders.service.test.ts
Normal file
624
src/modules/sales/__tests__/orders.service.test.ts
Normal file
@ -0,0 +1,624 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockSalesOrder, createMockSalesOrderLine } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock query functions
|
||||||
|
const mockQuery = jest.fn();
|
||||||
|
const mockQueryOne = jest.fn();
|
||||||
|
const mockGetClient = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../config/database.js', () => ({
|
||||||
|
query: (...args: any[]) => mockQuery(...args),
|
||||||
|
queryOne: (...args: any[]) => mockQueryOne(...args),
|
||||||
|
getClient: () => mockGetClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock taxesService
|
||||||
|
jest.mock('../../financial/taxes.service.js', () => ({
|
||||||
|
taxesService: {
|
||||||
|
calculateTaxes: jest.fn(() => Promise.resolve({
|
||||||
|
amountUntaxed: 1000,
|
||||||
|
amountTax: 160,
|
||||||
|
amountTotal: 1160,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock sequencesService
|
||||||
|
jest.mock('../../core/sequences.service.js', () => ({
|
||||||
|
sequencesService: {
|
||||||
|
getNextNumber: jest.fn(() => Promise.resolve('SO-000001')),
|
||||||
|
},
|
||||||
|
SEQUENCE_CODES: {
|
||||||
|
SALES_ORDER: 'SO',
|
||||||
|
PICKING_OUT: 'WH/OUT',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock stockReservationService
|
||||||
|
jest.mock('../../inventory/stock-reservation.service.js', () => ({
|
||||||
|
stockReservationService: {
|
||||||
|
reserveWithClient: jest.fn(() => Promise.resolve({ success: true, errors: [] })),
|
||||||
|
releaseWithClient: jest.fn(() => Promise.resolve()),
|
||||||
|
checkAvailability: jest.fn(() => Promise.resolve({ available: true, lines: [] })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { ordersService } from '../orders.service.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
|
describe('OrdersService', () => {
|
||||||
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
const userId = 'test-user-uuid';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return orders with pagination', async () => {
|
||||||
|
const mockOrders = [
|
||||||
|
createMockSalesOrder({ id: '1', name: 'SO-000001' }),
|
||||||
|
createMockSalesOrder({ id: '2', name: 'SO-000002' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '2' });
|
||||||
|
mockQuery.mockResolvedValue(mockOrders);
|
||||||
|
|
||||||
|
const result = await ordersService.findAll(tenantId, { page: 1, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by company_id', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { company_id: 'company-uuid' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('so.company_id = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'company-uuid'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by partner_id', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { partner_id: 'partner-uuid' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('so.partner_id = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'partner-uuid'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by status', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { status: 'draft' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('so.status = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'draft'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by invoice_status', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { invoice_status: 'pending' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('so.invoice_status = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'pending'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by delivery_status', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { delivery_status: 'pending' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('so.delivery_status = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'pending'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by date range', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { date_from: '2024-01-01', date_to: '2024-12-31' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('so.order_date >= $'),
|
||||||
|
expect.arrayContaining([tenantId, '2024-01-01', '2024-12-31'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by search term', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { search: 'Test' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('so.name ILIKE'),
|
||||||
|
expect.arrayContaining([tenantId, '%Test%'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply pagination correctly', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '50' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.findAll(tenantId, { page: 3, limit: 10 });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('LIMIT'),
|
||||||
|
expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should return order with lines when found', async () => {
|
||||||
|
const mockOrder = createMockSalesOrder();
|
||||||
|
const mockLines = [createMockSalesOrderLine()];
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(mockOrder);
|
||||||
|
mockQuery.mockResolvedValue(mockLines);
|
||||||
|
|
||||||
|
const result = await ordersService.findById('order-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ...mockOrder, lines: mockLines });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when order not found', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.findById('nonexistent-id', tenantId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const createDto = {
|
||||||
|
company_id: 'company-uuid',
|
||||||
|
partner_id: 'partner-uuid',
|
||||||
|
currency_id: 'currency-uuid',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create order with auto-generated number', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(createMockSalesOrder({ name: 'SO-000001' }));
|
||||||
|
|
||||||
|
const result = await ordersService.create(createDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.name).toBe('SO-000001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use provided order_date', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(createMockSalesOrder());
|
||||||
|
|
||||||
|
await ordersService.create({ ...createDto, order_date: '2024-06-15' }, tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQueryOne).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('INSERT INTO sales.sales_orders'),
|
||||||
|
expect.arrayContaining(['2024-06-15'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set default invoice_policy to order', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(createMockSalesOrder());
|
||||||
|
|
||||||
|
await ordersService.create(createDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQueryOne).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('INSERT INTO sales.sales_orders'),
|
||||||
|
expect.arrayContaining(['order'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update order in draft status', async () => {
|
||||||
|
const existingOrder = createMockSalesOrder({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(existingOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.update(
|
||||||
|
'order-uuid-1',
|
||||||
|
{ partner_id: 'new-partner-uuid' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE sales.sales_orders SET'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is not draft', async () => {
|
||||||
|
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(confirmedOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.update('order-uuid-1', { partner_id: 'new-partner' }, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unchanged order when no fields to update', async () => {
|
||||||
|
const existingOrder = createMockSalesOrder({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(existingOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await ordersService.update(
|
||||||
|
'order-uuid-1',
|
||||||
|
{},
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.id).toBe(existingOrder.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete order in draft status', async () => {
|
||||||
|
const draftOrder = createMockSalesOrder({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(draftOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.delete('order-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM sales.sales_orders'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is not draft', async () => {
|
||||||
|
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(confirmedOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.delete('order-uuid-1', tenantId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addLine', () => {
|
||||||
|
const lineDto = {
|
||||||
|
product_id: 'product-uuid',
|
||||||
|
description: 'Test product',
|
||||||
|
quantity: 5,
|
||||||
|
uom_id: 'uom-uuid',
|
||||||
|
price_unit: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should add line to draft order', async () => {
|
||||||
|
const draftOrder = createMockSalesOrder({ status: 'draft' });
|
||||||
|
const newLine = createMockSalesOrderLine();
|
||||||
|
|
||||||
|
// findById: queryOne for order, query for lines
|
||||||
|
// addLine: queryOne for INSERT, query for updateTotals
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(draftOrder) // findById - get order
|
||||||
|
.mockResolvedValueOnce(newLine); // INSERT line
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce([]) // findById - get lines
|
||||||
|
.mockResolvedValueOnce([]); // updateTotals
|
||||||
|
|
||||||
|
const result = await ordersService.addLine('order-uuid-1', lineDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.id).toBe(newLine.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is not draft', async () => {
|
||||||
|
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(confirmedOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.addLine('order-uuid-1', lineDto, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeLine', () => {
|
||||||
|
it('should remove line from draft order', async () => {
|
||||||
|
const draftOrder = createMockSalesOrder({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(draftOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ordersService.removeLine('order-uuid-1', 'line-uuid', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM sales.sales_order_lines'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is not draft', async () => {
|
||||||
|
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(confirmedOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.removeLine('order-uuid-1', 'line-uuid', tenantId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('confirm', () => {
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetClient.mockResolvedValue(mockClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should confirm draft order with lines', async () => {
|
||||||
|
const order = createMockSalesOrder({
|
||||||
|
status: 'draft',
|
||||||
|
company_id: 'company-uuid',
|
||||||
|
lines: [createMockSalesOrderLine()],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(order);
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce([createMockSalesOrderLine()]) // findById lines
|
||||||
|
.mockResolvedValueOnce(undefined); // UPDATE status
|
||||||
|
|
||||||
|
// Mock client.query calls for confirm flow
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-loc-uuid', warehouse_id: 'wh-uuid' }] }) // stock location
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'customer-loc-uuid' }] }) // customer location
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'picking-uuid' }] }) // INSERT picking
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // INSERT stock_move
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE status
|
||||||
|
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||||
|
|
||||||
|
const result = await ordersService.confirm('order-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is not draft', async () => {
|
||||||
|
const confirmedOrder = createMockSalesOrder({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(confirmedOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.confirm('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order has no lines', async () => {
|
||||||
|
const order = createMockSalesOrder({ status: 'draft', lines: [] });
|
||||||
|
mockQueryOne.mockResolvedValue(order);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.confirm('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback on error', async () => {
|
||||||
|
const order = createMockSalesOrder({
|
||||||
|
status: 'draft',
|
||||||
|
company_id: 'company-uuid',
|
||||||
|
lines: [createMockSalesOrderLine()],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(order);
|
||||||
|
mockQuery.mockResolvedValue([createMockSalesOrderLine()]);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ location_id: 'stock-loc-uuid', warehouse_id: 'wh-uuid' }] }) // stock location
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'customer-loc-uuid' }] }) // customer location
|
||||||
|
.mockRejectedValueOnce(new Error('DB Error')); // INSERT picking fails
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.confirm('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow('DB Error');
|
||||||
|
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
|
||||||
|
expect(mockClient.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancel', () => {
|
||||||
|
const mockClientCancel = {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetClient.mockResolvedValue(mockClientCancel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel draft order', async () => {
|
||||||
|
const draftOrder = createMockSalesOrder({
|
||||||
|
status: 'draft',
|
||||||
|
delivery_status: 'pending',
|
||||||
|
invoice_status: 'pending',
|
||||||
|
});
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(draftOrder) // findById
|
||||||
|
.mockResolvedValueOnce({ ...draftOrder, status: 'cancelled' }); // findById after cancel
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
// Mock client.query for cancel flow (draft orders don't need stock release)
|
||||||
|
mockClientCancel.query
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // UPDATE status
|
||||||
|
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||||
|
|
||||||
|
await ordersService.cancel('order-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockClientCancel.query).toHaveBeenCalledWith('BEGIN');
|
||||||
|
expect(mockClientCancel.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is done', async () => {
|
||||||
|
const doneOrder = createMockSalesOrder({ status: 'done' });
|
||||||
|
mockQueryOne.mockResolvedValue(doneOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.cancel('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is already cancelled', async () => {
|
||||||
|
const cancelledOrder = createMockSalesOrder({ status: 'cancelled' });
|
||||||
|
mockQueryOne.mockResolvedValue(cancelledOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.cancel('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order has deliveries', async () => {
|
||||||
|
const orderWithDeliveries = createMockSalesOrder({
|
||||||
|
status: 'sent',
|
||||||
|
delivery_status: 'partial',
|
||||||
|
});
|
||||||
|
mockQueryOne.mockResolvedValue(orderWithDeliveries);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.cancel('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order has invoices', async () => {
|
||||||
|
const orderWithInvoices = createMockSalesOrder({
|
||||||
|
status: 'sent',
|
||||||
|
invoice_status: 'partial',
|
||||||
|
});
|
||||||
|
mockQueryOne.mockResolvedValue(orderWithInvoices);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.cancel('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createInvoice', () => {
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetClient.mockResolvedValue(mockClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create invoice from confirmed order', async () => {
|
||||||
|
const order = createMockSalesOrder({
|
||||||
|
status: 'sent',
|
||||||
|
invoice_status: 'pending',
|
||||||
|
invoice_policy: 'order',
|
||||||
|
lines: [createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(order);
|
||||||
|
mockQuery.mockResolvedValue([createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })]);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'invoice-uuid' }] }) // INSERT invoice
|
||||||
|
.mockResolvedValueOnce(undefined) // INSERT line
|
||||||
|
.mockResolvedValueOnce(undefined) // UPDATE qty_invoiced
|
||||||
|
.mockResolvedValueOnce(undefined) // UPDATE invoice totals
|
||||||
|
.mockResolvedValueOnce(undefined) // UPDATE order status
|
||||||
|
.mockResolvedValueOnce(undefined); // COMMIT
|
||||||
|
|
||||||
|
const result = await ordersService.createInvoice('order-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.invoiceId).toBe('invoice-uuid');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is draft', async () => {
|
||||||
|
const draftOrder = createMockSalesOrder({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(draftOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.createInvoice('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when order is fully invoiced', async () => {
|
||||||
|
const fullyInvoicedOrder = createMockSalesOrder({
|
||||||
|
status: 'sent',
|
||||||
|
invoice_status: 'invoiced',
|
||||||
|
});
|
||||||
|
mockQueryOne.mockResolvedValue(fullyInvoicedOrder);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.createInvoice('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when no lines to invoice', async () => {
|
||||||
|
const order = createMockSalesOrder({
|
||||||
|
status: 'sent',
|
||||||
|
invoice_status: 'pending',
|
||||||
|
invoice_policy: 'order',
|
||||||
|
lines: [createMockSalesOrderLine({ quantity: 10, qty_invoiced: 10 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(order);
|
||||||
|
mockQuery.mockResolvedValue([createMockSalesOrderLine({ quantity: 10, qty_invoiced: 10 })]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.createInvoice('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback on error', async () => {
|
||||||
|
const order = createMockSalesOrder({
|
||||||
|
status: 'sent',
|
||||||
|
invoice_status: 'pending',
|
||||||
|
invoice_policy: 'order',
|
||||||
|
lines: [createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(order);
|
||||||
|
mockQuery.mockResolvedValue([createMockSalesOrderLine({ quantity: 10, qty_invoiced: 0 })]);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockRejectedValueOnce(new Error('DB Error')); // sequence fails
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordersService.createInvoice('order-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow('DB Error');
|
||||||
|
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
|
||||||
|
expect(mockClient.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
476
src/modules/sales/__tests__/quotations.service.test.ts
Normal file
476
src/modules/sales/__tests__/quotations.service.test.ts
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockQuotation, createMockQuotationLine } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock query functions
|
||||||
|
const mockQuery = jest.fn();
|
||||||
|
const mockQueryOne = jest.fn();
|
||||||
|
const mockGetClient = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../config/database.js', () => ({
|
||||||
|
query: (...args: any[]) => mockQuery(...args),
|
||||||
|
queryOne: (...args: any[]) => mockQueryOne(...args),
|
||||||
|
getClient: () => mockGetClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock taxesService
|
||||||
|
jest.mock('../../financial/taxes.service.js', () => ({
|
||||||
|
taxesService: {
|
||||||
|
calculateTaxes: jest.fn(() => Promise.resolve({
|
||||||
|
amountUntaxed: 1000,
|
||||||
|
amountTax: 160,
|
||||||
|
amountTotal: 1160,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { quotationsService } from '../quotations.service.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
|
describe('QuotationsService', () => {
|
||||||
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
const userId = 'test-user-uuid';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return quotations with pagination', async () => {
|
||||||
|
const mockQuotations = [
|
||||||
|
createMockQuotation({ id: '1', name: 'QUO-000001' }),
|
||||||
|
createMockQuotation({ id: '2', name: 'QUO-000002' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '2' });
|
||||||
|
mockQuery.mockResolvedValue(mockQuotations);
|
||||||
|
|
||||||
|
const result = await quotationsService.findAll(tenantId, { page: 1, limit: 20 });
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by company_id', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.findAll(tenantId, { company_id: 'company-uuid' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('q.company_id = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'company-uuid'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by partner_id', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.findAll(tenantId, { partner_id: 'partner-uuid' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('q.partner_id = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'partner-uuid'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by status', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.findAll(tenantId, { status: 'draft' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('q.status = $'),
|
||||||
|
expect.arrayContaining([tenantId, 'draft'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by date range', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.findAll(tenantId, { date_from: '2024-01-01', date_to: '2024-12-31' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('q.quotation_date >= $'),
|
||||||
|
expect.arrayContaining([tenantId, '2024-01-01', '2024-12-31'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by search term', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '0' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.findAll(tenantId, { search: 'Test' });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('q.name ILIKE'),
|
||||||
|
expect.arrayContaining([tenantId, '%Test%'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply pagination correctly', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue({ count: '50' });
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.findAll(tenantId, { page: 3, limit: 10 });
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('LIMIT'),
|
||||||
|
expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should return quotation with lines when found', async () => {
|
||||||
|
const mockQuotation = createMockQuotation();
|
||||||
|
const mockLines = [createMockQuotationLine()];
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(mockQuotation);
|
||||||
|
mockQuery.mockResolvedValue(mockLines);
|
||||||
|
|
||||||
|
const result = await quotationsService.findById('quotation-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ...mockQuotation, lines: mockLines });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundError when quotation not found', async () => {
|
||||||
|
mockQueryOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.findById('nonexistent-id', tenantId)
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const createDto = {
|
||||||
|
company_id: 'company-uuid',
|
||||||
|
partner_id: 'partner-uuid',
|
||||||
|
validity_date: '2024-12-31',
|
||||||
|
currency_id: 'currency-uuid',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create quotation with auto-generated number', async () => {
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce({ next_num: 1 }) // sequence
|
||||||
|
.mockResolvedValueOnce(createMockQuotation({ name: 'QUO-000001' })); // INSERT
|
||||||
|
|
||||||
|
const result = await quotationsService.create(createDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.name).toBe('QUO-000001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use provided quotation_date', async () => {
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce({ next_num: 2 })
|
||||||
|
.mockResolvedValueOnce(createMockQuotation());
|
||||||
|
|
||||||
|
await quotationsService.create({ ...createDto, quotation_date: '2024-06-15' }, tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQueryOne).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('INSERT INTO sales.quotations'),
|
||||||
|
expect.arrayContaining(['2024-06-15'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update quotation in draft status', async () => {
|
||||||
|
const existingQuotation = createMockQuotation({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(existingQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.update(
|
||||||
|
'quotation-uuid-1',
|
||||||
|
{ partner_id: 'new-partner-uuid' },
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE sales.quotations SET'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation is not draft', async () => {
|
||||||
|
const sentQuotation = createMockQuotation({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(sentQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.update('quotation-uuid-1', { partner_id: 'new-partner' }, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unchanged quotation when no fields to update', async () => {
|
||||||
|
const existingQuotation = createMockQuotation({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(existingQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await quotationsService.update(
|
||||||
|
'quotation-uuid-1',
|
||||||
|
{},
|
||||||
|
tenantId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.id).toBe(existingQuotation.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete quotation in draft status', async () => {
|
||||||
|
const draftQuotation = createMockQuotation({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(draftQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.delete('quotation-uuid-1', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM sales.quotations'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation is not draft', async () => {
|
||||||
|
const sentQuotation = createMockQuotation({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(sentQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.delete('quotation-uuid-1', tenantId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addLine', () => {
|
||||||
|
const lineDto = {
|
||||||
|
product_id: 'product-uuid',
|
||||||
|
description: 'Test product',
|
||||||
|
quantity: 5,
|
||||||
|
uom_id: 'uom-uuid',
|
||||||
|
price_unit: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should add line to draft quotation', async () => {
|
||||||
|
const draftQuotation = createMockQuotation({ status: 'draft' });
|
||||||
|
const newLine = createMockQuotationLine();
|
||||||
|
|
||||||
|
// findById: queryOne for quotation, query for lines
|
||||||
|
// addLine: queryOne for INSERT, query for updateTotals
|
||||||
|
mockQueryOne
|
||||||
|
.mockResolvedValueOnce(draftQuotation) // findById - get quotation
|
||||||
|
.mockResolvedValueOnce(newLine); // INSERT line
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce([]) // findById - get lines
|
||||||
|
.mockResolvedValueOnce([]); // updateTotals
|
||||||
|
|
||||||
|
const result = await quotationsService.addLine('quotation-uuid-1', lineDto, tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.id).toBe(newLine.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation is not draft', async () => {
|
||||||
|
const sentQuotation = createMockQuotation({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(sentQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.addLine('quotation-uuid-1', lineDto, tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeLine', () => {
|
||||||
|
it('should remove line from draft quotation', async () => {
|
||||||
|
const draftQuotation = createMockQuotation({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(draftQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.removeLine('quotation-uuid-1', 'line-uuid', tenantId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('DELETE FROM sales.quotation_lines'),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation is not draft', async () => {
|
||||||
|
const sentQuotation = createMockQuotation({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(sentQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.removeLine('quotation-uuid-1', 'line-uuid', tenantId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('send', () => {
|
||||||
|
it('should send draft quotation with lines', async () => {
|
||||||
|
const draftQuotation = createMockQuotation({ status: 'draft' });
|
||||||
|
const mockLines = [createMockQuotationLine()];
|
||||||
|
|
||||||
|
// findById: queryOne for quotation, query for lines
|
||||||
|
// send: query for UPDATE
|
||||||
|
mockQueryOne.mockResolvedValue(draftQuotation);
|
||||||
|
mockQuery
|
||||||
|
.mockResolvedValueOnce(mockLines) // findById - get lines
|
||||||
|
.mockResolvedValueOnce([]); // UPDATE status
|
||||||
|
|
||||||
|
await quotationsService.send('quotation-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("status = 'sent'"),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation is not draft', async () => {
|
||||||
|
const sentQuotation = createMockQuotation({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(sentQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.send('quotation-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation has no lines', async () => {
|
||||||
|
const draftQuotation = createMockQuotation({ status: 'draft', lines: [] });
|
||||||
|
mockQueryOne.mockResolvedValue(draftQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.send('quotation-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('confirm', () => {
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGetClient.mockResolvedValue(mockClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should confirm quotation and create sales order', async () => {
|
||||||
|
const quotation = createMockQuotation({
|
||||||
|
status: 'draft',
|
||||||
|
lines: [createMockQuotationLine()],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(quotation);
|
||||||
|
mockQuery.mockResolvedValue([createMockQuotationLine()]);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 'order-uuid' }] }) // INSERT order
|
||||||
|
.mockResolvedValueOnce(undefined) // INSERT lines
|
||||||
|
.mockResolvedValueOnce(undefined) // UPDATE quotation
|
||||||
|
.mockResolvedValueOnce(undefined); // COMMIT
|
||||||
|
|
||||||
|
const result = await quotationsService.confirm('quotation-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(result.orderId).toBe('order-uuid');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation status is invalid', async () => {
|
||||||
|
const cancelledQuotation = createMockQuotation({ status: 'cancelled' });
|
||||||
|
mockQueryOne.mockResolvedValue(cancelledQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.confirm('quotation-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation has no lines', async () => {
|
||||||
|
const quotation = createMockQuotation({ status: 'draft', lines: [] });
|
||||||
|
mockQueryOne.mockResolvedValue(quotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.confirm('quotation-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback on error', async () => {
|
||||||
|
const quotation = createMockQuotation({
|
||||||
|
status: 'draft',
|
||||||
|
lines: [createMockQuotationLine()],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQueryOne.mockResolvedValue(quotation);
|
||||||
|
mockQuery.mockResolvedValue([createMockQuotationLine()]);
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce(undefined) // BEGIN
|
||||||
|
.mockRejectedValueOnce(new Error('DB Error')); // sequence fails
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.confirm('quotation-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow('DB Error');
|
||||||
|
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
|
||||||
|
expect(mockClient.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancel', () => {
|
||||||
|
it('should cancel draft quotation', async () => {
|
||||||
|
const draftQuotation = createMockQuotation({ status: 'draft' });
|
||||||
|
mockQueryOne.mockResolvedValue(draftQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.cancel('quotation-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("status = 'cancelled'"),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel sent quotation', async () => {
|
||||||
|
const sentQuotation = createMockQuotation({ status: 'sent' });
|
||||||
|
mockQueryOne.mockResolvedValue(sentQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await quotationsService.cancel('quotation-uuid-1', tenantId, userId);
|
||||||
|
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("status = 'cancelled'"),
|
||||||
|
expect.any(Array)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation is confirmed', async () => {
|
||||||
|
const confirmedQuotation = createMockQuotation({ status: 'confirmed' });
|
||||||
|
mockQueryOne.mockResolvedValue(confirmedQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.cancel('quotation-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ValidationError when quotation is already cancelled', async () => {
|
||||||
|
const cancelledQuotation = createMockQuotation({ status: 'cancelled' });
|
||||||
|
mockQueryOne.mockResolvedValue(cancelledQuotation);
|
||||||
|
mockQuery.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
quotationsService.cancel('quotation-uuid-1', tenantId, userId)
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
177
src/modules/sales/controllers/index.ts
Normal file
177
src/modules/sales/controllers/index.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { SalesService } from '../services';
|
||||||
|
import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto';
|
||||||
|
|
||||||
|
export class QuotationsController {
|
||||||
|
public router: Router;
|
||||||
|
constructor(private readonly salesService: SalesService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.router.get('/', this.findAll.bind(this));
|
||||||
|
this.router.get('/:id', this.findOne.bind(this));
|
||||||
|
this.router.post('/', this.create.bind(this));
|
||||||
|
this.router.patch('/:id', this.update.bind(this));
|
||||||
|
this.router.delete('/:id', this.delete.bind(this));
|
||||||
|
this.router.post('/:id/convert', this.convert.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findAll(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const { partnerId, status, userId, limit, offset } = req.query;
|
||||||
|
const result = await this.salesService.findAllQuotations({ tenantId, partnerId: partnerId as string, status: status as string, userId: userId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined });
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOne(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const quotation = await this.salesService.findQuotation(req.params.id, tenantId);
|
||||||
|
if (!quotation) { res.status(404).json({ error: 'Not found' }); return; }
|
||||||
|
res.json({ data: quotation });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async create(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const quotation = await this.salesService.createQuotation(tenantId, req.body, userId);
|
||||||
|
res.status(201).json({ data: quotation });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async update(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const quotation = await this.salesService.updateQuotation(req.params.id, tenantId, req.body, userId);
|
||||||
|
if (!quotation) { res.status(404).json({ error: 'Not found' }); return; }
|
||||||
|
res.json({ data: quotation });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delete(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const deleted = await this.salesService.deleteQuotation(req.params.id, tenantId);
|
||||||
|
if (!deleted) { res.status(404).json({ error: 'Not found' }); return; }
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async convert(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const order = await this.salesService.convertQuotationToOrder(req.params.id, tenantId, userId);
|
||||||
|
res.json({ data: order });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SalesOrdersController {
|
||||||
|
public router: Router;
|
||||||
|
constructor(private readonly salesService: SalesService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.router.get('/', this.findAll.bind(this));
|
||||||
|
this.router.get('/:id', this.findOne.bind(this));
|
||||||
|
this.router.post('/', this.create.bind(this));
|
||||||
|
this.router.patch('/:id', this.update.bind(this));
|
||||||
|
this.router.delete('/:id', this.delete.bind(this));
|
||||||
|
this.router.post('/:id/confirm', this.confirm.bind(this));
|
||||||
|
this.router.post('/:id/ship', this.ship.bind(this));
|
||||||
|
this.router.post('/:id/deliver', this.deliver.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findAll(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const { partnerId, status, userId, limit, offset } = req.query;
|
||||||
|
const result = await this.salesService.findAllOrders({ tenantId, partnerId: partnerId as string, status: status as string, userId: userId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined });
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOne(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const order = await this.salesService.findOrder(req.params.id, tenantId);
|
||||||
|
if (!order) { res.status(404).json({ error: 'Not found' }); return; }
|
||||||
|
res.json({ data: order });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async create(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const order = await this.salesService.createSalesOrder(tenantId, req.body, userId);
|
||||||
|
res.status(201).json({ data: order });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async update(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const order = await this.salesService.updateSalesOrder(req.params.id, tenantId, req.body, userId);
|
||||||
|
if (!order) { res.status(404).json({ error: 'Not found' }); return; }
|
||||||
|
res.json({ data: order });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delete(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const deleted = await this.salesService.deleteSalesOrder(req.params.id, tenantId);
|
||||||
|
if (!deleted) { res.status(404).json({ error: 'Not found' }); return; }
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async confirm(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const order = await this.salesService.confirmOrder(req.params.id, tenantId, userId);
|
||||||
|
if (!order) { res.status(400).json({ error: 'Cannot confirm' }); return; }
|
||||||
|
res.json({ data: order });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ship(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
const { trackingNumber, carrier } = req.body;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const order = await this.salesService.shipOrder(req.params.id, tenantId, trackingNumber, carrier, userId);
|
||||||
|
if (!order) { res.status(400).json({ error: 'Cannot ship' }); return; }
|
||||||
|
res.json({ data: order });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliver(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; }
|
||||||
|
const order = await this.salesService.deliverOrder(req.params.id, tenantId, userId);
|
||||||
|
if (!order) { res.status(400).json({ error: 'Cannot deliver' }); return; }
|
||||||
|
res.json({ data: order });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/modules/sales/customer-groups.service.ts
Normal file
209
src/modules/sales/customer-groups.service.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface CustomerGroupMember {
|
||||||
|
id: string;
|
||||||
|
customer_group_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
joined_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerGroup {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
discount_percentage: number;
|
||||||
|
members?: CustomerGroupMember[];
|
||||||
|
member_count?: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomerGroupDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
discount_percentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomerGroupDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
discount_percentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerGroupFilters {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomerGroupsService {
|
||||||
|
async findAll(tenantId: string, filters: CustomerGroupFilters = {}): Promise<{ data: CustomerGroup[]; total: number }> {
|
||||||
|
const { search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE cg.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (cg.name ILIKE $${paramIndex} OR cg.description ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM sales.customer_groups cg ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<CustomerGroup>(
|
||||||
|
`SELECT cg.*,
|
||||||
|
(SELECT COUNT(*) FROM sales.customer_group_members cgm WHERE cgm.customer_group_id = cg.id) as member_count
|
||||||
|
FROM sales.customer_groups cg
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY cg.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<CustomerGroup> {
|
||||||
|
const group = await queryOne<CustomerGroup>(
|
||||||
|
`SELECT cg.*,
|
||||||
|
(SELECT COUNT(*) FROM sales.customer_group_members cgm WHERE cgm.customer_group_id = cg.id) as member_count
|
||||||
|
FROM sales.customer_groups cg
|
||||||
|
WHERE cg.id = $1 AND cg.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundError('Grupo de clientes no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get members
|
||||||
|
const members = await query<CustomerGroupMember>(
|
||||||
|
`SELECT cgm.*,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM sales.customer_group_members cgm
|
||||||
|
LEFT JOIN core.partners p ON cgm.partner_id = p.id
|
||||||
|
WHERE cgm.customer_group_id = $1
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
group.members = members;
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCustomerGroupDto, tenantId: string, userId: string): Promise<CustomerGroup> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.customer_groups WHERE tenant_id = $1 AND name = $2`,
|
||||||
|
[tenantId, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un grupo de clientes con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne<CustomerGroup>(
|
||||||
|
`INSERT INTO sales.customer_groups (tenant_id, name, description, discount_percentage, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.description, dto.discount_percentage || 0, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return group!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCustomerGroupDto, tenantId: string): Promise<CustomerGroup> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.customer_groups WHERE tenant_id = $1 AND name = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.name, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un grupo de clientes con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.discount_percentage !== undefined) {
|
||||||
|
updateFields.push(`discount_percentage = $${paramIndex++}`);
|
||||||
|
values.push(dto.discount_percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.customer_groups SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const group = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (group.member_count && group.member_count > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un grupo con miembros');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM sales.customer_groups WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(groupId: string, partnerId: string, tenantId: string): Promise<CustomerGroupMember> {
|
||||||
|
await this.findById(groupId, tenantId);
|
||||||
|
|
||||||
|
// Check if already member
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.customer_group_members WHERE customer_group_id = $1 AND partner_id = $2`,
|
||||||
|
[groupId, partnerId]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('El cliente ya es miembro de este grupo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await queryOne<CustomerGroupMember>(
|
||||||
|
`INSERT INTO sales.customer_group_members (customer_group_id, partner_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING *`,
|
||||||
|
[groupId, partnerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return member!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(groupId: string, memberId: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(groupId, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.customer_group_members WHERE id = $1 AND customer_group_id = $2`,
|
||||||
|
[memberId, groupId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customerGroupsService = new CustomerGroupsService();
|
||||||
82
src/modules/sales/dto/index.ts
Normal file
82
src/modules/sales/dto/index.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { IsString, IsOptional, IsNumber, IsUUID, IsDateString, IsArray, IsObject, MaxLength, IsEnum, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateQuotationDto {
|
||||||
|
@IsUUID() partnerId: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) partnerName?: string;
|
||||||
|
@IsOptional() @IsString() partnerEmail?: string;
|
||||||
|
@IsOptional() @IsObject() billingAddress?: object;
|
||||||
|
@IsOptional() @IsObject() shippingAddress?: object;
|
||||||
|
@IsOptional() @IsDateString() quotationDate?: string;
|
||||||
|
@IsOptional() @IsDateString() validUntil?: string;
|
||||||
|
@IsOptional() @IsUUID() salesRepId?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(3) currency?: string;
|
||||||
|
@IsOptional() @IsNumber() paymentTermDays?: number;
|
||||||
|
@IsOptional() @IsString() paymentMethod?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsString() termsAndConditions?: string;
|
||||||
|
@IsOptional() @IsArray() items?: CreateQuotationItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateQuotationItemDto {
|
||||||
|
@IsOptional() @IsUUID() productId?: string;
|
||||||
|
@IsString() @MaxLength(200) productName: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(50) productSku?: string;
|
||||||
|
@IsOptional() @IsString() description?: string;
|
||||||
|
@IsNumber() @Min(0) quantity: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(20) uom?: string;
|
||||||
|
@IsNumber() @Min(0) unitPrice: number;
|
||||||
|
@IsOptional() @IsNumber() discountPercent?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateQuotationDto {
|
||||||
|
@IsOptional() @IsUUID() partnerId?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) partnerName?: string;
|
||||||
|
@IsOptional() @IsObject() billingAddress?: object;
|
||||||
|
@IsOptional() @IsObject() shippingAddress?: object;
|
||||||
|
@IsOptional() @IsDateString() validUntil?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(3) currency?: string;
|
||||||
|
@IsOptional() @IsNumber() paymentTermDays?: number;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsEnum(['draft', 'sent', 'accepted', 'rejected', 'expired']) status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateSalesOrderDto {
|
||||||
|
@IsUUID() partnerId: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) partnerName?: string;
|
||||||
|
@IsOptional() @IsUUID() quotationId?: string;
|
||||||
|
@IsOptional() @IsObject() billingAddress?: object;
|
||||||
|
@IsOptional() @IsObject() shippingAddress?: object;
|
||||||
|
@IsOptional() @IsDateString() requestedDate?: string;
|
||||||
|
@IsOptional() @IsDateString() promisedDate?: string;
|
||||||
|
@IsOptional() @IsUUID() salesRepId?: string;
|
||||||
|
@IsOptional() @IsUUID() warehouseId?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(3) currency?: string;
|
||||||
|
@IsOptional() @IsNumber() paymentTermDays?: number;
|
||||||
|
@IsOptional() @IsString() paymentMethod?: string;
|
||||||
|
@IsOptional() @IsString() shippingMethod?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsArray() items?: CreateOrderItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateOrderItemDto {
|
||||||
|
@IsOptional() @IsUUID() productId?: string;
|
||||||
|
@IsString() @MaxLength(200) productName: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(50) productSku?: string;
|
||||||
|
@IsNumber() @Min(0) quantity: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(20) uom?: string;
|
||||||
|
@IsNumber() @Min(0) unitPrice: number;
|
||||||
|
@IsOptional() @IsNumber() unitCost?: number;
|
||||||
|
@IsOptional() @IsNumber() discountPercent?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateSalesOrderDto {
|
||||||
|
@IsOptional() @IsObject() shippingAddress?: object;
|
||||||
|
@IsOptional() @IsDateString() promisedDate?: string;
|
||||||
|
@IsOptional() @IsString() shippingMethod?: string;
|
||||||
|
@IsOptional() @IsString() trackingNumber?: string;
|
||||||
|
@IsOptional() @IsString() carrier?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsEnum(['draft', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled']) status?: string;
|
||||||
|
}
|
||||||
4
src/modules/sales/entities/index.ts
Normal file
4
src/modules/sales/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { Quotation } from './quotation.entity';
|
||||||
|
export { QuotationItem } from './quotation-item.entity';
|
||||||
|
export { SalesOrder } from './sales-order.entity';
|
||||||
|
export { SalesOrderItem } from './sales-order-item.entity';
|
||||||
65
src/modules/sales/entities/quotation-item.entity.ts
Normal file
65
src/modules/sales/entities/quotation-item.entity.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { Quotation } from './quotation.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'quotation_items', schema: 'sales' })
|
||||||
|
export class QuotationItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'quotation_id', type: 'uuid' })
|
||||||
|
quotationId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Quotation, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'quotation_id' })
|
||||||
|
quotation: Quotation;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'product_id', type: 'uuid', nullable: true })
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'line_number', type: 'int', default: 1 })
|
||||||
|
lineNumber: number;
|
||||||
|
|
||||||
|
@Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true })
|
||||||
|
productSku?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'product_name', type: 'varchar', length: 200 })
|
||||||
|
productName: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'PZA' })
|
||||||
|
uom: string;
|
||||||
|
|
||||||
|
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
unitPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||||
|
discountPercent: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
discountAmount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 })
|
||||||
|
taxRate: number;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
taxAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
subtotal: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
101
src/modules/sales/entities/quotation.entity.ts
Normal file
101
src/modules/sales/entities/quotation.entity.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'quotations', schema: 'sales' })
|
||||||
|
export class Quotation {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'quotation_number', type: 'varchar', length: 30 })
|
||||||
|
quotationNumber: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'partner_id', type: 'uuid' })
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
partnerName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'partner_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
partnerEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_address', type: 'jsonb', nullable: true })
|
||||||
|
billingAddress: object;
|
||||||
|
|
||||||
|
@Column({ name: 'shipping_address', type: 'jsonb', nullable: true })
|
||||||
|
shippingAddress: object;
|
||||||
|
|
||||||
|
@Column({ name: 'quotation_date', type: 'date', default: () => 'CURRENT_DATE' })
|
||||||
|
quotationDate: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'valid_until', type: 'date', nullable: true })
|
||||||
|
validUntil: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'expected_close_date', type: 'date', nullable: true })
|
||||||
|
expectedCloseDate: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'sales_rep_id', type: 'uuid', nullable: true })
|
||||||
|
salesRepId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
subtotal: number;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
taxAmount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
discountAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_term_days', type: 'int', default: 0 })
|
||||||
|
paymentTermDays: number;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true })
|
||||||
|
paymentMethod: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||||
|
status: 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted';
|
||||||
|
|
||||||
|
@Column({ name: 'converted_to_order', type: 'boolean', default: false })
|
||||||
|
convertedToOrder: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'order_id', type: 'uuid', nullable: true })
|
||||||
|
orderId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'converted_at', type: 'timestamptz', nullable: true })
|
||||||
|
convertedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
@Column({ name: 'internal_notes', type: 'text', nullable: true })
|
||||||
|
internalNotes: string;
|
||||||
|
|
||||||
|
@Column({ name: 'terms_and_conditions', type: 'text', nullable: true })
|
||||||
|
termsAndConditions: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
90
src/modules/sales/entities/sales-order-item.entity.ts
Normal file
90
src/modules/sales/entities/sales-order-item.entity.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { SalesOrder } from './sales-order.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'sales_order_items', schema: 'sales' })
|
||||||
|
export class SalesOrderItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'order_id', type: 'uuid' })
|
||||||
|
orderId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => SalesOrder, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'order_id' })
|
||||||
|
order: SalesOrder;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'product_id', type: 'uuid', nullable: true })
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'line_number', type: 'int', default: 1 })
|
||||||
|
lineNumber: number;
|
||||||
|
|
||||||
|
@Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true })
|
||||||
|
productSku?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'product_name', type: 'varchar', length: 200 })
|
||||||
|
productName: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 4, default: 1 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_reserved', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityReserved: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityShipped: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_delivered', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityDelivered: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quantity_returned', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
quantityReturned: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'PZA' })
|
||||||
|
uom: string;
|
||||||
|
|
||||||
|
@Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
unitPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 })
|
||||||
|
unitCost: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||||
|
discountPercent: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
discountAmount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 })
|
||||||
|
taxRate: number;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
taxAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
subtotal: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
status: 'pending' | 'reserved' | 'shipped' | 'delivered' | 'cancelled';
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
143
src/modules/sales/entities/sales-order.entity.ts
Normal file
143
src/modules/sales/entities/sales-order.entity.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { PaymentTerm } from '../../core/entities/payment-term.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sales Order Entity
|
||||||
|
*
|
||||||
|
* Aligned with SQL schema used by orders.service.ts
|
||||||
|
* Supports full Order-to-Cash flow with:
|
||||||
|
* - PaymentTerms integration
|
||||||
|
* - Automatic picking creation
|
||||||
|
* - Stock reservation
|
||||||
|
* - Invoice and delivery status tracking
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'sales_orders', schema: 'sales' })
|
||||||
|
export class SalesOrder {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
// Order identification
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 30 })
|
||||||
|
name: string; // Order number (e.g., SO-000001)
|
||||||
|
|
||||||
|
@Column({ name: 'client_order_ref', type: 'varchar', length: 100, nullable: true })
|
||||||
|
clientOrderRef: string | null; // Customer's reference number
|
||||||
|
|
||||||
|
@Column({ name: 'quotation_id', type: 'uuid', nullable: true })
|
||||||
|
quotationId: string | null;
|
||||||
|
|
||||||
|
// Partner/Customer
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'partner_id', type: 'uuid' })
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
@Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' })
|
||||||
|
orderDate: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'validity_date', type: 'date', nullable: true })
|
||||||
|
validityDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'commitment_date', type: 'date', nullable: true })
|
||||||
|
commitmentDate: Date | null; // Promised delivery date
|
||||||
|
|
||||||
|
// Currency and pricing
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'currency_id', type: 'uuid' })
|
||||||
|
currencyId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'pricelist_id', type: 'uuid', nullable: true })
|
||||||
|
pricelistId: string | null;
|
||||||
|
|
||||||
|
// Payment terms integration (TASK-003-01)
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'payment_term_id', type: 'uuid', nullable: true })
|
||||||
|
paymentTermId: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => PaymentTerm)
|
||||||
|
@JoinColumn({ name: 'payment_term_id' })
|
||||||
|
paymentTerm: PaymentTerm;
|
||||||
|
|
||||||
|
// Sales team
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string | null; // Sales representative
|
||||||
|
|
||||||
|
@Column({ name: 'sales_team_id', type: 'uuid', nullable: true })
|
||||||
|
salesTeamId: string | null;
|
||||||
|
|
||||||
|
// Amounts
|
||||||
|
@Column({ name: 'amount_untaxed', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
amountUntaxed: number;
|
||||||
|
|
||||||
|
@Column({ name: 'amount_tax', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
amountTax: number;
|
||||||
|
|
||||||
|
@Column({ name: 'amount_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||||
|
amountTotal: number;
|
||||||
|
|
||||||
|
// Status fields (Order-to-Cash tracking)
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||||
|
status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'invoice_status', type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
invoiceStatus: 'pending' | 'partial' | 'invoiced';
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'delivery_status', type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
deliveryStatus: 'pending' | 'partial' | 'delivered';
|
||||||
|
|
||||||
|
@Column({ name: 'invoice_policy', type: 'varchar', length: 20, default: 'order' })
|
||||||
|
invoicePolicy: 'order' | 'delivery';
|
||||||
|
|
||||||
|
// Delivery/Picking integration (TASK-003-03)
|
||||||
|
@Column({ name: 'picking_id', type: 'uuid', nullable: true })
|
||||||
|
pickingId: string | null;
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'terms_conditions', type: 'text', nullable: true })
|
||||||
|
termsConditions: string | null;
|
||||||
|
|
||||||
|
// Confirmation tracking
|
||||||
|
@Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true })
|
||||||
|
confirmedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'confirmed_by', type: 'uuid', nullable: true })
|
||||||
|
confirmedBy: string | null;
|
||||||
|
|
||||||
|
// Cancellation tracking
|
||||||
|
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'cancelled_by', type: 'uuid', nullable: true })
|
||||||
|
cancelledBy: string | null;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
}
|
||||||
5
src/modules/sales/index.ts
Normal file
5
src/modules/sales/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { SalesModule, SalesModuleOptions } from './sales.module';
|
||||||
|
export * from './entities';
|
||||||
|
export { SalesService } from './services';
|
||||||
|
export { QuotationsController, SalesOrdersController } from './controllers';
|
||||||
|
export * from './dto';
|
||||||
889
src/modules/sales/orders.service.ts
Normal file
889
src/modules/sales/orders.service.ts
Normal file
@ -0,0 +1,889 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { taxesService } from '../financial/taxes.service.js';
|
||||||
|
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
|
||||||
|
import { stockReservationService, ReservationLine } from '../inventory/stock-reservation.service.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface SalesOrderLine {
|
||||||
|
id: string;
|
||||||
|
order_id: string;
|
||||||
|
product_id: string;
|
||||||
|
product_name?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
qty_delivered: number;
|
||||||
|
qty_invoiced: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount: number;
|
||||||
|
tax_ids: string[];
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
analytic_account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesOrder {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
client_order_ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
order_date: Date;
|
||||||
|
validity_date?: Date;
|
||||||
|
commitment_date?: Date;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
pricelist_name?: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
sales_team_name?: string;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled';
|
||||||
|
invoice_status: 'pending' | 'partial' | 'invoiced';
|
||||||
|
delivery_status: 'pending' | 'partial' | 'delivered';
|
||||||
|
invoice_policy: 'order' | 'delivery';
|
||||||
|
picking_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
lines?: SalesOrderLine[];
|
||||||
|
created_at: Date;
|
||||||
|
confirmed_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesOrderDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
client_order_ref?: string;
|
||||||
|
order_date?: string;
|
||||||
|
validity_date?: string;
|
||||||
|
commitment_date?: string;
|
||||||
|
currency_id: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
invoice_policy?: 'order' | 'delivery';
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSalesOrderDto {
|
||||||
|
partner_id?: string;
|
||||||
|
client_order_ref?: string | null;
|
||||||
|
order_date?: string;
|
||||||
|
validity_date?: string | null;
|
||||||
|
commitment_date?: string | null;
|
||||||
|
currency_id?: string;
|
||||||
|
pricelist_id?: string | null;
|
||||||
|
payment_term_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
invoice_policy?: 'order' | 'delivery';
|
||||||
|
notes?: string | null;
|
||||||
|
terms_conditions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesOrderLineDto {
|
||||||
|
product_id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
analytic_account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSalesOrderLineDto {
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
uom_id?: string;
|
||||||
|
price_unit?: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
analytic_account_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesOrderFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
status?: string;
|
||||||
|
invoice_status?: string;
|
||||||
|
delivery_status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrdersService {
|
||||||
|
async findAll(tenantId: string, filters: SalesOrderFilters = {}): Promise<{ data: SalesOrder[]; total: number }> {
|
||||||
|
const { company_id, partner_id, status, invoice_status, delivery_status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE so.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND so.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND so.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND so.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice_status) {
|
||||||
|
whereClause += ` AND so.invoice_status = $${paramIndex++}`;
|
||||||
|
params.push(invoice_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delivery_status) {
|
||||||
|
whereClause += ` AND so.delivery_status = $${paramIndex++}`;
|
||||||
|
params.push(delivery_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND so.order_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND so.order_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (so.name ILIKE $${paramIndex} OR so.client_order_ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM sales.sales_orders so
|
||||||
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<SalesOrder>(
|
||||||
|
`SELECT so.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.sales_orders so
|
||||||
|
LEFT JOIN auth.companies c ON so.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON so.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON so.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY so.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<SalesOrder> {
|
||||||
|
const order = await queryOne<SalesOrder>(
|
||||||
|
`SELECT so.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.sales_orders so
|
||||||
|
LEFT JOIN auth.companies c ON so.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON so.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON so.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id
|
||||||
|
WHERE so.id = $1 AND so.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundError('Orden de venta no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<SalesOrderLine>(
|
||||||
|
`SELECT sol.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
um.name as uom_name
|
||||||
|
FROM sales.sales_order_lines sol
|
||||||
|
LEFT JOIN inventory.products pr ON sol.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom um ON sol.uom_id = um.id
|
||||||
|
WHERE sol.order_id = $1
|
||||||
|
ORDER BY sol.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
order.lines = lines;
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
// Generate sequence number using atomic database function
|
||||||
|
const orderNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.SALES_ORDER, tenantId);
|
||||||
|
|
||||||
|
const orderDate = dto.order_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const order = await queryOne<SalesOrder>(
|
||||||
|
`INSERT INTO sales.sales_orders (
|
||||||
|
tenant_id, company_id, name, client_order_ref, partner_id, order_date,
|
||||||
|
validity_date, commitment_date, currency_id, pricelist_id, payment_term_id,
|
||||||
|
user_id, sales_team_id, invoice_policy, notes, terms_conditions, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, orderNumber, dto.client_order_ref, dto.partner_id,
|
||||||
|
orderDate, dto.validity_date, dto.commitment_date, dto.currency_id,
|
||||||
|
dto.pricelist_id, dto.payment_term_id, userId, dto.sales_team_id,
|
||||||
|
dto.invoice_policy || 'order', dto.notes, dto.terms_conditions, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return order!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.client_order_ref !== undefined) {
|
||||||
|
updateFields.push(`client_order_ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.client_order_ref);
|
||||||
|
}
|
||||||
|
if (dto.order_date !== undefined) {
|
||||||
|
updateFields.push(`order_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.order_date);
|
||||||
|
}
|
||||||
|
if (dto.validity_date !== undefined) {
|
||||||
|
updateFields.push(`validity_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.validity_date);
|
||||||
|
}
|
||||||
|
if (dto.commitment_date !== undefined) {
|
||||||
|
updateFields.push(`commitment_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.commitment_date);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.pricelist_id !== undefined) {
|
||||||
|
updateFields.push(`pricelist_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.pricelist_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_term_id !== undefined) {
|
||||||
|
updateFields.push(`payment_term_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_term_id);
|
||||||
|
}
|
||||||
|
if (dto.sales_team_id !== undefined) {
|
||||||
|
updateFields.push(`sales_team_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.sales_team_id);
|
||||||
|
}
|
||||||
|
if (dto.invoice_policy !== undefined) {
|
||||||
|
updateFields.push(`invoice_policy = $${paramIndex++}`);
|
||||||
|
values.push(dto.invoice_policy);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
if (dto.terms_conditions !== undefined) {
|
||||||
|
updateFields.push(`terms_conditions = $${paramIndex++}`);
|
||||||
|
values.push(dto.terms_conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_orders SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.sales_orders WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(orderId: string, dto: CreateSalesOrderLineDto, tenantId: string, userId: string): Promise<SalesOrderLine> {
|
||||||
|
const order = await this.findById(orderId, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amounts with taxes using taxesService
|
||||||
|
const taxResult = await taxesService.calculateTaxes(
|
||||||
|
{
|
||||||
|
quantity: dto.quantity,
|
||||||
|
priceUnit: dto.price_unit,
|
||||||
|
discount: dto.discount || 0,
|
||||||
|
taxIds: dto.tax_ids || [],
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
'sales'
|
||||||
|
);
|
||||||
|
const amountUntaxed = taxResult.amountUntaxed;
|
||||||
|
const amountTax = taxResult.amountTax;
|
||||||
|
const amountTotal = taxResult.amountTotal;
|
||||||
|
|
||||||
|
const line = await queryOne<SalesOrderLine>(
|
||||||
|
`INSERT INTO sales.sales_order_lines (
|
||||||
|
order_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total, analytic_account_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
orderId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
||||||
|
dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.analytic_account_id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
await this.updateTotals(orderId);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(orderId: string, lineId: string, dto: UpdateSalesOrderLineDto, tenantId: string): Promise<SalesOrderLine> {
|
||||||
|
const order = await this.findById(orderId, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas de órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = order.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea de orden no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const quantity = dto.quantity ?? existingLine.quantity;
|
||||||
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
||||||
|
const discount = dto.discount ?? existingLine.discount;
|
||||||
|
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.quantity !== undefined) {
|
||||||
|
updateFields.push(`quantity = $${paramIndex++}`);
|
||||||
|
values.push(dto.quantity);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
if (dto.price_unit !== undefined) {
|
||||||
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
||||||
|
values.push(dto.price_unit);
|
||||||
|
}
|
||||||
|
if (dto.discount !== undefined) {
|
||||||
|
updateFields.push(`discount = $${paramIndex++}`);
|
||||||
|
values.push(dto.discount);
|
||||||
|
}
|
||||||
|
if (dto.tax_ids !== undefined) {
|
||||||
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_ids);
|
||||||
|
}
|
||||||
|
if (dto.analytic_account_id !== undefined) {
|
||||||
|
updateFields.push(`analytic_account_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.analytic_account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate amounts
|
||||||
|
const subtotal = quantity * priceUnit;
|
||||||
|
const discountAmount = subtotal * discount / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
const amountTax = 0; // TODO: Calculate taxes
|
||||||
|
const amountTotal = amountUntaxed + amountTax;
|
||||||
|
|
||||||
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
||||||
|
values.push(amountUntaxed);
|
||||||
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
||||||
|
values.push(amountTax);
|
||||||
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
||||||
|
values.push(amountTotal);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(lineId, orderId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_order_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND order_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
await this.updateTotals(orderId);
|
||||||
|
|
||||||
|
const updated = await queryOne<SalesOrderLine>(
|
||||||
|
`SELECT * FROM sales.sales_order_lines WHERE id = $1`,
|
||||||
|
[lineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(orderId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const order = await this.findById(orderId, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas de órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.sales_order_lines WHERE id = $1 AND order_id = $2`,
|
||||||
|
[lineId, orderId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order totals
|
||||||
|
await this.updateTotals(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden confirmar órdenes en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.lines || order.lines.length === 0) {
|
||||||
|
throw new ValidationError('La orden debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Get default outgoing location for the company
|
||||||
|
const locationResult = await client.query(
|
||||||
|
`SELECT l.id as location_id, w.id as warehouse_id
|
||||||
|
FROM inventory.locations l
|
||||||
|
INNER JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
||||||
|
WHERE w.tenant_id = $1
|
||||||
|
AND w.company_id = $2
|
||||||
|
AND l.location_type = 'internal'
|
||||||
|
AND l.active = true
|
||||||
|
ORDER BY w.is_default DESC, l.name ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId, order.company_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceLocationId = locationResult.rows[0]?.location_id;
|
||||||
|
const warehouseId = locationResult.rows[0]?.warehouse_id;
|
||||||
|
|
||||||
|
if (!sourceLocationId) {
|
||||||
|
throw new ValidationError('No hay ubicación de stock configurada para esta empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get customer location (or create virtual one)
|
||||||
|
const custLocationResult = await client.query(
|
||||||
|
`SELECT id FROM inventory.locations
|
||||||
|
WHERE tenant_id = $1 AND location_type = 'customer'
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let customerLocationId = custLocationResult.rows[0]?.id;
|
||||||
|
|
||||||
|
if (!customerLocationId) {
|
||||||
|
// Create a default customer location
|
||||||
|
const newLocResult = await client.query(
|
||||||
|
`INSERT INTO inventory.locations (tenant_id, name, location_type, active)
|
||||||
|
VALUES ($1, 'Customers', 'customer', true)
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
customerLocationId = newLocResult.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK-003-04: Reserve stock for order lines
|
||||||
|
const reservationLines: ReservationLine[] = order.lines.map(line => ({
|
||||||
|
productId: line.product_id,
|
||||||
|
locationId: sourceLocationId,
|
||||||
|
quantity: line.quantity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reservationResult = await stockReservationService.reserveWithClient(
|
||||||
|
client,
|
||||||
|
reservationLines,
|
||||||
|
tenantId,
|
||||||
|
order.name,
|
||||||
|
false // Don't allow partial - fail if insufficient stock
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reservationResult.success) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Stock insuficiente: ${reservationResult.errors.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TASK-003-03: Create outgoing picking
|
||||||
|
const pickingNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.PICKING_OUT, tenantId);
|
||||||
|
|
||||||
|
const pickingResult = await client.query(
|
||||||
|
`INSERT INTO inventory.pickings (
|
||||||
|
tenant_id, company_id, name, picking_type, location_id, location_dest_id,
|
||||||
|
partner_id, scheduled_date, origin, status, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, 'outgoing', $4, $5, $6, $7, $8, 'confirmed', $9)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
order.company_id,
|
||||||
|
pickingNumber,
|
||||||
|
sourceLocationId,
|
||||||
|
customerLocationId,
|
||||||
|
order.partner_id,
|
||||||
|
order.commitment_date || new Date().toISOString().split('T')[0],
|
||||||
|
order.name, // origin = sales order reference
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const pickingId = pickingResult.rows[0].id;
|
||||||
|
|
||||||
|
// Create stock moves for each order line
|
||||||
|
for (const line of order.lines) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory.stock_moves (
|
||||||
|
tenant_id, picking_id, product_id, product_uom_id, location_id,
|
||||||
|
location_dest_id, product_qty, status, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'confirmed', $8)`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
pickingId,
|
||||||
|
line.product_id,
|
||||||
|
line.uom_id,
|
||||||
|
sourceLocationId,
|
||||||
|
customerLocationId,
|
||||||
|
line.quantity,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order: status to 'sent', link picking
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
status = 'sent',
|
||||||
|
picking_id = $1,
|
||||||
|
confirmed_at = CURRENT_TIMESTAMP,
|
||||||
|
confirmed_by = $2,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3`,
|
||||||
|
[pickingId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info('Sales order confirmed with picking', {
|
||||||
|
orderId: id,
|
||||||
|
orderName: order.name,
|
||||||
|
pickingId,
|
||||||
|
pickingName: pickingNumber,
|
||||||
|
linesCount: order.lines.length,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('Error confirming sales order', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
orderId: id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<SalesOrder> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status === 'done') {
|
||||||
|
throw new ValidationError('No se pueden cancelar órdenes completadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La orden ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any deliveries or invoices
|
||||||
|
if (order.delivery_status !== 'pending') {
|
||||||
|
throw new ValidationError('No se puede cancelar: ya hay entregas asociadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.invoice_status !== 'pending') {
|
||||||
|
throw new ValidationError('No se puede cancelar: ya hay facturas asociadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Release stock reservations if order was confirmed
|
||||||
|
if (order.status === 'sent' || order.status === 'sale') {
|
||||||
|
// Get the source location from picking
|
||||||
|
if (order.picking_id) {
|
||||||
|
const pickingResult = await client.query(
|
||||||
|
`SELECT location_id FROM inventory.pickings WHERE id = $1`,
|
||||||
|
[order.picking_id]
|
||||||
|
);
|
||||||
|
const sourceLocationId = pickingResult.rows[0]?.location_id;
|
||||||
|
|
||||||
|
if (sourceLocationId && order.lines) {
|
||||||
|
const releaseLines: ReservationLine[] = order.lines.map(line => ({
|
||||||
|
productId: line.product_id,
|
||||||
|
locationId: sourceLocationId,
|
||||||
|
quantity: line.quantity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await stockReservationService.releaseWithClient(
|
||||||
|
client,
|
||||||
|
releaseLines,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the picking
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.pickings SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||||
|
[userId, order.picking_id]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE inventory.stock_moves SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE picking_id = $2`,
|
||||||
|
[userId, order.picking_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
status = 'cancelled',
|
||||||
|
cancelled_at = CURRENT_TIMESTAMP,
|
||||||
|
cancelled_by = $1,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logger.info('Sales order cancelled', {
|
||||||
|
orderId: id,
|
||||||
|
orderName: order.name,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
logger.error('Error cancelling sales order', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
orderId: id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(id: string, tenantId: string, userId: string): Promise<{ orderId: string; invoiceId: string }> {
|
||||||
|
const order = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (order.status !== 'sent' && order.status !== 'sale' && order.status !== 'done') {
|
||||||
|
throw new ValidationError('Solo se pueden facturar órdenes confirmadas (sent/sale)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.invoice_status === 'invoiced') {
|
||||||
|
throw new ValidationError('La orden ya está completamente facturada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are quantities to invoice
|
||||||
|
const linesToInvoice = order.lines?.filter(l => {
|
||||||
|
if (order.invoice_policy === 'order') {
|
||||||
|
return l.quantity > l.qty_invoiced;
|
||||||
|
} else {
|
||||||
|
return l.qty_delivered > l.qty_invoiced;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!linesToInvoice || linesToInvoice.length === 0) {
|
||||||
|
throw new ValidationError('No hay líneas para facturar');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate invoice number
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM financial.invoices WHERE tenant_id = $1 AND name LIKE 'INV-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const invoiceNumber = `INV-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// Create invoice
|
||||||
|
const invoiceResult = await client.query(
|
||||||
|
`INSERT INTO financial.invoices (
|
||||||
|
tenant_id, company_id, name, partner_id, invoice_date, due_date,
|
||||||
|
currency_id, invoice_type, amount_untaxed, amount_tax, amount_total,
|
||||||
|
source_document, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_DATE, CURRENT_DATE + INTERVAL '30 days',
|
||||||
|
$5, 'customer', 0, 0, 0, $6, $7)
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId, order.company_id, invoiceNumber, order.partner_id, order.currency_id, order.name, userId]
|
||||||
|
);
|
||||||
|
const invoiceId = invoiceResult.rows[0].id;
|
||||||
|
|
||||||
|
// Create invoice lines and update qty_invoiced
|
||||||
|
for (const line of linesToInvoice) {
|
||||||
|
const qtyToInvoice = order.invoice_policy === 'order'
|
||||||
|
? line.quantity - line.qty_invoiced
|
||||||
|
: line.qty_delivered - line.qty_invoiced;
|
||||||
|
|
||||||
|
const lineAmount = qtyToInvoice * line.price_unit * (1 - line.discount / 100);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.invoice_lines (
|
||||||
|
invoice_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, discount, amount_untaxed, amount_tax, amount_total
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $9)`,
|
||||||
|
[invoiceId, tenantId, line.product_id, line.description, qtyToInvoice, line.uom_id, line.price_unit, line.discount, lineAmount]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_order_lines SET qty_invoiced = qty_invoiced + $1 WHERE id = $2`,
|
||||||
|
[qtyToInvoice, line.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_untaxed = (SELECT COALESCE(SUM(amount_untaxed), 0) FROM financial.invoice_lines WHERE invoice_id = $1),
|
||||||
|
amount_total = (SELECT COALESCE(SUM(amount_total), 0) FROM financial.invoice_lines WHERE invoice_id = $1)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update order invoice_status
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
invoice_status = CASE
|
||||||
|
WHEN (SELECT SUM(qty_invoiced) FROM sales.sales_order_lines WHERE order_id = $1) >=
|
||||||
|
(SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1)
|
||||||
|
THEN 'invoiced'::sales.invoice_status
|
||||||
|
ELSE 'partial'::sales.invoice_status
|
||||||
|
END,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return { orderId: id, invoiceId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTotals(orderId: string): Promise<void> {
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_orders SET
|
||||||
|
amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.sales_order_lines WHERE order_id = $1), 0),
|
||||||
|
amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.sales_order_lines WHERE order_id = $1), 0),
|
||||||
|
amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.sales_order_lines WHERE order_id = $1), 0)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[orderId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ordersService = new OrdersService();
|
||||||
249
src/modules/sales/pricelists.service.ts
Normal file
249
src/modules/sales/pricelists.service.ts
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface PricelistItem {
|
||||||
|
id: string;
|
||||||
|
pricelist_id: string;
|
||||||
|
product_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
product_category_id?: string;
|
||||||
|
category_name?: string;
|
||||||
|
price: number;
|
||||||
|
min_quantity: number;
|
||||||
|
valid_from?: Date;
|
||||||
|
valid_to?: Date;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pricelist {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id?: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
active: boolean;
|
||||||
|
items?: PricelistItem[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePricelistDto {
|
||||||
|
company_id?: string;
|
||||||
|
name: string;
|
||||||
|
currency_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePricelistDto {
|
||||||
|
name?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePricelistItemDto {
|
||||||
|
product_id?: string;
|
||||||
|
product_category_id?: string;
|
||||||
|
price: number;
|
||||||
|
min_quantity?: number;
|
||||||
|
valid_from?: string;
|
||||||
|
valid_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricelistFilters {
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PricelistsService {
|
||||||
|
async findAll(tenantId: string, filters: PricelistFilters = {}): Promise<{ data: Pricelist[]; total: number }> {
|
||||||
|
const { company_id, active, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND p.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM sales.pricelists p ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Pricelist>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM sales.pricelists p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Pricelist> {
|
||||||
|
const pricelist = await queryOne<Pricelist>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM sales.pricelists p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pricelist) {
|
||||||
|
throw new NotFoundError('Lista de precios no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items
|
||||||
|
const items = await query<PricelistItem>(
|
||||||
|
`SELECT pi.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
pc.name as category_name
|
||||||
|
FROM sales.pricelist_items pi
|
||||||
|
LEFT JOIN inventory.products pr ON pi.product_id = pr.id
|
||||||
|
LEFT JOIN core.product_categories pc ON pi.product_category_id = pc.id
|
||||||
|
WHERE pi.pricelist_id = $1
|
||||||
|
ORDER BY pi.min_quantity, pr.name`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
pricelist.items = items;
|
||||||
|
|
||||||
|
return pricelist;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePricelistDto, tenantId: string, userId: string): Promise<Pricelist> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.pricelists WHERE tenant_id = $1 AND name = $2`,
|
||||||
|
[tenantId, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una lista de precios con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricelist = await queryOne<Pricelist>(
|
||||||
|
`INSERT INTO sales.pricelists (tenant_id, company_id, name, currency_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.currency_id, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return pricelist!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePricelistDto, tenantId: string, userId: string): Promise<Pricelist> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.pricelists WHERE tenant_id = $1 AND name = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.name, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una lista de precios con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.pricelists SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addItem(pricelistId: string, dto: CreatePricelistItemDto, tenantId: string, userId: string): Promise<PricelistItem> {
|
||||||
|
await this.findById(pricelistId, tenantId);
|
||||||
|
|
||||||
|
if (!dto.product_id && !dto.product_category_id) {
|
||||||
|
throw new ValidationError('Debe especificar un producto o una categoría');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.product_id && dto.product_category_id) {
|
||||||
|
throw new ValidationError('Debe especificar solo un producto o solo una categoría, no ambos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await queryOne<PricelistItem>(
|
||||||
|
`INSERT INTO sales.pricelist_items (pricelist_id, product_id, product_category_id, price, min_quantity, valid_from, valid_to, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[pricelistId, dto.product_id, dto.product_category_id, dto.price, dto.min_quantity || 1, dto.valid_from, dto.valid_to, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return item!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(pricelistId: string, itemId: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(pricelistId, tenantId);
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM sales.pricelist_items WHERE id = $1 AND pricelist_id = $2`,
|
||||||
|
[itemId, pricelistId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductPrice(productId: string, pricelistId: string, quantity: number = 1): Promise<number | null> {
|
||||||
|
const item = await queryOne<{ price: number }>(
|
||||||
|
`SELECT price FROM sales.pricelist_items
|
||||||
|
WHERE pricelist_id = $1
|
||||||
|
AND (product_id = $2 OR product_category_id = (SELECT category_id FROM inventory.products WHERE id = $2))
|
||||||
|
AND active = true
|
||||||
|
AND min_quantity <= $3
|
||||||
|
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||||
|
AND (valid_to IS NULL OR valid_to >= CURRENT_DATE)
|
||||||
|
ORDER BY product_id NULLS LAST, min_quantity DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[pricelistId, productId, quantity]
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.price || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pricelistsService = new PricelistsService();
|
||||||
588
src/modules/sales/quotations.service.ts
Normal file
588
src/modules/sales/quotations.service.ts
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { taxesService } from '../financial/taxes.service.js';
|
||||||
|
|
||||||
|
export interface QuotationLine {
|
||||||
|
id: string;
|
||||||
|
quotation_id: string;
|
||||||
|
product_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
uom_name?: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount: number;
|
||||||
|
tax_ids: string[];
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quotation {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
quotation_date: Date;
|
||||||
|
validity_date: Date;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
pricelist_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
sales_team_name?: string;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
status: 'draft' | 'sent' | 'confirmed' | 'cancelled' | 'expired';
|
||||||
|
sale_order_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
lines?: QuotationLine[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuotationDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
quotation_date?: string;
|
||||||
|
validity_date: string;
|
||||||
|
currency_id: string;
|
||||||
|
pricelist_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
terms_conditions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateQuotationDto {
|
||||||
|
partner_id?: string;
|
||||||
|
quotation_date?: string;
|
||||||
|
validity_date?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
pricelist_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
terms_conditions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuotationLineDto {
|
||||||
|
product_id?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id: string;
|
||||||
|
price_unit: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateQuotationLineDto {
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
uom_id?: string;
|
||||||
|
price_unit?: number;
|
||||||
|
discount?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotationFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuotationsService {
|
||||||
|
async findAll(tenantId: string, filters: QuotationFilters = {}): Promise<{ data: Quotation[]; total: number }> {
|
||||||
|
const { company_id, partner_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE q.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND q.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND q.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND q.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND q.quotation_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND q.quotation_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (q.name ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM sales.quotations q
|
||||||
|
LEFT JOIN core.partners p ON q.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Quotation>(
|
||||||
|
`SELECT q.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.quotations q
|
||||||
|
LEFT JOIN auth.companies c ON q.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON q.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON q.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON q.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Quotation> {
|
||||||
|
const quotation = await queryOne<Quotation>(
|
||||||
|
`SELECT q.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
pl.name as pricelist_name,
|
||||||
|
u.name as user_name,
|
||||||
|
st.name as sales_team_name
|
||||||
|
FROM sales.quotations q
|
||||||
|
LEFT JOIN auth.companies c ON q.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON q.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON q.currency_id = cu.id
|
||||||
|
LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id
|
||||||
|
LEFT JOIN auth.users u ON q.user_id = u.id
|
||||||
|
LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id
|
||||||
|
WHERE q.id = $1 AND q.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!quotation) {
|
||||||
|
throw new NotFoundError('Cotización no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<QuotationLine>(
|
||||||
|
`SELECT ql.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
um.name as uom_name
|
||||||
|
FROM sales.quotation_lines ql
|
||||||
|
LEFT JOIN inventory.products pr ON ql.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom um ON ql.uom_id = um.id
|
||||||
|
WHERE ql.quotation_id = $1
|
||||||
|
ORDER BY ql.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
quotation.lines = lines;
|
||||||
|
|
||||||
|
return quotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateQuotationDto, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
// Generate sequence number
|
||||||
|
const seqResult = await queryOne<{ next_num: number }>(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'QUO-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const quotationNumber = `QUO-${String(seqResult?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
const quotationDate = dto.quotation_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const quotation = await queryOne<Quotation>(
|
||||||
|
`INSERT INTO sales.quotations (
|
||||||
|
tenant_id, company_id, name, partner_id, quotation_date, validity_date,
|
||||||
|
currency_id, pricelist_id, user_id, sales_team_id, notes, terms_conditions, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, quotationNumber, dto.partner_id,
|
||||||
|
quotationDate, dto.validity_date, dto.currency_id, dto.pricelist_id,
|
||||||
|
userId, dto.sales_team_id, dto.notes, dto.terms_conditions, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return quotation!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateQuotationDto, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.quotation_date !== undefined) {
|
||||||
|
updateFields.push(`quotation_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.quotation_date);
|
||||||
|
}
|
||||||
|
if (dto.validity_date !== undefined) {
|
||||||
|
updateFields.push(`validity_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.validity_date);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.pricelist_id !== undefined) {
|
||||||
|
updateFields.push(`pricelist_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.pricelist_id);
|
||||||
|
}
|
||||||
|
if (dto.sales_team_id !== undefined) {
|
||||||
|
updateFields.push(`sales_team_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.sales_team_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
if (dto.terms_conditions !== undefined) {
|
||||||
|
updateFields.push(`terms_conditions = $${paramIndex++}`);
|
||||||
|
values.push(dto.terms_conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.quotations WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(quotationId: string, dto: CreateQuotationLineDto, tenantId: string, userId: string): Promise<QuotationLine> {
|
||||||
|
const quotation = await this.findById(quotationId, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amounts with taxes using taxesService
|
||||||
|
const taxResult = await taxesService.calculateTaxes(
|
||||||
|
{
|
||||||
|
quantity: dto.quantity,
|
||||||
|
priceUnit: dto.price_unit,
|
||||||
|
discount: dto.discount || 0,
|
||||||
|
taxIds: dto.tax_ids || [],
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
'sales'
|
||||||
|
);
|
||||||
|
const amountUntaxed = taxResult.amountUntaxed;
|
||||||
|
const amountTax = taxResult.amountTax;
|
||||||
|
const amountTotal = taxResult.amountTotal;
|
||||||
|
|
||||||
|
const line = await queryOne<QuotationLine>(
|
||||||
|
`INSERT INTO sales.quotation_lines (
|
||||||
|
quotation_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
quotationId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
||||||
|
dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation totals
|
||||||
|
await this.updateTotals(quotationId);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(quotationId: string, lineId: string, dto: UpdateQuotationLineDto, tenantId: string): Promise<QuotationLine> {
|
||||||
|
const quotation = await this.findById(quotationId, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas de cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = quotation.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea de cotización no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const quantity = dto.quantity ?? existingLine.quantity;
|
||||||
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
||||||
|
const discount = dto.discount ?? existingLine.discount;
|
||||||
|
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.quantity !== undefined) {
|
||||||
|
updateFields.push(`quantity = $${paramIndex++}`);
|
||||||
|
values.push(dto.quantity);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
if (dto.price_unit !== undefined) {
|
||||||
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
||||||
|
values.push(dto.price_unit);
|
||||||
|
}
|
||||||
|
if (dto.discount !== undefined) {
|
||||||
|
updateFields.push(`discount = $${paramIndex++}`);
|
||||||
|
values.push(dto.discount);
|
||||||
|
}
|
||||||
|
if (dto.tax_ids !== undefined) {
|
||||||
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate amounts
|
||||||
|
const subtotal = quantity * priceUnit;
|
||||||
|
const discountAmount = subtotal * discount / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
const amountTax = 0; // TODO: Calculate taxes
|
||||||
|
const amountTotal = amountUntaxed + amountTax;
|
||||||
|
|
||||||
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
||||||
|
values.push(amountUntaxed);
|
||||||
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
||||||
|
values.push(amountTax);
|
||||||
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
||||||
|
values.push(amountTotal);
|
||||||
|
|
||||||
|
values.push(lineId, quotationId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotation_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND quotation_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation totals
|
||||||
|
await this.updateTotals(quotationId);
|
||||||
|
|
||||||
|
const updated = await queryOne<QuotationLine>(
|
||||||
|
`SELECT * FROM sales.quotation_lines WHERE id = $1`,
|
||||||
|
[lineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(quotationId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const quotation = await this.findById(quotationId, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas de cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.quotation_lines WHERE id = $1 AND quotation_id = $2`,
|
||||||
|
[lineId, quotationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation totals
|
||||||
|
await this.updateTotals(quotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(id: string, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
const quotation = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden enviar cotizaciones en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quotation.lines || quotation.lines.length === 0) {
|
||||||
|
throw new ValidationError('La cotización debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET status = 'sent', updated_by = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Send email notification
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(id: string, tenantId: string, userId: string): Promise<{ quotation: Quotation; orderId: string }> {
|
||||||
|
const quotation = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (!['draft', 'sent'].includes(quotation.status)) {
|
||||||
|
throw new ValidationError('Solo se pueden confirmar cotizaciones en estado borrador o enviado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quotation.lines || quotation.lines.length === 0) {
|
||||||
|
throw new ValidationError('La cotización debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate order sequence number
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 4) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM sales.sales_orders WHERE tenant_id = $1 AND name LIKE 'SO-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const orderNumber = `SO-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// Create sales order
|
||||||
|
const orderResult = await client.query(
|
||||||
|
`INSERT INTO sales.sales_orders (
|
||||||
|
tenant_id, company_id, name, partner_id, order_date, currency_id,
|
||||||
|
pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax,
|
||||||
|
amount_total, notes, terms_conditions, created_by
|
||||||
|
)
|
||||||
|
SELECT tenant_id, company_id, $1, partner_id, CURRENT_DATE, currency_id,
|
||||||
|
pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax,
|
||||||
|
amount_total, notes, terms_conditions, $2
|
||||||
|
FROM sales.quotations WHERE id = $3
|
||||||
|
RETURNING id`,
|
||||||
|
[orderNumber, userId, id]
|
||||||
|
);
|
||||||
|
const orderId = orderResult.rows[0].id;
|
||||||
|
|
||||||
|
// Copy lines to order (include tenant_id for multi-tenant security)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO sales.sales_order_lines (
|
||||||
|
order_id, tenant_id, product_id, description, quantity, uom_id, price_unit,
|
||||||
|
discount, tax_ids, amount_untaxed, amount_tax, amount_total
|
||||||
|
)
|
||||||
|
SELECT $1, $3, product_id, description, quantity, uom_id, price_unit,
|
||||||
|
discount, tax_ids, amount_untaxed, amount_tax, amount_total
|
||||||
|
FROM sales.quotation_lines WHERE quotation_id = $2 AND tenant_id = $3`,
|
||||||
|
[orderId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update quotation status
|
||||||
|
await client.query(
|
||||||
|
`UPDATE sales.quotations SET status = 'confirmed', sale_order_id = $1,
|
||||||
|
updated_by = $2, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3`,
|
||||||
|
[orderId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
quotation: await this.findById(id, tenantId),
|
||||||
|
orderId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Quotation> {
|
||||||
|
const quotation = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (quotation.status === 'confirmed') {
|
||||||
|
throw new ValidationError('No se pueden cancelar cotizaciones confirmadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotation.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La cotización ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTotals(quotationId: string): Promise<void> {
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.quotations SET
|
||||||
|
amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.quotation_lines WHERE quotation_id = $1), 0),
|
||||||
|
amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.quotation_lines WHERE quotation_id = $1), 0),
|
||||||
|
amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.quotation_lines WHERE quotation_id = $1), 0)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[quotationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const quotationsService = new QuotationsService();
|
||||||
241
src/modules/sales/sales-teams.service.ts
Normal file
241
src/modules/sales/sales-teams.service.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface SalesTeamMember {
|
||||||
|
id: string;
|
||||||
|
sales_team_id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name?: string;
|
||||||
|
user_email?: string;
|
||||||
|
role?: string;
|
||||||
|
joined_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesTeam {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
team_leader_id?: string;
|
||||||
|
team_leader_name?: string;
|
||||||
|
target_monthly?: number;
|
||||||
|
target_annual?: number;
|
||||||
|
active: boolean;
|
||||||
|
members?: SalesTeamMember[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesTeamDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
team_leader_id?: string;
|
||||||
|
target_monthly?: number;
|
||||||
|
target_annual?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSalesTeamDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
team_leader_id?: string | null;
|
||||||
|
target_monthly?: number | null;
|
||||||
|
target_annual?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesTeamFilters {
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SalesTeamsService {
|
||||||
|
async findAll(tenantId: string, filters: SalesTeamFilters = {}): Promise<{ data: SalesTeam[]; total: number }> {
|
||||||
|
const { company_id, active, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE st.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND st.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND st.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM sales.sales_teams st ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<SalesTeam>(
|
||||||
|
`SELECT st.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.full_name as team_leader_name
|
||||||
|
FROM sales.sales_teams st
|
||||||
|
LEFT JOIN auth.companies c ON st.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON st.team_leader_id = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY st.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<SalesTeam> {
|
||||||
|
const team = await queryOne<SalesTeam>(
|
||||||
|
`SELECT st.*,
|
||||||
|
c.name as company_name,
|
||||||
|
u.full_name as team_leader_name
|
||||||
|
FROM sales.sales_teams st
|
||||||
|
LEFT JOIN auth.companies c ON st.company_id = c.id
|
||||||
|
LEFT JOIN auth.users u ON st.team_leader_id = u.id
|
||||||
|
WHERE st.id = $1 AND st.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new NotFoundError('Equipo de ventas no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get members
|
||||||
|
const members = await query<SalesTeamMember>(
|
||||||
|
`SELECT stm.*,
|
||||||
|
u.full_name as user_name,
|
||||||
|
u.email as user_email
|
||||||
|
FROM sales.sales_team_members stm
|
||||||
|
LEFT JOIN auth.users u ON stm.user_id = u.id
|
||||||
|
WHERE stm.sales_team_id = $1
|
||||||
|
ORDER BY stm.joined_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
team.members = members;
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateSalesTeamDto, tenantId: string, userId: string): Promise<SalesTeam> {
|
||||||
|
// Check unique code in company
|
||||||
|
if (dto.code) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.sales_teams WHERE company_id = $1 AND code = $2`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un equipo con ese código en esta empresa');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await queryOne<SalesTeam>(
|
||||||
|
`INSERT INTO sales.sales_teams (tenant_id, company_id, name, code, team_leader_id, target_monthly, target_annual, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.code, dto.team_leader_id, dto.target_monthly, dto.target_annual, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return team!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateSalesTeamDto, tenantId: string, userId: string): Promise<SalesTeam> {
|
||||||
|
const team = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.code !== undefined) {
|
||||||
|
// Check unique code
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.sales_teams WHERE company_id = $1 AND code = $2 AND id != $3`,
|
||||||
|
[team.company_id, dto.code, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un equipo con ese código en esta empresa');
|
||||||
|
}
|
||||||
|
updateFields.push(`code = $${paramIndex++}`);
|
||||||
|
values.push(dto.code);
|
||||||
|
}
|
||||||
|
if (dto.team_leader_id !== undefined) {
|
||||||
|
updateFields.push(`team_leader_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.team_leader_id);
|
||||||
|
}
|
||||||
|
if (dto.target_monthly !== undefined) {
|
||||||
|
updateFields.push(`target_monthly = $${paramIndex++}`);
|
||||||
|
values.push(dto.target_monthly);
|
||||||
|
}
|
||||||
|
if (dto.target_annual !== undefined) {
|
||||||
|
updateFields.push(`target_annual = $${paramIndex++}`);
|
||||||
|
values.push(dto.target_annual);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE sales.sales_teams SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(teamId: string, userId: string, role: string, tenantId: string): Promise<SalesTeamMember> {
|
||||||
|
await this.findById(teamId, tenantId);
|
||||||
|
|
||||||
|
// Check if already member
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM sales.sales_team_members WHERE sales_team_id = $1 AND user_id = $2`,
|
||||||
|
[teamId, userId]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('El usuario ya es miembro de este equipo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await queryOne<SalesTeamMember>(
|
||||||
|
`INSERT INTO sales.sales_team_members (sales_team_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[teamId, userId, role]
|
||||||
|
);
|
||||||
|
|
||||||
|
return member!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(teamId: string, memberId: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(teamId, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM sales.sales_team_members WHERE id = $1 AND sales_team_id = $2`,
|
||||||
|
[memberId, teamId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesTeamsService = new SalesTeamsService();
|
||||||
889
src/modules/sales/sales.controller.ts
Normal file
889
src/modules/sales/sales.controller.ts
Normal file
@ -0,0 +1,889 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { pricelistsService, CreatePricelistDto, UpdatePricelistDto, CreatePricelistItemDto, PricelistFilters } from './pricelists.service.js';
|
||||||
|
import { salesTeamsService, CreateSalesTeamDto, UpdateSalesTeamDto, SalesTeamFilters } from './sales-teams.service.js';
|
||||||
|
import { customerGroupsService, CreateCustomerGroupDto, UpdateCustomerGroupDto, CustomerGroupFilters } from './customer-groups.service.js';
|
||||||
|
import { quotationsService, CreateQuotationDto, UpdateQuotationDto, CreateQuotationLineDto, UpdateQuotationLineDto, QuotationFilters } from './quotations.service.js';
|
||||||
|
import { ordersService, CreateSalesOrderDto, UpdateSalesOrderDto, CreateSalesOrderLineDto, UpdateSalesOrderLineDto, SalesOrderFilters } from './orders.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Pricelist schemas
|
||||||
|
const createPricelistSchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
currency_id: z.string().uuid({ message: 'La moneda es requerida' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePricelistSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPricelistItemSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
product_category_id: z.string().uuid().optional(),
|
||||||
|
price: z.number().min(0, 'El precio debe ser positivo'),
|
||||||
|
min_quantity: z.number().positive().default(1),
|
||||||
|
valid_from: z.string().optional(),
|
||||||
|
valid_to: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pricelistQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sales Team schemas
|
||||||
|
const createSalesTeamSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
code: z.string().max(50).optional(),
|
||||||
|
team_leader_id: z.string().uuid().optional(),
|
||||||
|
target_monthly: z.number().positive().optional(),
|
||||||
|
target_annual: z.number().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSalesTeamSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
code: z.string().max(50).optional(),
|
||||||
|
team_leader_id: z.string().uuid().optional().nullable(),
|
||||||
|
target_monthly: z.number().positive().optional().nullable(),
|
||||||
|
target_annual: z.number().positive().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addTeamMemberSchema = z.object({
|
||||||
|
user_id: z.string().uuid({ message: 'El usuario es requerido' }),
|
||||||
|
role: z.string().max(100).default('member'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const salesTeamQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Customer Group schemas
|
||||||
|
const createCustomerGroupSchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
discount_percentage: z.number().min(0).max(100).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCustomerGroupSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
discount_percentage: z.number().min(0).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addGroupMemberSchema = z.object({
|
||||||
|
partner_id: z.string().uuid({ message: 'El cliente es requerido' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerGroupQuerySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quotation schemas
|
||||||
|
const createQuotationSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
partner_id: z.string().uuid({ message: 'El cliente es requerido' }),
|
||||||
|
quotation_date: z.string().optional(),
|
||||||
|
validity_date: z.string({ message: 'La fecha de validez es requerida' }),
|
||||||
|
currency_id: z.string().uuid({ message: 'La moneda es requerida' }),
|
||||||
|
pricelist_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms_conditions: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuotationSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
quotation_date: z.string().optional(),
|
||||||
|
validity_date: z.string().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
pricelist_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
terms_conditions: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createQuotationLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().min(1, 'La descripción es requerida'),
|
||||||
|
quantity: z.number().positive('La cantidad debe ser positiva'),
|
||||||
|
uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }),
|
||||||
|
price_unit: z.number().min(0, 'El precio debe ser positivo'),
|
||||||
|
discount: z.number().min(0).max(100).default(0),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuotationLineSchema = z.object({
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
price_unit: z.number().min(0).optional(),
|
||||||
|
discount: z.number().min(0).max(100).optional(),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotationQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'sent', 'confirmed', 'cancelled', 'expired']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sales Order schemas
|
||||||
|
const createSalesOrderSchema = z.object({
|
||||||
|
company_id: z.string().uuid({ message: 'La empresa es requerida' }),
|
||||||
|
partner_id: z.string().uuid({ message: 'El cliente es requerido' }),
|
||||||
|
client_order_ref: z.string().max(100).optional(),
|
||||||
|
order_date: z.string().optional(),
|
||||||
|
validity_date: z.string().optional(),
|
||||||
|
commitment_date: z.string().optional(),
|
||||||
|
currency_id: z.string().uuid({ message: 'La moneda es requerida' }),
|
||||||
|
pricelist_id: z.string().uuid().optional(),
|
||||||
|
payment_term_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
invoice_policy: z.enum(['order', 'delivery']).default('order'),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
terms_conditions: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSalesOrderSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
client_order_ref: z.string().max(100).optional().nullable(),
|
||||||
|
order_date: z.string().optional(),
|
||||||
|
validity_date: z.string().optional().nullable(),
|
||||||
|
commitment_date: z.string().optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
pricelist_id: z.string().uuid().optional().nullable(),
|
||||||
|
payment_term_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
invoice_policy: z.enum(['order', 'delivery']).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
terms_conditions: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSalesOrderLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid({ message: 'El producto es requerido' }),
|
||||||
|
description: z.string().min(1, 'La descripción es requerida'),
|
||||||
|
quantity: z.number().positive('La cantidad debe ser positiva'),
|
||||||
|
uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }),
|
||||||
|
price_unit: z.number().min(0, 'El precio debe ser positivo'),
|
||||||
|
discount: z.number().min(0).max(100).default(0),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
analytic_account_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSalesOrderLineSchema = z.object({
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
price_unit: z.number().min(0).optional(),
|
||||||
|
discount: z.number().min(0).max(100).optional(),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
analytic_account_id: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const salesOrderQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'sent', 'sale', 'done', 'cancelled']).optional(),
|
||||||
|
invoice_status: z.enum(['pending', 'partial', 'invoiced']).optional(),
|
||||||
|
delivery_status: z.enum(['pending', 'partial', 'delivered']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class SalesController {
|
||||||
|
// ========== PRICELISTS ==========
|
||||||
|
async getPricelists(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = pricelistQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: PricelistFilters = queryResult.data;
|
||||||
|
const result = await pricelistsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pricelist = await pricelistsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: pricelist });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPricelistSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lista de precios inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePricelistDto = parseResult.data;
|
||||||
|
const pricelist = await pricelistsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: pricelist,
|
||||||
|
message: 'Lista de precios creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updatePricelistSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lista de precios inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdatePricelistDto = parseResult.data;
|
||||||
|
const pricelist = await pricelistsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: pricelist,
|
||||||
|
message: 'Lista de precios actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPricelistItem(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPricelistItemSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de item inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreatePricelistItemDto = parseResult.data;
|
||||||
|
const item = await pricelistsService.addItem(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: item,
|
||||||
|
message: 'Item agregado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePricelistItem(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pricelistsService.removeItem(req.params.id, req.params.itemId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Item eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== SALES TEAMS ==========
|
||||||
|
async getSalesTeams(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = salesTeamQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: SalesTeamFilters = queryResult.data;
|
||||||
|
const result = await salesTeamsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const team = await salesTeamsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: team });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createSalesTeamSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de equipo de ventas inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateSalesTeamDto = parseResult.data;
|
||||||
|
const team = await salesTeamsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: team,
|
||||||
|
message: 'Equipo de ventas creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateSalesTeamSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de equipo de ventas inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateSalesTeamDto = parseResult.data;
|
||||||
|
const team = await salesTeamsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: team,
|
||||||
|
message: 'Equipo de ventas actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSalesTeamMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = addTeamMemberSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await salesTeamsService.addMember(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.user_id,
|
||||||
|
parseResult.data.role,
|
||||||
|
req.tenantId!
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: member,
|
||||||
|
message: 'Miembro agregado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSalesTeamMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await salesTeamsService.removeMember(req.params.id, req.params.memberId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Miembro eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CUSTOMER GROUPS ==========
|
||||||
|
async getCustomerGroups(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = customerGroupQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: CustomerGroupFilters = queryResult.data;
|
||||||
|
const result = await customerGroupsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const group = await customerGroupsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: group });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCustomerGroupSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de grupo de clientes inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateCustomerGroupDto = parseResult.data;
|
||||||
|
const group = await customerGroupsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: group,
|
||||||
|
message: 'Grupo de clientes creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateCustomerGroupSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de grupo de clientes inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateCustomerGroupDto = parseResult.data;
|
||||||
|
const group = await customerGroupsService.update(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: group,
|
||||||
|
message: 'Grupo de clientes actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await customerGroupsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Grupo de clientes eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCustomerGroupMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = addGroupMemberSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await customerGroupsService.addMember(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.partner_id,
|
||||||
|
req.tenantId!
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: member,
|
||||||
|
message: 'Cliente agregado al grupo exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCustomerGroupMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await customerGroupsService.removeMember(req.params.id, req.params.memberId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Cliente eliminado del grupo exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== QUOTATIONS ==========
|
||||||
|
async getQuotations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = quotationQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: QuotationFilters = queryResult.data;
|
||||||
|
const result = await quotationsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const quotation = await quotationsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: quotation });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createQuotationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cotización inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateQuotationDto = parseResult.data;
|
||||||
|
const quotation = await quotationsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateQuotationSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cotización inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateQuotationDto = parseResult.data;
|
||||||
|
const quotation = await quotationsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await quotationsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Cotización eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createQuotationLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateQuotationLineDto = parseResult.data;
|
||||||
|
const line = await quotationsService.addLine(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea agregada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateQuotationLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateQuotationLineDto = parseResult.data;
|
||||||
|
const line = await quotationsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await quotationsService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const quotation = await quotationsService.send(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización enviada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await quotationsService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.quotation,
|
||||||
|
orderId: result.orderId,
|
||||||
|
message: 'Cotización confirmada y orden de venta creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const quotation = await quotationsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: quotation,
|
||||||
|
message: 'Cotización cancelada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== SALES ORDERS ==========
|
||||||
|
async getOrders(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = salesOrderQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: SalesOrderFilters = queryResult.data;
|
||||||
|
const result = await ordersService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await ordersService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: order });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createSalesOrderSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de orden inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateSalesOrderDto = parseResult.data;
|
||||||
|
const order = await ordersService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateSalesOrderSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de orden inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateSalesOrderDto = parseResult.data;
|
||||||
|
const order = await ordersService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ordersService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Orden de venta eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createSalesOrderLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateSalesOrderLineDto = parseResult.data;
|
||||||
|
const line = await ordersService.addLine(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea agregada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateSalesOrderLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateSalesOrderLineDto = parseResult.data;
|
||||||
|
const line = await ordersService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: line,
|
||||||
|
message: 'Línea actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ordersService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await ordersService.confirm(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta confirmada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const order = await ordersService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Orden de venta cancelada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrderInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await ordersService.createInvoice(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Factura creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesController = new SalesController();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user