diff --git a/package-lock.json b/package-lock.json index 1c059c4c..e4113964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "reflect-metadata": "^0.1.13", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.17", - "yamljs": "^0.3.0" + "winston": "^3.19.0", + "yamljs": "^0.3.0", + "zod": "^4.3.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -565,6 +567,15 @@ "dev": true, "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": { "version": "0.8.1", "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" } }, + "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": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -1375,6 +1397,16 @@ "@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": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -1712,6 +1744,12 @@ "@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": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -2123,6 +2161,12 @@ "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": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2727,6 +2771,19 @@ "dev": true, "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2745,6 +2802,48 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3079,6 +3178,12 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3553,6 +3658,12 @@ "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": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3651,6 +3762,12 @@ "dev": true, "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": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4298,7 +4415,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5141,6 +5257,12 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -5250,6 +5372,23 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5611,6 +5750,15 @@ "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": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -6203,6 +6351,20 @@ "dev": true, "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6367,6 +6529,15 @@ ], "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6749,6 +6920,15 @@ "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": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -6781,6 +6961,15 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6971,6 +7160,12 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -7031,6 +7226,15 @@ "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": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -7535,6 +7739,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -7643,6 +7853,42 @@ "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": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7812,6 +8058,15 @@ "funding": { "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" + } } } } diff --git a/package.json b/package.json index 894e097e..8d923014 100644 --- a/package.json +++ b/package.json @@ -32,31 +32,33 @@ "author": "Tu Empresa", "license": "UNLICENSED", "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", - "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", "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", - "yamljs": "^0.3.0" + "typeorm": "^0.3.17", + "winston": "^3.19.0", + "yamljs": "^0.3.0", + "zod": "^4.3.5" }, "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/jsonwebtoken": "^9.0.5", - "@types/swagger-ui-express": "^4.1.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", "@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/parser": "^6.15.0", "eslint": "^8.56.0", diff --git a/src/modules/auth/apiKeys.service.ts b/src/modules/auth/apiKeys.service.ts new file mode 100644 index 00000000..784640ac --- /dev/null +++ b/src/modules/auth/apiKeys.service.ts @@ -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; + 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 { + 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 { + 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 { + // 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( + `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[]> { + 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( + `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 | null> { + const apiKey = await queryOne( + `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> { + 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( + `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 { + 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 { + 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 { + // 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( + `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 { + const existing = await queryOne( + '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( + `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(); diff --git a/src/modules/auth/entities/api-key.entity.ts b/src/modules/auth/entities/api-key.entity.ts new file mode 100644 index 00000000..418fe2a8 --- /dev/null +++ b/src/modules/auth/entities/api-key.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/country.entity.ts b/src/modules/core/entities/country.entity.ts new file mode 100644 index 00000000..e3a63844 --- /dev/null +++ b/src/modules/core/entities/country.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/currency-rate.entity.ts b/src/modules/core/entities/currency-rate.entity.ts new file mode 100644 index 00000000..1be963bd --- /dev/null +++ b/src/modules/core/entities/currency-rate.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/currency.entity.ts b/src/modules/core/entities/currency.entity.ts new file mode 100644 index 00000000..f3222223 --- /dev/null +++ b/src/modules/core/entities/currency.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/discount-rule.entity.ts b/src/modules/core/entities/discount-rule.entity.ts new file mode 100644 index 00000000..1454a4ae --- /dev/null +++ b/src/modules/core/entities/discount-rule.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/index.ts b/src/modules/core/entities/index.ts index e828c0e9..db947b6f 100644 --- a/src/modules/core/entities/index.ts +++ b/src/modules/core/entities/index.ts @@ -1,6 +1,10 @@ -/** - * Core Entities Index - */ - -export { Tenant } from './tenant.entity'; -export { User } from './user.entity'; +export { Currency } from './currency.entity.js'; +export { Country } from './country.entity.js'; +export { State } from './state.entity.js'; +export { CurrencyRate, RateSource } from './currency-rate.entity.js'; +export { UomCategory } from './uom-category.entity.js'; +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'; diff --git a/src/modules/core/entities/payment-term.entity.ts b/src/modules/core/entities/payment-term.entity.ts new file mode 100644 index 00000000..38c3e177 --- /dev/null +++ b/src/modules/core/entities/payment-term.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/product-category.entity.ts b/src/modules/core/entities/product-category.entity.ts new file mode 100644 index 00000000..d9fdd080 --- /dev/null +++ b/src/modules/core/entities/product-category.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/sequence.entity.ts b/src/modules/core/entities/sequence.entity.ts new file mode 100644 index 00000000..cc288290 --- /dev/null +++ b/src/modules/core/entities/sequence.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/state.entity.ts b/src/modules/core/entities/state.entity.ts new file mode 100644 index 00000000..0355f5eb --- /dev/null +++ b/src/modules/core/entities/state.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/uom-category.entity.ts b/src/modules/core/entities/uom-category.entity.ts new file mode 100644 index 00000000..c1158005 --- /dev/null +++ b/src/modules/core/entities/uom-category.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/uom.entity.ts b/src/modules/core/entities/uom.entity.ts new file mode 100644 index 00000000..98ba8aae --- /dev/null +++ b/src/modules/core/entities/uom.entity.ts @@ -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; +} diff --git a/src/modules/core/sequences.service.ts b/src/modules/core/sequences.service.ts new file mode 100644 index 00000000..7c5982ae --- /dev/null +++ b/src/modules/core/sequences.service.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/crm/__tests__/leads.service.test.ts b/src/modules/crm/__tests__/leads.service.test.ts new file mode 100644 index 00000000..e39314a1 --- /dev/null +++ b/src/modules/crm/__tests__/leads.service.test.ts @@ -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); + }); + }); +}); diff --git a/src/modules/crm/__tests__/opportunities.service.test.ts b/src/modules/crm/__tests__/opportunities.service.test.ts new file mode 100644 index 00000000..e35fa1da --- /dev/null +++ b/src/modules/crm/__tests__/opportunities.service.test.ts @@ -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); + }); + }); +}); diff --git a/src/modules/crm/__tests__/stages.service.test.ts b/src/modules/crm/__tests__/stages.service.test.ts new file mode 100644 index 00000000..135bc192 --- /dev/null +++ b/src/modules/crm/__tests__/stages.service.test.ts @@ -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); + }); + }); + }); +}); diff --git a/src/modules/crm/activities.service.ts b/src/modules/crm/activities.service.ts new file mode 100644 index 00000000..9798e9bc --- /dev/null +++ b/src/modules/crm/activities.service.ts @@ -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; +} + +// ============================================================================ +// 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( + `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 { + const activity = await queryOne( + `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 { + const activity = await queryOne( + `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 { + 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 { + 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 { + 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 { + 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 { + 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( + `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 { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/crm/crm.controller.ts b/src/modules/crm/crm.controller.ts new file mode 100644 index 00000000..d69bce63 --- /dev/null +++ b/src/modules/crm/crm.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/crm/crm.routes.ts b/src/modules/crm/crm.routes.ts new file mode 100644 index 00000000..8445ca97 --- /dev/null +++ b/src/modules/crm/crm.routes.ts @@ -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; diff --git a/src/modules/crm/forecasting.service.ts b/src/modules/crm/forecasting.service.ts new file mode 100644 index 00000000..bcfaeca2 --- /dev/null +++ b/src/modules/crm/forecasting.service.ts @@ -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 { + 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( + `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 { + 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( + `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 { + 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(); diff --git a/src/modules/crm/index.ts b/src/modules/crm/index.ts new file mode 100644 index 00000000..11200386 --- /dev/null +++ b/src/modules/crm/index.ts @@ -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'; diff --git a/src/modules/crm/leads.service.ts b/src/modules/crm/leads.service.ts new file mode 100644 index 00000000..4dfeadc9 --- /dev/null +++ b/src/modules/crm/leads.service.ts @@ -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( + `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 { + const lead = await queryOne( + `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 { + const lead = await queryOne( + `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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/crm/opportunities.service.ts b/src/modules/crm/opportunities.service.ts new file mode 100644 index 00000000..7d051a7b --- /dev/null +++ b/src/modules/crm/opportunities.service.ts @@ -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( + `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 { + const opportunity = await queryOne( + `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 { + const opportunity = await queryOne( + `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 { + 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 { + 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 { + 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 { + 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 { + 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( + `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(); diff --git a/src/modules/crm/stages.service.ts b/src/modules/crm/stages.service.ts new file mode 100644 index 00000000..92f01f9a --- /dev/null +++ b/src/modules/crm/stages.service.ts @@ -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 { + let whereClause = 'WHERE tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND active = TRUE'; + } + + return query( + `SELECT * FROM crm.lead_stages ${whereClause} ORDER BY sequence`, + [tenantId] + ); + } + + async getLeadStageById(id: string, tenantId: string): Promise { + const stage = await queryOne( + `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 { + // 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( + `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 { + 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 { + 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 { + let whereClause = 'WHERE tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND active = TRUE'; + } + + return query( + `SELECT * FROM crm.opportunity_stages ${whereClause} ORDER BY sequence`, + [tenantId] + ); + } + + async getOpportunityStageById(id: string, tenantId: string): Promise { + const stage = await queryOne( + `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 { + // 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( + `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 { + 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 { + 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 { + let whereClause = 'WHERE tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND active = TRUE'; + } + + return query( + `SELECT * FROM crm.lost_reasons ${whereClause} ORDER BY name`, + [tenantId] + ); + } + + async getLostReasonById(id: string, tenantId: string): Promise { + const reason = await queryOne( + `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 { + 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( + `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 { + 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 { + 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(); diff --git a/src/modules/financial/index.ts b/src/modules/financial/index.ts new file mode 100644 index 00000000..4dad9e42 --- /dev/null +++ b/src/modules/financial/index.ts @@ -0,0 +1 @@ +export * from './taxes.service.js'; diff --git a/src/modules/financial/taxes.service.ts b/src/modules/financial/taxes.service.ts new file mode 100644 index 00000000..d856ca33 --- /dev/null +++ b/src/modules/financial/taxes.service.ts @@ -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( + `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 { + const tax = await queryOne( + `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 { + // 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( + `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 { + 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 { + 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 { + // 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( + `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 { + 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(); + 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(); diff --git a/src/modules/inventory/MIGRATION_STATUS.md b/src/modules/inventory/MIGRATION_STATUS.md new file mode 100644 index 00000000..90f2310a --- /dev/null +++ b/src/modules/inventory/MIGRATION_STATUS.md @@ -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; + + 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. diff --git a/src/modules/inventory/adjustments.service.ts b/src/modules/inventory/adjustments.service.ts new file mode 100644 index 00000000..967450f5 --- /dev/null +++ b/src/modules/inventory/adjustments.service.ts @@ -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( + `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 { + const adjustment = await queryOne( + `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( + `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 { + 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 { + 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 { + 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( + `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 { + 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( + `UPDATE inventory.inventory_adjustment_lines SET ${updateFields.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + return line!; + } + + async removeLine(adjustmentId: string, lineId: string, tenantId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/inventory/controllers/inventory.controller.ts b/src/modules/inventory/controllers/inventory.controller.ts new file mode 100644 index 00000000..b7efb394 --- /dev/null +++ b/src/modules/inventory/controllers/inventory.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/modules/inventory/dto/create-inventory.dto.ts b/src/modules/inventory/dto/create-inventory.dto.ts new file mode 100644 index 00000000..25502619 --- /dev/null +++ b/src/modules/inventory/dto/create-inventory.dto.ts @@ -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; +} diff --git a/src/modules/inventory/dto/index.ts b/src/modules/inventory/dto/index.ts new file mode 100644 index 00000000..2011421c --- /dev/null +++ b/src/modules/inventory/dto/index.ts @@ -0,0 +1,6 @@ +export { + CreateStockMovementDto, + AdjustStockDto, + TransferStockDto, + ReserveStockDto, +} from './create-inventory.dto'; diff --git a/src/modules/inventory/entities/index.ts b/src/modules/inventory/entities/index.ts index 818b0433..75c2282b 100644 --- a/src/modules/inventory/entities/index.ts +++ b/src/modules/inventory/entities/index.ts @@ -2,9 +2,46 @@ * Inventory Entities Index * @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 './requisicion-obra.entity'; export * from './requisicion-linea.entity'; diff --git a/src/modules/inventory/entities/inventory-adjustment-line.entity.ts b/src/modules/inventory/entities/inventory-adjustment-line.entity.ts new file mode 100644 index 00000000..870c1baf --- /dev/null +++ b/src/modules/inventory/entities/inventory-adjustment-line.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/inventory-adjustment.entity.ts b/src/modules/inventory/entities/inventory-adjustment.entity.ts new file mode 100644 index 00000000..2ad84a92 --- /dev/null +++ b/src/modules/inventory/entities/inventory-adjustment.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/inventory-count-line.entity.ts b/src/modules/inventory/entities/inventory-count-line.entity.ts new file mode 100644 index 00000000..5aa12970 --- /dev/null +++ b/src/modules/inventory/entities/inventory-count-line.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/inventory-count.entity.ts b/src/modules/inventory/entities/inventory-count.entity.ts new file mode 100644 index 00000000..229c5f0c --- /dev/null +++ b/src/modules/inventory/entities/inventory-count.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/location.entity.ts b/src/modules/inventory/entities/location.entity.ts new file mode 100644 index 00000000..28dcc576 --- /dev/null +++ b/src/modules/inventory/entities/location.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/lot.entity.ts b/src/modules/inventory/entities/lot.entity.ts new file mode 100644 index 00000000..aaed4be9 --- /dev/null +++ b/src/modules/inventory/entities/lot.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/picking.entity.ts b/src/modules/inventory/entities/picking.entity.ts new file mode 100644 index 00000000..9254b6a4 --- /dev/null +++ b/src/modules/inventory/entities/picking.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/product.entity.ts b/src/modules/inventory/entities/product.entity.ts new file mode 100644 index 00000000..85a159a8 --- /dev/null +++ b/src/modules/inventory/entities/product.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/stock-level.entity.ts b/src/modules/inventory/entities/stock-level.entity.ts new file mode 100644 index 00000000..7a29f95a --- /dev/null +++ b/src/modules/inventory/entities/stock-level.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/stock-move.entity.ts b/src/modules/inventory/entities/stock-move.entity.ts new file mode 100644 index 00000000..c6c8988f --- /dev/null +++ b/src/modules/inventory/entities/stock-move.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/stock-movement.entity.ts b/src/modules/inventory/entities/stock-movement.entity.ts new file mode 100644 index 00000000..424f4beb --- /dev/null +++ b/src/modules/inventory/entities/stock-movement.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/stock-quant.entity.ts b/src/modules/inventory/entities/stock-quant.entity.ts new file mode 100644 index 00000000..3111644a --- /dev/null +++ b/src/modules/inventory/entities/stock-quant.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/stock-valuation-layer.entity.ts b/src/modules/inventory/entities/stock-valuation-layer.entity.ts new file mode 100644 index 00000000..25712d03 --- /dev/null +++ b/src/modules/inventory/entities/stock-valuation-layer.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/transfer-order-line.entity.ts b/src/modules/inventory/entities/transfer-order-line.entity.ts new file mode 100644 index 00000000..a2a21330 --- /dev/null +++ b/src/modules/inventory/entities/transfer-order-line.entity.ts @@ -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; +} diff --git a/src/modules/inventory/entities/transfer-order.entity.ts b/src/modules/inventory/entities/transfer-order.entity.ts new file mode 100644 index 00000000..7deb1f03 --- /dev/null +++ b/src/modules/inventory/entities/transfer-order.entity.ts @@ -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; +} diff --git a/src/modules/inventory/index.ts b/src/modules/inventory/index.ts new file mode 100644 index 00000000..25f38d41 --- /dev/null +++ b/src/modules/inventory/index.ts @@ -0,0 +1,5 @@ +export { InventoryModule, InventoryModuleOptions } from './inventory.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/inventory/inventory.controller.ts b/src/modules/inventory/inventory.controller.ts new file mode 100644 index 00000000..96d32234 --- /dev/null +++ b/src/modules/inventory/inventory.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/inventory/inventory.module.ts b/src/modules/inventory/inventory.module.ts new file mode 100644 index 00000000..178a3010 --- /dev/null +++ b/src/modules/inventory/inventory.module.ts @@ -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]; + } +} diff --git a/src/modules/inventory/inventory.routes.ts b/src/modules/inventory/inventory.routes.ts new file mode 100644 index 00000000..6f45bf61 --- /dev/null +++ b/src/modules/inventory/inventory.routes.ts @@ -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; diff --git a/src/modules/inventory/locations.service.ts b/src/modules/inventory/locations.service.ts new file mode 100644 index 00000000..c55aba4d --- /dev/null +++ b/src/modules/inventory/locations.service.ts @@ -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( + `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 { + const location = await queryOne( + `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 { + // Validate parent location if specified + if (dto.parent_id) { + const parent = await queryOne( + `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( + `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 { + 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( + `UPDATE inventory.locations SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} + RETURNING *`, + values + ); + + return location!; + } + + async getStock(locationId: string, tenantId: string): Promise { + 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(); diff --git a/src/modules/inventory/lots.service.ts b/src/modules/inventory/lots.service.ts new file mode 100644 index 00000000..2a9d5e81 --- /dev/null +++ b/src/modules/inventory/lots.service.ts @@ -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( + `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 { + const lot = await queryOne( + `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 { + // 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( + `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 { + 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 { + await this.findById(id, tenantId); + + const movements = await query( + `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 { + 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(); diff --git a/src/modules/inventory/pickings.service.ts b/src/modules/inventory/pickings.service.ts new file mode 100644 index 00000000..27d66782 --- /dev/null +++ b/src/modules/inventory/pickings.service.ts @@ -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[]; +} + +export interface UpdatePickingDto { + partner_id?: string | null; + scheduled_date?: string | null; + origin?: string | null; + notes?: string | null; + moves?: Omit[]; +} + +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( + `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 { + const picking = await queryOne( + `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( + `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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/inventory/products.service.ts b/src/modules/inventory/products.service.ts new file mode 100644 index 00000000..29334c37 --- /dev/null +++ b/src/modules/inventory/products.service.ts @@ -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; + private stockQuantRepository: Repository; + + 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 { + 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 { + return this.productRepository.findOne({ + where: { + code, + tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Create a new product + */ + async create(dto: CreateProductDto, tenantId: string, userId: string): Promise { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/inventory/reorder-alerts.service.ts b/src/modules/inventory/reorder-alerts.service.ts new file mode 100644 index 00000000..a2066698 --- /dev/null +++ b/src/modules/inventory/reorder-alerts.service.ts @@ -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( + `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( + `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 { + let whereClause = `WHERE sq.tenant_id = $1`; + const params: any[] = [tenantId]; + + if (productId) { + whereClause += ` AND sq.product_id = $2`; + params.push(productId); + } + + return query( + `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 { + 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( + `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(); diff --git a/src/modules/inventory/services/inventory.service.ts b/src/modules/inventory/services/inventory.service.ts new file mode 100644 index 00000000..7a083320 --- /dev/null +++ b/src/modules/inventory/services/inventory.service.ts @@ -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, + private readonly movementRepository: Repository, + 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 { + return this.stockLevelRepository.find({ + where: { productId, tenantId }, + order: { warehouseId: 'ASC' }, + }); + } + + async getStockByWarehouse( + warehouseId: string, + tenantId: string + ): Promise { + return this.stockLevelRepository.find({ + where: { warehouseId, tenantId }, + order: { productId: 'ASC' }, + }); + } + + async getAvailableStock( + productId: string, + warehouseId: string, + tenantId: string + ): Promise { + 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 { + return this.movementRepository.findOne({ where: { id, tenantId } }); + } + + async createMovement( + tenantId: string, + dto: CreateStockMovementDto, + createdBy?: string + ): Promise { + // 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 { + 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 { + 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 { + 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; + } + + async transferStock( + tenantId: string, + dto: TransferStockDto, + userId?: string + ): Promise { + // 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; + } + + async reserveStock(tenantId: string, dto: ReserveStockDto): Promise { + 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 { + 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 { + const where: FindOptionsWhere = { + 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 { + 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 { + 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); + } + + 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); + } +} diff --git a/src/modules/inventory/stock-reservation.service.ts b/src/modules/inventory/stock-reservation.service.ts new file mode 100644 index 00000000..4be2f87f --- /dev/null +++ b/src/modules/inventory/stock-reservation.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/inventory/valuation.controller.ts b/src/modules/inventory/valuation.controller.ts new file mode 100644 index 00000000..b72a96e7 --- /dev/null +++ b/src/modules/inventory/valuation.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/inventory/valuation.service.ts b/src/modules/inventory/valuation.service.ts new file mode 100644 index 00000000..a4909a70 --- /dev/null +++ b/src/modules/inventory/valuation.service.ts @@ -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 { + 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 { + 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 { + // 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 { + const result = await queryOne( + `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 { + const whereClause = includeEmpty + ? '' + : 'AND remaining_qty > 0'; + + return query( + `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 { + return query( + `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 { + 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 { + 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(); diff --git a/src/modules/inventory/warehouses.service.ts b/src/modules/inventory/warehouses.service.ts new file mode 100644 index 00000000..73e0a2c4 --- /dev/null +++ b/src/modules/inventory/warehouses.service.ts @@ -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; + private locationRepository: Repository; + private stockQuantRepository: Repository; + + 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 { + 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 { + 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 = { + 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 { + 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 { + 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 { + await this.findById(warehouseId, tenantId); + + return this.locationRepository.find({ + where: { + warehouseId, + tenantId, + }, + order: { name: 'ASC' }, + }); + } + + async getStock(warehouseId: string, tenantId: string): Promise { + 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(); diff --git a/src/modules/partners/__tests__/partners.controller.test.ts b/src/modules/partners/__tests__/partners.controller.test.ts new file mode 100644 index 00000000..d2cd0eca --- /dev/null +++ b/src/modules/partners/__tests__/partners.controller.test.ts @@ -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; + let mockRes: Partial; + 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)); + }); + }); +}); diff --git a/src/modules/partners/__tests__/partners.service.test.ts b/src/modules/partners/__tests__/partners.service.test.ts new file mode 100644 index 00000000..e10f4707 --- /dev/null +++ b/src/modules/partners/__tests__/partners.service.test.ts @@ -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); + }); + }); +}); diff --git a/src/modules/partners/controllers/index.ts b/src/modules/partners/controllers/index.ts new file mode 100644 index 00000000..66e2ab78 --- /dev/null +++ b/src/modules/partners/controllers/index.ts @@ -0,0 +1 @@ +export { PartnersController } from './partners.controller'; diff --git a/src/modules/partners/controllers/partners.controller.ts b/src/modules/partners/controllers/partners.controller.ts new file mode 100644 index 00000000..1afaec16 --- /dev/null +++ b/src/modules/partners/controllers/partners.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/modules/partners/dto/create-partner.dto.ts b/src/modules/partners/dto/create-partner.dto.ts new file mode 100644 index 00000000..275501ae --- /dev/null +++ b/src/modules/partners/dto/create-partner.dto.ts @@ -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; +} diff --git a/src/modules/partners/dto/index.ts b/src/modules/partners/dto/index.ts new file mode 100644 index 00000000..ef0bc759 --- /dev/null +++ b/src/modules/partners/dto/index.ts @@ -0,0 +1,7 @@ +export { + CreatePartnerDto, + UpdatePartnerDto, + CreatePartnerAddressDto, + CreatePartnerContactDto, + CreatePartnerBankAccountDto, +} from './create-partner.dto'; diff --git a/src/modules/partners/entities/index.ts b/src/modules/partners/entities/index.ts new file mode 100644 index 00000000..ee4a6068 --- /dev/null +++ b/src/modules/partners/entities/index.ts @@ -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'; diff --git a/src/modules/partners/entities/partner-address.entity.ts b/src/modules/partners/entities/partner-address.entity.ts new file mode 100644 index 00000000..566becca --- /dev/null +++ b/src/modules/partners/entities/partner-address.entity.ts @@ -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; +} diff --git a/src/modules/partners/entities/partner-bank-account.entity.ts b/src/modules/partners/entities/partner-bank-account.entity.ts new file mode 100644 index 00000000..a5cce38b --- /dev/null +++ b/src/modules/partners/entities/partner-bank-account.entity.ts @@ -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; +} diff --git a/src/modules/partners/entities/partner-contact.entity.ts b/src/modules/partners/entities/partner-contact.entity.ts new file mode 100644 index 00000000..d4479fe4 --- /dev/null +++ b/src/modules/partners/entities/partner-contact.entity.ts @@ -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; +} diff --git a/src/modules/partners/entities/partner.entity.ts b/src/modules/partners/entities/partner.entity.ts new file mode 100644 index 00000000..31738924 --- /dev/null +++ b/src/modules/partners/entities/partner.entity.ts @@ -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; +} diff --git a/src/modules/partners/index.ts b/src/modules/partners/index.ts new file mode 100644 index 00000000..df1c9978 --- /dev/null +++ b/src/modules/partners/index.ts @@ -0,0 +1,5 @@ +export { PartnersModule, PartnersModuleOptions } from './partners.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/partners/partners.controller.ts b/src/modules/partners/partners.controller.ts new file mode 100644 index 00000000..891f1503 --- /dev/null +++ b/src/modules/partners/partners.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/partners/partners.module.ts b/src/modules/partners/partners.module.ts new file mode 100644 index 00000000..8e6e8c87 --- /dev/null +++ b/src/modules/partners/partners.module.ts @@ -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]; + } +} diff --git a/src/modules/partners/partners.routes.ts b/src/modules/partners/partners.routes.ts new file mode 100644 index 00000000..d4c65f7b --- /dev/null +++ b/src/modules/partners/partners.routes.ts @@ -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; diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts new file mode 100644 index 00000000..67b459e2 --- /dev/null +++ b/src/modules/partners/partners.service.ts @@ -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; + + 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 { + 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 { + 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 = { + 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 { + 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 { + 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 + ): Promise<{ data: Partner[]; total: number }> { + return this.findAll(tenantId, { ...filters, partnerType: 'customer' }); + } + + /** + * Get suppliers only + */ + async findSuppliers( + tenantId: string, + filters: Omit + ): Promise<{ data: Partner[]; total: number }> { + return this.findAll(tenantId, { ...filters, partnerType: 'supplier' }); + } +} + +// ===== Export Singleton Instance ===== + +export const partnersService = new PartnersService(); diff --git a/src/modules/partners/ranking.controller.ts b/src/modules/partners/ranking.controller.ts new file mode 100644 index 00000000..95e15c1d --- /dev/null +++ b/src/modules/partners/ranking.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/partners/ranking.service.ts b/src/modules/partners/ranking.service.ts new file mode 100644 index 00000000..26473156 --- /dev/null +++ b/src/modules/partners/ranking.service.ts @@ -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; + + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/partners/services/index.ts b/src/modules/partners/services/index.ts new file mode 100644 index 00000000..bd0ac0d0 --- /dev/null +++ b/src/modules/partners/services/index.ts @@ -0,0 +1 @@ +export { PartnersService, PartnerSearchParams } from './partners.service'; diff --git a/src/modules/partners/services/partners.service.ts b/src/modules/partners/services/partners.service.ts new file mode 100644 index 00000000..cac026de --- /dev/null +++ b/src/modules/partners/services/partners.service.ts @@ -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, + private readonly addressRepository: Repository, + private readonly contactRepository: Repository, + private readonly bankAccountRepository: Repository + ) {} + + // ==================== 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[] = []; + const baseWhere: FindOptionsWhere = { 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 { + return this.partnerRepository.findOne({ where: { id, tenantId } }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { code, tenantId } }); + } + + async findByTaxId(taxId: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { taxId, tenantId } }); + } + + async create(tenantId: string, dto: CreatePartnerDto, createdBy?: string): Promise { + // 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 { + 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 { + 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 { + return this.partnerRepository.find({ + where: [ + { tenantId, partnerType: 'customer', isActive: true }, + { tenantId, partnerType: 'both', isActive: true }, + ], + order: { displayName: 'ASC' }, + }); + } + + async getSuppliers(tenantId: string): Promise { + return this.partnerRepository.find({ + where: [ + { tenantId, partnerType: 'supplier', isActive: true }, + { tenantId, partnerType: 'both', isActive: true }, + ], + order: { displayName: 'ASC' }, + }); + } + + // ==================== Addresses ==================== + + async getAddresses(partnerId: string): Promise { + return this.addressRepository.find({ + where: { partnerId }, + order: { isDefault: 'DESC', addressType: 'ASC' }, + }); + } + + async createAddress(dto: CreatePartnerAddressDto): Promise { + // 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 { + const result = await this.addressRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + // ==================== Contacts ==================== + + async getContacts(partnerId: string): Promise { + return this.contactRepository.find({ + where: { partnerId }, + order: { isPrimary: 'DESC', fullName: 'ASC' }, + }); + } + + async createContact(dto: CreatePartnerContactDto): Promise { + // 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 { + const result = await this.contactRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + // ==================== Bank Accounts ==================== + + async getBankAccounts(partnerId: string): Promise { + return this.bankAccountRepository.find({ + where: { partnerId }, + order: { isDefault: 'DESC', bankName: 'ASC' }, + }); + } + + async createBankAccount(dto: CreatePartnerBankAccountDto): Promise { + // 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 { + const result = await this.bankAccountRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async verifyBankAccount(id: string): Promise { + const bankAccount = await this.bankAccountRepository.findOne({ where: { id } }); + if (!bankAccount) return null; + + bankAccount.isVerified = true; + bankAccount.verifiedAt = new Date(); + + return this.bankAccountRepository.save(bankAccount); + } +} diff --git a/src/modules/sales/__tests__/orders.service.test.ts b/src/modules/sales/__tests__/orders.service.test.ts new file mode 100644 index 00000000..c96c6d2f --- /dev/null +++ b/src/modules/sales/__tests__/orders.service.test.ts @@ -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(); + }); + }); +}); diff --git a/src/modules/sales/__tests__/quotations.service.test.ts b/src/modules/sales/__tests__/quotations.service.test.ts new file mode 100644 index 00000000..c066e71c --- /dev/null +++ b/src/modules/sales/__tests__/quotations.service.test.ts @@ -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); + }); + }); +}); diff --git a/src/modules/sales/controllers/index.ts b/src/modules/sales/controllers/index.ts new file mode 100644 index 00000000..af526668 --- /dev/null +++ b/src/modules/sales/controllers/index.ts @@ -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); } + } +} diff --git a/src/modules/sales/customer-groups.service.ts b/src/modules/sales/customer-groups.service.ts new file mode 100644 index 00000000..5a165033 --- /dev/null +++ b/src/modules/sales/customer-groups.service.ts @@ -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( + `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 { + const group = await queryOne( + `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( + `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 { + // 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( + `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 { + 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 { + 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 { + 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( + `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 { + 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(); diff --git a/src/modules/sales/dto/index.ts b/src/modules/sales/dto/index.ts new file mode 100644 index 00000000..67418746 --- /dev/null +++ b/src/modules/sales/dto/index.ts @@ -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; +} diff --git a/src/modules/sales/entities/index.ts b/src/modules/sales/entities/index.ts new file mode 100644 index 00000000..cca5d8f4 --- /dev/null +++ b/src/modules/sales/entities/index.ts @@ -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'; diff --git a/src/modules/sales/entities/quotation-item.entity.ts b/src/modules/sales/entities/quotation-item.entity.ts new file mode 100644 index 00000000..95928bde --- /dev/null +++ b/src/modules/sales/entities/quotation-item.entity.ts @@ -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; +} diff --git a/src/modules/sales/entities/quotation.entity.ts b/src/modules/sales/entities/quotation.entity.ts new file mode 100644 index 00000000..88e9bdda --- /dev/null +++ b/src/modules/sales/entities/quotation.entity.ts @@ -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; +} diff --git a/src/modules/sales/entities/sales-order-item.entity.ts b/src/modules/sales/entities/sales-order-item.entity.ts new file mode 100644 index 00000000..3a38976f --- /dev/null +++ b/src/modules/sales/entities/sales-order-item.entity.ts @@ -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; +} diff --git a/src/modules/sales/entities/sales-order.entity.ts b/src/modules/sales/entities/sales-order.entity.ts new file mode 100644 index 00000000..f23829bc --- /dev/null +++ b/src/modules/sales/entities/sales-order.entity.ts @@ -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; +} diff --git a/src/modules/sales/index.ts b/src/modules/sales/index.ts new file mode 100644 index 00000000..705ff682 --- /dev/null +++ b/src/modules/sales/index.ts @@ -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'; diff --git a/src/modules/sales/orders.service.ts b/src/modules/sales/orders.service.ts new file mode 100644 index 00000000..1caeb921 --- /dev/null +++ b/src/modules/sales/orders.service.ts @@ -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( + `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 { + const order = await queryOne( + `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( + `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 { + // 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( + `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 { + 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 { + 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 { + 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( + `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 { + 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( + `SELECT * FROM sales.sales_order_lines WHERE id = $1`, + [lineId] + ); + + return updated!; + } + + async removeLine(orderId: string, lineId: string, tenantId: string): Promise { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/sales/pricelists.service.ts b/src/modules/sales/pricelists.service.ts new file mode 100644 index 00000000..edbe75f7 --- /dev/null +++ b/src/modules/sales/pricelists.service.ts @@ -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( + `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 { + const pricelist = await queryOne( + `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( + `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 { + // 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( + `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 { + 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 { + 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( + `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 { + 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 { + 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(); diff --git a/src/modules/sales/quotations.service.ts b/src/modules/sales/quotations.service.ts new file mode 100644 index 00000000..9485e140 --- /dev/null +++ b/src/modules/sales/quotations.service.ts @@ -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( + `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 { + const quotation = await queryOne( + `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( + `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 { + // 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( + `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 { + 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 { + 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 { + 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( + `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 { + 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( + `SELECT * FROM sales.quotation_lines WHERE id = $1`, + [lineId] + ); + + return updated!; + } + + async removeLine(quotationId: string, lineId: string, tenantId: string): Promise { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/sales/sales-teams.service.ts b/src/modules/sales/sales-teams.service.ts new file mode 100644 index 00000000..b9185b57 --- /dev/null +++ b/src/modules/sales/sales-teams.service.ts @@ -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( + `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 { + const team = await queryOne( + `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( + `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 { + // 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( + `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 { + 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 { + 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( + `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 { + 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(); diff --git a/src/modules/sales/sales.controller.ts b/src/modules/sales/sales.controller.ts new file mode 100644 index 00000000..efd8a832 --- /dev/null +++ b/src/modules/sales/sales.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/sales/sales.module.ts b/src/modules/sales/sales.module.ts new file mode 100644 index 00000000..ae5fa333 --- /dev/null +++ b/src/modules/sales/sales.module.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { SalesService } from './services'; +import { QuotationsController, SalesOrdersController } from './controllers'; +import { Quotation, SalesOrder } from './entities'; + +export interface SalesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class SalesModule { + public router: Router; + public salesService: SalesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: SalesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const quotationRepository = this.dataSource.getRepository(Quotation); + const orderRepository = this.dataSource.getRepository(SalesOrder); + this.salesService = new SalesService(quotationRepository, orderRepository); + } + + private initializeRoutes(): void { + const quotationsController = new QuotationsController(this.salesService); + const ordersController = new SalesOrdersController(this.salesService); + this.router.use(`${this.basePath}/quotations`, quotationsController.router); + this.router.use(`${this.basePath}/sales-orders`, ordersController.router); + } + + static getEntities(): Function[] { + return [Quotation, SalesOrder]; + } +} diff --git a/src/modules/sales/sales.routes.ts b/src/modules/sales/sales.routes.ts new file mode 100644 index 00000000..6da96320 --- /dev/null +++ b/src/modules/sales/sales.routes.ts @@ -0,0 +1,159 @@ +import { Router } from 'express'; +import { salesController } from './sales.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== PRICELISTS ========== +router.get('/pricelists', (req, res, next) => salesController.getPricelists(req, res, next)); + +router.get('/pricelists/:id', (req, res, next) => salesController.getPricelist(req, res, next)); + +router.post('/pricelists', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.createPricelist(req, res, next) +); + +router.put('/pricelists/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.updatePricelist(req, res, next) +); + +router.post('/pricelists/:id/items', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.addPricelistItem(req, res, next) +); + +router.delete('/pricelists/:id/items/:itemId', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.removePricelistItem(req, res, next) +); + +// ========== SALES TEAMS ========== +router.get('/teams', (req, res, next) => salesController.getSalesTeams(req, res, next)); + +router.get('/teams/:id', (req, res, next) => salesController.getSalesTeam(req, res, next)); + +router.post('/teams', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.createSalesTeam(req, res, next) +); + +router.put('/teams/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.updateSalesTeam(req, res, next) +); + +router.post('/teams/:id/members', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.addSalesTeamMember(req, res, next) +); + +router.delete('/teams/:id/members/:memberId', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.removeSalesTeamMember(req, res, next) +); + +// ========== CUSTOMER GROUPS ========== +router.get('/customer-groups', (req, res, next) => salesController.getCustomerGroups(req, res, next)); + +router.get('/customer-groups/:id', (req, res, next) => salesController.getCustomerGroup(req, res, next)); + +router.post('/customer-groups', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.createCustomerGroup(req, res, next) +); + +router.put('/customer-groups/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateCustomerGroup(req, res, next) +); + +router.delete('/customer-groups/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + salesController.deleteCustomerGroup(req, res, next) +); + +router.post('/customer-groups/:id/members', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.addCustomerGroupMember(req, res, next) +); + +router.delete('/customer-groups/:id/members/:memberId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.removeCustomerGroupMember(req, res, next) +); + +// ========== QUOTATIONS ========== +router.get('/quotations', (req, res, next) => salesController.getQuotations(req, res, next)); + +router.get('/quotations/:id', (req, res, next) => salesController.getQuotation(req, res, next)); + +router.post('/quotations', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.createQuotation(req, res, next) +); + +router.put('/quotations/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateQuotation(req, res, next) +); + +router.delete('/quotations/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.deleteQuotation(req, res, next) +); + +router.post('/quotations/:id/lines', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.addQuotationLine(req, res, next) +); + +router.put('/quotations/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateQuotationLine(req, res, next) +); + +router.delete('/quotations/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.removeQuotationLine(req, res, next) +); + +router.post('/quotations/:id/send', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.sendQuotation(req, res, next) +); + +router.post('/quotations/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.confirmQuotation(req, res, next) +); + +router.post('/quotations/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.cancelQuotation(req, res, next) +); + +// ========== SALES ORDERS ========== +router.get('/orders', (req, res, next) => salesController.getOrders(req, res, next)); + +router.get('/orders/:id', (req, res, next) => salesController.getOrder(req, res, next)); + +router.post('/orders', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.createOrder(req, res, next) +); + +router.put('/orders/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateOrder(req, res, next) +); + +router.delete('/orders/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.deleteOrder(req, res, next) +); + +router.post('/orders/:id/lines', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.addOrderLine(req, res, next) +); + +router.put('/orders/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateOrderLine(req, res, next) +); + +router.delete('/orders/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.removeOrderLine(req, res, next) +); + +router.post('/orders/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.confirmOrder(req, res, next) +); + +router.post('/orders/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.cancelOrder(req, res, next) +); + +router.post('/orders/:id/invoice', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) => + salesController.createOrderInvoice(req, res, next) +); + +export default router; diff --git a/src/modules/sales/services/index.ts b/src/modules/sales/services/index.ts new file mode 100644 index 00000000..29d721c4 --- /dev/null +++ b/src/modules/sales/services/index.ts @@ -0,0 +1,173 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Quotation, SalesOrder } from '../entities/index.js'; +import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto/index.js'; + +/** + * @deprecated Use ordersService from '../orders.service.js' for full Order-to-Cash flow + * This TypeORM-based service provides basic CRUD operations. + * For advanced features (stock reservation, auto-picking, delivery tracking), + * use the SQL-based ordersService instead. + */ +export interface SalesSearchParams { + tenantId: string; + search?: string; + partnerId?: string; + status?: string; + userId?: string; // Changed from salesRepId to match entity + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +} + +/** + * @deprecated Use ordersService from '../orders.service.js' for full Order-to-Cash flow + */ +export class SalesService { + constructor( + private readonly quotationRepository: Repository, + private readonly orderRepository: Repository + ) {} + + async findAllQuotations(params: SalesSearchParams): Promise<{ data: Quotation[]; total: number }> { + const { tenantId, search, partnerId, status, userId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + if (userId) where.salesRepId = userId; + const [data, total] = await this.quotationRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findQuotation(id: string, tenantId: string): Promise { + return this.quotationRepository.findOne({ where: { id, tenantId } }); + } + + async createQuotation(tenantId: string, dto: CreateQuotationDto, createdBy?: string): Promise { + const count = await this.quotationRepository.count({ where: { tenantId } }); + const quotationNumber = `COT-${String(count + 1).padStart(6, '0')}`; + const quotation = this.quotationRepository.create({ ...dto, tenantId, quotationNumber, createdBy, quotationDate: dto.quotationDate ? new Date(dto.quotationDate) : new Date(), validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined }); + return this.quotationRepository.save(quotation); + } + + async updateQuotation(id: string, tenantId: string, dto: UpdateQuotationDto, updatedBy?: string): Promise { + const quotation = await this.findQuotation(id, tenantId); + if (!quotation) return null; + Object.assign(quotation, { ...dto, updatedBy }); + return this.quotationRepository.save(quotation); + } + + async deleteQuotation(id: string, tenantId: string): Promise { + const result = await this.quotationRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + /** + * @deprecated Use ordersService.confirm() for proper picking and stock flow + */ + async convertQuotationToOrder(id: string, tenantId: string, userId?: string): Promise { + const quotation = await this.findQuotation(id, tenantId); + if (!quotation) throw new Error('Quotation not found'); + if (quotation.convertedToOrder) throw new Error('Quotation already converted'); + + const order = await this.createSalesOrder(tenantId, { + partnerId: quotation.partnerId, + quotationId: quotation.id, + notes: quotation.notes, + }, userId); + + quotation.convertedToOrder = true; + quotation.orderId = order.id; + quotation.convertedAt = new Date(); + quotation.status = 'converted'; + await this.quotationRepository.save(quotation); + + return order; + } + + async findAllOrders(params: SalesSearchParams): Promise<{ data: SalesOrder[]; total: number }> { + const { tenantId, search, partnerId, status, userId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + if (userId) where.userId = userId; + const [data, total] = await this.orderRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findOrder(id: string, tenantId: string): Promise { + return this.orderRepository.findOne({ where: { id, tenantId } }); + } + + /** + * @deprecated Use ordersService.create() for proper sequence generation + */ + async createSalesOrder(tenantId: string, dto: CreateSalesOrderDto, createdBy?: string): Promise { + const count = await this.orderRepository.count({ where: { tenantId } }); + const orderName = `SO-${String(count + 1).padStart(6, '0')}`; + const orderData: Partial = { + tenantId, + companyId: (dto as any).companyId || '00000000-0000-0000-0000-000000000000', + name: orderName, + partnerId: dto.partnerId, + quotationId: dto.quotationId || null, + currencyId: (dto as any).currencyId || '00000000-0000-0000-0000-000000000000', + orderDate: new Date(), + commitmentDate: dto.promisedDate ? new Date(dto.promisedDate) : null, + notes: dto.notes || null, + createdBy: createdBy || null, + }; + const order = this.orderRepository.create(orderData as SalesOrder); + return this.orderRepository.save(order); + } + + async updateSalesOrder(id: string, tenantId: string, dto: UpdateSalesOrderDto, updatedBy?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order) return null; + if (dto.notes !== undefined) order.notes = dto.notes || null; + order.updatedBy = updatedBy || null; + return this.orderRepository.save(order); + } + + async deleteSalesOrder(id: string, tenantId: string): Promise { + const result = await this.orderRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + /** + * @deprecated Use ordersService.confirm() for proper picking and stock reservation + */ + async confirmOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || order.status !== 'draft') return null; + order.status = 'sent'; // Changed from 'confirmed' to match entity enum + order.updatedBy = userId || null; + return this.orderRepository.save(order); + } + + /** + * @deprecated Use pickings validation flow for proper delivery tracking + */ + async shipOrder(id: string, tenantId: string, _trackingNumber?: string, _carrier?: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || order.status !== 'sent') return null; + order.status = 'sale'; + order.deliveryStatus = 'partial'; + order.updatedBy = userId || null; + return this.orderRepository.save(order); + } + + /** + * @deprecated Use pickings validation flow for proper delivery tracking + */ + async deliverOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || order.status !== 'sale') return null; + order.status = 'done'; + order.deliveryStatus = 'delivered'; + order.updatedBy = userId || null; + return this.orderRepository.save(order); + } +} + +export { SalesService as default }; diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 00000000..93cdde04 --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1,18 @@ +// Re-export all error classes from types +export { + AppError, + ValidationError, + UnauthorizedError, + ForbiddenError, + NotFoundError, +} from '../types/index.js'; + +// Additional error class not in types +import { AppError } from '../types/index.js'; + +export class ConflictError extends AppError { + constructor(message: string = 'Conflicto con el recurso existente') { + super(message, 409, 'CONFLICT'); + this.name = 'ConflictError'; + } +} diff --git a/src/shared/middleware/apiKeyAuth.middleware.ts b/src/shared/middleware/apiKeyAuth.middleware.ts new file mode 100644 index 00000000..db513dad --- /dev/null +++ b/src/shared/middleware/apiKeyAuth.middleware.ts @@ -0,0 +1,217 @@ +import { Response, NextFunction } from 'express'; +import { apiKeysService } from '../../modules/auth/apiKeys.service.js'; +import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// ============================================================================ +// API KEY AUTHENTICATION MIDDLEWARE +// ============================================================================ + +/** + * Header name for API Key authentication + * Supports both X-API-Key and Authorization: ApiKey xxx + */ +const API_KEY_HEADER = 'x-api-key'; +const API_KEY_AUTH_PREFIX = 'ApiKey '; + +/** + * Extract API key from request headers + */ +function extractApiKey(req: AuthenticatedRequest): string | null { + // Check X-API-Key header first + const xApiKey = req.headers[API_KEY_HEADER] as string; + if (xApiKey) { + return xApiKey; + } + + // Check Authorization header with ApiKey prefix + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith(API_KEY_AUTH_PREFIX)) { + return authHeader.substring(API_KEY_AUTH_PREFIX.length); + } + + return null; +} + +/** + * Get client IP address from request + */ +function getClientIp(req: AuthenticatedRequest): string | undefined { + // Check X-Forwarded-For header (for proxies/load balancers) + const forwardedFor = req.headers['x-forwarded-for']; + if (forwardedFor) { + const ips = (forwardedFor as string).split(','); + return ips[0].trim(); + } + + // Check X-Real-IP header + const realIp = req.headers['x-real-ip'] as string; + if (realIp) { + return realIp; + } + + // Fallback to socket remote address + return req.socket.remoteAddress; +} + +/** + * Authenticate request using API Key + * Use this middleware for API endpoints that should accept API Key authentication + */ +export function authenticateApiKey( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): void { + (async () => { + try { + const apiKey = extractApiKey(req); + + if (!apiKey) { + throw new UnauthorizedError('API key requerida'); + } + + const clientIp = getClientIp(req); + const result = await apiKeysService.validate(apiKey, clientIp); + + if (!result.valid || !result.user) { + logger.warn('API key validation failed', { + error: result.error, + clientIp, + }); + throw new UnauthorizedError(result.error || 'API key inválida'); + } + + // Set user info on request (same format as JWT auth) + req.user = { + userId: result.user.id, + tenantId: result.user.tenant_id, + email: result.user.email, + roles: result.user.roles, + }; + req.tenantId = result.user.tenant_id; + + // Mark request as authenticated via API Key (for logging/audit) + (req as any).authMethod = 'api_key'; + (req as any).apiKeyId = result.apiKey?.id; + + next(); + } catch (error) { + next(error); + } + })(); +} + +/** + * Authenticate request using either JWT or API Key + * Use this for endpoints that should accept both authentication methods + */ +export function authenticateJwtOrApiKey( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const apiKey = extractApiKey(req); + const jwtToken = req.headers.authorization?.startsWith('Bearer '); + + if (apiKey) { + // Use API Key authentication + authenticateApiKey(req, res, next); + } else if (jwtToken) { + // Use JWT authentication - import dynamically to avoid circular deps + import('./auth.middleware.js').then(({ authenticate }) => { + authenticate(req, res, next); + }); + } else { + next(new UnauthorizedError('Autenticación requerida (JWT o API Key)')); + } +} + +/** + * Require specific API key scope + * Use after authenticateApiKey to enforce scope restrictions + */ +export function requireApiKeyScope(requiredScope: string) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + const apiKeyId = (req as any).apiKeyId; + const authMethod = (req as any).authMethod; + + // Only check scope for API Key auth + if (authMethod !== 'api_key') { + return next(); + } + + // Get API key scope from database (cached in validation result) + // For now, we'll re-validate - in production, cache this + (async () => { + const apiKey = extractApiKey(req); + if (!apiKey) { + throw new ForbiddenError('API key no encontrada'); + } + + const result = await apiKeysService.validate(apiKey); + if (!result.valid || !result.apiKey) { + throw new ForbiddenError('API key inválida'); + } + + // Null scope means full access + if (result.apiKey.scope === null) { + return next(); + } + + // Check if scope matches + if (result.apiKey.scope !== requiredScope) { + logger.warn('API key scope mismatch', { + apiKeyId, + requiredScope, + actualScope: result.apiKey.scope, + }); + throw new ForbiddenError(`API key no tiene el scope requerido: ${requiredScope}`); + } + + next(); + })(); + } catch (error) { + next(error); + } + }; +} + +/** + * Rate limiting for API Key requests + * Simple in-memory rate limiter - use Redis in production + */ +const rateLimitStore = new Map(); + +export function apiKeyRateLimit(maxRequests: number = 1000, windowMs: number = 60000) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + const apiKeyId = (req as any).apiKeyId; + if (!apiKeyId) { + return next(); + } + + const now = Date.now(); + const record = rateLimitStore.get(apiKeyId); + + if (!record || now > record.resetTime) { + rateLimitStore.set(apiKeyId, { + count: 1, + resetTime: now + windowMs, + }); + return next(); + } + + if (record.count >= maxRequests) { + logger.warn('API key rate limit exceeded', { apiKeyId, count: record.count }); + throw new ForbiddenError('Rate limit excedido. Intente más tarde.'); + } + + record.count++; + next(); + } catch (error) { + next(error); + } + }; +} diff --git a/src/shared/middleware/auth.middleware.ts b/src/shared/middleware/auth.middleware.ts new file mode 100644 index 00000000..a502890c --- /dev/null +++ b/src/shared/middleware/auth.middleware.ts @@ -0,0 +1,119 @@ +import { Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../../config/index.js'; +import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// Re-export AuthenticatedRequest for convenience +export { AuthenticatedRequest } from '../types/index.js'; + +export function authenticate( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): void { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedError('Token de acceso requerido'); + } + + const token = authHeader.substring(7); + + try { + const payload = jwt.verify(token, config.jwt.secret) as JwtPayload; + req.user = payload; + req.tenantId = payload.tenantId; + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new UnauthorizedError('Token expirado'); + } + throw new UnauthorizedError('Token inválido'); + } + } catch (error) { + next(error); + } +} + +export function requireRoles(...roles: string[]) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + // Superusers bypass role checks + if (req.user.roles.includes('super_admin')) { + return next(); + } + + const hasRole = roles.some(role => req.user!.roles.includes(role)); + if (!hasRole) { + logger.warn('Access denied - insufficient roles', { + userId: req.user.userId, + requiredRoles: roles, + userRoles: req.user.roles, + }); + throw new ForbiddenError('No tiene permisos para esta acción'); + } + + next(); + } catch (error) { + next(error); + } + }; +} + +export function requirePermission(resource: string, action: string) { + return async (req: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + // Superusers bypass permission checks + if (req.user.roles.includes('super_admin')) { + return next(); + } + + // TODO: Check permission in database + // For now, we'll implement this when we have the permission checking service + logger.debug('Permission check', { + userId: req.user.userId, + resource, + action, + }); + + next(); + } catch (error) { + next(error); + } + }; +} + +export function optionalAuth( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): void { + try { + const authHeader = req.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + try { + const payload = jwt.verify(token, config.jwt.secret) as JwtPayload; + req.user = payload; + req.tenantId = payload.tenantId; + } catch { + // Token invalid, but that's okay for optional auth + } + } + + next(); + } catch (error) { + next(error); + } +} diff --git a/src/shared/middleware/fieldPermissions.middleware.ts b/src/shared/middleware/fieldPermissions.middleware.ts new file mode 100644 index 00000000..16581682 --- /dev/null +++ b/src/shared/middleware/fieldPermissions.middleware.ts @@ -0,0 +1,343 @@ +import { Response, NextFunction } from 'express'; +import { query, queryOne } from '../../config/database.js'; +import { AuthenticatedRequest } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface FieldPermission { + field_name: string; + can_read: boolean; + can_write: boolean; +} + +export interface ModelFieldPermissions { + model_name: string; + fields: Map; +} + +// Cache for field permissions per user/model +const permissionsCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Get cache key for user/model combination + */ +function getCacheKey(userId: string, tenantId: string, modelName: string): string { + return `${tenantId}:${userId}:${modelName}`; +} + +/** + * Load field permissions for a user on a specific model + */ +async function loadFieldPermissions( + userId: string, + tenantId: string, + modelName: string +): Promise { + // Check cache first + const cacheKey = getCacheKey(userId, tenantId, modelName); + const cached = permissionsCache.get(cacheKey); + + if (cached && cached.expires > Date.now()) { + return cached.permissions; + } + + // Load from database + const result = await query<{ + field_name: string; + can_read: boolean; + can_write: boolean; + }>( + `SELECT + mf.name as field_name, + COALESCE(fp.can_read, true) as can_read, + COALESCE(fp.can_write, true) as can_write + FROM auth.model_fields mf + JOIN auth.models m ON mf.model_id = m.id + LEFT JOIN auth.field_permissions fp ON mf.id = fp.field_id + LEFT JOIN auth.user_groups ug ON fp.group_id = ug.group_id + WHERE m.model = $1 + AND m.tenant_id = $2 + AND (ug.user_id = $3 OR fp.group_id IS NULL) + GROUP BY mf.name, fp.can_read, fp.can_write`, + [modelName, tenantId, userId] + ); + + if (result.length === 0) { + // No permissions defined = allow all + return null; + } + + const permissions: ModelFieldPermissions = { + model_name: modelName, + fields: new Map(), + }; + + for (const row of result) { + permissions.fields.set(row.field_name, { + field_name: row.field_name, + can_read: row.can_read, + can_write: row.can_write, + }); + } + + // Cache the result + permissionsCache.set(cacheKey, { + permissions, + expires: Date.now() + CACHE_TTL, + }); + + return permissions; +} + +/** + * Filter object fields based on read permissions + */ +function filterReadFields>( + data: T, + permissions: ModelFieldPermissions | null +): Partial { + // No permissions defined = return all fields + if (!permissions || permissions.fields.size === 0) { + return data; + } + + const filtered: Record = {}; + + for (const [key, value] of Object.entries(data)) { + const fieldPerm = permissions.fields.get(key); + + // If no permission defined for field, allow it + // If permission exists and can_read is true, allow it + if (!fieldPerm || fieldPerm.can_read) { + filtered[key] = value; + } + } + + return filtered as Partial; +} + +/** + * Filter array of objects + */ +function filterReadFieldsArray>( + data: T[], + permissions: ModelFieldPermissions | null +): Partial[] { + return data.map(item => filterReadFields(item, permissions)); +} + +/** + * Validate write permissions for incoming data + */ +function validateWriteFields>( + data: T, + permissions: ModelFieldPermissions | null +): { valid: boolean; forbiddenFields: string[] } { + // No permissions defined = allow all writes + if (!permissions || permissions.fields.size === 0) { + return { valid: true, forbiddenFields: [] }; + } + + const forbiddenFields: string[] = []; + + for (const key of Object.keys(data)) { + const fieldPerm = permissions.fields.get(key); + + // If permission exists and can_write is false, it's forbidden + if (fieldPerm && !fieldPerm.can_write) { + forbiddenFields.push(key); + } + } + + return { + valid: forbiddenFields.length === 0, + forbiddenFields, + }; +} + +// ============================================================================ +// MIDDLEWARE FACTORIES +// ============================================================================ + +/** + * Middleware to filter response fields based on read permissions + * Use this on GET endpoints + */ +export function filterResponseFields(modelName: string) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + // Store original json method + const originalJson = res.json.bind(res); + + // Override json method to filter fields + res.json = function(body: any) { + (async () => { + try { + // Only filter for authenticated requests + if (!req.user) { + return originalJson(body); + } + + // Load permissions + const permissions = await loadFieldPermissions( + req.user.userId, + req.user.tenantId, + modelName + ); + + // If no permissions defined or super_admin, return original + if (!permissions || req.user.roles.includes('super_admin')) { + return originalJson(body); + } + + // Filter the response + if (body && typeof body === 'object') { + if (body.data) { + if (Array.isArray(body.data)) { + body.data = filterReadFieldsArray(body.data, permissions); + } else if (typeof body.data === 'object') { + body.data = filterReadFields(body.data, permissions); + } + } else if (Array.isArray(body)) { + body = filterReadFieldsArray(body, permissions); + } + } + + return originalJson(body); + } catch (error) { + logger.error('Error filtering response fields', { error, modelName }); + return originalJson(body); + } + })(); + } as typeof res.json; + + next(); + }; +} + +/** + * Middleware to validate write permissions on incoming data + * Use this on POST/PUT/PATCH endpoints + */ +export function validateWritePermissions(modelName: string) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + try { + // Skip for unauthenticated requests (they'll fail auth anyway) + if (!req.user) { + return next(); + } + + // Super admins bypass field permission checks + if (req.user.roles.includes('super_admin')) { + return next(); + } + + // Load permissions + const permissions = await loadFieldPermissions( + req.user.userId, + req.user.tenantId, + modelName + ); + + // No permissions defined = allow all + if (!permissions) { + return next(); + } + + // Validate write fields in request body + if (req.body && typeof req.body === 'object') { + const { valid, forbiddenFields } = validateWriteFields(req.body, permissions); + + if (!valid) { + logger.warn('Write permission denied for fields', { + userId: req.user.userId, + modelName, + forbiddenFields, + }); + + res.status(403).json({ + success: false, + error: `No tiene permisos para modificar los campos: ${forbiddenFields.join(', ')}`, + forbiddenFields, + }); + return; + } + } + + next(); + } catch (error) { + logger.error('Error validating write permissions', { error, modelName }); + next(error); + } + }; +} + +/** + * Combined middleware for both read and write validation + */ +export function fieldPermissions(modelName: string) { + const readFilter = filterResponseFields(modelName); + const writeValidator = validateWritePermissions(modelName); + + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + // For write operations, validate first + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + await writeValidator(req, res, () => { + // If write validation passed, apply read filter for response + readFilter(req, res, next); + }); + } else { + // For read operations, just apply read filter + await readFilter(req, res, next); + } + }; +} + +/** + * Clear permissions cache for a user (call after permission changes) + */ +export function clearPermissionsCache(userId?: string, tenantId?: string): void { + if (userId && tenantId) { + // Clear specific user's cache + const prefix = `${tenantId}:${userId}:`; + for (const key of permissionsCache.keys()) { + if (key.startsWith(prefix)) { + permissionsCache.delete(key); + } + } + } else { + // Clear all cache + permissionsCache.clear(); + } +} + +/** + * Get list of restricted fields for a user on a model + * Useful for frontend to know which fields to hide/disable + */ +export async function getRestrictedFields( + userId: string, + tenantId: string, + modelName: string +): Promise<{ readRestricted: string[]; writeRestricted: string[] }> { + const permissions = await loadFieldPermissions(userId, tenantId, modelName); + + const readRestricted: string[] = []; + const writeRestricted: string[] = []; + + if (permissions) { + for (const [fieldName, perm] of permissions.fields) { + if (!perm.can_read) readRestricted.push(fieldName); + if (!perm.can_write) writeRestricted.push(fieldName); + } + } + + return { readRestricted, writeRestricted }; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 00000000..0452cd62 --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1,151 @@ +import { Request } from 'express'; + +// API Response types +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; + meta?: PaginationMeta; +} + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface PaginationParams { + page: number; + limit: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +// Auth types +export interface JwtPayload { + userId: string; + tenantId: string; + email: string; + roles: string[]; + sessionId?: string; + jti?: string; + iat?: number; + exp?: number; +} + +export interface AuthenticatedRequest extends Request { + user?: JwtPayload; + tenantId?: string; +} + +// User types (matching auth.users table) +export interface User { + id: string; + tenant_id: string; + email: string; + password_hash?: string; + full_name: string; + status: 'active' | 'inactive' | 'pending' | 'suspended'; + is_superuser: boolean; + email_verified_at?: Date; + last_login_at?: Date; + created_at: Date; + updated_at: Date; +} + +// Role types (matching auth.roles table) +export interface Role { + id: string; + tenant_id: string; + name: string; + code: string; + description?: string; + is_system: boolean; + color?: string; + created_at: Date; +} + +// Permission types (matching auth.permissions table) +export interface Permission { + id: string; + resource: string; + action: string; + description?: string; + module: string; +} + +// Tenant types (matching auth.tenants table) +export interface Tenant { + id: string; + name: string; + subdomain: string; + schema_name: string; + status: 'active' | 'inactive' | 'suspended'; + settings: Record; + plan: string; + max_users: number; + created_at: Date; +} + +// Company types (matching auth.companies table) +export interface Company { + id: string; + tenant_id: string; + parent_company_id?: string; + name: string; + legal_name?: string; + tax_id?: string; + currency_id?: string; + settings: Record; + created_at: Date; +} + +// Error types +export class AppError extends Error { + constructor( + public message: string, + public statusCode: number = 500, + public code?: string + ) { + super(message); + this.name = 'AppError'; + Error.captureStackTrace(this, this.constructor); + } +} + +export class ValidationError extends AppError { + constructor(message: string, public details?: any[]) { + super(message, 400, 'VALIDATION_ERROR'); + this.name = 'ValidationError'; + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = 'No autorizado') { + super(message, 401, 'UNAUTHORIZED'); + this.name = 'UnauthorizedError'; + } +} + +export class ForbiddenError extends AppError { + constructor(message: string = 'Acceso denegado') { + super(message, 403, 'FORBIDDEN'); + this.name = 'ForbiddenError'; + } +} + +export class NotFoundError extends AppError { + constructor(message: string = 'Recurso no encontrado') { + super(message, 404, 'NOT_FOUND'); + this.name = 'NotFoundError'; + } +} + +export class ConflictError extends AppError { + constructor(message: string = 'Conflicto con recurso existente') { + super(message, 409, 'CONFLICT'); + this.name = 'ConflictError'; + } +} diff --git a/src/shared/utils/circuit-breaker.ts b/src/shared/utils/circuit-breaker.ts new file mode 100644 index 00000000..41053b25 --- /dev/null +++ b/src/shared/utils/circuit-breaker.ts @@ -0,0 +1,158 @@ +/** + * Circuit Breaker Pattern Implementation + * Previene llamadas a servicios externos cuando estos estan fallando + */ + +export class CircuitBreakerOpenError extends Error { + constructor(public readonly circuitName: string) { + super(`Circuit breaker '${circuitName}' is OPEN. Service temporarily unavailable.`); + this.name = 'CircuitBreakerOpenError'; + } +} + +export type CircuitBreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + +export interface CircuitBreakerOptions { + failureThreshold?: number; + resetTimeout?: number; + halfOpenRequests?: number; + onStateChange?: (name: string, from: CircuitBreakerState, to: CircuitBreakerState) => void; +} + +export class CircuitBreaker { + private failures: number = 0; + private successes: number = 0; + private lastFailureTime: number = 0; + private state: CircuitBreakerState = 'CLOSED'; + private halfOpenAttempts: number = 0; + + private readonly failureThreshold: number; + private readonly resetTimeout: number; + private readonly halfOpenRequests: number; + private readonly onStateChange?: (name: string, from: CircuitBreakerState, to: CircuitBreakerState) => void; + + constructor( + private readonly name: string, + options: CircuitBreakerOptions = {} + ) { + this.failureThreshold = options.failureThreshold ?? 5; + this.resetTimeout = options.resetTimeout ?? 60000; // 1 minuto + this.halfOpenRequests = options.halfOpenRequests ?? 3; + this.onStateChange = options.onStateChange; + } + + async execute(fn: () => Promise): Promise { + if (this.state === 'OPEN') { + if (Date.now() - this.lastFailureTime >= this.resetTimeout) { + this.transitionTo('HALF_OPEN'); + } else { + throw new CircuitBreakerOpenError(this.name); + } + } + + if (this.state === 'HALF_OPEN' && this.halfOpenAttempts >= this.halfOpenRequests) { + throw new CircuitBreakerOpenError(this.name); + } + + try { + if (this.state === 'HALF_OPEN') { + this.halfOpenAttempts++; + } + + const result = await fn(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess(): void { + if (this.state === 'HALF_OPEN') { + this.successes++; + if (this.successes >= this.halfOpenRequests) { + this.transitionTo('CLOSED'); + } + } else { + this.failures = 0; + } + } + + private onFailure(): void { + this.failures++; + this.lastFailureTime = Date.now(); + + if (this.state === 'HALF_OPEN') { + this.transitionTo('OPEN'); + } else if (this.failures >= this.failureThreshold) { + this.transitionTo('OPEN'); + } + } + + private transitionTo(newState: CircuitBreakerState): void { + const oldState = this.state; + this.state = newState; + + if (newState === 'CLOSED') { + this.failures = 0; + this.successes = 0; + this.halfOpenAttempts = 0; + } else if (newState === 'HALF_OPEN') { + this.successes = 0; + this.halfOpenAttempts = 0; + } + + if (this.onStateChange) { + this.onStateChange(this.name, oldState, newState); + } + } + + getState(): CircuitBreakerState { + return this.state; + } + + getStats(): { + name: string; + state: CircuitBreakerState; + failures: number; + successes: number; + lastFailureTime: number; + } { + return { + name: this.name, + state: this.state, + failures: this.failures, + successes: this.successes, + lastFailureTime: this.lastFailureTime, + }; + } + + reset(): void { + this.transitionTo('CLOSED'); + } +} + +// Singleton registry para circuit breakers +class CircuitBreakerRegistry { + private breakers: Map = new Map(); + + get(name: string, options?: CircuitBreakerOptions): CircuitBreaker { + let breaker = this.breakers.get(name); + if (!breaker) { + breaker = new CircuitBreaker(name, options); + this.breakers.set(name, breaker); + } + return breaker; + } + + getAll(): Map { + return this.breakers; + } + + getAllStats(): Array> { + return Array.from(this.breakers.values()).map((b) => b.getStats()); + } +} + +export const circuitBreakerRegistry = new CircuitBreakerRegistry(); diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 00000000..be02c105 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,7 @@ +export { + CircuitBreaker, + CircuitBreakerOpenError, + CircuitBreakerState, + CircuitBreakerOptions, + circuitBreakerRegistry, +} from './circuit-breaker'; diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts new file mode 100644 index 00000000..e415c4ee --- /dev/null +++ b/src/shared/utils/logger.ts @@ -0,0 +1,40 @@ +import winston from 'winston'; +import { config } from '../../config/index.js'; + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +const logFormat = printf(({ level, message, timestamp, ...metadata }) => { + let msg = `${timestamp} [${level}]: ${message}`; + if (Object.keys(metadata).length > 0) { + msg += ` ${JSON.stringify(metadata)}`; + } + return msg; +}); + +export const logger = winston.createLogger({ + level: config.logging.level, + format: combine( + errors({ stack: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat + ), + transports: [ + new winston.transports.Console({ + format: combine( + colorize(), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat + ), + }), + ], +}); + +// Add file transport in production +if (config.env === 'production') { + logger.add( + new winston.transports.File({ filename: 'logs/error.log', level: 'error' }) + ); + logger.add( + new winston.transports.File({ filename: 'logs/combined.log' }) + ); +}