diff --git a/package-lock.json b/package-lock.json index 8e181df..a431e64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,29 +11,39 @@ "dependencies": { "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", + "class-validator": "^0.14.3", + "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", - "reflect-metadata": "^0.1.13", + "reflect-metadata": "^0.2.2", + "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", - "typeorm": "^0.3.17", - "yamljs": "^0.3.0" + "typeorm": "^0.3.28", + "uuid": "^9.0.0", + "winston": "^3.11.0", + "yamljs": "^0.3.0", + "zod": "^3.22.4" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", "@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-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", + "@types/uuid": "^9.0.7", + "@types/yamljs": "^0.2.34", "@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/parser": "^6.15.0", "eslint": "^8.56.0", @@ -44,10 +54,54 @@ "typescript": "^5.3.3" }, "engines": { - "node": ">=18.0.0", + "node": ">=20.0.0", "npm": ">=9.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -564,6 +618,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", @@ -588,6 +651,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", @@ -737,6 +811,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1292,6 +1372,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1374,6 +1460,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", @@ -1471,6 +1567,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1576,7 +1683,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -1699,6 +1805,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/swagger-ui-express": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", @@ -1710,12 +1823,32 @@ "@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/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/yamljs": { + "version": "0.2.34", + "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.34.tgz", + "integrity": "sha512-gJvfRlv9ErxdOv7ux7UsJVePtX54NAvQyd8ncoiFqK8G5aeHIfQfGH2fbruvjAQ9657HwAaO54waS+Dsk2QTUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2100,7 +2233,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -2119,6 +2251,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", @@ -2544,6 +2682,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2704,6 +2848,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2722,6 +2875,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", @@ -2740,6 +2906,111 @@ "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/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2916,6 +3187,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2982,7 +3262,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -3074,6 +3353,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", @@ -3334,7 +3619,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -3546,6 +3830,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", @@ -3644,6 +3934,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", @@ -4168,6 +4464,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4291,7 +4611,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" @@ -5007,7 +5326,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5133,6 +5451,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", @@ -5186,18 +5510,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -5236,12 +5586,35 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "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", @@ -5603,6 +5976,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", @@ -5619,6 +6001,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6194,6 +6583,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", @@ -6207,10 +6610,31 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", - "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, "node_modules/require-directory": { @@ -6358,6 +6782,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", @@ -6740,6 +7173,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", @@ -6763,6 +7205,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6772,6 +7220,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", @@ -6899,6 +7356,81 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/swagger-ui-dist": { "version": "5.30.3", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.3.tgz", @@ -6962,6 +7494,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", @@ -7022,6 +7560,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", @@ -7433,11 +7980,18 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/typeorm/node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "node_modules/typeorm/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } }, "node_modules/typescript": { "version": "5.9.3", @@ -7524,6 +8078,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", @@ -7534,16 +8094,16 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -7632,6 +8192,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", @@ -7729,6 +8325,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yamljs": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", @@ -7801,6 +8406,45 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 894e097..9ab1177 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,10 @@ "license": "UNLICENSED", "dependencies": { "express": "^4.18.2", - "typeorm": "^0.3.17", + "typeorm": "^0.3.28", "pg": "^8.11.3", - "reflect-metadata": "^0.1.13", - "class-validator": "^0.14.0", + "reflect-metadata": "^0.2.2", + "class-validator": "^0.14.3", "class-transformer": "^0.5.1", "dotenv": "^16.3.1", "cors": "^2.8.5", @@ -46,6 +46,12 @@ "bcryptjs": "^2.4.3", "jsonwebtoken": "^9.0.2", "swagger-ui-express": "^5.0.0", + "swagger-jsdoc": "^6.2.8", + "winston": "^3.11.0", + "ioredis": "^5.3.2", + "zod": "^3.22.4", + "uuid": "^9.0.0", + "compression": "^1.7.4", "yamljs": "^0.3.0" }, "devDependencies": { @@ -56,6 +62,10 @@ "@types/bcryptjs": "^2.4.6", "@types/jsonwebtoken": "^9.0.5", "@types/swagger-ui-express": "^4.1.6", + "@types/compression": "^1.7.5", + "@types/swagger-jsdoc": "^6.0.4", + "@types/uuid": "^9.0.7", + "@types/yamljs": "^0.2.34", "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/parser": "^6.15.0", @@ -67,7 +77,7 @@ "typescript": "^5.3.3" }, "engines": { - "node": ">=18.0.0", + "node": ">=20.0.0", "npm": ">=9.0.0" } } diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..642ef18 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,41 @@ +/** + * Configuración centralizada del proyecto + * Bridge desde variables de entorno a objeto tipado + * Compatible con erp-core config interface + */ + +import dotenv from 'dotenv'; + +dotenv.config(); + +export const config = { + env: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '3000', 10), + + jwt: { + secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production-minimum-32-chars', + expiresIn: process.env.JWT_EXPIRES_IN || '1d', + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', + }, + + database: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + name: process.env.DB_NAME || 'erp_construccion_db', + user: process.env.DB_USER || 'erp_admin', + password: process.env.DB_PASSWORD || 'erp_dev_2026', + }, + + redis: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + }, + + logging: { + level: process.env.LOG_LEVEL || 'info', + }, + + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + }, +}; diff --git a/src/modules/assets/dto/asset.dto.ts b/src/modules/assets/dto/asset.dto.ts new file mode 100644 index 0000000..68462b9 --- /dev/null +++ b/src/modules/assets/dto/asset.dto.ts @@ -0,0 +1,530 @@ +/** + * Asset DTOs - Data Transfer Objects para Activos + * + * Maquinaria, equipo, vehiculos y herramientas de construccion. + * + * @module Assets (MAE-015) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + IsPositive, + Min, + Max, + MinLength, + MaxLength, + IsArray, +} from 'class-validator'; + +/** + * Tipo de activo + */ +export enum AssetType { + HEAVY_MACHINERY = 'heavy_machinery', + LIGHT_EQUIPMENT = 'light_equipment', + VEHICLE = 'vehicle', + TOOL = 'tool', + COMPUTER = 'computer', + FURNITURE = 'furniture', + OTHER = 'other', +} + +/** + * Estado del activo + */ +export enum AssetStatus { + AVAILABLE = 'available', + ASSIGNED = 'assigned', + IN_MAINTENANCE = 'in_maintenance', + IN_TRANSIT = 'in_transit', + INACTIVE = 'inactive', + RETIRED = 'retired', + SOLD = 'sold', +} + +/** + * Tipo de propiedad + */ +export enum OwnershipType { + OWNED = 'owned', + LEASED = 'leased', + RENTED = 'rented', + BORROWED = 'borrowed', +} + +/** + * Metodo de depreciacion + */ +export enum DepreciationMethod { + STRAIGHT_LINE = 'straight_line', + DECLINING_BALANCE = 'declining_balance', + UNITS_OF_PRODUCTION = 'units_of_production', + SUM_OF_YEARS = 'sum_of_years', +} + +/** + * DTO para crear un nuevo activo + */ +export class CreateAssetDto { + @IsString() + @MinLength(1) + @MaxLength(50) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(AssetType) + type?: AssetType; + + @IsOptional() + @IsEnum(OwnershipType) + ownershipType?: OwnershipType; + + @IsOptional() + @IsString() + @MaxLength(100) + serialNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + model?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + brand?: string; + + @IsOptional() + @IsNumber() + @Min(1900) + @Max(2100) + yearManufactured?: number; + + @IsOptional() + @IsDateString() + acquisitionDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + acquisitionCost?: number; + + @IsOptional() + @IsString() + @MaxLength(3) + purchaseCurrency?: string; + + @IsOptional() + @IsEnum(DepreciationMethod) + depreciationMethod?: DepreciationMethod; + + @IsOptional() + @IsNumber() + @IsPositive() + @Max(600) + usefulLifeMonths?: number; + + @IsOptional() + @IsNumber() + @Min(0) + salvageValue?: number; + + @IsOptional() + @IsNumber() + @Min(0) + currentValue?: number; + + @IsOptional() + @IsString() + @MaxLength(255) + location?: string; + + @IsOptional() + @IsEnum(AssetStatus) + status?: AssetStatus; + + @IsOptional() + @IsString() + @MaxLength(100) + capacity?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + powerRating?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + fuelType?: string; + + @IsOptional() + @IsNumber() + @Min(0) + fuelCapacity?: number; + + @IsOptional() + @IsNumber() + @Min(0) + fuelConsumptionRate?: number; + + @IsOptional() + @IsUUID() + supplierId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + invoiceNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + photoUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + manualUrl?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; +} + +/** + * DTO para actualizar un activo existente + */ +export class UpdateAssetDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(AssetType) + type?: AssetType; + + @IsOptional() + @IsEnum(OwnershipType) + ownershipType?: OwnershipType; + + @IsOptional() + @IsString() + @MaxLength(100) + serialNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + model?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + brand?: string; + + @IsOptional() + @IsNumber() + @Min(1900) + @Max(2100) + yearManufactured?: number; + + @IsOptional() + @IsDateString() + acquisitionDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + acquisitionCost?: number; + + @IsOptional() + @IsString() + @MaxLength(3) + purchaseCurrency?: string; + + @IsOptional() + @IsEnum(DepreciationMethod) + depreciationMethod?: DepreciationMethod; + + @IsOptional() + @IsNumber() + @IsPositive() + @Max(600) + usefulLifeMonths?: number; + + @IsOptional() + @IsNumber() + @Min(0) + salvageValue?: number; + + @IsOptional() + @IsNumber() + @Min(0) + currentValue?: number; + + @IsOptional() + @IsString() + @MaxLength(255) + location?: string; + + @IsOptional() + @IsEnum(AssetStatus) + status?: AssetStatus; + + @IsOptional() + @IsString() + @MaxLength(100) + capacity?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + powerRating?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + fuelType?: string; + + @IsOptional() + @IsNumber() + @Min(0) + fuelCapacity?: number; + + @IsOptional() + @IsNumber() + @Min(0) + fuelConsumptionRate?: number; + + @IsOptional() + @IsUUID() + supplierId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + invoiceNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + photoUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + manualUrl?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsUUID() + currentProjectId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + currentHours?: number; + + @IsOptional() + @IsNumber() + @Min(0) + currentKilometers?: number; + + @IsOptional() + @IsUUID() + assignedOperatorId?: string; +} + +/** + * DTO para filtrar activos en listados + */ +export class AssetFiltersDto { + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(AssetType) + type?: AssetType; + + @IsOptional() + @IsEnum(AssetStatus) + status?: AssetStatus; + + @IsOptional() + @IsEnum(OwnershipType) + ownershipType?: OwnershipType; + + @IsOptional() + @IsString() + location?: string; + + @IsOptional() + @IsUUID() + currentProjectId?: string; + + @IsOptional() + @IsUUID() + assignedOperatorId?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO para asignar un activo a un proyecto + */ +export class AssignAssetDto { + @IsUUID() + projectId: string; + + @IsOptional() + @IsDateString() + assignmentDate?: string; + + @IsOptional() + @IsDateString() + expectedReturnDate?: string; + + @IsOptional() + @IsUUID() + operatorId?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar metricas de uso + */ +export class UpdateUsageMetricsDto { + @IsOptional() + @IsNumber() + @Min(0) + currentHours?: number; + + @IsOptional() + @IsNumber() + @Min(0) + currentKilometers?: number; + + @IsOptional() + @IsNumber() + currentLatitude?: number; + + @IsOptional() + @IsNumber() + currentLongitude?: number; + + @IsOptional() + @IsString() + @MaxLength(255) + currentLocationName?: string; +} + +/** + * DTO de respuesta para un activo + */ +export class AssetResponseDto { + id: string; + tenantId: string; + assetCode: string; + name: string; + description?: string; + categoryId?: string; + category?: { + id: string; + name: string; + code: string; + }; + assetType: AssetType; + status: AssetStatus; + ownershipType: OwnershipType; + brand?: string; + model?: string; + serialNumber?: string; + yearManufactured?: number; + purchaseDate?: Date; + purchasePrice?: number; + purchaseCurrency: string; + currentBookValue?: number; + currentHours: number; + currentKilometers: number; + currentProjectId?: string; + currentLocationName?: string; + assignedOperatorId?: string; + nextMaintenanceDate?: Date; + photoUrl?: string; + tags?: string[]; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/modules/assets/dto/fuel-log.dto.ts b/src/modules/assets/dto/fuel-log.dto.ts new file mode 100644 index 0000000..56c3f68 --- /dev/null +++ b/src/modules/assets/dto/fuel-log.dto.ts @@ -0,0 +1,313 @@ +/** + * FuelLog DTOs - Data Transfer Objects para Registro de Combustible + * + * Cargas de combustible y calculo de rendimiento. + * + * @module Assets (MAE-015) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsNumber, + IsDateString, + IsPositive, + Min, + Max, + MaxLength, +} from 'class-validator'; + +/** + * DTO para crear un nuevo registro de combustible + */ +export class CreateFuelLogDto { + @IsUUID() + assetId: string; + + @IsDateString() + date: string; + + @IsOptional() + @IsString() + @MaxLength(8) + time?: string; + + @IsNumber() + @IsPositive() + liters: number; + + @IsNumber() + @IsPositive() + unitPrice: number; + + @IsNumber() + @IsPositive() + cost: number; + + @IsOptional() + @IsNumber() + @Min(0) + odometer?: number; + + @IsOptional() + @IsNumber() + @Min(0) + hoursReading?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + fuelType?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + supplier?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + invoiceNumber?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + location?: string; + + @IsOptional() + @IsUUID() + operatorId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + operatorName?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar un registro de combustible + */ +export class UpdateFuelLogDto { + @IsOptional() + @IsDateString() + date?: string; + + @IsOptional() + @IsString() + @MaxLength(8) + time?: string; + + @IsOptional() + @IsNumber() + @IsPositive() + liters?: number; + + @IsOptional() + @IsNumber() + @IsPositive() + unitPrice?: number; + + @IsOptional() + @IsNumber() + @IsPositive() + cost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + odometer?: number; + + @IsOptional() + @IsNumber() + @Min(0) + hoursReading?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + fuelType?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + supplier?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + invoiceNumber?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + location?: string; + + @IsOptional() + @IsUUID() + operatorId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + operatorName?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para filtrar registros de combustible + */ +export class FuelLogFiltersDto { + @IsOptional() + @IsUUID() + assetId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + fuelType?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsNumber() + @Min(0) + minLiters?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxLiters?: number; + + @IsOptional() + @IsNumber() + @Min(0) + minCost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxCost?: number; + + @IsOptional() + @IsUUID() + operatorId?: string; + + @IsOptional() + @IsString() + supplier?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO para reporte de consumo de combustible + */ +export class FuelConsumptionReportDto { + @IsUUID() + assetId: string; + + @IsDateString() + dateFrom: string; + + @IsDateString() + dateTo: string; +} + +/** + * DTO de respuesta para un registro de combustible + */ +export class FuelLogResponseDto { + id: string; + tenantId: string; + assetId: string; + asset?: { + id: string; + assetCode: string; + name: string; + }; + logDate: Date; + logTime?: string; + projectId?: string; + location?: string; + fuelType: string; + quantityLiters: number; + unitPrice: number; + totalCost: number; + odometerReading?: number; + hoursReading?: number; + kilometersSinceLast?: number; + hoursSinceLast?: number; + litersPer100km?: number; + litersPerHour?: number; + vendorName?: string; + invoiceNumber?: string; + operatorId?: string; + operatorName?: string; + notes?: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * DTO de respuesta para resumen de consumo + */ +export class FuelConsumptionSummaryDto { + assetId: string; + assetCode: string; + assetName: string; + totalLiters: number; + totalCost: number; + totalKilometers?: number; + totalHours?: number; + averageLitersPer100km?: number; + averageLitersPerHour?: number; + logsCount: number; + periodStart: Date; + periodEnd: Date; +} diff --git a/src/modules/assets/dto/index.ts b/src/modules/assets/dto/index.ts new file mode 100644 index 0000000..a0c64c7 --- /dev/null +++ b/src/modules/assets/dto/index.ts @@ -0,0 +1,47 @@ +/** + * Assets Module DTOs - Barrel Export + * + * @module Assets (MAE-015) + */ + +// Asset DTOs +export { + AssetType, + AssetStatus, + OwnershipType, + DepreciationMethod, + CreateAssetDto, + UpdateAssetDto, + AssetFiltersDto, + AssignAssetDto, + UpdateUsageMetricsDto, + AssetResponseDto, +} from './asset.dto'; + +// Fuel Log DTOs +export { + CreateFuelLogDto, + UpdateFuelLogDto, + FuelLogFiltersDto, + FuelConsumptionReportDto, + FuelLogResponseDto, + FuelConsumptionSummaryDto, +} from './fuel-log.dto'; + +// Work Order DTOs +export { + MaintenanceType, + WorkOrderStatus, + WorkOrderPriority, + CreateWorkOrderDto, + UpdateWorkOrderDto, + WorkOrderFiltersDto, + CreateWorkOrderPartDto, + UpdateWorkOrderPartDto, + CompleteWorkOrderDto, + CancelWorkOrderDto, + WorkOrderResponseDto, + WorkOrderPartResponseDto, + WorkOrderReportFiltersDto, + WorkOrderSummaryDto, +} from './work-order.dto'; diff --git a/src/modules/assets/dto/work-order.dto.ts b/src/modules/assets/dto/work-order.dto.ts new file mode 100644 index 0000000..d03a076 --- /dev/null +++ b/src/modules/assets/dto/work-order.dto.ts @@ -0,0 +1,658 @@ +/** + * WorkOrder DTOs - Data Transfer Objects para Ordenes de Trabajo de Mantenimiento + * + * Registro de mantenimientos preventivos y correctivos. + * + * @module Assets (MAE-015) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + IsPositive, + Min, + Max, + MinLength, + MaxLength, + IsArray, + IsBoolean, +} from 'class-validator'; + +/** + * Tipo de mantenimiento + */ +export enum MaintenanceType { + PREVENTIVE = 'preventive', + CORRECTIVE = 'corrective', + PREDICTIVE = 'predictive', + EMERGENCY = 'emergency', +} + +/** + * Estado de la orden de trabajo + */ +export enum WorkOrderStatus { + DRAFT = 'draft', + SCHEDULED = 'scheduled', + IN_PROGRESS = 'in_progress', + ON_HOLD = 'on_hold', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +/** + * Prioridad de la orden de trabajo + */ +export enum WorkOrderPriority { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +/** + * DTO para crear una nueva orden de trabajo + */ +export class CreateWorkOrderDto { + @IsUUID() + assetId: string; + + @IsEnum(MaintenanceType) + type: MaintenanceType; + + @IsEnum(WorkOrderPriority) + priority: WorkOrderPriority; + + @IsString() + @MinLength(3) + @MaxLength(255) + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + problemReported?: string; + + @IsOptional() + @IsDateString() + scheduledDate?: string; + + @IsOptional() + @IsDateString() + scheduledEndDate?: string; + + @IsOptional() + @IsUUID() + assignedToId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + assignedToName?: string; + + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + teamIds?: string[]; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + projectName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + locationDescription?: string; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedHours?: number; + + @IsOptional() + @IsNumber() + @Min(0) + hoursAtWorkOrder?: number; + + @IsOptional() + @IsNumber() + @Min(0) + kilometersAtWorkOrder?: number; + + @IsOptional() + @IsUUID() + planId?: string; + + @IsOptional() + @IsUUID() + scheduleId?: string; + + @IsOptional() + @IsBoolean() + isScheduled?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar una orden de trabajo + */ +export class UpdateWorkOrderDto { + @IsOptional() + @IsEnum(MaintenanceType) + type?: MaintenanceType; + + @IsOptional() + @IsEnum(WorkOrderPriority) + priority?: WorkOrderPriority; + + @IsOptional() + @IsEnum(WorkOrderStatus) + status?: WorkOrderStatus; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + problemReported?: string; + + @IsOptional() + @IsString() + diagnosis?: string; + + @IsOptional() + @IsDateString() + scheduledStartDate?: string; + + @IsOptional() + @IsDateString() + scheduledEndDate?: string; + + @IsOptional() + @IsDateString() + actualStartDate?: string; + + @IsOptional() + @IsDateString() + actualEndDate?: string; + + @IsOptional() + @IsUUID() + assignedToId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + assignedToName?: string; + + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + teamIds?: string[]; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + projectName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + locationDescription?: string; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedHours?: number; + + @IsOptional() + @IsNumber() + @Min(0) + actualHours?: number; + + @IsOptional() + @IsString() + workPerformed?: string; + + @IsOptional() + @IsString() + findings?: string; + + @IsOptional() + @IsString() + recommendations?: string; + + @IsOptional() + @IsNumber() + @Min(0) + laborCost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + partsCost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + externalServiceCost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + otherCosts?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + photosBefore?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + photosAfter?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + documents?: string[]; + + @IsOptional() + @IsBoolean() + requiresFollowup?: boolean; + + @IsOptional() + @IsString() + followupNotes?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + completionNotes?: string; +} + +/** + * DTO para filtrar ordenes de trabajo + */ +export class WorkOrderFiltersDto { + @IsOptional() + @IsUUID() + assetId?: string; + + @IsOptional() + @IsEnum(MaintenanceType) + type?: MaintenanceType; + + @IsOptional() + @IsEnum(WorkOrderStatus) + status?: WorkOrderStatus; + + @IsOptional() + @IsEnum(WorkOrderPriority) + priority?: WorkOrderPriority; + + @IsOptional() + @IsUUID() + assignedToId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsBoolean() + isScheduled?: boolean; + + @IsOptional() + @IsBoolean() + requiresFollowup?: boolean; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsDateString() + scheduledDateFrom?: string; + + @IsOptional() + @IsDateString() + scheduledDateTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO para crear una parte/refaccion en orden de trabajo + */ +export class CreateWorkOrderPartDto { + @IsUUID() + workOrderId: string; + + @IsOptional() + @IsUUID() + partId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + partCode?: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + partName: string; + + @IsOptional() + @IsString() + partDescription?: string; + + @IsNumber() + @IsPositive() + quantity: number; + + @IsOptional() + @IsNumber() + @Min(0) + unitCost?: number; + + @IsOptional() + @IsBoolean() + fromInventory?: boolean; + + @IsOptional() + @IsUUID() + purchaseOrderId?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar una parte/refaccion en orden de trabajo + */ +export class UpdateWorkOrderPartDto { + @IsOptional() + @IsString() + @MaxLength(50) + partCode?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + partName?: string; + + @IsOptional() + @IsString() + partDescription?: string; + + @IsOptional() + @IsNumber() + @IsPositive() + quantityRequired?: number; + + @IsOptional() + @IsNumber() + @Min(0) + quantityUsed?: number; + + @IsOptional() + @IsNumber() + @Min(0) + unitCost?: number; + + @IsOptional() + @IsBoolean() + fromInventory?: boolean; + + @IsOptional() + @IsUUID() + purchaseOrderId?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para completar una orden de trabajo + */ +export class CompleteWorkOrderDto { + @IsString() + @MinLength(10) + workPerformed: string; + + @IsOptional() + @IsString() + findings?: string; + + @IsOptional() + @IsString() + recommendations?: string; + + @IsOptional() + @IsNumber() + @Min(0) + actualHours?: number; + + @IsOptional() + @IsNumber() + @Min(0) + laborCost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + partsCost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + externalServiceCost?: number; + + @IsOptional() + @IsNumber() + @Min(0) + otherCosts?: number; + + @IsOptional() + @IsString() + @MaxLength(500) + completionSignatureUrl?: string; + + @IsOptional() + @IsString() + completionNotes?: string; + + @IsOptional() + @IsBoolean() + requiresFollowup?: boolean; + + @IsOptional() + @IsString() + followupNotes?: string; +} + +/** + * DTO para cancelar una orden de trabajo + */ +export class CancelWorkOrderDto { + @IsString() + @MinLength(10) + reason: string; +} + +/** + * DTO de respuesta para una orden de trabajo + */ +export class WorkOrderResponseDto { + id: string; + tenantId: string; + workOrderNumber: string; + assetId: string; + asset?: { + id: string; + assetCode: string; + name: string; + assetType: string; + }; + maintenanceType: MaintenanceType; + status: WorkOrderStatus; + priority: WorkOrderPriority; + title: string; + description?: string; + problemReported?: string; + diagnosis?: string; + projectId?: string; + projectName?: string; + locationDescription?: string; + requestedDate: Date; + scheduledStartDate?: Date; + scheduledEndDate?: Date; + actualStartDate?: Date; + actualEndDate?: Date; + assignedToId?: string; + assignedToName?: string; + teamIds?: string[]; + estimatedHours?: number; + actualHours?: number; + laborCost: number; + partsCost: number; + externalServiceCost: number; + otherCosts: number; + totalCost: number; + partsUsedCount: number; + partsUsed?: WorkOrderPartResponseDto[]; + workPerformed?: string; + findings?: string; + recommendations?: string; + requiresFollowup: boolean; + followupNotes?: string; + createdAt: Date; + updatedAt: Date; + createdBy?: string; + completedById?: string; + completedByName?: string; +} + +/** + * DTO de respuesta para una parte/refaccion + */ +export class WorkOrderPartResponseDto { + id: string; + tenantId: string; + workOrderId: string; + partId?: string; + partCode?: string; + partName: string; + partDescription?: string; + quantityRequired: number; + quantityUsed?: number; + unitCost?: number; + totalCost?: number; + fromInventory: boolean; + purchaseOrderId?: string; + notes?: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * DTO para reporte de ordenes de trabajo + */ +export class WorkOrderReportFiltersDto { + @IsOptional() + @IsUUID() + assetId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsDateString() + dateFrom: string; + + @IsDateString() + dateTo: string; + + @IsOptional() + @IsEnum(MaintenanceType) + type?: MaintenanceType; + + @IsOptional() + @IsBoolean() + includeDetails?: boolean; +} + +/** + * DTO de respuesta para resumen de ordenes de trabajo + */ +export class WorkOrderSummaryDto { + totalWorkOrders: number; + byStatus: Record; + byType: Record; + byPriority: Record; + totalLaborCost: number; + totalPartsCost: number; + totalExternalCost: number; + totalCost: number; + averageCompletionTime?: number; + periodStart: Date; + periodEnd: Date; +} diff --git a/src/modules/audit/entities/audit-log.entity.ts b/src/modules/audit/entities/audit-log.entity.ts new file mode 100644 index 0000000..7ff4c9a --- /dev/null +++ b/src/modules/audit/entities/audit-log.entity.ts @@ -0,0 +1,116 @@ +/** + * AuditLog Entity + * General activity tracking with full request context + * Compatible with erp-core audit-log.entity + * + * @module Audit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type AuditAction = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'export'; +export type AuditCategory = 'data' | 'auth' | 'system' | 'config' | 'billing'; +export type AuditStatus = 'success' | 'failure' | 'partial'; + +@Entity({ name: 'audit_logs', schema: 'audit' }) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'user_email', type: 'varchar', length: 255, nullable: true }) + userEmail: string; + + @Column({ name: 'user_name', type: 'varchar', length: 200, nullable: true }) + userName: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Column({ name: 'impersonator_id', type: 'uuid', nullable: true }) + impersonatorId: string; + + @Index() + @Column({ name: 'action', type: 'varchar', length: 50 }) + action: AuditAction; + + @Index() + @Column({ name: 'action_category', type: 'varchar', length: 50, nullable: true }) + actionCategory: AuditCategory; + + @Index() + @Column({ name: 'resource_type', type: 'varchar', length: 100 }) + resourceType: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'resource_name', type: 'varchar', length: 255, nullable: true }) + resourceName: string; + + @Column({ name: 'old_values', type: 'jsonb', nullable: true }) + oldValues: Record; + + @Column({ name: 'new_values', type: 'jsonb', nullable: true }) + newValues: Record; + + @Column({ name: 'changed_fields', type: 'text', array: true, nullable: true }) + changedFields: string[]; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'device_info', type: 'jsonb', default: {} }) + deviceInfo: Record; + + @Column({ name: 'location', type: 'jsonb', default: {} }) + location: Record; + + @Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true }) + requestId: string; + + @Column({ name: 'request_method', type: 'varchar', length: 10, nullable: true }) + requestMethod: string; + + @Column({ name: 'request_path', type: 'text', nullable: true }) + requestPath: string; + + @Column({ name: 'request_params', type: 'jsonb', default: {} }) + requestParams: Record; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'success' }) + status: AuditStatus; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'duration_ms', type: 'int', nullable: true }) + durationMs: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/audit/entities/config-change.entity.ts b/src/modules/audit/entities/config-change.entity.ts new file mode 100644 index 0000000..ecb212d --- /dev/null +++ b/src/modules/audit/entities/config-change.entity.ts @@ -0,0 +1,55 @@ +/** + * ConfigChange Entity + * System configuration change auditing + * Compatible with erp-core config-change.entity + * + * @module Audit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ConfigType = 'tenant_settings' | 'user_settings' | 'system_settings' | 'feature_flags'; + +@Entity({ name: 'config_changes', schema: 'audit' }) +export class ConfigChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Column({ name: 'changed_by', type: 'uuid' }) + changedBy: string; + + @Index() + @Column({ name: 'config_type', type: 'varchar', length: 50 }) + configType: ConfigType; + + @Column({ name: 'config_key', type: 'varchar', length: 100 }) + configKey: string; + + @Column({ name: 'config_path', type: 'text', nullable: true }) + configPath: string; + + @Column({ name: 'old_value', type: 'jsonb', nullable: true }) + oldValue: Record; + + @Column({ name: 'new_value', type: 'jsonb', nullable: true }) + newValue: Record; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Column({ name: 'ticket_id', type: 'varchar', length: 50, nullable: true }) + ticketId: string; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/data-export.entity.ts b/src/modules/audit/entities/data-export.entity.ts new file mode 100644 index 0000000..54f38a8 --- /dev/null +++ b/src/modules/audit/entities/data-export.entity.ts @@ -0,0 +1,88 @@ +/** + * DataExport Entity + * GDPR/reporting data export request management + * Compatible with erp-core data-export.entity + * + * @module Audit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ExportType = 'report' | 'backup' | 'gdpr_request' | 'bulk_export'; +export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json'; +export type ExportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired'; + +@Entity({ name: 'data_exports', schema: 'audit' }) +export class DataExport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'export_type', type: 'varchar', length: 50 }) + exportType: ExportType; + + @Column({ name: 'export_format', type: 'varchar', length: 20, nullable: true }) + exportFormat: ExportFormat; + + @Column({ name: 'entity_types', type: 'text', array: true }) + entityTypes: string[]; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'date_range_start', type: 'timestamptz', nullable: true }) + dateRangeStart: Date; + + @Column({ name: 'date_range_end', type: 'timestamptz', nullable: true }) + dateRangeEnd: Date; + + @Column({ name: 'record_count', type: 'int', nullable: true }) + recordCount: number; + + @Column({ name: 'file_size_bytes', type: 'bigint', nullable: true }) + fileSizeBytes: number; + + @Column({ name: 'file_hash', type: 'varchar', length: 64, nullable: true }) + fileHash: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: ExportStatus; + + @Column({ name: 'download_url', type: 'text', nullable: true }) + downloadUrl: string; + + @Column({ name: 'download_expires_at', type: 'timestamptz', nullable: true }) + downloadExpiresAt: Date; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Index() + @Column({ name: 'requested_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + requestedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; +} diff --git a/src/modules/audit/entities/entity-change.entity.ts b/src/modules/audit/entities/entity-change.entity.ts new file mode 100644 index 0000000..6b73ce6 --- /dev/null +++ b/src/modules/audit/entities/entity-change.entity.ts @@ -0,0 +1,63 @@ +/** + * EntityChange Entity + * Data modification versioning and change history + * Compatible with erp-core entity-change.entity + * + * @module Audit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ChangeType = 'create' | 'update' | 'delete' | 'restore'; + +@Entity({ name: 'entity_changes', schema: 'audit' }) +export class EntityChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100 }) + entityType: string; + + @Index() + @Column({ name: 'entity_id', type: 'uuid' }) + entityId: string; + + @Column({ name: 'entity_name', type: 'varchar', length: 255, nullable: true }) + entityName: string; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'previous_version', type: 'int', nullable: true }) + previousVersion: number; + + @Column({ name: 'data_snapshot', type: 'jsonb' }) + dataSnapshot: Record; + + @Column({ name: 'changes', type: 'jsonb', default: [] }) + changes: Record[]; + + @Index() + @Column({ name: 'changed_by', type: 'uuid', nullable: true }) + changedBy: string; + + @Column({ name: 'change_reason', type: 'text', nullable: true }) + changeReason: string; + + @Column({ name: 'change_type', type: 'varchar', length: 20 }) + changeType: ChangeType; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/index.ts b/src/modules/audit/entities/index.ts new file mode 100644 index 0000000..feda39f --- /dev/null +++ b/src/modules/audit/entities/index.ts @@ -0,0 +1,11 @@ +/** + * Audit Entities - Export + */ + +export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity'; +export { EntityChange, ChangeType } from './entity-change.entity'; +export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity'; +export { SensitiveDataAccess, DataType, AccessType } from './sensitive-data-access.entity'; +export { DataExport, ExportType, ExportFormat, ExportStatus } from './data-export.entity'; +export { PermissionChange, PermissionChangeType, PermissionScope } from './permission-change.entity'; +export { ConfigChange, ConfigType } from './config-change.entity'; diff --git a/src/modules/audit/entities/login-history.entity.ts b/src/modules/audit/entities/login-history.entity.ts new file mode 100644 index 0000000..8fc339e --- /dev/null +++ b/src/modules/audit/entities/login-history.entity.ts @@ -0,0 +1,114 @@ +/** + * LoginHistory Entity + * Authentication event tracking with device, location and risk scoring + * Compatible with erp-core login-history.entity + * + * @module Audit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type LoginStatus = 'success' | 'failed' | 'blocked' | 'mfa_required' | 'mfa_failed'; +export type AuthMethod = 'password' | 'sso' | 'oauth' | 'mfa' | 'magic_link' | 'biometric'; +export type MfaMethod = 'totp' | 'sms' | 'email' | 'push'; + +@Entity({ name: 'login_history', schema: 'audit' }) +export class LoginHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'email', type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ name: 'username', type: 'varchar', length: 100, nullable: true }) + username: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20 }) + status: LoginStatus; + + @Column({ name: 'auth_method', type: 'varchar', length: 30, nullable: true }) + authMethod: AuthMethod; + + @Column({ name: 'oauth_provider', type: 'varchar', length: 30, nullable: true }) + oauthProvider: string; + + @Column({ name: 'mfa_method', type: 'varchar', length: 20, nullable: true }) + mfaMethod: MfaMethod; + + @Column({ name: 'mfa_verified', type: 'boolean', nullable: true }) + mfaVerified: boolean; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'device_fingerprint', type: 'varchar', length: 255, nullable: true }) + deviceFingerprint: string; + + @Column({ name: 'device_type', type: 'varchar', length: 30, nullable: true }) + deviceType: string; + + @Column({ name: 'device_os', type: 'varchar', length: 50, nullable: true }) + deviceOs: string; + + @Column({ name: 'device_browser', type: 'varchar', length: 50, nullable: true }) + deviceBrowser: string; + + @Index() + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'country_code', type: 'varchar', length: 2, nullable: true }) + countryCode: string; + + @Column({ name: 'city', type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ name: 'latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ name: 'longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Column({ name: 'risk_score', type: 'int', nullable: true }) + riskScore: number; + + @Column({ name: 'risk_factors', type: 'jsonb', default: [] }) + riskFactors: string[]; + + @Index() + @Column({ name: 'is_suspicious', type: 'boolean', default: false }) + isSuspicious: boolean; + + @Column({ name: 'is_new_device', type: 'boolean', default: false }) + isNewDevice: boolean; + + @Column({ name: 'is_new_location', type: 'boolean', default: false }) + isNewLocation: boolean; + + @Column({ name: 'failure_reason', type: 'varchar', length: 100, nullable: true }) + failureReason: string; + + @Column({ name: 'failure_count', type: 'int', nullable: true }) + failureCount: number; + + @Index() + @Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + attemptedAt: Date; +} diff --git a/src/modules/audit/entities/permission-change.entity.ts b/src/modules/audit/entities/permission-change.entity.ts new file mode 100644 index 0000000..6f075d3 --- /dev/null +++ b/src/modules/audit/entities/permission-change.entity.ts @@ -0,0 +1,71 @@ +/** + * PermissionChange Entity + * Access control change auditing + * Compatible with erp-core permission-change.entity + * + * @module Audit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type PermissionChangeType = 'role_assigned' | 'role_revoked' | 'permission_granted' | 'permission_revoked'; +export type PermissionScope = 'global' | 'tenant' | 'branch'; + +@Entity({ name: 'permission_changes', schema: 'audit' }) +export class PermissionChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'changed_by', type: 'uuid' }) + changedBy: string; + + @Index() + @Column({ name: 'target_user_id', type: 'uuid' }) + targetUserId: string; + + @Column({ name: 'target_user_email', type: 'varchar', length: 255, nullable: true }) + targetUserEmail: string; + + @Column({ name: 'change_type', type: 'varchar', length: 30 }) + changeType: PermissionChangeType; + + @Column({ name: 'role_id', type: 'uuid', nullable: true }) + roleId: string; + + @Column({ name: 'role_code', type: 'varchar', length: 50, nullable: true }) + roleCode: string; + + @Column({ name: 'permission_id', type: 'uuid', nullable: true }) + permissionId: string; + + @Column({ name: 'permission_code', type: 'varchar', length: 100, nullable: true }) + permissionCode: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Column({ name: 'scope', type: 'varchar', length: 30, nullable: true }) + scope: PermissionScope; + + @Column({ name: 'previous_roles', type: 'text', array: true, nullable: true }) + previousRoles: string[]; + + @Column({ name: 'previous_permissions', type: 'text', array: true, nullable: true }) + previousPermissions: string[]; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/sensitive-data-access.entity.ts b/src/modules/audit/entities/sensitive-data-access.entity.ts new file mode 100644 index 0000000..5dbba5a --- /dev/null +++ b/src/modules/audit/entities/sensitive-data-access.entity.ts @@ -0,0 +1,70 @@ +/** + * SensitiveDataAccess Entity + * Security/compliance logging for PII, financial, medical and credential access + * Compatible with erp-core sensitive-data-access.entity + * + * @module Audit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type DataType = 'pii' | 'financial' | 'medical' | 'credentials'; +export type AccessType = 'view' | 'export' | 'modify' | 'decrypt'; + +@Entity({ name: 'sensitive_data_access', schema: 'audit' }) +export class SensitiveDataAccess { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Index() + @Column({ name: 'data_type', type: 'varchar', length: 100 }) + dataType: DataType; + + @Column({ name: 'data_category', type: 'varchar', length: 100, nullable: true }) + dataCategory: string; + + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'access_type', type: 'varchar', length: 30 }) + accessType: AccessType; + + @Column({ name: 'access_reason', type: 'text', nullable: true }) + accessReason: string; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Index() + @Column({ name: 'was_authorized', type: 'boolean', default: true }) + wasAuthorized: boolean; + + @Column({ name: 'denial_reason', type: 'text', nullable: true }) + denialReason: string; + + @Index() + @Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + accessedAt: Date; +} 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 0000000..fe825a9 --- /dev/null +++ b/src/modules/auth/entities/api-key.entity.ts @@ -0,0 +1,84 @@ +/** + * ApiKey Entity + * Gestión de API Keys para integraciones headless/CI/CD + * Compatible con erp-core api-key.entity + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; + +@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; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 16, nullable: false, name: 'key_index' }) + keyIndex: string; + + @Column({ type: 'varchar', length: 255, nullable: false, name: 'key_hash' }) + keyHash: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + scope: string | null; + + @Column({ type: 'inet', array: true, nullable: true, name: 'allowed_ips' }) + allowedIps: string[] | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'expiration_date' }) + expirationDate: Date | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' }) + lastUsedAt: Date | null; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @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; + + @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/auth/entities/company.entity.ts b/src/modules/auth/entities/company.entity.ts new file mode 100644 index 0000000..22b919c --- /dev/null +++ b/src/modules/auth/entities/company.entity.ts @@ -0,0 +1,79 @@ +/** + * Company Entity + * Soporte multi-empresa dentro de un tenant + * Compatible con erp-core company.entity + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +@Entity({ schema: 'auth', name: 'companies' }) +@Index('idx_companies_tenant_id', ['tenantId']) +@Index('idx_companies_parent_company_id', ['parentCompanyId']) +@Index('idx_companies_active', ['tenantId'], { where: 'deleted_at IS NULL' }) +@Index('idx_companies_tax_id', ['taxId']) +export class Company { + @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: 255, nullable: true, name: 'legal_name' }) + legalName: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true, name: 'tax_id' }) + taxId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'currency_id' }) + currencyId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'parent_company_id' }) + parentCompanyId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'partner_id' }) + partnerId: string | null; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Company, { nullable: true }) + @JoinColumn({ name: 'parent_company_id' }) + parentCompany: Company | null; + + @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/auth/entities/group.entity.ts b/src/modules/auth/entities/group.entity.ts new file mode 100644 index 0000000..e2b6b3c --- /dev/null +++ b/src/modules/auth/entities/group.entity.ts @@ -0,0 +1,75 @@ +/** + * Group Entity + * Agrupación de usuarios para gestión de permisos + * Compatible con erp-core group.entity + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +@Entity({ schema: 'auth', name: 'groups' }) +@Index('idx_groups_tenant_id', ['tenantId']) +@Index('idx_groups_code', ['code']) +@Index('idx_groups_category', ['category']) +@Index('idx_groups_is_system', ['isSystem']) +export class Group { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + code: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' }) + isSystem: boolean; + + @Column({ type: 'varchar', length: 100, nullable: true }) + category: string | null; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string | null; + + @Column({ type: 'integer', default: 30, nullable: true, name: 'api_key_max_duration_days' }) + apiKeyMaxDurationDays: number | null; + + @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @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/auth/entities/index.ts b/src/modules/auth/entities/index.ts index a028313..1dd8bd8 100644 --- a/src/modules/auth/entities/index.ts +++ b/src/modules/auth/entities/index.ts @@ -6,3 +6,8 @@ export { RefreshToken } from './refresh-token.entity'; export { Role } from './role.entity'; export { Permission } from './permission.entity'; export { UserRole } from './user-role.entity'; +export { Session, SessionStatus } from './session.entity'; +export { ApiKey } from './api-key.entity'; +export { PasswordReset } from './password-reset.entity'; +export { Company } from './company.entity'; +export { Group } from './group.entity'; diff --git a/src/modules/auth/entities/password-reset.entity.ts b/src/modules/auth/entities/password-reset.entity.ts new file mode 100644 index 0000000..b8d5ac5 --- /dev/null +++ b/src/modules/auth/entities/password-reset.entity.ts @@ -0,0 +1,49 @@ +/** + * PasswordReset Entity + * Flujo seguro de recuperación de contraseƱa con token temporal + * Compatible con erp-core password-reset.entity + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'auth', name: 'password_resets' }) +@Index('idx_password_resets_user_id', ['userId']) +@Index('idx_password_resets_token', ['token']) +@Index('idx_password_resets_expires_at', ['expiresAt']) +export class PasswordReset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'varchar', length: 500, unique: true, nullable: false }) + token: string; + + @Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) + expiresAt: Date; + + @Column({ type: 'timestamp', nullable: true, name: 'used_at' }) + usedAt: Date | null; + + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/auth/entities/role.entity.ts b/src/modules/auth/entities/role.entity.ts index a69a83a..08f5065 100644 --- a/src/modules/auth/entities/role.entity.ts +++ b/src/modules/auth/entities/role.entity.ts @@ -1,6 +1,7 @@ /** * Role Entity * Roles del sistema para RBAC + * Compatible con erp-core role.entity * * @module Auth */ @@ -13,16 +14,26 @@ import { UpdateDateColumn, OneToMany, ManyToMany, + ManyToOne, + JoinColumn, JoinTable, + Index, } from 'typeorm'; import { Permission } from './permission.entity'; import { UserRole } from './user-role.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; @Entity({ schema: 'auth', name: 'roles' }) +@Index('idx_roles_tenant_id', ['tenantId']) +@Index('idx_roles_code', ['code']) +@Index('idx_roles_is_system', ['isSystem']) export class Role { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', nullable: true, name: 'tenant_id' }) + tenantId: string | null; + @Column({ type: 'varchar', length: 50, unique: true }) code: string; @@ -30,7 +41,7 @@ export class Role { name: string; @Column({ type: 'text', nullable: true }) - description: string; + description: string | null; @Column({ name: 'is_system', type: 'boolean', default: false }) isSystem: boolean; @@ -38,16 +49,18 @@ export class Role { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive: boolean; - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt: Date; + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string | null; // Relations + @ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant | null; + @ManyToMany(() => Permission) @JoinTable({ name: 'role_permissions', + schema: 'auth', joinColumn: { name: 'role_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }, }) @@ -55,4 +68,23 @@ export class Role { @OneToMany(() => UserRole, (userRole) => userRole.role) userRoles: UserRole[]; + + // Audit trail + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ 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/auth/entities/session.entity.ts b/src/modules/auth/entities/session.entity.ts new file mode 100644 index 0000000..346ffdd --- /dev/null +++ b/src/modules/auth/entities/session.entity.ts @@ -0,0 +1,85 @@ +/** + * Session Entity + * Gestión de sesiones de usuario (reemplaza refresh tokens simples) + * Compatible con erp-core session.entity + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; + +export enum SessionStatus { + ACTIVE = 'active', + EXPIRED = 'expired', + REVOKED = 'revoked', +} + +@Entity({ schema: 'auth', name: 'sessions' }) +@Index('idx_sessions_user_id', ['userId']) +@Index('idx_sessions_token', ['token']) +@Index('idx_sessions_status', ['status']) +@Index('idx_sessions_expires_at', ['expiresAt']) +export class Session { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'varchar', length: 500, unique: true, nullable: false }) + token: string; + + @Column({ + type: 'varchar', + length: 500, + unique: true, + nullable: true, + name: 'refresh_token', + }) + refreshToken: string | null; + + @Column({ + type: 'enum', + enum: SessionStatus, + default: SessionStatus.ACTIVE, + nullable: false, + }) + status: SessionStatus; + + @Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) + expiresAt: Date; + + @Column({ type: 'timestamp', nullable: true, name: 'refresh_expires_at' }) + refreshExpiresAt: Date | null; + + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + @Column({ type: 'text', nullable: true, name: 'user_agent' }) + userAgent: string | null; + + @Column({ type: 'jsonb', nullable: true, name: 'device_info' }) + deviceInfo: Record | null; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'timestamp', nullable: true, name: 'revoked_at' }) + revokedAt: Date | null; + + @Column({ type: 'varchar', length: 100, nullable: true, name: 'revoked_reason' }) + revokedReason: string | null; +} diff --git a/src/modules/bidding/controllers/bid-analytics.controller.ts b/src/modules/bidding/controllers/bid-analytics.controller.ts index 05b6c45..1a575df 100644 --- a/src/modules/bidding/controllers/bid-analytics.controller.ts +++ b/src/modules/bidding/controllers/bid-analytics.controller.ts @@ -1,7 +1,7 @@ /** - * BidAnalyticsController - Controller de AnĆ”lisis de Licitaciones + * BidAnalyticsController - Controller de Analisis de Licitaciones * - * Endpoints REST para dashboards y anĆ”lisis de preconstrucción. + * Endpoints REST para dashboards y analisis de preconstruccion. * * @module Bidding */ @@ -11,9 +11,9 @@ import { DataSource } from 'typeorm'; import { BidAnalyticsService } from '../services/bid-analytics.service'; import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; import { AuthService } from '../../auth/services/auth.service'; -import { Bid } from '../entities/bid.entity'; +import { Tender } from '../entities/tender.entity'; import { Opportunity } from '../entities/opportunity.entity'; -import { BidCompetitor } from '../entities/bid-competitor.entity'; +import { Proposal } from '../entities/proposal.entity'; import { User } from '../../core/entities/user.entity'; import { Tenant } from '../../core/entities/tenant.entity'; import { RefreshToken } from '../../auth/entities/refresh-token.entity'; @@ -23,15 +23,15 @@ export function createBidAnalyticsController(dataSource: DataSource): Router { const router = Router(); // Repositorios - const bidRepository = dataSource.getRepository(Bid); + const tenderRepository = dataSource.getRepository(Tender); const opportunityRepository = dataSource.getRepository(Opportunity); - const competitorRepository = dataSource.getRepository(BidCompetitor); + const proposalRepository = dataSource.getRepository(Proposal); const userRepository = dataSource.getRepository(User); const tenantRepository = dataSource.getRepository(Tenant); const refreshTokenRepository = dataSource.getRepository(RefreshToken); // Servicios - const analyticsService = new BidAnalyticsService(bidRepository, opportunityRepository, competitorRepository); + const analyticsService = new BidAnalyticsService(tenderRepository, opportunityRepository, proposalRepository); const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); const authMiddleware = new AuthMiddleware(authService, dataSource); @@ -169,6 +169,80 @@ export function createBidAnalyticsController(dataSource: DataSource): Router { } }); + /** + * GET /bid-analytics/win-rate + */ + router.get('/win-rate', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dateFrom = req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined; + const dateTo = req.query.dateTo ? new Date(req.query.dateTo as string) : undefined; + + const data = await analyticsService.getWinRate(getContext(req), { dateFrom, dateTo }); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/pipeline + */ + router.get('/pipeline', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const data = await analyticsService.getPipelineValue(getContext(req)); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/averages + */ + router.get('/averages', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const data = await analyticsService.getAverages(getContext(req)); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/sources + */ + router.get('/sources', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dateFrom = req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined; + const dateTo = req.query.dateTo ? new Date(req.query.dateTo as string) : undefined; + + const data = await analyticsService.getOpportunitiesBySource(getContext(req), dateFrom, dateTo); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/src/modules/bidding/controllers/bid-budget.controller.ts b/src/modules/bidding/controllers/bid-budget.controller.ts deleted file mode 100644 index 8f51b60..0000000 --- a/src/modules/bidding/controllers/bid-budget.controller.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * BidBudgetController - Controller de Presupuestos de Licitación - * - * Endpoints REST para gestión de propuestas económicas. - * - * @module Bidding - */ - -import { Router, Request, Response, NextFunction } from 'express'; -import { DataSource } from 'typeorm'; -import { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters } from '../services/bid-budget.service'; -import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; -import { AuthService } from '../../auth/services/auth.service'; -import { BidBudget } from '../entities/bid-budget.entity'; -import { User } from '../../core/entities/user.entity'; -import { Tenant } from '../../core/entities/tenant.entity'; -import { RefreshToken } from '../../auth/entities/refresh-token.entity'; -import { ServiceContext } from '../../../shared/services/base.service'; - -export function createBidBudgetController(dataSource: DataSource): Router { - const router = Router(); - - // Repositorios - const budgetRepository = dataSource.getRepository(BidBudget); - const userRepository = dataSource.getRepository(User); - const tenantRepository = dataSource.getRepository(Tenant); - const refreshTokenRepository = dataSource.getRepository(RefreshToken); - - // Servicios - const budgetService = new BidBudgetService(budgetRepository); - const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); - const authMiddleware = new AuthMiddleware(authService, dataSource); - - // Helper para crear contexto - const getContext = (req: Request): ServiceContext => { - if (!req.tenantId) { - throw new Error('Tenant ID is required'); - } - return { - tenantId: req.tenantId, - userId: req.user?.sub, - }; - }; - - /** - * GET /bid-budgets - */ - router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const bidId = req.query.bidId as string; - if (!bidId) { - res.status(400).json({ error: 'Bad Request', message: 'bidId is required' }); - return; - } - - const page = parseInt(req.query.page as string) || 1; - const limit = Math.min(parseInt(req.query.limit as string) || 100, 500); - - const filters: BudgetFilters = { bidId }; - if (req.query.itemType) filters.itemType = req.query.itemType as any; - if (req.query.status) filters.status = req.query.status as any; - if (req.query.parentId !== undefined) { - filters.parentId = req.query.parentId === 'null' ? null : req.query.parentId as string; - } - if (req.query.isSummary !== undefined) filters.isSummary = req.query.isSummary === 'true'; - - const result = await budgetService.findWithFilters(getContext(req), filters, page, limit); - - res.status(200).json({ - success: true, - data: result.data, - pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages }, - }); - } catch (error) { - next(error); - } - }); - - /** - * GET /bid-budgets/tree - */ - router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const bidId = req.query.bidId as string; - if (!bidId) { - res.status(400).json({ error: 'Bad Request', message: 'bidId is required' }); - return; - } - - const tree = await budgetService.getTree(getContext(req), bidId); - res.status(200).json({ success: true, data: tree }); - } catch (error) { - next(error); - } - }); - - /** - * GET /bid-budgets/summary - */ - router.get('/summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const bidId = req.query.bidId as string; - if (!bidId) { - res.status(400).json({ error: 'Bad Request', message: 'bidId is required' }); - return; - } - - const summary = await budgetService.getSummary(getContext(req), bidId); - res.status(200).json({ success: true, data: summary }); - } catch (error) { - next(error); - } - }); - - /** - * GET /bid-budgets/:id - */ - router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const item = await budgetService.findById(getContext(req), req.params.id); - if (!item) { - res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); - return; - } - - res.status(200).json({ success: true, data: item }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bid-budgets - */ - router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const dto: CreateBudgetItemDto = req.body; - if (!dto.bidId || !dto.code || !dto.name || !dto.itemType) { - res.status(400).json({ - error: 'Bad Request', - message: 'bidId, code, name, and itemType are required', - }); - return; - } - - const item = await budgetService.create(getContext(req), dto); - res.status(201).json({ success: true, data: item }); - } catch (error) { - next(error); - } - }); - - /** - * PUT /bid-budgets/:id - */ - router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const dto: UpdateBudgetItemDto = req.body; - const item = await budgetService.update(getContext(req), req.params.id, dto); - - if (!item) { - res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); - return; - } - - res.status(200).json({ success: true, data: item }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bid-budgets/status - */ - router.post('/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const { bidId, status } = req.body; - if (!bidId || !status) { - res.status(400).json({ error: 'Bad Request', message: 'bidId and status are required' }); - return; - } - - const updated = await budgetService.changeStatus(getContext(req), bidId, status); - res.status(200).json({ - success: true, - message: `Updated ${updated} budget items`, - data: { updated }, - }); - } catch (error) { - next(error); - } - }); - - /** - * DELETE /bid-budgets/:id - */ - router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const deleted = await budgetService.softDelete(getContext(req), req.params.id); - if (!deleted) { - res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); - return; - } - - res.status(200).json({ success: true, message: 'Budget item deleted' }); - } catch (error) { - next(error); - } - }); - - return router; -} - -export default createBidBudgetController; diff --git a/src/modules/bidding/controllers/bid.controller.ts b/src/modules/bidding/controllers/bid.controller.ts deleted file mode 100644 index aa9babc..0000000 --- a/src/modules/bidding/controllers/bid.controller.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * BidController - Controller de Licitaciones - * - * Endpoints REST para gestión de licitaciones/propuestas. - * - * @module Bidding - */ - -import { Router, Request, Response, NextFunction } from 'express'; -import { DataSource } from 'typeorm'; -import { BidService, CreateBidDto, UpdateBidDto, BidFilters } from '../services/bid.service'; -import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; -import { AuthService } from '../../auth/services/auth.service'; -import { Bid, BidStatus } from '../entities/bid.entity'; -import { User } from '../../core/entities/user.entity'; -import { Tenant } from '../../core/entities/tenant.entity'; -import { RefreshToken } from '../../auth/entities/refresh-token.entity'; -import { ServiceContext } from '../../../shared/services/base.service'; - -export function createBidController(dataSource: DataSource): Router { - const router = Router(); - - // Repositorios - const bidRepository = dataSource.getRepository(Bid); - const userRepository = dataSource.getRepository(User); - const tenantRepository = dataSource.getRepository(Tenant); - const refreshTokenRepository = dataSource.getRepository(RefreshToken); - - // Servicios - const bidService = new BidService(bidRepository); - const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); - const authMiddleware = new AuthMiddleware(authService, dataSource); - - // Helper para crear contexto - const getContext = (req: Request): ServiceContext => { - if (!req.tenantId) { - throw new Error('Tenant ID is required'); - } - return { - tenantId: req.tenantId, - userId: req.user?.sub, - }; - }; - - /** - * GET /bids - */ - router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const page = parseInt(req.query.page as string) || 1; - const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); - - const filters: BidFilters = {}; - if (req.query.status) { - const statuses = (req.query.status as string).split(',') as BidStatus[]; - filters.status = statuses.length === 1 ? statuses[0] : statuses; - } - if (req.query.bidType) filters.bidType = req.query.bidType as any; - if (req.query.stage) filters.stage = req.query.stage as any; - if (req.query.opportunityId) filters.opportunityId = req.query.opportunityId as string; - if (req.query.bidManagerId) filters.bidManagerId = req.query.bidManagerId as string; - if (req.query.contractingEntity) filters.contractingEntity = req.query.contractingEntity as string; - if (req.query.deadlineFrom) filters.deadlineFrom = new Date(req.query.deadlineFrom as string); - if (req.query.deadlineTo) filters.deadlineTo = new Date(req.query.deadlineTo as string); - if (req.query.minBudget) filters.minBudget = parseFloat(req.query.minBudget as string); - if (req.query.maxBudget) filters.maxBudget = parseFloat(req.query.maxBudget as string); - if (req.query.search) filters.search = req.query.search as string; - - const result = await bidService.findWithFilters(getContext(req), filters, page, limit); - - res.status(200).json({ - success: true, - data: result.data, - pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages }, - }); - } catch (error) { - next(error); - } - }); - - /** - * GET /bids/upcoming-deadlines - */ - router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const days = parseInt(req.query.days as string) || 7; - const bids = await bidService.getUpcomingDeadlines(getContext(req), days); - res.status(200).json({ success: true, data: bids }); - } catch (error) { - next(error); - } - }); - - /** - * GET /bids/stats - */ - router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const year = req.query.year ? parseInt(req.query.year as string) : undefined; - const stats = await bidService.getStats(getContext(req), year); - res.status(200).json({ success: true, data: stats }); - } catch (error) { - next(error); - } - }); - - /** - * GET /bids/:id - */ - router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const bid = await bidService.findById(getContext(req), req.params.id); - if (!bid) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); - return; - } - - res.status(200).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bids - */ - router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const dto: CreateBidDto = req.body; - if (!dto.opportunityId || !dto.code || !dto.name || !dto.bidType || !dto.submissionDeadline) { - res.status(400).json({ - error: 'Bad Request', - message: 'opportunityId, code, name, bidType, and submissionDeadline are required', - }); - return; - } - - const bid = await bidService.create(getContext(req), dto); - res.status(201).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * PUT /bids/:id - */ - router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const dto: UpdateBidDto = req.body; - const bid = await bidService.update(getContext(req), req.params.id, dto); - - if (!bid) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); - return; - } - - res.status(200).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bids/:id/status - */ - router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const { status } = req.body; - if (!status) { - res.status(400).json({ error: 'Bad Request', message: 'status is required' }); - return; - } - - const bid = await bidService.changeStatus(getContext(req), req.params.id, status); - - if (!bid) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); - return; - } - - res.status(200).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bids/:id/stage - */ - router.post('/:id/stage', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const { stage } = req.body; - if (!stage) { - res.status(400).json({ error: 'Bad Request', message: 'stage is required' }); - return; - } - - const bid = await bidService.changeStage(getContext(req), req.params.id, stage); - - if (!bid) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); - return; - } - - res.status(200).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bids/:id/submit - */ - router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const { proposalAmount } = req.body; - if (!proposalAmount) { - res.status(400).json({ error: 'Bad Request', message: 'proposalAmount is required' }); - return; - } - - const bid = await bidService.submit(getContext(req), req.params.id, proposalAmount); - - if (!bid) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); - return; - } - - res.status(200).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bids/:id/result - */ - router.post('/:id/result', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const { won, winnerName, winningAmount, rankingPosition, rejectionReason, lessonsLearned } = req.body; - if (won === undefined) { - res.status(400).json({ error: 'Bad Request', message: 'won is required' }); - return; - } - - const bid = await bidService.recordResult(getContext(req), req.params.id, won, { - winnerName, - winningAmount, - rankingPosition, - rejectionReason, - lessonsLearned, - }); - - if (!bid) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); - return; - } - - res.status(200).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * POST /bids/:id/convert - */ - router.post('/:id/convert', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const { projectId } = req.body; - if (!projectId) { - res.status(400).json({ error: 'Bad Request', message: 'projectId is required' }); - return; - } - - const bid = await bidService.convertToProject(getContext(req), req.params.id, projectId); - - if (!bid) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found or not awarded' }); - return; - } - - res.status(200).json({ success: true, data: bid }); - } catch (error) { - next(error); - } - }); - - /** - * DELETE /bids/:id - */ - router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { - try { - if (!req.tenantId) { - res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); - return; - } - - const deleted = await bidService.softDelete(getContext(req), req.params.id); - if (!deleted) { - res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); - return; - } - - res.status(200).json({ success: true, message: 'Bid deleted' }); - } catch (error) { - next(error); - } - }); - - return router; -} - -export default createBidController; diff --git a/src/modules/bidding/controllers/index.ts b/src/modules/bidding/controllers/index.ts index f3eb096..72dbd64 100644 --- a/src/modules/bidding/controllers/index.ts +++ b/src/modules/bidding/controllers/index.ts @@ -1,9 +1,16 @@ /** * Bidding Controllers Index + * + * Exports all controllers for the MAI-018 Bidding/Preconstruction module. + * * @module Bidding */ +// Core bidding controllers export { createOpportunityController } from './opportunity.controller'; -export { createBidController } from './bid.controller'; -export { createBidBudgetController } from './bid-budget.controller'; export { createBidAnalyticsController } from './bid-analytics.controller'; + +// Tender/Proposal management controllers +export { createTenderController } from './tender.controller'; +export { createProposalController } from './proposal.controller'; +export { createVendorController } from './vendor.controller'; diff --git a/src/modules/bidding/controllers/opportunity.controller.ts b/src/modules/bidding/controllers/opportunity.controller.ts index b27b607..62560fa 100644 --- a/src/modules/bidding/controllers/opportunity.controller.ts +++ b/src/modules/bidding/controllers/opportunity.controller.ts @@ -1,14 +1,14 @@ /** * OpportunityController - Controller de Oportunidades * - * Endpoints REST para gestión del pipeline de oportunidades. + * Endpoints REST para gestion del pipeline de oportunidades. * * @module Bidding */ import { Router, Request, Response, NextFunction } from 'express'; import { DataSource } from 'typeorm'; -import { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from '../services/opportunity.service'; +import { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, GoNoGoDecisionDto } from '../services/opportunity.service'; import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; import { AuthService } from '../../auth/services/auth.service'; import { Opportunity, OpportunityStatus } from '../entities/opportunity.entity'; @@ -52,27 +52,26 @@ export function createOpportunityController(dataSource: DataSource): Router { return; } - const page = parseInt(req.query.page as string) || 1; - const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const filters: OpportunityFilters = { + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; - const filters: OpportunityFilters = {}; if (req.query.status) { const statuses = (req.query.status as string).split(',') as OpportunityStatus[]; filters.status = statuses.length === 1 ? statuses[0] : statuses; } if (req.query.source) filters.source = req.query.source as any; - if (req.query.projectType) filters.projectType = req.query.projectType as any; + if (req.query.projectType) filters.projectType = req.query.projectType as string; if (req.query.priority) filters.priority = req.query.priority as any; - if (req.query.assignedToId) filters.assignedToId = req.query.assignedToId as string; if (req.query.clientName) filters.clientName = req.query.clientName as string; - if (req.query.state) filters.state = req.query.state as string; if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); if (req.query.minValue) filters.minValue = parseFloat(req.query.minValue as string); if (req.query.maxValue) filters.maxValue = parseFloat(req.query.maxValue as string); if (req.query.search) filters.search = req.query.search as string; - const result = await opportunityService.findWithFilters(getContext(req), filters, page, limit); + const result = await opportunityService.findAll(getContext(req), filters); res.status(200).json({ success: true, @@ -121,6 +120,7 @@ export function createOpportunityController(dataSource: DataSource): Router { /** * GET /opportunities/stats + * Get statistics */ router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { try { @@ -137,8 +137,32 @@ export function createOpportunityController(dataSource: DataSource): Router { } }); + /** + * GET /opportunities/code/:code + * Get opportunity by code + */ + router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const opportunity = await opportunityService.findByCode(getContext(req), req.params.code); + if (!opportunity) { + res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' }); + return; + } + + res.status(200).json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + }); + /** * GET /opportunities/:id + * Get opportunity by ID */ router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { try { @@ -170,10 +194,10 @@ export function createOpportunityController(dataSource: DataSource): Router { } const dto: CreateOpportunityDto = req.body; - if (!dto.code || !dto.name || !dto.source || !dto.projectType || !dto.clientName || !dto.identificationDate) { + if (!dto.title || !dto.source || !dto.projectType || !dto.clientName || !dto.deadlineDate) { res.status(400).json({ error: 'Bad Request', - message: 'code, name, source, projectType, clientName, and identificationDate are required', + message: 'title, source, projectType, clientName, and deadlineDate are required', }); return; } @@ -187,6 +211,7 @@ export function createOpportunityController(dataSource: DataSource): Router { /** * PUT /opportunities/:id + * Full update opportunity */ router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { try { @@ -209,8 +234,65 @@ export function createOpportunityController(dataSource: DataSource): Router { } }); + /** + * PATCH /opportunities/:id + * Partial update opportunity + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateOpportunityDto = req.body; + const opportunity = await opportunityService.update(getContext(req), req.params.id, dto); + + if (!opportunity) { + res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' }); + return; + } + + res.status(200).json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + }); + + /** + * POST /opportunities/:id/evaluate + * Evaluate Go/No-Go decision + */ + router.post('/:id/evaluate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { decision, reason } = req.body; + if (!decision || !['go', 'no_go'].includes(decision)) { + res.status(400).json({ error: 'Bad Request', message: 'decision is required and must be go or no_go' }); + return; + } + + if (!reason) { + res.status(400).json({ error: 'Bad Request', message: 'reason is required' }); + return; + } + + const dto: GoNoGoDecisionDto = { decision, reason }; + const opportunity = await opportunityService.evaluateGoNoGo(getContext(req), req.params.id, dto); + + res.status(200).json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + }); + /** * POST /opportunities/:id/status + * Change opportunity status */ router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { try { diff --git a/src/modules/bidding/controllers/proposal.controller.ts b/src/modules/bidding/controllers/proposal.controller.ts new file mode 100644 index 0000000..d159f63 --- /dev/null +++ b/src/modules/bidding/controllers/proposal.controller.ts @@ -0,0 +1,262 @@ +/** + * ProposalController - Controller de Propuestas de Proveedores + * + * Endpoints REST para gestion de propuestas recibidas de proveedores. + * + * @module Bidding + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ProposalService, CreateProposalDto, UpdateProposalDto, ProposalFilters, EvaluateProposalDto } from '../services/proposal.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Proposal, ProposalStatus } from '../entities/proposal.entity'; +import { Tender } from '../entities/tender.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createProposalController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const proposalRepository = dataSource.getRepository(Proposal); + const tenderRepository = dataSource.getRepository(Tender); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const proposalService = new ProposalService(proposalRepository, tenderRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /proposals + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: ProposalFilters = { + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + if (req.query.tenderId) filters.tenderId = req.query.tenderId as string; + if (req.query.vendorId) filters.vendorId = req.query.vendorId as string; + if (req.query.status) { + const statuses = (req.query.status as string).split(',') as ProposalStatus[]; + filters.status = statuses.length === 1 ? statuses[0] : statuses; + } + if (req.query.search) filters.search = req.query.search as string; + + const result = await proposalService.findAll(getContext(req), filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /proposals/compare/:tenderId + */ + router.get('/compare/:tenderId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const comparison = await proposalService.compareProposals(getContext(req), req.params.tenderId); + res.status(200).json({ success: true, data: comparison }); + } catch (error) { + next(error); + } + }); + + /** + * GET /proposals/tender/:tenderId + */ + router.get('/tender/:tenderId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const proposals = await proposalService.findByTender(getContext(req), req.params.tenderId); + res.status(200).json({ success: true, data: proposals }); + } catch (error) { + next(error); + } + }); + + /** + * GET /proposals/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const proposal = await proposalService.findById(getContext(req), req.params.id); + if (!proposal) { + res.status(404).json({ error: 'Not Found', message: 'Proposal not found' }); + return; + } + + res.status(200).json({ success: true, data: proposal }); + } catch (error) { + next(error); + } + }); + + /** + * POST /proposals + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateProposalDto = req.body; + if (!dto.tenderId || !dto.vendorId || !dto.proposedAmount || !dto.proposedScheduleDays) { + res.status(400).json({ + error: 'Bad Request', + message: 'tenderId, vendorId, proposedAmount, and proposedScheduleDays are required', + }); + return; + } + + const proposal = await proposalService.create(getContext(req), dto); + res.status(201).json({ success: true, data: proposal }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /proposals/:id + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateProposalDto = req.body; + const proposal = await proposalService.update(getContext(req), req.params.id, dto); + + if (!proposal) { + res.status(404).json({ error: 'Not Found', message: 'Proposal not found' }); + return; + } + + res.status(200).json({ success: true, data: proposal }); + } catch (error) { + next(error); + } + }); + + /** + * POST /proposals/:id/evaluate + */ + router.post('/:id/evaluate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { technicalScore, economicScore } = req.body; + if (technicalScore === undefined || economicScore === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'technicalScore and economicScore are required' }); + return; + } + + const dto: EvaluateProposalDto = { technicalScore, economicScore }; + const proposal = await proposalService.evaluate(getContext(req), req.params.id, dto); + + res.status(200).json({ success: true, data: proposal }); + } catch (error) { + next(error); + } + }); + + /** + * POST /proposals/:id/disqualify + */ + router.post('/:id/disqualify', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { reason } = req.body; + if (!reason) { + res.status(400).json({ error: 'Bad Request', message: 'reason is required' }); + return; + } + + const proposal = await proposalService.disqualify(getContext(req), req.params.id, reason); + + res.status(200).json({ success: true, data: proposal }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /proposals/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await proposalService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Proposal not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Proposal deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createProposalController; diff --git a/src/modules/bidding/controllers/tender.controller.ts b/src/modules/bidding/controllers/tender.controller.ts new file mode 100644 index 0000000..cbf00d2 --- /dev/null +++ b/src/modules/bidding/controllers/tender.controller.ts @@ -0,0 +1,302 @@ +/** + * TenderController - Controller de Licitaciones + * + * Endpoints REST para gestion de licitaciones. + * + * @module Bidding + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TenderService, CreateTenderDto, UpdateTenderDto, TenderFilters } from '../services/tender.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Tender, TenderStatus, TenderType } from '../entities/tender.entity'; +import { Opportunity } from '../entities/opportunity.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createTenderController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const tenderRepository = dataSource.getRepository(Tender); + const opportunityRepository = dataSource.getRepository(Opportunity); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const tenderService = new TenderService(tenderRepository, opportunityRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /tenders + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: TenderFilters = { + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + if (req.query.opportunityId) filters.opportunityId = req.query.opportunityId as string; + if (req.query.type) filters.type = req.query.type as TenderType; + if (req.query.status) { + const statuses = (req.query.status as string).split(',') as TenderStatus[]; + filters.status = statuses.length === 1 ? statuses[0] : statuses; + } + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + if (req.query.search) filters.search = req.query.search as string; + + const result = await tenderService.findAll(getContext(req), filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /tenders/stats + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const year = req.query.year ? parseInt(req.query.year as string) : undefined; + const stats = await tenderService.getStats(getContext(req), year); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /tenders/number/:number + */ + router.get('/number/:number', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const tender = await tenderService.findByNumber(getContext(req), req.params.number); + if (!tender) { + res.status(404).json({ error: 'Not Found', message: 'Tender not found' }); + return; + } + + res.status(200).json({ success: true, data: tender }); + } catch (error) { + next(error); + } + }); + + /** + * GET /tenders/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const tender = await tenderService.findById(getContext(req), req.params.id); + if (!tender) { + res.status(404).json({ error: 'Not Found', message: 'Tender not found' }); + return; + } + + res.status(200).json({ success: true, data: tender }); + } catch (error) { + next(error); + } + }); + + /** + * POST /tenders + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateTenderDto = req.body; + if (!dto.opportunityId || !dto.title || !dto.proposalDeadline) { + res.status(400).json({ + error: 'Bad Request', + message: 'opportunityId, title, and proposalDeadline are required', + }); + return; + } + + const tender = await tenderService.create(getContext(req), dto); + res.status(201).json({ success: true, data: tender }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /tenders/:id + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateTenderDto = req.body; + const tender = await tenderService.update(getContext(req), req.params.id, dto); + + if (!tender) { + res.status(404).json({ error: 'Not Found', message: 'Tender not found' }); + return; + } + + res.status(200).json({ success: true, data: tender }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /tenders/:id + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateTenderDto = req.body; + const tender = await tenderService.update(getContext(req), req.params.id, dto); + + if (!tender) { + res.status(404).json({ error: 'Not Found', message: 'Tender not found' }); + return; + } + + res.status(200).json({ success: true, data: tender }); + } catch (error) { + next(error); + } + }); + + /** + * POST /tenders/:id/publish + */ + router.post('/:id/publish', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const tender = await tenderService.publish(getContext(req), req.params.id); + res.status(200).json({ success: true, data: tender }); + } catch (error) { + next(error); + } + }); + + /** + * POST /tenders/:id/award + */ + router.post('/:id/award', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { proposalId } = req.body; + if (!proposalId) { + res.status(400).json({ error: 'Bad Request', message: 'proposalId is required' }); + return; + } + + const tender = await tenderService.awardWinner(getContext(req), req.params.id, proposalId); + res.status(200).json({ success: true, data: tender }); + } catch (error) { + next(error); + } + }); + + /** + * POST /tenders/:id/convert + */ + router.post('/:id/convert', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const result = await tenderService.convertToProject(getContext(req), req.params.id); + res.status(200).json({ success: true, data: result }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /tenders/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await tenderService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Tender not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Tender deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createTenderController; diff --git a/src/modules/bidding/controllers/vendor.controller.ts b/src/modules/bidding/controllers/vendor.controller.ts new file mode 100644 index 0000000..50148de --- /dev/null +++ b/src/modules/bidding/controllers/vendor.controller.ts @@ -0,0 +1,259 @@ +/** + * VendorController - Controller de Proveedores + * + * Endpoints REST para gestion de proveedores/contratistas. + * + * @module Bidding + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { VendorService, CreateVendorDto, UpdateVendorDto, VendorFilters } from '../services/vendor.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Vendor } from '../entities/vendor.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createVendorController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const vendorRepository = dataSource.getRepository(Vendor); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const vendorService = new VendorService(vendorRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /vendors + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: VendorFilters = { + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + if (req.query.search) filters.search = req.query.search as string; + if (req.query.specialty) filters.specialty = req.query.specialty as string; + if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true'; + if (req.query.minRating) filters.minRating = parseFloat(req.query.minRating as string); + + const result = await vendorService.findAll(getContext(req), filters); + + res.status(200).json({ + success: true, + data: result.data, + pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /vendors/code/:code + */ + router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const vendor = await vendorService.findByCode(getContext(req), req.params.code); + if (!vendor) { + res.status(404).json({ error: 'Not Found', message: 'Vendor not found' }); + return; + } + + res.status(200).json({ success: true, data: vendor }); + } catch (error) { + next(error); + } + }); + + /** + * GET /vendors/rfc/:rfc + */ + router.get('/rfc/:rfc', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const vendor = await vendorService.findByRfc(getContext(req), req.params.rfc); + if (!vendor) { + res.status(404).json({ error: 'Not Found', message: 'Vendor not found' }); + return; + } + + res.status(200).json({ success: true, data: vendor }); + } catch (error) { + next(error); + } + }); + + /** + * GET /vendors/:id/performance + */ + router.get('/:id/performance', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const performance = await vendorService.getPerformanceHistory(getContext(req), req.params.id); + res.status(200).json({ success: true, data: performance }); + } catch (error) { + next(error); + } + }); + + /** + * GET /vendors/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const vendor = await vendorService.findById(getContext(req), req.params.id); + if (!vendor) { + res.status(404).json({ error: 'Not Found', message: 'Vendor not found' }); + return; + } + + res.status(200).json({ success: true, data: vendor }); + } catch (error) { + next(error); + } + }); + + /** + * POST /vendors + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateVendorDto = req.body; + if (!dto.companyName) { + res.status(400).json({ + error: 'Bad Request', + message: 'companyName is required', + }); + return; + } + + const vendor = await vendorService.create(getContext(req), dto); + res.status(201).json({ success: true, data: vendor }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /vendors/:id + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateVendorDto = req.body; + const vendor = await vendorService.update(getContext(req), req.params.id, dto); + + if (!vendor) { + res.status(404).json({ error: 'Not Found', message: 'Vendor not found' }); + return; + } + + res.status(200).json({ success: true, data: vendor }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /vendors/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await vendorService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Vendor not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Vendor deleted' }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /vendors/:id/rating + */ + router.patch('/:id/rating', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { rating } = req.body; + if (rating === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'rating is required' }); + return; + } + + const vendor = await vendorService.updateRating(getContext(req), req.params.id, rating); + + res.status(200).json({ success: true, data: vendor }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createVendorController; diff --git a/src/modules/bidding/dto/bid-calendar.dto.ts b/src/modules/bidding/dto/bid-calendar.dto.ts new file mode 100644 index 0000000..fcb3a0a --- /dev/null +++ b/src/modules/bidding/dto/bid-calendar.dto.ts @@ -0,0 +1,200 @@ +/** + * BidCalendar DTOs - Data Transfer Objects para Calendario de Licitacion + * + * Eventos y fechas importantes del proceso de licitacion. + * + * @module Bidding (MAI-018) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + IsBoolean, + MinLength, + MaxLength, + Min, + Max, +} from 'class-validator'; + +/** + * Tipo de evento en el calendario + */ +export enum CalendarEventType { + SITE_VISIT = 'site_visit', + CLARIFICATION_MEETING = 'clarification_meeting', + SUBMISSION_DEADLINE = 'submission_deadline', + TECHNICAL_OPENING = 'technical_opening', + ECONOMIC_OPENING = 'economic_opening', + AWARD_DATE = 'award_date', + OTHER = 'other', +} + +/** + * DTO para crear un nuevo evento de calendario + */ +export class CreateBidCalendarDto { + @IsUUID() + tenderId: string; + + @IsEnum(CalendarEventType) + eventType: CalendarEventType; + + @IsDateString() + eventDate: string; + + @IsString() + @MinLength(5) + @MaxLength(255) + description: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(30) + alertDaysBefore?: number; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar un evento de calendario existente + */ +export class UpdateBidCalendarDto { + @IsOptional() + @IsEnum(CalendarEventType) + eventType?: CalendarEventType; + + @IsOptional() + @IsDateString() + eventDate?: string; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(255) + description?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(30) + alertDaysBefore?: number; + + @IsOptional() + @IsBoolean() + alertSent?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para marcar alerta como enviada + */ +export class MarkAlertSentDto { + @IsOptional() + @IsBoolean() + alertSent?: boolean; +} + +/** + * DTO para filtrar eventos de calendario + */ +export class BidCalendarFiltersDto { + @IsOptional() + @IsUUID() + tenderId?: string; + + @IsOptional() + @IsEnum(CalendarEventType) + eventType?: CalendarEventType; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsBoolean() + alertSent?: boolean; + + @IsOptional() + @IsBoolean() + pendingAlerts?: boolean; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un evento de calendario + */ +export class BidCalendarResponseDto { + id: string; + tenantId: string; + tenderId: string; + tender?: { + id: string; + number: string; + title: string; + }; + eventType: CalendarEventType; + eventDate: Date; + description: string; + alertDaysBefore: number; + alertSent: boolean; + notes?: string; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} + +/** + * DTO para obtener eventos proximos que requieren alerta + */ +export class UpcomingEventsAlertDto { + @IsOptional() + @IsNumber() + @Min(1) + @Max(30) + daysAhead?: number; + + @IsOptional() + @IsEnum(CalendarEventType) + eventType?: CalendarEventType; +} diff --git a/src/modules/bidding/dto/bid-document.dto.ts b/src/modules/bidding/dto/bid-document.dto.ts new file mode 100644 index 0000000..56cdf8c --- /dev/null +++ b/src/modules/bidding/dto/bid-document.dto.ts @@ -0,0 +1,208 @@ +/** + * BidDocument DTOs - Data Transfer Objects para Documentos de Licitacion + * + * Almacena documentos asociados a una licitacion. + * + * @module Bidding (MAI-018) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MinLength, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Tipo/categoria de documento + */ +export enum BidDocumentType { + BASES = 'bases', + TECHNICAL_ANNEX = 'technical_annex', + ECONOMIC_ANNEX = 'economic_annex', + CLARIFICATION = 'clarification', + PROPOSAL_TECH = 'proposal_tech', + PROPOSAL_ECON = 'proposal_econ', + CONTRACT = 'contract', + OTHER = 'other', +} + +/** + * DTO para crear un nuevo documento + */ +export class CreateBidDocumentDto { + @IsUUID() + tenderId: string; + + @IsEnum(BidDocumentType) + documentType: BidDocumentType; + + @IsString() + @MinLength(3) + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsString() + @MaxLength(500) + fileUrl: string; + + @IsNumber() + @Min(1) + fileSize: number; + + @IsString() + @MaxLength(100) + mimeType: string; + + @IsOptional() + @IsNumber() + @Min(1) + version?: number; + + @IsOptional() + @IsDateString() + uploadedAt?: string; +} + +/** + * DTO para actualizar un documento existente + */ +export class UpdateBidDocumentDto { + @IsOptional() + @IsEnum(BidDocumentType) + documentType?: BidDocumentType; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsNumber() + @Min(1) + version?: number; +} + +/** + * DTO para subir nueva version de documento + */ +export class UploadNewVersionDto { + @IsString() + @MaxLength(500) + fileUrl: string; + + @IsNumber() + @Min(1) + fileSize: number; + + @IsString() + @MaxLength(100) + mimeType: string; + + @IsOptional() + @IsString() + description?: string; +} + +/** + * DTO para filtrar documentos + */ +export class BidDocumentFiltersDto { + @IsOptional() + @IsUUID() + tenderId?: string; + + @IsOptional() + @IsEnum(BidDocumentType) + documentType?: BidDocumentType; + + @IsOptional() + @IsUUID() + uploadedById?: string; + + @IsOptional() + @IsDateString() + uploadedFrom?: string; + + @IsOptional() + @IsDateString() + uploadedTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un documento + */ +export class BidDocumentResponseDto { + id: string; + tenantId: string; + tenderId: string; + tender?: { + id: string; + number: string; + title: string; + }; + documentType: BidDocumentType; + name: string; + description?: string; + fileUrl: string; + fileSize: string; + mimeType: string; + version: number; + uploadedById: string; + uploadedBy?: { + id: string; + firstName: string; + lastName: string; + }; + uploadedAt: Date; + createdAt: Date; + createdById?: string; + updatedAt: Date; + updatedById?: string; +} + +/** + * DTO para resumen de documentos por tipo + */ +export class DocumentSummaryByTypeDto { + documentType: BidDocumentType; + count: number; + totalSize: number; + latestVersion: number; +} diff --git a/src/modules/bidding/dto/index.ts b/src/modules/bidding/dto/index.ts new file mode 100644 index 0000000..363717f --- /dev/null +++ b/src/modules/bidding/dto/index.ts @@ -0,0 +1,101 @@ +/** + * Bidding DTOs Index + * Barrel file exporting all bidding module DTOs and Enums. + * + * @module Bidding (MAI-018) + */ + +// ============================================================================ +// OPPORTUNITY DTOs +// ============================================================================ +export { + // Enums + OpportunitySource, + OpportunityStatus, + Priority, + // DTOs + CreateOpportunityDto, + UpdateOpportunityDto, + EvaluateOpportunityDto, + OpportunityFiltersDto, + OpportunityResponseDto, +} from './opportunity.dto'; + +// ============================================================================ +// TENDER DTOs +// ============================================================================ +export { + // Enums + TenderType, + TenderStatus, + // DTOs + CreateTenderDto, + UpdateTenderDto, + PublishTenderDto, + AwardTenderDto, + CancelTenderDto, + TenderFiltersDto, + TenderResponseDto, +} from './tender.dto'; + +// ============================================================================ +// PROPOSAL DTOs +// ============================================================================ +export { + // Enums + ProposalStatus, + // DTOs + CreateProposalDto, + UpdateProposalDto, + EvaluateProposalDto, + DisqualifyProposalDto, + ProposalFiltersDto, + ProposalResponseDto, +} from './proposal.dto'; + +// ============================================================================ +// VENDOR DTOs +// ============================================================================ +export { + // Supporting DTOs + VendorCertificationDto, + VendorPerformanceEntryDto, + // DTOs + CreateVendorDto, + UpdateVendorDto, + UpdateRatingDto, + AddPerformanceEntryDto, + AddCertificationDto, + VendorFiltersDto, + VendorResponseDto, +} from './vendor.dto'; + +// ============================================================================ +// BID CALENDAR DTOs +// ============================================================================ +export { + // Enums + CalendarEventType, + // DTOs + CreateBidCalendarDto, + UpdateBidCalendarDto, + MarkAlertSentDto, + BidCalendarFiltersDto, + BidCalendarResponseDto, + UpcomingEventsAlertDto, +} from './bid-calendar.dto'; + +// ============================================================================ +// BID DOCUMENT DTOs +// ============================================================================ +export { + // Enums + BidDocumentType, + // DTOs + CreateBidDocumentDto, + UpdateBidDocumentDto, + UploadNewVersionDto, + BidDocumentFiltersDto, + BidDocumentResponseDto, + DocumentSummaryByTypeDto, +} from './bid-document.dto'; diff --git a/src/modules/bidding/dto/opportunity.dto.ts b/src/modules/bidding/dto/opportunity.dto.ts new file mode 100644 index 0000000..54972b6 --- /dev/null +++ b/src/modules/bidding/dto/opportunity.dto.ts @@ -0,0 +1,266 @@ +/** + * Opportunity DTOs - Data Transfer Objects para Oportunidades de Licitacion + * + * Representa oportunidades de licitacion/proyecto en el pipeline comercial. + * + * @module Bidding (MAI-018) + */ + +import { + IsString, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MinLength, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Fuente de la oportunidad + */ +export enum OpportunitySource { + GOVERNMENT_PORTAL = 'government_portal', + PRIVATE_CLIENT = 'private_client', + REFERRAL = 'referral', + OTHER = 'other', +} + +/** + * Estado de la oportunidad en el pipeline + */ +export enum OpportunityStatus { + REGISTERED = 'registered', + EVALUATING = 'evaluating', + GO = 'go', + NO_GO = 'no_go', + PREPARING = 'preparing', + CONVERTED = 'converted', +} + +/** + * Prioridad de la oportunidad + */ +export enum Priority { + HIGH = 'high', + MEDIUM = 'medium', + LOW = 'low', +} + +/** + * DTO para crear una nueva oportunidad + */ +export class CreateOpportunityDto { + @IsString() + @MinLength(3) + @MaxLength(50) + code: string; + + @IsString() + @MinLength(5) + @MaxLength(255) + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(OpportunitySource) + source: OpportunitySource; + + @IsString() + @MaxLength(255) + clientName: string; + + @IsString() + @MaxLength(100) + projectType: string; + + @IsOptional() + @IsString() + @MaxLength(255) + location?: string; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedUnits?: number; + + @IsOptional() + @IsEnum(Priority) + priority?: Priority; + + @IsDateString() + deadlineDate: string; +} + +/** + * DTO para actualizar una oportunidad existente + */ +export class UpdateOpportunityDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(OpportunitySource) + source?: OpportunitySource; + + @IsOptional() + @IsString() + @MaxLength(255) + clientName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + projectType?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + location?: string; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + estimatedUnits?: number; + + @IsOptional() + @IsEnum(Priority) + priority?: Priority; + + @IsOptional() + @IsDateString() + deadlineDate?: string; +} + +/** + * DTO para evaluar una oportunidad (Go/No-Go decision) + */ +export class EvaluateOpportunityDto { + @IsEnum(['go', 'no_go']) + decision: 'go' | 'no_go'; + + @IsString() + @MinLength(10) + reason: string; +} + +/** + * DTO para filtrar oportunidades en listados + */ +export class OpportunityFiltersDto { + @IsOptional() + @IsEnum(OpportunitySource) + source?: OpportunitySource; + + @IsOptional() + @IsEnum(OpportunityStatus) + status?: OpportunityStatus; + + @IsOptional() + @IsEnum(Priority) + priority?: Priority; + + @IsOptional() + @IsString() + clientName?: string; + + @IsOptional() + @IsString() + projectType?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsDateString() + deadlineFrom?: string; + + @IsOptional() + @IsDateString() + deadlineTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una oportunidad + */ +export class OpportunityResponseDto { + id: string; + tenantId: string; + code: string; + title: string; + description?: string; + source: OpportunitySource; + clientName: string; + projectType: string; + location?: string; + estimatedAmount?: string; + estimatedUnits?: number; + status: OpportunityStatus; + goDecisionDate?: Date; + goDecisionReason?: string; + priority: Priority; + deadlineDate: Date; + tendersCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/bidding/dto/proposal.dto.ts b/src/modules/bidding/dto/proposal.dto.ts new file mode 100644 index 0000000..2b82c43 --- /dev/null +++ b/src/modules/bidding/dto/proposal.dto.ts @@ -0,0 +1,210 @@ +/** + * Proposal DTOs - Data Transfer Objects para Propuestas Enviadas + * + * Representa una propuesta enviada a una licitacion. + * + * @module Bidding (MAI-018) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MaxLength, + Min, + Max, + MinLength, +} from 'class-validator'; + +/** + * Estado de la propuesta + */ +export enum ProposalStatus { + RECEIVED = 'received', + EVALUATING = 'evaluating', + QUALIFIED = 'qualified', + DISQUALIFIED = 'disqualified', + WINNER = 'winner', +} + +/** + * DTO para crear una nueva propuesta + */ +export class CreateProposalDto { + @IsUUID() + tenderId: string; + + @IsUUID() + vendorId: string; + + @IsNumber() + @Min(0) + proposedAmount: number; + + @IsNumber() + @Min(1) + proposedScheduleDays: number; + + @IsOptional() + @IsString() + @MaxLength(500) + technicalProposalUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + economicProposalUrl?: string; + + @IsOptional() + @IsDateString() + submittedAt?: string; +} + +/** + * DTO para actualizar una propuesta existente + */ +export class UpdateProposalDto { + @IsOptional() + @IsNumber() + @Min(0) + proposedAmount?: number; + + @IsOptional() + @IsNumber() + @Min(1) + proposedScheduleDays?: number; + + @IsOptional() + @IsString() + @MaxLength(500) + technicalProposalUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + economicProposalUrl?: string; +} + +/** + * DTO para evaluar una propuesta (asignar puntajes) + */ +export class EvaluateProposalDto { + @IsNumber() + @Min(0) + @Max(100) + technicalScore: number; + + @IsNumber() + @Min(0) + @Max(100) + economicScore: number; +} + +/** + * DTO para descalificar una propuesta + */ +export class DisqualifyProposalDto { + @IsString() + @MinLength(10) + reason: string; +} + +/** + * DTO para filtrar propuestas en listados + */ +export class ProposalFiltersDto { + @IsOptional() + @IsUUID() + tenderId?: string; + + @IsOptional() + @IsUUID() + vendorId?: string; + + @IsOptional() + @IsEnum(ProposalStatus) + status?: ProposalStatus; + + @IsOptional() + @IsDateString() + submittedFrom?: string; + + @IsOptional() + @IsDateString() + submittedTo?: string; + + @IsOptional() + @IsNumber() + @Min(0) + minAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxAmount?: number; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una propuesta + */ +export class ProposalResponseDto { + id: string; + tenantId: string; + tenderId: string; + tender?: { + id: string; + number: string; + title: string; + referenceAmount?: string; + }; + vendorId: string; + vendor?: { + id: string; + code: string; + businessName: string; + rating?: number; + }; + proposedAmount: string; + proposedScheduleDays: number; + technicalProposalUrl?: string; + economicProposalUrl?: string; + technicalScore?: number; + economicScore?: number; + totalScore?: number; + status: ProposalStatus; + submittedAt: Date; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/bidding/dto/tender.dto.ts b/src/modules/bidding/dto/tender.dto.ts new file mode 100644 index 0000000..2463089 --- /dev/null +++ b/src/modules/bidding/dto/tender.dto.ts @@ -0,0 +1,279 @@ +/** + * Tender DTOs - Data Transfer Objects para Licitaciones Formales + * + * Representa una licitacion formal vinculada a una oportunidad. + * + * @module Bidding (MAI-018) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MinLength, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Tipo de licitacion + */ +export enum TenderType { + PUBLIC = 'public', + PRIVATE = 'private', + INVITATION_ONLY = 'invitation_only', +} + +/** + * Estado de la licitacion + */ +export enum TenderStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + RECEIVING = 'receiving', + EVALUATING = 'evaluating', + AWARDED = 'awarded', + CANCELLED = 'cancelled', + CONVERTING = 'converting', + CONVERTED = 'converted', +} + +/** + * DTO para crear una nueva licitacion + */ +export class CreateTenderDto { + @IsUUID() + opportunityId: string; + + @IsEnum(TenderType) + type: TenderType; + + @IsString() + @MinLength(5) + @MaxLength(255) + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsNumber() + @Min(0) + referenceAmount?: number; + + @IsOptional() + @IsDateString() + publicationDate?: string; + + @IsOptional() + @IsDateString() + clarificationMeetingDate?: string; + + @IsDateString() + proposalDeadline: string; + + @IsOptional() + @IsDateString() + awardDate?: string; + + @IsOptional() + @IsNumber() + @Min(1) + contractDurationDays?: number; +} + +/** + * DTO para actualizar una licitacion existente + */ +export class UpdateTenderDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(50) + number?: string; + + @IsOptional() + @IsEnum(TenderType) + type?: TenderType; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsNumber() + @Min(0) + referenceAmount?: number; + + @IsOptional() + @IsDateString() + publicationDate?: string; + + @IsOptional() + @IsDateString() + clarificationMeetingDate?: string; + + @IsOptional() + @IsDateString() + proposalDeadline?: string; + + @IsOptional() + @IsDateString() + awardDate?: string; + + @IsOptional() + @IsNumber() + @Min(1) + contractDurationDays?: number; + + @IsOptional() + @IsEnum(TenderStatus) + status?: TenderStatus; +} + +/** + * DTO para publicar una licitacion + */ +export class PublishTenderDto { + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsDateString() + publicationDate?: string; +} + +/** + * DTO para adjudicar una licitacion a una propuesta ganadora + */ +export class AwardTenderDto { + @IsUUID() + proposalId: string; + + @IsOptional() + @IsDateString() + awardDate?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para cancelar una licitacion + */ +export class CancelTenderDto { + @IsString() + @MinLength(10) + reason: string; +} + +/** + * DTO para filtrar licitaciones en listados + */ +export class TenderFiltersDto { + @IsOptional() + @IsUUID() + opportunityId?: string; + + @IsOptional() + @IsEnum(TenderType) + type?: TenderType; + + @IsOptional() + @IsEnum(TenderStatus) + status?: TenderStatus; + + @IsOptional() + @IsDateString() + proposalDeadlineFrom?: string; + + @IsOptional() + @IsDateString() + proposalDeadlineTo?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una licitacion + */ +export class TenderResponseDto { + id: string; + tenantId: string; + opportunityId: string; + opportunity?: { + id: string; + code: string; + title: string; + clientName: string; + }; + number: string; + type: TenderType; + title: string; + description?: string; + referenceAmount?: string; + publicationDate?: Date; + clarificationMeetingDate?: Date; + proposalDeadline: Date; + awardDate?: Date; + contractDurationDays?: number; + status: TenderStatus; + winnerId?: string; + winner?: { + id: string; + vendorId: string; + proposedAmount: string; + }; + proposalsCount?: number; + documentsCount?: number; + calendarEventsCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/bidding/dto/vendor.dto.ts b/src/modules/bidding/dto/vendor.dto.ts new file mode 100644 index 0000000..077742f --- /dev/null +++ b/src/modules/bidding/dto/vendor.dto.ts @@ -0,0 +1,347 @@ +/** + * Vendor DTOs - Data Transfer Objects para Proveedores/Contratistas + * + * Representa proveedores y contratistas que participan en licitaciones. + * + * @module Bidding (MAI-018) + */ + +import { + IsString, + IsOptional, + IsEnum, + IsNumber, + IsBoolean, + IsArray, + IsEmail, + ValidateNested, + MinLength, + MaxLength, + Min, + Max, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Interface para certificaciones del proveedor + */ +export class VendorCertificationDto { + @IsString() + @MaxLength(255) + name: string; + + @IsString() + @MaxLength(255) + issuedBy: string; + + @IsString() + issuedDate: string; + + @IsOptional() + @IsString() + expiryDate?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + documentUrl?: string; +} + +/** + * Interface para historial de desempeno del proveedor + */ +export class VendorPerformanceEntryDto { + @IsString() + @MaxLength(255) + projectName: string; + + @IsString() + @MaxLength(255) + clientName: string; + + @IsNumber() + @Min(0) + contractAmount: number; + + @IsString() + completedDate: string; + + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para crear un nuevo proveedor/contratista + */ +export class CreateVendorDto { + @IsString() + @MinLength(3) + @MaxLength(20) + code: string; + + @IsString() + @MinLength(3) + @MaxLength(255) + businessName: string; + + @IsOptional() + @IsString() + @MaxLength(13) + @Matches(/^[A-Z&]{3,4}[0-9]{6}[A-Z0-9]{3}$/, { + message: 'RFC debe tener formato valido (ej: XAXX010101000)', + }) + rfc?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + specialties?: string[]; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + rating?: number; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VendorCertificationDto) + certifications?: VendorCertificationDto[]; + + @IsOptional() + @IsString() + @MaxLength(255) + contactName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + contactEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + contactPhone?: string; +} + +/** + * DTO para actualizar un proveedor existente + */ +export class UpdateVendorDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(20) + code?: string; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(255) + businessName?: string; + + @IsOptional() + @IsString() + @MaxLength(13) + @Matches(/^[A-Z&]{3,4}[0-9]{6}[A-Z0-9]{3}$/, { + message: 'RFC debe tener formato valido (ej: XAXX010101000)', + }) + rfc?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + specialties?: string[]; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + rating?: number; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VendorCertificationDto) + certifications?: VendorCertificationDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VendorPerformanceEntryDto) + performanceHistory?: VendorPerformanceEntryDto[]; + + @IsOptional() + @IsBoolean() + documentationValid?: boolean; + + @IsOptional() + @IsString() + @MaxLength(255) + contactName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + contactEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + contactPhone?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +/** + * DTO para actualizar la calificacion de un proveedor + */ +export class UpdateRatingDto { + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para agregar entrada de desempeno + */ +export class AddPerformanceEntryDto { + @IsString() + @MaxLength(255) + projectName: string; + + @IsString() + @MaxLength(255) + clientName: string; + + @IsNumber() + @Min(0) + contractAmount: number; + + @IsString() + completedDate: string; + + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para agregar certificacion + */ +export class AddCertificationDto { + @IsString() + @MaxLength(255) + name: string; + + @IsString() + @MaxLength(255) + issuedBy: string; + + @IsString() + issuedDate: string; + + @IsOptional() + @IsString() + expiryDate?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + documentUrl?: string; +} + +/** + * DTO para filtrar proveedores en listados + */ +export class VendorFiltersDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + specialty?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + documentationValid?: boolean; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + minRating?: number; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un proveedor + */ +export class VendorResponseDto { + id: string; + tenantId: string; + code: string; + businessName: string; + rfc?: string; + specialties?: string[]; + rating?: number; + certifications?: VendorCertificationDto[]; + performanceHistory?: VendorPerformanceEntryDto[]; + documentationValid: boolean; + contactName?: string; + contactEmail?: string; + contactPhone?: string; + isActive: boolean; + proposalsCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/bidding/entities/bid-budget.entity.ts b/src/modules/bidding/entities/bid-budget.entity.ts deleted file mode 100644 index 86e2675..0000000 --- a/src/modules/bidding/entities/bid-budget.entity.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * BidBudget Entity - Presupuesto de Licitación - * - * Desglose del presupuesto para la propuesta económica. - * - * @module Bidding - */ - -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { Bid } from './bid.entity'; - -export type BudgetItemType = - | 'direct_cost' - | 'indirect_cost' - | 'labor' - | 'materials' - | 'equipment' - | 'subcontract' - | 'overhead' - | 'profit' - | 'contingency' - | 'financing' - | 'taxes' - | 'bonds' - | 'other'; - -export type BudgetStatus = 'draft' | 'calculated' | 'reviewed' | 'approved' | 'locked'; - -@Entity('bid_budget', { schema: 'bidding' }) -@Index(['tenantId', 'bidId']) -@Index(['tenantId', 'itemType']) -export class BidBudget { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId!: string; - - // Referencia a licitación - @Column({ name: 'bid_id', type: 'uuid' }) - bidId!: string; - - @ManyToOne(() => Bid, (bid) => bid.budgetItems) - @JoinColumn({ name: 'bid_id' }) - bid?: Bid; - - // JerarquĆ­a - @Column({ name: 'parent_id', type: 'uuid', nullable: true }) - parentId?: string; - - @Column({ name: 'sort_order', type: 'int', default: 0 }) - sortOrder!: number; - - @Column({ type: 'int', default: 0 }) - level!: number; - - @Column({ length: 50 }) - code!: string; - - // Información del item - @Column({ length: 255 }) - name!: string; - - @Column({ type: 'text', nullable: true }) - description?: string; - - @Column({ - name: 'item_type', - type: 'enum', - enum: ['direct_cost', 'indirect_cost', 'labor', 'materials', 'equipment', 'subcontract', 'overhead', 'profit', 'contingency', 'financing', 'taxes', 'bonds', 'other'], - enumName: 'bid_budget_item_type', - }) - itemType!: BudgetItemType; - - @Column({ - type: 'enum', - enum: ['draft', 'calculated', 'reviewed', 'approved', 'locked'], - enumName: 'bid_budget_status', - default: 'draft', - }) - status!: BudgetStatus; - - // Unidad y cantidad - @Column({ length: 20, nullable: true }) - unit?: string; - - @Column({ - type: 'decimal', - precision: 18, - scale: 4, - default: 0, - }) - quantity!: number; - - // Precios - @Column({ - name: 'unit_price', - type: 'decimal', - precision: 18, - scale: 4, - default: 0, - }) - unitPrice!: number; - - @Column({ - name: 'total_amount', - type: 'decimal', - precision: 18, - scale: 2, - default: 0, - }) - totalAmount!: number; - - // Desglose de costos directos - @Column({ - name: 'materials_cost', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - materialsCost?: number; - - @Column({ - name: 'labor_cost', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - laborCost?: number; - - @Column({ - name: 'equipment_cost', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - equipmentCost?: number; - - @Column({ - name: 'subcontract_cost', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - subcontractCost?: number; - - // Porcentajes - @Column({ - name: 'indirect_percentage', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - indirectPercentage?: number; - - @Column({ - name: 'profit_percentage', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - profitPercentage?: number; - - @Column({ - name: 'financing_percentage', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - financingPercentage?: number; - - // Comparación con base de licitación - @Column({ - name: 'base_amount', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - baseAmount?: number; - - @Column({ - name: 'variance_amount', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - varianceAmount?: number; - - @Column({ - name: 'variance_percentage', - type: 'decimal', - precision: 8, - scale: 2, - nullable: true, - }) - variancePercentage?: number; - - // Flags - @Column({ name: 'is_summary', type: 'boolean', default: false }) - isSummary!: boolean; - - @Column({ name: 'is_calculated', type: 'boolean', default: false }) - isCalculated!: boolean; - - @Column({ name: 'is_adjusted', type: 'boolean', default: false }) - isAdjusted!: boolean; - - @Column({ name: 'adjustment_reason', type: 'text', nullable: true }) - adjustmentReason?: string; - - // Referencia a concepto de catĆ”logo - @Column({ name: 'catalog_concept_id', type: 'uuid', nullable: true }) - catalogConceptId?: string; - - // Notas y metadatos - @Column({ type: 'text', nullable: true }) - notes?: string; - - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; - - // AuditorĆ­a - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; - - @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt?: Date; -} diff --git a/src/modules/bidding/entities/bid-calendar.entity.ts b/src/modules/bidding/entities/bid-calendar.entity.ts index 0b0a025..c291dce 100644 --- a/src/modules/bidding/entities/bid-calendar.entity.ts +++ b/src/modules/bidding/entities/bid-calendar.entity.ts @@ -1,9 +1,10 @@ /** - * BidCalendar Entity - Calendario de Licitación - * + * BidCalendar Entity - Fechas Clave de Licitación * Eventos y fechas importantes del proceso de licitación. * - * @module Bidding + * @module Bidding (MAI-018) + * @table bidding.bid_calendar + * @ddl schemas/XX-bidding-schema-ddl.sql */ import { @@ -16,173 +17,94 @@ import { JoinColumn, Index, } from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; import { User } from '../../core/entities/user.entity'; -import { Bid } from './bid.entity'; +import { Tender } from './tender.entity'; +/** Type of calendar event */ export type CalendarEventType = - | 'publication' | 'site_visit' | 'clarification_meeting' - | 'clarification_deadline' | 'submission_deadline' - | 'opening' - | 'technical_evaluation' - | 'economic_evaluation' - | 'award_notification' - | 'contract_signing' - | 'kick_off' - | 'milestone' - | 'internal_review' - | 'team_meeting' - | 'reminder' + | 'technical_opening' + | 'economic_opening' + | 'award_date' | 'other'; -export type EventPriority = 'low' | 'medium' | 'high' | 'critical'; - -export type EventStatus = 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed'; - -@Entity('bid_calendar', { schema: 'bidding' }) -@Index(['tenantId', 'bidId']) -@Index(['tenantId', 'eventDate']) +@Entity({ schema: 'bidding', name: 'bid_calendar' }) +@Index(['tenantId']) +@Index(['tenantId', 'tenderId']) @Index(['tenantId', 'eventType']) +@Index(['eventDate']) +@Index(['alertSent']) export class BidCalendar { @PrimaryGeneratedColumn('uuid') - id!: string; + id: string; @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId!: string; + tenantId: string; - // Referencia a licitación - @Column({ name: 'bid_id', type: 'uuid' }) - bidId!: string; - - @ManyToOne(() => Bid, (bid) => bid.calendarEvents) - @JoinColumn({ name: 'bid_id' }) - bid?: Bid; - - // Información del evento - @Column({ length: 255 }) - title!: string; - - @Column({ type: 'text', nullable: true }) - description?: string; + /** Reference to the tender */ + @Column({ name: 'tender_id', type: 'uuid' }) + tenderId: string; @Column({ name: 'event_type', - type: 'enum', - enum: ['publication', 'site_visit', 'clarification_meeting', 'clarification_deadline', 'submission_deadline', 'opening', 'technical_evaluation', 'economic_evaluation', 'award_notification', 'contract_signing', 'kick_off', 'milestone', 'internal_review', 'team_meeting', 'reminder', 'other'], - enumName: 'calendar_event_type', + type: 'varchar', + length: 50, }) - eventType!: CalendarEventType; + eventType: CalendarEventType; - @Column({ - type: 'enum', - enum: ['low', 'medium', 'high', 'critical'], - enumName: 'event_priority', - default: 'medium', - }) - priority!: EventPriority; - - @Column({ - type: 'enum', - enum: ['scheduled', 'in_progress', 'completed', 'cancelled', 'postponed'], - enumName: 'event_status', - default: 'scheduled', - }) - status!: EventStatus; - - // Fechas y hora + /** Date and time of the event */ @Column({ name: 'event_date', type: 'timestamptz' }) - eventDate!: Date; + eventDate: Date; - @Column({ name: 'end_date', type: 'timestamptz', nullable: true }) - endDate?: Date; + /** Description of the event */ + @Column({ type: 'varchar', length: 255 }) + description: string; - @Column({ name: 'is_all_day', type: 'boolean', default: false }) - isAllDay!: boolean; + /** Days before the event to send alert */ + @Column({ name: 'alert_days_before', type: 'int', default: 3 }) + alertDaysBefore: number; - @Column({ name: 'timezone', length: 50, default: 'America/Mexico_City' }) - timezone!: string; + /** Whether alert has been sent */ + @Column({ name: 'alert_sent', type: 'boolean', default: false }) + alertSent: boolean; - // Ubicación - @Column({ length: 255, nullable: true }) - location?: string; + /** Additional notes */ + @Column({ type: 'text', nullable: true }) + notes: string; - @Column({ name: 'is_virtual', type: 'boolean', default: false }) - isVirtual!: boolean; + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; - @Column({ name: 'meeting_link', length: 500, nullable: true }) - meetingLink?: string; + @ManyToOne(() => Tender, (tender) => tender.calendarEvents) + @JoinColumn({ name: 'tender_id' }) + tender: Tender; - // Recordatorios - @Column({ name: 'reminder_minutes', type: 'int', array: true, nullable: true }) - reminderMinutes?: number[]; + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; - @Column({ name: 'reminder_sent', type: 'boolean', default: false }) - reminderSent!: boolean; - - @Column({ name: 'last_reminder_at', type: 'timestamptz', nullable: true }) - lastReminderAt?: Date; - - // Asignación - @Column({ name: 'assigned_to_id', type: 'uuid', nullable: true }) - assignedToId?: string; + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; @ManyToOne(() => User, { nullable: true }) - @JoinColumn({ name: 'assigned_to_id' }) - assignedTo?: User; - - @Column({ name: 'attendees', type: 'uuid', array: true, nullable: true }) - attendees?: string[]; - - // Resultado del evento - @Column({ name: 'outcome', type: 'text', nullable: true }) - outcome?: string; - - @Column({ name: 'action_items', type: 'jsonb', nullable: true }) - actionItems?: Record[]; - - // Recurrencia - @Column({ name: 'is_recurring', type: 'boolean', default: false }) - isRecurring!: boolean; - - @Column({ name: 'recurrence_rule', length: 255, nullable: true }) - recurrenceRule?: string; - - @Column({ name: 'parent_event_id', type: 'uuid', nullable: true }) - parentEventId?: string; - - // Flags - @Column({ name: 'is_mandatory', type: 'boolean', default: false }) - isMandatory!: boolean; - - @Column({ name: 'is_external', type: 'boolean', default: false }) - isExternal!: boolean; - - @Column({ name: 'requires_preparation', type: 'boolean', default: false }) - requiresPreparation!: boolean; - - // Notas y metadatos - @Column({ type: 'text', nullable: true }) - notes?: string; - - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; - - // AuditorĆ­a - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; + @JoinColumn({ name: 'created_by' }) + createdBy: User; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt?: Date; + deletedAt: Date; } diff --git a/src/modules/bidding/entities/bid-competitor.entity.ts b/src/modules/bidding/entities/bid-competitor.entity.ts deleted file mode 100644 index 9779d7f..0000000 --- a/src/modules/bidding/entities/bid-competitor.entity.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * BidCompetitor Entity - Competidores en Licitación - * - * Información de competidores en el proceso de licitación. - * - * @module Bidding - */ - -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { Bid } from './bid.entity'; - -export type CompetitorStatus = - | 'identified' - | 'registered' - | 'qualified' - | 'disqualified' - | 'withdrew' - | 'submitted' - | 'winner' - | 'loser'; - -export type ThreatLevel = 'low' | 'medium' | 'high' | 'critical'; - -@Entity('bid_competitors', { schema: 'bidding' }) -@Index(['tenantId', 'bidId']) -export class BidCompetitor { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId!: string; - - // Referencia a licitación - @Column({ name: 'bid_id', type: 'uuid' }) - bidId!: string; - - @ManyToOne(() => Bid, (bid) => bid.competitors) - @JoinColumn({ name: 'bid_id' }) - bid?: Bid; - - // Información del competidor - @Column({ name: 'company_name', length: 255 }) - companyName!: string; - - @Column({ name: 'trade_name', length: 255, nullable: true }) - tradeName?: string; - - @Column({ name: 'rfc', length: 13, nullable: true }) - rfc?: string; - - @Column({ name: 'contact_name', length: 255, nullable: true }) - contactName?: string; - - @Column({ name: 'contact_email', length: 255, nullable: true }) - contactEmail?: string; - - @Column({ name: 'contact_phone', length: 50, nullable: true }) - contactPhone?: string; - - @Column({ length: 255, nullable: true }) - website?: string; - - // Estado y anĆ”lisis - @Column({ - type: 'enum', - enum: ['identified', 'registered', 'qualified', 'disqualified', 'withdrew', 'submitted', 'winner', 'loser'], - enumName: 'competitor_status', - default: 'identified', - }) - status!: CompetitorStatus; - - @Column({ - name: 'threat_level', - type: 'enum', - enum: ['low', 'medium', 'high', 'critical'], - enumName: 'competitor_threat_level', - default: 'medium', - }) - threatLevel!: ThreatLevel; - - // Capacidades conocidas - @Column({ - name: 'estimated_annual_revenue', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - estimatedAnnualRevenue?: number; - - @Column({ name: 'employee_count', type: 'int', nullable: true }) - employeeCount?: number; - - @Column({ name: 'years_in_business', type: 'int', nullable: true }) - yearsInBusiness?: number; - - @Column({ name: 'certifications', type: 'text', array: true, nullable: true }) - certifications?: string[]; - - @Column({ name: 'specializations', type: 'text', array: true, nullable: true }) - specializations?: string[]; - - // Histórico de competencia - @Column({ name: 'previous_encounters', type: 'int', default: 0 }) - previousEncounters!: number; - - @Column({ name: 'wins_against', type: 'int', default: 0 }) - winsAgainst!: number; - - @Column({ name: 'losses_against', type: 'int', default: 0 }) - lossesAgainst!: number; - - // Información de propuesta (si es pĆŗblica) - @Column({ - name: 'proposed_amount', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - proposedAmount?: number; - - @Column({ - name: 'technical_score', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - technicalScore?: number; - - @Column({ - name: 'economic_score', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - economicScore?: number; - - @Column({ - name: 'final_score', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - finalScore?: number; - - @Column({ name: 'ranking_position', type: 'int', nullable: true }) - rankingPosition?: number; - - // Fortalezas y debilidades - @Column({ type: 'text', array: true, nullable: true }) - strengths?: string[]; - - @Column({ type: 'text', array: true, nullable: true }) - weaknesses?: string[]; - - // AnĆ”lisis FODA resumido - @Column({ name: 'competitive_advantage', type: 'text', nullable: true }) - competitiveAdvantage?: string; - - @Column({ name: 'vulnerability', type: 'text', nullable: true }) - vulnerability?: string; - - // Razón de descalificación/retiro - @Column({ name: 'disqualification_reason', type: 'text', nullable: true }) - disqualificationReason?: string; - - // Notas y metadatos - @Column({ type: 'text', nullable: true }) - notes?: string; - - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; - - // AuditorĆ­a - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; - - @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt?: Date; -} diff --git a/src/modules/bidding/entities/bid-document.entity.ts b/src/modules/bidding/entities/bid-document.entity.ts index e7f340c..5594a34 100644 --- a/src/modules/bidding/entities/bid-document.entity.ts +++ b/src/modules/bidding/entities/bid-document.entity.ts @@ -1,9 +1,10 @@ /** * BidDocument Entity - Documentos de Licitación - * * Almacena documentos asociados a una licitación. * - * @module Bidding + * @module Bidding (MAI-018) + * @table bidding.bid_documents + * @ddl schemas/XX-bidding-schema-ddl.sql */ import { @@ -16,155 +17,110 @@ import { JoinColumn, Index, } from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; import { User } from '../../core/entities/user.entity'; -import { Bid } from './bid.entity'; +import { Tender } from './tender.entity'; -export type DocumentCategory = - | 'tender_bases' - | 'clarifications' - | 'annexes' - | 'technical_proposal' - | 'economic_proposal' - | 'legal_documents' - | 'experience_certificates' - | 'financial_statements' - | 'bonds' - | 'contracts' - | 'correspondence' - | 'meeting_minutes' +/** Type/category of document */ +export type BidDocumentType = + | 'bases' + | 'technical_annex' + | 'economic_annex' + | 'clarification' + | 'proposal_tech' + | 'proposal_econ' + | 'contract' | 'other'; -export type DocumentStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'submitted' | 'archived'; - -@Entity('bid_documents', { schema: 'bidding' }) -@Index(['tenantId', 'bidId']) -@Index(['tenantId', 'category']) +@Entity({ schema: 'bidding', name: 'bid_documents' }) +@Index(['tenantId']) +@Index(['tenantId', 'tenderId']) +@Index(['tenantId', 'documentType']) +@Index(['uploadedAt']) export class BidDocument { @PrimaryGeneratedColumn('uuid') - id!: string; + id: string; @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId!: string; + tenantId: string; - // Referencia a licitación - @Column({ name: 'bid_id', type: 'uuid' }) - bidId!: string; + /** Reference to the tender */ + @Column({ name: 'tender_id', type: 'uuid' }) + tenderId: string; - @ManyToOne(() => Bid, (bid) => bid.documents) - @JoinColumn({ name: 'bid_id' }) - bid?: Bid; + @Column({ + name: 'document_type', + type: 'varchar', + length: 50, + }) + documentType: BidDocumentType; - // Información del documento - @Column({ length: 255 }) - name!: string; + /** Document name */ + @Column({ type: 'varchar', length: 255 }) + name: string; + /** Document description */ @Column({ type: 'text', nullable: true }) - description?: string; + description: string; - @Column({ - type: 'enum', - enum: ['tender_bases', 'clarifications', 'annexes', 'technical_proposal', 'economic_proposal', 'legal_documents', 'experience_certificates', 'financial_statements', 'bonds', 'contracts', 'correspondence', 'meeting_minutes', 'other'], - enumName: 'bid_document_category', - }) - category!: DocumentCategory; - - @Column({ - type: 'enum', - enum: ['draft', 'pending_review', 'approved', 'rejected', 'submitted', 'archived'], - enumName: 'bid_document_status', - default: 'draft', - }) - status!: DocumentStatus; - - // Archivo - @Column({ name: 'file_path', length: 500 }) - filePath!: string; - - @Column({ name: 'file_name', length: 255 }) - fileName!: string; - - @Column({ name: 'file_type', length: 100 }) - fileType!: string; + /** URL/path to the file */ + @Column({ name: 'file_url', type: 'varchar', length: 500 }) + fileUrl: string; + /** File size in bytes */ @Column({ name: 'file_size', type: 'bigint' }) - fileSize!: number; + fileSize: string; - @Column({ name: 'mime_type', length: 100, nullable: true }) - mimeType?: string; + /** MIME type of the file */ + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType: string; - // Versión + /** Version number */ @Column({ type: 'int', default: 1 }) - version!: number; + version: number; - @Column({ name: 'is_current_version', type: 'boolean', default: true }) - isCurrentVersion!: boolean; + /** User who uploaded the document */ + @Column({ name: 'uploaded_by', type: 'uuid' }) + uploadedById: string; - @Column({ name: 'previous_version_id', type: 'uuid', nullable: true }) - previousVersionId?: string; + /** Timestamp when document was uploaded */ + @Column({ name: 'uploaded_at', type: 'timestamptz' }) + uploadedAt: Date; - // Metadatos de revisión - @Column({ name: 'reviewed_by_id', type: 'uuid', nullable: true }) - reviewedById?: string; + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; - @ManyToOne(() => User, { nullable: true }) - @JoinColumn({ name: 'reviewed_by_id' }) - reviewedBy?: User; + @ManyToOne(() => Tender, (tender) => tender.documents) + @JoinColumn({ name: 'tender_id' }) + tender: Tender; - @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) - reviewedAt?: Date; + @ManyToOne(() => User) + @JoinColumn({ name: 'uploaded_by' }) + uploadedBy: User; - @Column({ name: 'review_comments', type: 'text', nullable: true }) - reviewComments?: string; - - // Flags - @Column({ name: 'is_required', type: 'boolean', default: false }) - isRequired!: boolean; - - @Column({ name: 'is_confidential', type: 'boolean', default: false }) - isConfidential!: boolean; - - @Column({ name: 'is_submitted', type: 'boolean', default: false }) - isSubmitted!: boolean; - - @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) - submittedAt?: Date; - - // Fecha de vencimiento (para documentos con vigencia) - @Column({ name: 'expiry_date', type: 'date', nullable: true }) - expiryDate?: Date; - - // Hash para verificación de integridad - @Column({ name: 'file_hash', length: 128, nullable: true }) - fileHash?: string; - - // Notas y metadatos - @Column({ type: 'text', nullable: true }) - notes?: string; - - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; - - // AuditorĆ­a - @Column({ name: 'uploaded_by_id', type: 'uuid', nullable: true }) - uploadedById?: string; - - @ManyToOne(() => User, { nullable: true }) - @JoinColumn({ name: 'uploaded_by_id' }) - uploadedBy?: User; + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; + createdById: string; - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt?: Date; + deletedAt: Date; } diff --git a/src/modules/bidding/entities/bid-team.entity.ts b/src/modules/bidding/entities/bid-team.entity.ts deleted file mode 100644 index d802f37..0000000 --- a/src/modules/bidding/entities/bid-team.entity.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * BidTeam Entity - Equipo de Licitación - * - * Miembros del equipo asignados a una licitación. - * - * @module Bidding - */ - -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; -import { User } from '../../core/entities/user.entity'; -import { Bid } from './bid.entity'; - -export type TeamRole = - | 'bid_manager' - | 'technical_lead' - | 'cost_engineer' - | 'legal_advisor' - | 'commercial_manager' - | 'project_manager' - | 'quality_manager' - | 'hse_manager' - | 'procurement_lead' - | 'design_lead' - | 'reviewer' - | 'contributor' - | 'support'; - -export type MemberStatus = 'active' | 'inactive' | 'pending' | 'removed'; - -@Entity('bid_team', { schema: 'bidding' }) -@Index(['tenantId', 'bidId']) -@Index(['tenantId', 'userId']) -export class BidTeam { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId!: string; - - // Referencia a licitación - @Column({ name: 'bid_id', type: 'uuid' }) - bidId!: string; - - @ManyToOne(() => Bid, (bid) => bid.teamMembers) - @JoinColumn({ name: 'bid_id' }) - bid?: Bid; - - // Referencia a usuario - @Column({ name: 'user_id', type: 'uuid' }) - userId!: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'user_id' }) - user?: User; - - // Rol y responsabilidades - @Column({ - type: 'enum', - enum: ['bid_manager', 'technical_lead', 'cost_engineer', 'legal_advisor', 'commercial_manager', 'project_manager', 'quality_manager', 'hse_manager', 'procurement_lead', 'design_lead', 'reviewer', 'contributor', 'support'], - enumName: 'bid_team_role', - }) - role!: TeamRole; - - @Column({ - type: 'enum', - enum: ['active', 'inactive', 'pending', 'removed'], - enumName: 'bid_team_status', - default: 'active', - }) - status!: MemberStatus; - - @Column({ type: 'text', array: true, nullable: true }) - responsibilities?: string[]; - - // Dedicación - @Column({ - name: 'allocation_percentage', - type: 'decimal', - precision: 5, - scale: 2, - default: 100, - }) - allocationPercentage!: number; - - @Column({ name: 'estimated_hours', type: 'decimal', precision: 8, scale: 2, nullable: true }) - estimatedHours?: number; - - @Column({ name: 'actual_hours', type: 'decimal', precision: 8, scale: 2, default: 0 }) - actualHours!: number; - - // Fechas de participación - @Column({ name: 'start_date', type: 'date' }) - startDate!: Date; - - @Column({ name: 'end_date', type: 'date', nullable: true }) - endDate?: Date; - - // Permisos especĆ­ficos - @Column({ name: 'can_edit_technical', type: 'boolean', default: false }) - canEditTechnical!: boolean; - - @Column({ name: 'can_edit_economic', type: 'boolean', default: false }) - canEditEconomic!: boolean; - - @Column({ name: 'can_approve', type: 'boolean', default: false }) - canApprove!: boolean; - - @Column({ name: 'can_submit', type: 'boolean', default: false }) - canSubmit!: boolean; - - // Notificaciones - @Column({ name: 'receive_notifications', type: 'boolean', default: true }) - receiveNotifications!: boolean; - - @Column({ name: 'notification_preferences', type: 'jsonb', nullable: true }) - notificationPreferences?: Record; - - // Evaluación de participación - @Column({ - name: 'performance_rating', - type: 'decimal', - precision: 3, - scale: 2, - nullable: true, - }) - performanceRating?: number; - - @Column({ name: 'performance_notes', type: 'text', nullable: true }) - performanceNotes?: string; - - // Información de contacto externa (si no es empleado) - @Column({ name: 'is_external', type: 'boolean', default: false }) - isExternal!: boolean; - - @Column({ name: 'external_company', length: 255, nullable: true }) - externalCompany?: string; - - @Column({ name: 'external_email', length: 255, nullable: true }) - externalEmail?: string; - - @Column({ name: 'external_phone', length: 50, nullable: true }) - externalPhone?: string; - - // Notas y metadatos - @Column({ type: 'text', nullable: true }) - notes?: string; - - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; - - // AuditorĆ­a - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; - - @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt?: Date; -} diff --git a/src/modules/bidding/entities/bid.entity.ts b/src/modules/bidding/entities/bid.entity.ts deleted file mode 100644 index 7712ea8..0000000 --- a/src/modules/bidding/entities/bid.entity.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Bid Entity - Licitaciones/Propuestas - * - * Representa una licitación o propuesta formal vinculada a una oportunidad. - * - * @module Bidding - */ - -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - ManyToOne, - OneToMany, - JoinColumn, - Index, -} from 'typeorm'; -import { User } from '../../core/entities/user.entity'; -import { Opportunity } from './opportunity.entity'; -import { BidDocument } from './bid-document.entity'; -import { BidCalendar } from './bid-calendar.entity'; -import { BidBudget } from './bid-budget.entity'; -import { BidCompetitor } from './bid-competitor.entity'; -import { BidTeam } from './bid-team.entity'; - -export type BidType = 'public' | 'private' | 'invitation' | 'direct_award' | 'framework_agreement'; - -export type BidStatus = - | 'draft' - | 'preparation' - | 'review' - | 'approved' - | 'submitted' - | 'clarification' - | 'evaluation' - | 'awarded' - | 'rejected' - | 'cancelled' - | 'withdrawn'; - -export type BidStage = - | 'initial' - | 'technical_proposal' - | 'economic_proposal' - | 'final_submission' - | 'post_submission'; - -@Entity('bids', { schema: 'bidding' }) -@Index(['tenantId', 'status']) -@Index(['tenantId', 'bidType']) -@Index(['tenantId', 'opportunityId']) -export class Bid { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId!: string; - - // Referencia a oportunidad - @Column({ name: 'opportunity_id', type: 'uuid' }) - opportunityId!: string; - - @ManyToOne(() => Opportunity, (opp) => opp.bids) - @JoinColumn({ name: 'opportunity_id' }) - opportunity?: Opportunity; - - // Información bĆ”sica - @Column({ length: 100 }) - code!: string; - - @Column({ length: 500 }) - name!: string; - - @Column({ type: 'text', nullable: true }) - description?: string; - - @Column({ - name: 'bid_type', - type: 'enum', - enum: ['public', 'private', 'invitation', 'direct_award', 'framework_agreement'], - enumName: 'bid_type', - }) - bidType!: BidType; - - @Column({ - type: 'enum', - enum: ['draft', 'preparation', 'review', 'approved', 'submitted', 'clarification', 'evaluation', 'awarded', 'rejected', 'cancelled', 'withdrawn'], - enumName: 'bid_status', - default: 'draft', - }) - status!: BidStatus; - - @Column({ - type: 'enum', - enum: ['initial', 'technical_proposal', 'economic_proposal', 'final_submission', 'post_submission'], - enumName: 'bid_stage', - default: 'initial', - }) - stage!: BidStage; - - // Referencia de convocatoria - @Column({ name: 'tender_number', length: 100, nullable: true }) - tenderNumber?: string; - - @Column({ name: 'tender_name', length: 500, nullable: true }) - tenderName?: string; - - @Column({ name: 'contracting_entity', length: 255, nullable: true }) - contractingEntity?: string; - - // Fechas clave - @Column({ name: 'publication_date', type: 'date', nullable: true }) - publicationDate?: Date; - - @Column({ name: 'site_visit_date', type: 'timestamptz', nullable: true }) - siteVisitDate?: Date; - - @Column({ name: 'clarification_deadline', type: 'timestamptz', nullable: true }) - clarificationDeadline?: Date; - - @Column({ name: 'submission_deadline', type: 'timestamptz' }) - submissionDeadline!: Date; - - @Column({ name: 'opening_date', type: 'timestamptz', nullable: true }) - openingDate?: Date; - - @Column({ name: 'award_date', type: 'date', nullable: true }) - awardDate?: Date; - - @Column({ name: 'contract_signing_date', type: 'date', nullable: true }) - contractSigningDate?: Date; - - // Montos - @Column({ - name: 'base_budget', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - baseBudget?: number; - - @Column({ - name: 'our_proposal_amount', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - ourProposalAmount?: number; - - @Column({ - name: 'winning_amount', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - winningAmount?: number; - - @Column({ name: 'currency', length: 3, default: 'MXN' }) - currency!: string; - - // Propuesta tĆ©cnica - @Column({ - name: 'technical_score', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - technicalScore?: number; - - @Column({ - name: 'technical_weight', - type: 'decimal', - precision: 5, - scale: 2, - default: 50, - }) - technicalWeight!: number; - - // Propuesta económica - @Column({ - name: 'economic_score', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - economicScore?: number; - - @Column({ - name: 'economic_weight', - type: 'decimal', - precision: 5, - scale: 2, - default: 50, - }) - economicWeight!: number; - - // Puntuación final - @Column({ - name: 'final_score', - type: 'decimal', - precision: 5, - scale: 2, - nullable: true, - }) - finalScore?: number; - - @Column({ name: 'ranking_position', type: 'int', nullable: true }) - rankingPosition?: number; - - // GarantĆ­as - @Column({ - name: 'bid_bond_amount', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - bidBondAmount?: number; - - @Column({ name: 'bid_bond_number', length: 100, nullable: true }) - bidBondNumber?: string; - - @Column({ name: 'bid_bond_expiry', type: 'date', nullable: true }) - bidBondExpiry?: Date; - - // Asignación - @Column({ name: 'bid_manager_id', type: 'uuid', nullable: true }) - bidManagerId?: string; - - @ManyToOne(() => User, { nullable: true }) - @JoinColumn({ name: 'bid_manager_id' }) - bidManager?: User; - - // Resultado - @Column({ name: 'winner_name', length: 255, nullable: true }) - winnerName?: string; - - @Column({ name: 'rejection_reason', type: 'text', nullable: true }) - rejectionReason?: string; - - @Column({ name: 'lessons_learned', type: 'text', nullable: true }) - lessonsLearned?: string; - - // Progreso - @Column({ - name: 'completion_percentage', - type: 'decimal', - precision: 5, - scale: 2, - default: 0, - }) - completionPercentage!: number; - - // Checklist de documentos - @Column({ name: 'checklist', type: 'jsonb', nullable: true }) - checklist?: Record; - - // Notas y metadatos - @Column({ type: 'text', nullable: true }) - notes?: string; - - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; - - // Relaciones - @OneToMany(() => BidDocument, (doc) => doc.bid) - documents?: BidDocument[]; - - @OneToMany(() => BidCalendar, (event) => event.bid) - calendarEvents?: BidCalendar[]; - - @OneToMany(() => BidBudget, (budget) => budget.bid) - budgetItems?: BidBudget[]; - - @OneToMany(() => BidCompetitor, (comp) => comp.bid) - competitors?: BidCompetitor[]; - - @OneToMany(() => BidTeam, (team) => team.bid) - teamMembers?: BidTeam[]; - - // Conversión a proyecto - @Column({ name: 'converted_to_project_id', type: 'uuid', nullable: true }) - convertedToProjectId?: string; - - @Column({ name: 'converted_at', type: 'timestamptz', nullable: true }) - convertedAt?: Date; - - // AuditorĆ­a - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; - - @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt?: Date; -} diff --git a/src/modules/bidding/entities/index.ts b/src/modules/bidding/entities/index.ts index dee4637..4193ec7 100644 --- a/src/modules/bidding/entities/index.ts +++ b/src/modules/bidding/entities/index.ts @@ -1,12 +1,24 @@ /** * Bidding Entities Index - * @module Bidding + * Barrel file exporting all bidding module entities. + * + * @module Bidding (MAI-018) */ -export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from './opportunity.entity'; -export { Bid, BidType, BidStatus, BidStage } from './bid.entity'; -export { BidDocument, DocumentCategory, DocumentStatus } from './bid-document.entity'; -export { BidCalendar, CalendarEventType, EventPriority, EventStatus } from './bid-calendar.entity'; -export { BidBudget, BudgetItemType, BudgetStatus } from './bid-budget.entity'; -export { BidCompetitor, CompetitorStatus, ThreatLevel } from './bid-competitor.entity'; -export { BidTeam, TeamRole, MemberStatus } from './bid-team.entity'; +// Opportunity +export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from './opportunity.entity'; + +// Tender +export { Tender, TenderType, TenderStatus } from './tender.entity'; + +// Proposal +export { Proposal, ProposalStatus } from './proposal.entity'; + +// Vendor +export { Vendor, VendorCertification, VendorPerformanceEntry } from './vendor.entity'; + +// Bid Calendar +export { BidCalendar, CalendarEventType } from './bid-calendar.entity'; + +// Bid Document +export { BidDocument, BidDocumentType } from './bid-document.entity'; diff --git a/src/modules/bidding/entities/opportunity.entity.ts b/src/modules/bidding/entities/opportunity.entity.ts index b1b8813..4ae197e 100644 --- a/src/modules/bidding/entities/opportunity.entity.ts +++ b/src/modules/bidding/entities/opportunity.entity.ts @@ -1,9 +1,10 @@ /** - * Opportunity Entity - Oportunidades de Negocio - * + * Opportunity Entity - Oportunidades de Licitación * Representa oportunidades de licitación/proyecto en el pipeline comercial. * - * @module Bidding + * @module Bidding (MAI-018) + * @table bidding.opportunities + * @ddl schemas/XX-bidding-schema-ddl.sql */ import { @@ -17,264 +18,124 @@ import { JoinColumn, Index, } from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; import { User } from '../../core/entities/user.entity'; -import { Bid } from './bid.entity'; +import { Tender } from './tender.entity'; -export type OpportunitySource = - | 'portal_compranet' - | 'portal_state' - | 'direct_invitation' - | 'referral' - | 'public_notice' - | 'networking' - | 'repeat_client' - | 'cold_call' - | 'website' - | 'other'; +/** Source of the opportunity */ +export type OpportunitySource = 'government_portal' | 'private_client' | 'referral' | 'other'; -export type OpportunityStatus = - | 'identified' - | 'qualified' - | 'pursuing' - | 'bid_submitted' - | 'won' - | 'lost' - | 'cancelled' - | 'on_hold'; +/** Status of the opportunity in the pipeline */ +export type OpportunityStatus = 'registered' | 'evaluating' | 'go' | 'no_go' | 'preparing' | 'converted'; -export type OpportunityPriority = 'low' | 'medium' | 'high' | 'critical'; +/** Priority level */ +export type OpportunityPriority = 'high' | 'medium' | 'low'; -export type ProjectType = - | 'residential' - | 'commercial' - | 'industrial' - | 'infrastructure' - | 'institutional' - | 'mixed_use' - | 'renovation' - | 'maintenance'; - -@Entity('opportunities', { schema: 'bidding' }) +@Entity({ schema: 'bidding', name: 'opportunities' }) +@Index(['tenantId']) +@Index(['tenantId', 'code'], { unique: true }) @Index(['tenantId', 'status']) @Index(['tenantId', 'source']) -@Index(['tenantId', 'assignedToId']) +@Index(['tenantId', 'priority']) +@Index(['deadlineDate']) export class Opportunity { @PrimaryGeneratedColumn('uuid') - id!: string; + id: string; @Column({ name: 'tenant_id', type: 'uuid' }) - @Index() - tenantId!: string; + tenantId: string; - // Información bĆ”sica - @Column({ length: 100 }) - code!: string; + /** Unique code within tenant, format: OPP-2026-001 */ + @Column({ type: 'varchar', length: 50 }) + code: string; - @Column({ length: 500 }) - name!: string; + @Column({ type: 'varchar', length: 255 }) + title: string; @Column({ type: 'text', nullable: true }) - description?: string; + description: string; @Column({ - type: 'enum', - enum: ['portal_compranet', 'portal_state', 'direct_invitation', 'referral', 'public_notice', 'networking', 'repeat_client', 'cold_call', 'website', 'other'], - enumName: 'opportunity_source', + type: 'varchar', + length: 50, + default: 'other', }) - source!: OpportunitySource; + source: OpportunitySource; + + /** Client/organization name */ + @Column({ name: 'client_name', type: 'varchar', length: 255 }) + clientName: string; + + /** Project type: vivienda_vertical, vivienda_horizontal, urbanizacion, etc. */ + @Column({ name: 'project_type', type: 'varchar', length: 100 }) + projectType: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + location: string; + + /** Estimated amount in cents (BIGINT) */ + @Column({ name: 'estimated_amount', type: 'bigint', nullable: true }) + estimatedAmount: string; + + /** Estimated number of housing units */ + @Column({ name: 'estimated_units', type: 'int', nullable: true }) + estimatedUnits: number; @Column({ - type: 'enum', - enum: ['identified', 'qualified', 'pursuing', 'bid_submitted', 'won', 'lost', 'cancelled', 'on_hold'], - enumName: 'opportunity_status', - default: 'identified', + type: 'varchar', + length: 50, + default: 'registered', }) - status!: OpportunityStatus; + status: OpportunityStatus; + + /** Date when go/no-go decision was made */ + @Column({ name: 'go_decision_date', type: 'date', nullable: true }) + goDecisionDate: Date; + + /** Reason for go/no-go decision */ + @Column({ name: 'go_decision_reason', type: 'text', nullable: true }) + goDecisionReason: string; @Column({ - type: 'enum', - enum: ['low', 'medium', 'high', 'critical'], - enumName: 'opportunity_priority', + type: 'varchar', + length: 20, default: 'medium', }) - priority!: OpportunityPriority; + priority: OpportunityPriority; - @Column({ - name: 'project_type', - type: 'enum', - enum: ['residential', 'commercial', 'industrial', 'infrastructure', 'institutional', 'mixed_use', 'renovation', 'maintenance'], - enumName: 'project_type', - }) - projectType!: ProjectType; + /** Deadline for opportunity (proposal submission, etc.) */ + @Column({ name: 'deadline_date', type: 'date' }) + deadlineDate: Date; - // Cliente/Convocante - @Column({ name: 'client_name', length: 255 }) - clientName!: string; + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; - @Column({ name: 'client_contact', length: 255, nullable: true }) - clientContact?: string; + @OneToMany(() => Tender, (tender) => tender.opportunity) + tenders: Tender[]; - @Column({ name: 'client_email', length: 255, nullable: true }) - clientEmail?: string; + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; - @Column({ name: 'client_phone', length: 50, nullable: true }) - clientPhone?: string; - - @Column({ name: 'client_type', length: 50, nullable: true }) - clientType?: string; // 'gobierno_federal', 'gobierno_estatal', 'privado', etc. - - // Ubicación - @Column({ length: 255, nullable: true }) - location?: string; - - @Column({ length: 100, nullable: true }) - state?: string; - - @Column({ length: 100, nullable: true }) - city?: string; - - // Montos estimados - @Column({ - name: 'estimated_value', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - estimatedValue?: number; - - @Column({ name: 'currency', length: 3, default: 'MXN' }) - currency!: string; - - @Column({ - name: 'construction_area_m2', - type: 'decimal', - precision: 12, - scale: 2, - nullable: true, - }) - constructionAreaM2?: number; - - @Column({ - name: 'land_area_m2', - type: 'decimal', - precision: 12, - scale: 2, - nullable: true, - }) - landAreaM2?: number; - - // Fechas clave - @Column({ name: 'identification_date', type: 'date' }) - identificationDate!: Date; - - @Column({ name: 'deadline_date', type: 'timestamptz', nullable: true }) - deadlineDate?: Date; - - @Column({ name: 'expected_award_date', type: 'date', nullable: true }) - expectedAwardDate?: Date; - - @Column({ name: 'expected_start_date', type: 'date', nullable: true }) - expectedStartDate?: Date; - - @Column({ name: 'expected_duration_months', type: 'int', nullable: true }) - expectedDurationMonths?: number; - - // Probabilidad y anĆ”lisis - @Column({ - name: 'win_probability', - type: 'decimal', - precision: 5, - scale: 2, - default: 0, - }) - winProbability!: number; - - @Column({ - name: 'weighted_value', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - weightedValue?: number; - - // Requisitos - @Column({ name: 'requires_bond', type: 'boolean', default: false }) - requiresBond!: boolean; - - @Column({ name: 'requires_experience', type: 'boolean', default: false }) - requiresExperience!: boolean; - - @Column({ - name: 'minimum_experience_years', - type: 'int', - nullable: true, - }) - minimumExperienceYears?: number; - - @Column({ - name: 'minimum_capital', - type: 'decimal', - precision: 18, - scale: 2, - nullable: true, - }) - minimumCapital?: number; - - @Column({ - name: 'required_certifications', - type: 'text', - array: true, - nullable: true, - }) - requiredCertifications?: string[]; - - // Asignación - @Column({ name: 'assigned_to_id', type: 'uuid', nullable: true }) - assignedToId?: string; + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; @ManyToOne(() => User, { nullable: true }) - @JoinColumn({ name: 'assigned_to_id' }) - assignedTo?: User; - - // Razón de resultado - @Column({ name: 'loss_reason', type: 'text', nullable: true }) - lossReason?: string; - - @Column({ name: 'win_factors', type: 'text', nullable: true }) - winFactors?: string; - - // Notas y metadatos - @Column({ type: 'text', nullable: true }) - notes?: string; - - @Column({ name: 'source_url', length: 500, nullable: true }) - sourceUrl?: string; - - @Column({ name: 'source_reference', length: 255, nullable: true }) - sourceReference?: string; - - @Column({ type: 'jsonb', nullable: true }) - metadata?: Record; - - // Relaciones - @OneToMany(() => Bid, (bid) => bid.opportunity) - bids?: Bid[]; - - // AuditorĆ­a - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt!: Date; + @JoinColumn({ name: 'created_by' }) + createdBy: User; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt!: Date; + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt?: Date; + deletedAt: Date; } diff --git a/src/modules/bidding/entities/proposal.entity.ts b/src/modules/bidding/entities/proposal.entity.ts new file mode 100644 index 0000000..73b37ed --- /dev/null +++ b/src/modules/bidding/entities/proposal.entity.ts @@ -0,0 +1,142 @@ +/** + * Proposal Entity - Propuestas Enviadas + * Representa una propuesta enviada a una licitación. + * + * @module Bidding (MAI-018) + * @table bidding.proposals + * @ddl schemas/XX-bidding-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tender } from './tender.entity'; +import { Vendor } from './vendor.entity'; + +/** Status of the proposal */ +export type ProposalStatus = 'received' | 'evaluating' | 'qualified' | 'disqualified' | 'winner'; + +@Entity({ schema: 'bidding', name: 'proposals' }) +@Index(['tenantId']) +@Index(['tenantId', 'tenderId']) +@Index(['tenantId', 'vendorId']) +@Index(['tenantId', 'status']) +@Index(['submittedAt']) +export class Proposal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + /** Reference to the tender */ + @Column({ name: 'tender_id', type: 'uuid' }) + tenderId: string; + + /** Reference to the vendor who submitted */ + @Column({ name: 'vendor_id', type: 'uuid' }) + vendorId: string; + + /** Proposed amount in cents */ + @Column({ name: 'proposed_amount', type: 'bigint' }) + proposedAmount: string; + + /** Proposed schedule in days */ + @Column({ name: 'proposed_schedule_days', type: 'int' }) + proposedScheduleDays: number; + + /** URL to technical proposal document */ + @Column({ name: 'technical_proposal_url', type: 'varchar', length: 500, nullable: true }) + technicalProposalUrl: string; + + /** URL to economic proposal document */ + @Column({ name: 'economic_proposal_url', type: 'varchar', length: 500, nullable: true }) + economicProposalUrl: string; + + /** Technical evaluation score */ + @Column({ + name: 'technical_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + technicalScore: number; + + /** Economic evaluation score */ + @Column({ + name: 'economic_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + economicScore: number; + + /** Total combined score */ + @Column({ + name: 'total_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + totalScore: number; + + @Column({ + type: 'varchar', + length: 30, + default: 'received', + }) + status: ProposalStatus; + + /** Timestamp when the proposal was submitted */ + @Column({ name: 'submitted_at', type: 'timestamptz' }) + submittedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Tender, (tender) => tender.proposals) + @JoinColumn({ name: 'tender_id' }) + tender: Tender; + + @ManyToOne(() => Vendor, (vendor) => vendor.proposals) + @JoinColumn({ name: 'vendor_id' }) + vendor: Vendor; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/bidding/entities/tender.entity.ts b/src/modules/bidding/entities/tender.entity.ts new file mode 100644 index 0000000..19c4a1b --- /dev/null +++ b/src/modules/bidding/entities/tender.entity.ts @@ -0,0 +1,157 @@ +/** + * Tender Entity - Licitaciones Formales + * Representa una licitación formal vinculada a una oportunidad. + * + * @module Bidding (MAI-018) + * @table bidding.tenders + * @ddl schemas/XX-bidding-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Opportunity } from './opportunity.entity'; +import { Proposal } from './proposal.entity'; +import { BidCalendar } from './bid-calendar.entity'; +import { BidDocument } from './bid-document.entity'; + +/** Type of tender process */ +export type TenderType = 'public' | 'private' | 'invitation_only'; + +/** Status of the tender */ +export type TenderStatus = + | 'draft' + | 'published' + | 'receiving' + | 'evaluating' + | 'awarded' + | 'cancelled' + | 'converting' + | 'converted'; + +@Entity({ schema: 'bidding', name: 'tenders' }) +@Index(['tenantId']) +@Index(['tenantId', 'number'], { unique: true }) +@Index(['tenantId', 'opportunityId']) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'type']) +@Index(['proposalDeadline']) +export class Tender { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + /** Reference to the opportunity */ + @Column({ name: 'opportunity_id', type: 'uuid' }) + opportunityId: string; + + /** Unique tender number within tenant, format: LIC-2026-001 */ + @Column({ type: 'varchar', length: 50 }) + number: string; + + @Column({ + type: 'varchar', + length: 30, + default: 'public', + }) + type: TenderType; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + /** Reference amount (budget) in cents */ + @Column({ name: 'reference_amount', type: 'bigint', nullable: true }) + referenceAmount: string; + + /** Date when the tender was published */ + @Column({ name: 'publication_date', type: 'date', nullable: true }) + publicationDate: Date; + + /** Date for clarification meeting (junta de aclaraciones) */ + @Column({ name: 'clarification_meeting_date', type: 'date', nullable: true }) + clarificationMeetingDate: Date; + + /** Deadline for proposal submission */ + @Column({ name: 'proposal_deadline', type: 'timestamptz' }) + proposalDeadline: Date; + + /** Expected or actual award date */ + @Column({ name: 'award_date', type: 'date', nullable: true }) + awardDate: Date; + + /** Contract duration in days */ + @Column({ name: 'contract_duration_days', type: 'int', nullable: true }) + contractDurationDays: number; + + @Column({ + type: 'varchar', + length: 30, + default: 'draft', + }) + status: TenderStatus; + + /** Reference to the winning proposal */ + @Column({ name: 'winner_id', type: 'uuid', nullable: true }) + winnerId: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Opportunity, (opp) => opp.tenders) + @JoinColumn({ name: 'opportunity_id' }) + opportunity: Opportunity; + + @ManyToOne(() => Proposal, { nullable: true }) + @JoinColumn({ name: 'winner_id' }) + winner: Proposal; + + @OneToMany(() => Proposal, (proposal) => proposal.tender) + proposals: Proposal[]; + + @OneToMany(() => BidCalendar, (event) => event.tender) + calendarEvents: BidCalendar[]; + + @OneToMany(() => BidDocument, (doc) => doc.tender) + documents: BidDocument[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/bidding/entities/vendor.entity.ts b/src/modules/bidding/entities/vendor.entity.ts new file mode 100644 index 0000000..db0efc8 --- /dev/null +++ b/src/modules/bidding/entities/vendor.entity.ts @@ -0,0 +1,143 @@ +/** + * Vendor Entity - Proveedores/Contratistas + * Representa proveedores y contratistas que participan en licitaciones. + * + * @module Bidding (MAI-018) + * @table bidding.vendors + * @ddl schemas/XX-bidding-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, + Check, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Proposal } from './proposal.entity'; + +/** Vendor certification structure */ +export interface VendorCertification { + name: string; + issuedBy: string; + issuedDate: string; + expiryDate?: string; + documentUrl?: string; +} + +/** Vendor performance history entry */ +export interface VendorPerformanceEntry { + projectName: string; + clientName: string; + contractAmount: number; + completedDate: string; + rating: number; + notes?: string; +} + +@Entity({ schema: 'bidding', name: 'vendors' }) +@Index(['tenantId']) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId', 'rfc']) +@Index(['tenantId', 'isActive']) +@Index(['rating']) +@Check('rating >= 1 AND rating <= 5') +export class Vendor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + /** Unique code within tenant */ + @Column({ type: 'varchar', length: 20 }) + code: string; + + /** Business name (razon social) */ + @Column({ name: 'business_name', type: 'varchar', length: 255 }) + businessName: string; + + /** RFC (tax ID) */ + @Column({ type: 'varchar', length: 13, nullable: true }) + rfc: string; + + /** List of specialties */ + @Column({ type: 'text', array: true, nullable: true }) + specialties: string[]; + + /** Rating from 1 to 5 */ + @Column({ + type: 'decimal', + precision: 2, + scale: 1, + nullable: true, + }) + rating: number; + + /** Certifications in JSON format */ + @Column({ type: 'jsonb', nullable: true }) + certifications: VendorCertification[]; + + /** Performance history in JSON format */ + @Column({ name: 'performance_history', type: 'jsonb', nullable: true }) + performanceHistory: VendorPerformanceEntry[]; + + /** Whether all documentation is valid/current */ + @Column({ name: 'documentation_valid', type: 'boolean', default: false }) + documentationValid: boolean; + + /** Contact person name */ + @Column({ name: 'contact_name', type: 'varchar', length: 255, nullable: true }) + contactName: string; + + /** Contact email */ + @Column({ name: 'contact_email', type: 'varchar', length: 255, nullable: true }) + contactEmail: string; + + /** Contact phone */ + @Column({ name: 'contact_phone', type: 'varchar', length: 50, nullable: true }) + contactPhone: string; + + /** Whether the vendor is active */ + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @OneToMany(() => Proposal, (proposal) => proposal.vendor) + proposals: Proposal[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/bidding/services/bid-analytics.service.ts b/src/modules/bidding/services/bid-analytics.service.ts index 8d443f9..33cceb2 100644 --- a/src/modules/bidding/services/bid-analytics.service.ts +++ b/src/modules/bidding/services/bid-analytics.service.ts @@ -1,22 +1,56 @@ /** - * BidAnalyticsService - AnĆ”lisis y Reportes de Licitaciones + * BidAnalyticsService - Analisis y Reportes de Licitaciones * - * EstadĆ­sticas, tendencias y anĆ”lisis de competitividad. + * Estadisticas, tendencias y analisis de competitividad. + * Proporciona dashboards consolidados, win rates, analisis de competidores y pipeline. * - * @module Bidding + * @module Bidding (MAI-018) */ import { Repository } from 'typeorm'; import { ServiceContext } from '../../../shared/services/base.service'; -import { Bid, BidStatus, BidType } from '../entities/bid.entity'; -import { Opportunity, OpportunitySource, OpportunityStatus } from '../entities/opportunity.entity'; -import { BidCompetitor } from '../entities/bid-competitor.entity'; +import { Tender, TenderStatus, TenderType } from '../entities/tender.entity'; +import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from '../entities/opportunity.entity'; +import { Proposal } from '../entities/proposal.entity'; + +// Interfaces for method results +export interface WinRateResult { + won: number; + lost: number; + rate: number; +} + +export interface PipelineValueResult { + totalValue: number; + byStatus: { status: OpportunityStatus; value: number; count: number }[]; + byPriority: { priority: OpportunityPriority; value: number; count: number }[]; +} + +export interface CompetitorFrequency { + companyName: string; + rfc?: string; + appearances: number; + winsAgainstUs: number; + lossesAgainstUs: number; + avgProposalDifference: number; +} + +export interface AveragesResult { + avgBidSize: number; + avgTimeToAward: number; + avgProposalCount: number; +} + +export interface DateRange { + dateFrom?: Date; + dateTo?: Date; +} export class BidAnalyticsService { constructor( - private readonly bidRepository: Repository, + private readonly tenderRepository: Repository, private readonly opportunityRepository: Repository, - private readonly competitorRepository: Repository + private readonly proposalRepository: Repository ) {} /** @@ -24,87 +58,85 @@ export class BidAnalyticsService { */ async getDashboard(ctx: ServiceContext): Promise { const now = new Date(); - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); // Oportunidades activas const activeOpportunities = await this.opportunityRepository.count({ where: { tenantId: ctx.tenantId, deletedAt: undefined, - status: 'pursuing' as OpportunityStatus, + status: 'go' as OpportunityStatus, }, }); // Licitaciones activas - const activeBids = await this.bidRepository.count({ + const activeTenders = await this.tenderRepository.count({ where: { tenantId: ctx.tenantId, deletedAt: undefined, - status: 'preparation' as BidStatus, + status: 'published' as TenderStatus, }, }); // Valor del pipeline const pipelineValue = await this.opportunityRepository .createQueryBuilder('o') - .select('SUM(o.weighted_value)', 'value') + .select('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] }) + .andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] }) .getRawOne(); - // Próximas fechas lĆ­mite - const upcomingDeadlines = await this.bidRepository - .createQueryBuilder('b') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'preparation', 'review', 'approved'] }) - .andWhere('b.submission_deadline >= :now', { now }) - .orderBy('b.submission_deadline', 'ASC') + // Proximas fechas limite + const upcomingDeadlines = await this.tenderRepository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'published', 'receiving', 'evaluating'] }) + .andWhere('t.proposal_deadline >= :now', { now }) + .orderBy('t.proposal_deadline', 'ASC') .take(5) .getMany(); - // Win rate Ćŗltimos 12 meses + // Win rate ultimos 12 meses const yearAgo = new Date(); yearAgo.setFullYear(yearAgo.getFullYear() - 1); - const winRateStats = await this.bidRepository - .createQueryBuilder('b') - .select('b.status', 'status') + const winRateStats = await this.tenderRepository + .createQueryBuilder('t') + .select('t.status', 'status') .addSelect('COUNT(*)', 'count') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'rejected'] }) - .andWhere('b.award_date >= :yearAgo', { yearAgo }) - .groupBy('b.status') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'cancelled'] }) + .andWhere('t.award_date >= :yearAgo', { yearAgo }) + .groupBy('t.status') .getRawMany(); const awarded = winRateStats.find((s) => s.status === 'awarded')?.count || 0; - const rejected = winRateStats.find((s) => s.status === 'rejected')?.count || 0; - const totalClosed = parseInt(awarded) + parseInt(rejected); + const cancelled = winRateStats.find((s) => s.status === 'cancelled')?.count || 0; + const totalClosed = parseInt(awarded) + parseInt(cancelled); const winRate = totalClosed > 0 ? (parseInt(awarded) / totalClosed) * 100 : 0; - // Valor ganado este aƱo + // Valor ganado este ano const startOfYear = new Date(now.getFullYear(), 0, 1); - const wonValue = await this.bidRepository - .createQueryBuilder('b') - .select('SUM(b.winning_amount)', 'value') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.status = :status', { status: 'awarded' }) - .andWhere('b.award_date >= :startOfYear', { startOfYear }) + const wonValue = await this.tenderRepository + .createQueryBuilder('t') + .select('SUM(CAST(t.reference_amount AS DECIMAL))', 'value') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.status = :status', { status: 'awarded' }) + .andWhere('t.award_date >= :startOfYear', { startOfYear }) .getRawOne(); return { activeOpportunities, - activeBids, + activeBids: activeTenders, pipelineValue: parseFloat(pipelineValue?.value) || 0, - upcomingDeadlines: upcomingDeadlines.map((b) => ({ - id: b.id, - name: b.name, - deadline: b.submissionDeadline, - status: b.status, + upcomingDeadlines: upcomingDeadlines.map((t) => ({ + id: t.id, + name: t.title, + deadline: t.proposalDeadline, + status: t.status, })), winRate, wonValueYTD: parseFloat(wonValue?.value) || 0, @@ -112,51 +144,49 @@ export class BidAnalyticsService { } /** - * AnĆ”lisis de pipeline por fuente + * Analisis de pipeline por fuente */ async getPipelineBySource(ctx: ServiceContext): Promise { const result = await this.opportunityRepository .createQueryBuilder('o') .select('o.source', 'source') .addSelect('COUNT(*)', 'count') - .addSelect('SUM(o.estimated_value)', 'totalValue') - .addSelect('SUM(o.weighted_value)', 'weightedValue') + .addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] }) + .andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] }) .groupBy('o.source') - .orderBy('SUM(o.weighted_value)', 'DESC') + .orderBy('SUM(CAST(o.estimated_amount AS DECIMAL))', 'DESC') .getRawMany(); return result.map((r) => ({ source: r.source as OpportunitySource, count: parseInt(r.count), totalValue: parseFloat(r.totalValue) || 0, - weightedValue: parseFloat(r.weightedValue) || 0, })); } /** - * AnĆ”lisis de win rate por tipo de licitación + * Analisis de win rate por tipo de licitacion */ async getWinRateByType(ctx: ServiceContext, months = 12): Promise { const fromDate = new Date(); fromDate.setMonth(fromDate.getMonth() - months); - const result = await this.bidRepository - .createQueryBuilder('b') - .select('b.bid_type', 'bidType') - .addSelect('COUNT(*) FILTER (WHERE b.status = \'awarded\')', 'won') - .addSelect('COUNT(*) FILTER (WHERE b.status IN (\'awarded\', \'rejected\'))', 'total') - .addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'wonValue') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.award_date >= :fromDate', { fromDate }) - .groupBy('b.bid_type') + const result = await this.tenderRepository + .createQueryBuilder('t') + .select('t.type', 'type') + .addSelect('COUNT(*) FILTER (WHERE t.status = \'awarded\')', 'won') + .addSelect('COUNT(*) FILTER (WHERE t.status IN (\'awarded\', \'cancelled\'))', 'total') + .addSelect('SUM(CASE WHEN t.status = \'awarded\' THEN CAST(t.reference_amount AS DECIMAL) ELSE 0 END)', 'wonValue') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.award_date >= :fromDate', { fromDate }) + .groupBy('t.type') .getRawMany(); return result.map((r) => ({ - bidType: r.bidType as BidType, + bidType: r.type as TenderType, won: parseInt(r.won) || 0, total: parseInt(r.total) || 0, winRate: parseInt(r.total) > 0 ? (parseInt(r.won) / parseInt(r.total)) * 100 : 0, @@ -173,16 +203,16 @@ export class BidAnalyticsService { const result = await this.opportunityRepository .createQueryBuilder('o') - .select("TO_CHAR(o.identification_date, 'YYYY-MM')", 'month') + .select("TO_CHAR(o.created_at, 'YYYY-MM')", 'month') .addSelect('COUNT(*)', 'identified') - .addSelect('COUNT(*) FILTER (WHERE o.status = \'won\')', 'won') - .addSelect('COUNT(*) FILTER (WHERE o.status = \'lost\')', 'lost') - .addSelect('SUM(CASE WHEN o.status = \'won\' THEN o.estimated_value ELSE 0 END)', 'wonValue') + .addSelect('COUNT(*) FILTER (WHERE o.status = \'converted\')', 'won') + .addSelect('COUNT(*) FILTER (WHERE o.status = \'no_go\')', 'lost') + .addSelect('SUM(CASE WHEN o.status = \'converted\' THEN CAST(o.estimated_amount AS DECIMAL) ELSE 0 END)', 'wonValue') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.identification_date >= :fromDate', { fromDate }) - .groupBy("TO_CHAR(o.identification_date, 'YYYY-MM')") - .orderBy("TO_CHAR(o.identification_date, 'YYYY-MM')", 'ASC') + .andWhere('o.created_at >= :fromDate', { fromDate }) + .groupBy("TO_CHAR(o.created_at, 'YYYY-MM')") + .orderBy("TO_CHAR(o.created_at, 'YYYY-MM')", 'ASC') .getRawMany(); return result.map((r) => ({ @@ -195,19 +225,20 @@ export class BidAnalyticsService { } /** - * AnĆ”lisis de competidores + * Analisis de competidores */ async getCompetitorAnalysis(ctx: ServiceContext): Promise { - const result = await this.competitorRepository - .createQueryBuilder('c') - .select('c.company_name', 'companyName') + const result = await this.proposalRepository + .createQueryBuilder('p') + .leftJoin('p.vendor', 'v') + .select('v.business_name', 'companyName') .addSelect('COUNT(*)', 'encounters') - .addSelect('SUM(CASE WHEN c.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins') - .addSelect('SUM(CASE WHEN c.status = \'loser\' THEN 1 ELSE 0 END)', 'ourWins') - .addSelect('AVG(c.proposed_amount)', 'avgProposedAmount') - .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('c.deleted_at IS NULL') - .groupBy('c.company_name') + .addSelect('SUM(CASE WHEN p.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins') + .addSelect('SUM(CASE WHEN p.status != \'winner\' AND p.status = \'qualified\' THEN 1 ELSE 0 END)', 'ourWins') + .addSelect('AVG(CAST(p.proposed_amount AS DECIMAL))', 'avgProposedAmount') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.deleted_at IS NULL') + .groupBy('v.business_name') .having('COUNT(*) >= 2') .orderBy('COUNT(*)', 'DESC') .take(20) @@ -226,7 +257,7 @@ export class BidAnalyticsService { } /** - * AnĆ”lisis de conversión del funnel + * Analisis de conversion del funnel */ async getFunnelAnalysis(ctx: ServiceContext, months = 12): Promise { const fromDate = new Date(); @@ -236,44 +267,44 @@ export class BidAnalyticsService { .createQueryBuilder('o') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.identification_date >= :fromDate', { fromDate }); + .andWhere('o.created_at >= :fromDate', { fromDate }); const identified = await baseQuery.clone().getCount(); - const qualified = await baseQuery.clone() - .andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['identified'] }) + const evaluating = await baseQuery.clone() + .andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['registered'] }) .getCount(); - const pursuing = await baseQuery.clone() - .andWhere('o.status IN (:...pursuitStatuses)', { pursuitStatuses: ['pursuing', 'bid_submitted', 'won', 'lost'] }) + const go = await baseQuery.clone() + .andWhere('o.status IN (:...goStatuses)', { goStatuses: ['go', 'preparing', 'converted'] }) .getCount(); - const bidSubmitted = await baseQuery.clone() - .andWhere('o.status IN (:...submittedStatuses)', { submittedStatuses: ['bid_submitted', 'won', 'lost'] }) + const preparing = await baseQuery.clone() + .andWhere('o.status IN (:...prepStatuses)', { prepStatuses: ['preparing', 'converted'] }) .getCount(); - const won = await baseQuery.clone() - .andWhere('o.status = :status', { status: 'won' }) + const converted = await baseQuery.clone() + .andWhere('o.status = :status', { status: 'converted' }) .getCount(); return { identified, - qualified, - pursuing, - bidSubmitted, - won, + qualified: evaluating, + pursuing: go, + bidSubmitted: preparing, + won: converted, conversionRates: { - identifiedToQualified: identified > 0 ? (qualified / identified) * 100 : 0, - qualifiedToPursuing: qualified > 0 ? (pursuing / qualified) * 100 : 0, - pursuingToSubmitted: pursuing > 0 ? (bidSubmitted / pursuing) * 100 : 0, - submittedToWon: bidSubmitted > 0 ? (won / bidSubmitted) * 100 : 0, - overallConversion: identified > 0 ? (won / identified) * 100 : 0, + identifiedToQualified: identified > 0 ? (evaluating / identified) * 100 : 0, + qualifiedToPursuing: evaluating > 0 ? (go / evaluating) * 100 : 0, + pursuingToSubmitted: go > 0 ? (preparing / go) * 100 : 0, + submittedToWon: preparing > 0 ? (converted / preparing) * 100 : 0, + overallConversion: identified > 0 ? (converted / identified) * 100 : 0, }, }; } /** - * AnĆ”lisis de tiempos de ciclo + * Analisis de tiempos de ciclo */ async getCycleTimeAnalysis(ctx: ServiceContext, months = 12): Promise { const fromDate = new Date(); @@ -281,23 +312,23 @@ export class BidAnalyticsService { const result = await this.opportunityRepository .createQueryBuilder('o') - .select('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays') - .addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'minDays') - .addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'maxDays') + .select('AVG(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'avgDays') + .addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'minDays') + .addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'maxDays') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] }) - .andWhere('o.identification_date >= :fromDate', { fromDate }) + .andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] }) + .andWhere('o.created_at >= :fromDate', { fromDate }) .getRawOne(); const byOutcome = await this.opportunityRepository .createQueryBuilder('o') .select('o.status', 'outcome') - .addSelect('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays') + .addSelect('AVG(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'avgDays') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] }) - .andWhere('o.identification_date >= :fromDate', { fromDate }) + .andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] }) + .andWhere('o.created_at >= :fromDate', { fromDate }) .groupBy('o.status') .getRawMany(); @@ -308,11 +339,222 @@ export class BidAnalyticsService { maxDays: Math.round(parseFloat(result?.maxDays) || 0), }, byOutcome: byOutcome.map((r) => ({ - outcome: r.outcome as 'won' | 'lost', + outcome: r.outcome === 'converted' ? 'won' : 'lost', avgDays: Math.round(parseFloat(r.avgDays) || 0), })), }; } + + /** + * Obtener win rate con filtros de fecha opcionales + */ + async getWinRate(ctx: ServiceContext, filters?: DateRange): Promise { + const qb = this.tenderRepository + .createQueryBuilder('t') + .select('t.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'cancelled'] }); + + if (filters?.dateFrom) { + qb.andWhere('t.award_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters?.dateTo) { + qb.andWhere('t.award_date <= :dateTo', { dateTo: filters.dateTo }); + } + + const results = await qb.groupBy('t.status').getRawMany(); + + const won = parseInt(results.find(r => r.status === 'awarded')?.count || '0', 10); + const lost = parseInt(results.find(r => r.status === 'cancelled')?.count || '0', 10); + const total = won + lost; + + return { + won, + lost, + rate: total > 0 ? (won / total) * 100 : 0, + }; + } + + /** + * Obtener valor del pipeline por status y prioridad + */ + async getPipelineValue(ctx: ServiceContext): Promise { + const activeStatuses = ['registered', 'evaluating', 'go', 'preparing']; + + // Total value + const totalResult = await this.opportunityRepository + .createQueryBuilder('o') + .select('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status IN (:...statuses)', { statuses: activeStatuses }) + .getRawOne(); + + // By status + const byStatusResults = await this.opportunityRepository + .createQueryBuilder('o') + .select('o.status', 'status') + .addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value') + .addSelect('COUNT(*)', 'count') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status IN (:...statuses)', { statuses: activeStatuses }) + .groupBy('o.status') + .getRawMany(); + + // By priority + const byPriorityResults = await this.opportunityRepository + .createQueryBuilder('o') + .select('o.priority', 'priority') + .addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value') + .addSelect('COUNT(*)', 'count') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status IN (:...statuses)', { statuses: activeStatuses }) + .groupBy('o.priority') + .getRawMany(); + + return { + totalValue: parseFloat(totalResult?.totalValue) || 0, + byStatus: byStatusResults.map(r => ({ + status: r.status as OpportunityStatus, + value: parseFloat(r.value) || 0, + count: parseInt(r.count, 10), + })), + byPriority: byPriorityResults.map(r => ({ + priority: r.priority as OpportunityPriority, + value: parseFloat(r.value) || 0, + count: parseInt(r.count, 10), + })), + }; + } + + /** + * Analisis de competidores mas frecuentes + */ + async getCompetitorFrequency(ctx: ServiceContext, filters?: DateRange): Promise { + const qb = this.proposalRepository + .createQueryBuilder('p') + .leftJoin('p.vendor', 'v') + .select('v.business_name', 'companyName') + .addSelect('v.rfc', 'rfc') + .addSelect('COUNT(*)', 'appearances') + .addSelect("SUM(CASE WHEN p.status = 'winner' THEN 1 ELSE 0 END)", 'winsAgainstUs') + .addSelect("SUM(CASE WHEN p.status != 'winner' AND p.status = 'qualified' THEN 1 ELSE 0 END)", 'lossesAgainstUs') + .addSelect('AVG(CAST(p.proposed_amount AS DECIMAL))', 'avgProposedAmount') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.deleted_at IS NULL'); + + if (filters?.dateFrom || filters?.dateTo) { + qb.innerJoin('p.tender', 't'); + if (filters.dateFrom) { + qb.andWhere('t.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('t.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + } + + const results = await qb + .groupBy('v.business_name') + .addGroupBy('v.rfc') + .orderBy('COUNT(*)', 'DESC') + .limit(20) + .getRawMany(); + + return results.map(r => ({ + companyName: r.companyName, + rfc: r.rfc, + appearances: parseInt(r.appearances, 10), + winsAgainstUs: parseInt(r.winsAgainstUs, 10), + lossesAgainstUs: parseInt(r.lossesAgainstUs, 10), + avgProposalDifference: 0, + })); + } + + /** + * Obtener promedios de licitaciones + */ + async getAverages(ctx: ServiceContext): Promise { + // Average bid size + const bidSizeResult = await this.tenderRepository + .createQueryBuilder('t') + .select('AVG(CAST(t.reference_amount AS DECIMAL))', 'avgBidSize') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.reference_amount IS NOT NULL') + .getRawOne(); + + // Average time to award (from proposal deadline to award) + const timeToAwardResult = await this.tenderRepository + .createQueryBuilder('t') + .select('AVG(EXTRACT(DAY FROM (t.award_date - t.proposal_deadline)))', 'avgDays') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'cancelled'] }) + .andWhere('t.award_date IS NOT NULL') + .getRawOne(); + + // Average proposal count per tender + const proposalCountResult = await this.proposalRepository + .createQueryBuilder('p') + .select('AVG(cnt)', 'avgCount') + .from(subQuery => { + return subQuery + .select('p2.tender_id', 'tenderId') + .addSelect('COUNT(*)', 'cnt') + .from('bidding.proposals', 'p2') + .where('p2.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p2.deleted_at IS NULL') + .groupBy('p2.tender_id'); + }, 'counts') + .getRawOne(); + + return { + avgBidSize: parseFloat(bidSizeResult?.avgBidSize) || 0, + avgTimeToAward: Math.round(parseFloat(timeToAwardResult?.avgDays) || 0), + avgProposalCount: parseFloat(proposalCountResult?.avgCount) || 0, + }; + } + + /** + * Obtener oportunidades por fuente + */ + async getOpportunitiesBySource( + ctx: ServiceContext, + dateFrom?: Date, + dateTo?: Date + ): Promise<{ source: OpportunitySource; count: number; value: number; winRate: number }[]> { + const qb = this.opportunityRepository + .createQueryBuilder('o') + .select('o.source', 'source') + .addSelect('COUNT(*)', 'count') + .addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value') + .addSelect("COUNT(*) FILTER (WHERE o.status = 'converted')", 'won') + .addSelect("COUNT(*) FILTER (WHERE o.status IN ('converted', 'no_go'))", 'closed') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL'); + + if (dateFrom) { + qb.andWhere('o.created_at >= :dateFrom', { dateFrom }); + } + if (dateTo) { + qb.andWhere('o.created_at <= :dateTo', { dateTo }); + } + + const results = await qb.groupBy('o.source').getRawMany(); + + return results.map(r => ({ + source: r.source as OpportunitySource, + count: parseInt(r.count, 10), + value: parseFloat(r.value) || 0, + winRate: parseInt(r.closed, 10) > 0 + ? (parseInt(r.won, 10) / parseInt(r.closed, 10)) * 100 + : 0, + })); + } } // Types @@ -320,7 +562,7 @@ export interface BidDashboard { activeOpportunities: number; activeBids: number; pipelineValue: number; - upcomingDeadlines: { id: string; name: string; deadline: Date; status: BidStatus }[]; + upcomingDeadlines: { id: string; name: string; deadline: Date; status: TenderStatus }[]; winRate: number; wonValueYTD: number; } @@ -329,11 +571,10 @@ export interface PipelineBySource { source: OpportunitySource; count: number; totalValue: number; - weightedValue: number; } export interface WinRateByType { - bidType: BidType; + bidType: TenderType; won: number; total: number; winRate: number; diff --git a/src/modules/bidding/services/bid-budget.service.ts b/src/modules/bidding/services/bid-budget.service.ts deleted file mode 100644 index 9bbf3a3..0000000 --- a/src/modules/bidding/services/bid-budget.service.ts +++ /dev/null @@ -1,386 +0,0 @@ -/** - * BidBudgetService - Gestión de Presupuestos de Licitación - * - * CRUD y cĆ”lculos para propuestas económicas. - * - * @module Bidding - */ - -import { Repository } from 'typeorm'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; -import { BidBudget, BudgetItemType, BudgetStatus } from '../entities/bid-budget.entity'; - -export interface CreateBudgetItemDto { - bidId: string; - parentId?: string; - code: string; - name: string; - description?: string; - itemType: BudgetItemType; - unit?: string; - quantity?: number; - unitPrice?: number; - materialsCost?: number; - laborCost?: number; - equipmentCost?: number; - subcontractCost?: number; - indirectPercentage?: number; - profitPercentage?: number; - financingPercentage?: number; - baseAmount?: number; - catalogConceptId?: string; - notes?: string; - metadata?: Record; -} - -export interface UpdateBudgetItemDto extends Partial { - status?: BudgetStatus; - adjustmentReason?: string; -} - -export interface BudgetFilters { - bidId: string; - itemType?: BudgetItemType; - status?: BudgetStatus; - parentId?: string | null; - isSummary?: boolean; -} - -export class BidBudgetService { - constructor(private readonly repository: Repository) {} - - /** - * Crear item de presupuesto - */ - async create(ctx: ServiceContext, data: CreateBudgetItemDto): Promise { - // Calcular nivel jerĆ”rquico - let level = 0; - if (data.parentId) { - const parent = await this.repository.findOne({ - where: { id: data.parentId, tenantId: ctx.tenantId }, - }); - if (parent) { - level = parent.level + 1; - } - } - - // Calcular orden - const lastItem = await this.repository - .createQueryBuilder('bb') - .where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('bb.bid_id = :bidId', { bidId: data.bidId }) - .andWhere(data.parentId ? 'bb.parent_id = :parentId' : 'bb.parent_id IS NULL', { parentId: data.parentId }) - .orderBy('bb.sort_order', 'DESC') - .getOne(); - - const sortOrder = lastItem ? lastItem.sortOrder + 1 : 0; - - // Calcular totales - const quantity = data.quantity || 0; - const unitPrice = data.unitPrice || 0; - const totalAmount = quantity * unitPrice; - - // Calcular varianza si hay base - let varianceAmount = null; - let variancePercentage = null; - if (data.baseAmount !== undefined && data.baseAmount > 0) { - varianceAmount = totalAmount - data.baseAmount; - variancePercentage = (varianceAmount / data.baseAmount) * 100; - } - - const item = this.repository.create({ - tenantId: ctx.tenantId, - bidId: data.bidId, - parentId: data.parentId, - code: data.code, - name: data.name, - description: data.description, - itemType: data.itemType, - unit: data.unit, - quantity: data.quantity || 0, - unitPrice: data.unitPrice || 0, - materialsCost: data.materialsCost, - laborCost: data.laborCost, - equipmentCost: data.equipmentCost, - subcontractCost: data.subcontractCost, - indirectPercentage: data.indirectPercentage, - profitPercentage: data.profitPercentage, - financingPercentage: data.financingPercentage, - baseAmount: data.baseAmount, - catalogConceptId: data.catalogConceptId, - notes: data.notes, - metadata: data.metadata, - level, - sortOrder, - totalAmount, - varianceAmount: varianceAmount ?? undefined, - variancePercentage: variancePercentage ?? undefined, - status: 'draft', - isSummary: false, - isCalculated: true, - createdBy: ctx.userId, - updatedBy: ctx.userId, - }); - - const saved = await this.repository.save(item); - - // Recalcular padres - if (data.parentId) { - await this.recalculateParent(ctx, data.parentId); - } - - return saved; - } - - /** - * Buscar por ID - */ - async findById(ctx: ServiceContext, id: string): Promise { - return this.repository.findOne({ - where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, - }); - } - - /** - * Buscar items de un presupuesto - */ - async findByBid(ctx: ServiceContext, bidId: string): Promise { - return this.repository.find({ - where: { bidId, tenantId: ctx.tenantId, deletedAt: undefined }, - order: { sortOrder: 'ASC' }, - }); - } - - /** - * Buscar items con filtros - */ - async findWithFilters( - ctx: ServiceContext, - filters: BudgetFilters, - page = 1, - limit = 100 - ): Promise> { - const qb = this.repository - .createQueryBuilder('bb') - .where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('bb.bid_id = :bidId', { bidId: filters.bidId }) - .andWhere('bb.deleted_at IS NULL'); - - if (filters.itemType) { - qb.andWhere('bb.item_type = :itemType', { itemType: filters.itemType }); - } - if (filters.status) { - qb.andWhere('bb.status = :status', { status: filters.status }); - } - if (filters.parentId !== undefined) { - if (filters.parentId === null) { - qb.andWhere('bb.parent_id IS NULL'); - } else { - qb.andWhere('bb.parent_id = :parentId', { parentId: filters.parentId }); - } - } - if (filters.isSummary !== undefined) { - qb.andWhere('bb.is_summary = :isSummary', { isSummary: filters.isSummary }); - } - - const skip = (page - 1) * limit; - qb.orderBy('bb.sort_order', 'ASC').skip(skip).take(limit); - - const [data, total] = await qb.getManyAndCount(); - - return { - data, - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }; - } - - /** - * Obtener Ć”rbol jerĆ”rquico - */ - async getTree(ctx: ServiceContext, bidId: string): Promise { - const items = await this.findByBid(ctx, bidId); - return this.buildTree(items); - } - - private buildTree(items: BidBudget[], parentId: string | null = null): (BidBudget & { children?: BidBudget[] })[] { - return items - .filter((item) => item.parentId === parentId) - .map((item) => ({ - ...item, - children: this.buildTree(items, item.id), - })); - } - - /** - * Actualizar item - */ - async update(ctx: ServiceContext, id: string, data: UpdateBudgetItemDto): Promise { - const item = await this.findById(ctx, id); - if (!item) return null; - - // Recalcular totales si cambian cantidad o precio - const quantity = data.quantity ?? item.quantity; - const unitPrice = data.unitPrice ?? item.unitPrice; - const totalAmount = quantity * unitPrice; - - // Recalcular varianza - const baseAmount = data.baseAmount ?? item.baseAmount; - let varianceAmount = item.varianceAmount; - let variancePercentage = item.variancePercentage; - if (baseAmount !== undefined && baseAmount > 0) { - varianceAmount = totalAmount - baseAmount; - variancePercentage = (varianceAmount / baseAmount) * 100; - } - - // Marcar como ajustado si hay razón - const isAdjusted = data.adjustmentReason ? true : item.isAdjusted; - - Object.assign(item, { - ...data, - totalAmount, - varianceAmount, - variancePercentage, - isAdjusted, - isCalculated: true, - updatedBy: ctx.userId, - }); - - const saved = await this.repository.save(item); - - // Recalcular padres - if (item.parentId) { - await this.recalculateParent(ctx, item.parentId); - } - - return saved; - } - - /** - * Recalcular item padre - */ - private async recalculateParent(ctx: ServiceContext, parentId: string): Promise { - const parent = await this.findById(ctx, parentId); - if (!parent) return; - - const children = await this.repository.find({ - where: { parentId, tenantId: ctx.tenantId, deletedAt: undefined }, - }); - - const totalAmount = children.reduce((sum, child) => sum + (Number(child.totalAmount) || 0), 0); - - parent.totalAmount = totalAmount; - parent.isSummary = children.length > 0; - parent.isCalculated = true; - parent.updatedBy = ctx.userId; - - // Recalcular varianza - if (parent.baseAmount !== undefined && parent.baseAmount > 0) { - parent.varianceAmount = totalAmount - Number(parent.baseAmount); - parent.variancePercentage = (parent.varianceAmount / Number(parent.baseAmount)) * 100; - } - - await this.repository.save(parent); - - // Recursivamente actualizar ancestros - if (parent.parentId) { - await this.recalculateParent(ctx, parent.parentId); - } - } - - /** - * Obtener resumen de presupuesto - */ - async getSummary(ctx: ServiceContext, bidId: string): Promise { - const items = await this.findByBid(ctx, bidId); - - const directCosts = items - .filter((i) => i.itemType === 'direct_cost' || i.itemType === 'labor' || i.itemType === 'materials' || i.itemType === 'equipment' || i.itemType === 'subcontract') - .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); - - const indirectCosts = items - .filter((i) => i.itemType === 'indirect_cost' || i.itemType === 'overhead') - .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); - - const profit = items - .filter((i) => i.itemType === 'profit') - .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); - - const financing = items - .filter((i) => i.itemType === 'financing') - .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); - - const taxes = items - .filter((i) => i.itemType === 'taxes') - .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); - - const bonds = items - .filter((i) => i.itemType === 'bonds') - .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); - - const contingency = items - .filter((i) => i.itemType === 'contingency') - .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); - - const subtotal = directCosts + indirectCosts + profit + financing + contingency + bonds; - const total = subtotal + taxes; - - const baseTotal = items.reduce((sum, i) => sum + (Number(i.baseAmount) || 0), 0); - - return { - directCosts, - indirectCosts, - profit, - financing, - taxes, - bonds, - contingency, - subtotal, - total, - baseTotal, - variance: total - baseTotal, - variancePercentage: baseTotal > 0 ? ((total - baseTotal) / baseTotal) * 100 : 0, - itemCount: items.length, - }; - } - - /** - * Cambiar estado del presupuesto - */ - async changeStatus(ctx: ServiceContext, bidId: string, status: BudgetStatus): Promise { - const result = await this.repository.update( - { bidId, tenantId: ctx.tenantId, deletedAt: undefined }, - { status, updatedBy: ctx.userId } - ); - return result.affected || 0; - } - - /** - * Soft delete - */ - async softDelete(ctx: ServiceContext, id: string): Promise { - const result = await this.repository.update( - { id, tenantId: ctx.tenantId }, - { deletedAt: new Date(), updatedBy: ctx.userId } - ); - return (result.affected || 0) > 0; - } -} - -export interface BudgetSummary { - directCosts: number; - indirectCosts: number; - profit: number; - financing: number; - taxes: number; - bonds: number; - contingency: number; - subtotal: number; - total: number; - baseTotal: number; - variance: number; - variancePercentage: number; - itemCount: number; -} diff --git a/src/modules/bidding/services/bid.service.ts b/src/modules/bidding/services/bid.service.ts deleted file mode 100644 index 0cf6969..0000000 --- a/src/modules/bidding/services/bid.service.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * BidService - Gestión de Licitaciones - * - * CRUD y lógica de negocio para licitaciones/propuestas. - * - * @module Bidding - */ - -import { Repository, In, Between } from 'typeorm'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; -import { Bid, BidType, BidStatus, BidStage } from '../entities/bid.entity'; - -export interface CreateBidDto { - opportunityId: string; - code: string; - name: string; - description?: string; - bidType: BidType; - tenderNumber?: string; - tenderName?: string; - contractingEntity?: string; - publicationDate?: Date; - siteVisitDate?: Date; - clarificationDeadline?: Date; - submissionDeadline: Date; - openingDate?: Date; - baseBudget?: number; - currency?: string; - technicalWeight?: number; - economicWeight?: number; - bidBondAmount?: number; - bidManagerId?: string; - notes?: string; - metadata?: Record; -} - -export interface UpdateBidDto extends Partial { - status?: BidStatus; - stage?: BidStage; - ourProposalAmount?: number; - technicalScore?: number; - economicScore?: number; - finalScore?: number; - rankingPosition?: number; - bidBondNumber?: string; - bidBondExpiry?: Date; - awardDate?: Date; - contractSigningDate?: Date; - winnerName?: string; - winningAmount?: number; - rejectionReason?: string; - lessonsLearned?: string; - completionPercentage?: number; - checklist?: Record; -} - -export interface BidFilters { - status?: BidStatus | BidStatus[]; - bidType?: BidType; - stage?: BidStage; - opportunityId?: string; - bidManagerId?: string; - contractingEntity?: string; - deadlineFrom?: Date; - deadlineTo?: Date; - minBudget?: number; - maxBudget?: number; - search?: string; -} - -export class BidService { - constructor(private readonly repository: Repository) {} - - /** - * Crear licitación - */ - async create(ctx: ServiceContext, data: CreateBidDto): Promise { - const bid = this.repository.create({ - tenantId: ctx.tenantId, - ...data, - status: 'draft', - stage: 'initial', - completionPercentage: 0, - createdBy: ctx.userId, - updatedBy: ctx.userId, - }); - - return this.repository.save(bid); - } - - /** - * Buscar por ID - */ - async findById(ctx: ServiceContext, id: string): Promise { - return this.repository.findOne({ - where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, - relations: ['opportunity', 'bidManager', 'documents', 'calendarEvents', 'teamMembers'], - }); - } - - /** - * Buscar con filtros - */ - async findWithFilters( - ctx: ServiceContext, - filters: BidFilters, - page = 1, - limit = 20 - ): Promise> { - const qb = this.repository - .createQueryBuilder('b') - .leftJoinAndSelect('b.opportunity', 'o') - .leftJoinAndSelect('b.bidManager', 'm') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL'); - - if (filters.status) { - if (Array.isArray(filters.status)) { - qb.andWhere('b.status IN (:...statuses)', { statuses: filters.status }); - } else { - qb.andWhere('b.status = :status', { status: filters.status }); - } - } - if (filters.bidType) { - qb.andWhere('b.bid_type = :bidType', { bidType: filters.bidType }); - } - if (filters.stage) { - qb.andWhere('b.stage = :stage', { stage: filters.stage }); - } - if (filters.opportunityId) { - qb.andWhere('b.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId }); - } - if (filters.bidManagerId) { - qb.andWhere('b.bid_manager_id = :bidManagerId', { bidManagerId: filters.bidManagerId }); - } - if (filters.contractingEntity) { - qb.andWhere('b.contracting_entity ILIKE :entity', { entity: `%${filters.contractingEntity}%` }); - } - if (filters.deadlineFrom) { - qb.andWhere('b.submission_deadline >= :deadlineFrom', { deadlineFrom: filters.deadlineFrom }); - } - if (filters.deadlineTo) { - qb.andWhere('b.submission_deadline <= :deadlineTo', { deadlineTo: filters.deadlineTo }); - } - if (filters.minBudget !== undefined) { - qb.andWhere('b.base_budget >= :minBudget', { minBudget: filters.minBudget }); - } - if (filters.maxBudget !== undefined) { - qb.andWhere('b.base_budget <= :maxBudget', { maxBudget: filters.maxBudget }); - } - if (filters.search) { - qb.andWhere( - '(b.name ILIKE :search OR b.code ILIKE :search OR b.tender_number ILIKE :search)', - { search: `%${filters.search}%` } - ); - } - - const skip = (page - 1) * limit; - qb.orderBy('b.submission_deadline', 'ASC').skip(skip).take(limit); - - const [data, total] = await qb.getManyAndCount(); - - return { - data, - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }; - } - - /** - * Actualizar licitación - */ - async update(ctx: ServiceContext, id: string, data: UpdateBidDto): Promise { - const bid = await this.findById(ctx, id); - if (!bid) return null; - - // Calcular puntuación final si hay scores - let finalScore = data.finalScore ?? bid.finalScore; - const techScore = data.technicalScore ?? bid.technicalScore; - const econScore = data.economicScore ?? bid.economicScore; - const techWeight = data.technicalWeight ?? bid.technicalWeight; - const econWeight = data.economicWeight ?? bid.economicWeight; - - if (techScore !== undefined && econScore !== undefined) { - finalScore = (techScore * techWeight / 100) + (econScore * econWeight / 100); - } - - Object.assign(bid, { - ...data, - finalScore, - updatedBy: ctx.userId, - }); - - return this.repository.save(bid); - } - - /** - * Cambiar estado - */ - async changeStatus(ctx: ServiceContext, id: string, status: BidStatus): Promise { - const bid = await this.findById(ctx, id); - if (!bid) return null; - - bid.status = status; - bid.updatedBy = ctx.userId; - - return this.repository.save(bid); - } - - /** - * Cambiar etapa - */ - async changeStage(ctx: ServiceContext, id: string, stage: BidStage): Promise { - const bid = await this.findById(ctx, id); - if (!bid) return null; - - bid.stage = stage; - bid.updatedBy = ctx.userId; - - return this.repository.save(bid); - } - - /** - * Marcar como presentada - */ - async submit(ctx: ServiceContext, id: string, proposalAmount: number): Promise { - const bid = await this.findById(ctx, id); - if (!bid) return null; - - bid.status = 'submitted'; - bid.stage = 'post_submission'; - bid.ourProposalAmount = proposalAmount; - bid.updatedBy = ctx.userId; - - return this.repository.save(bid); - } - - /** - * Registrar resultado - */ - async recordResult( - ctx: ServiceContext, - id: string, - won: boolean, - details: { - winnerName?: string; - winningAmount?: number; - rankingPosition?: number; - rejectionReason?: string; - lessonsLearned?: string; - } - ): Promise { - const bid = await this.findById(ctx, id); - if (!bid) return null; - - bid.status = won ? 'awarded' : 'rejected'; - bid.awardDate = new Date(); - Object.assign(bid, details); - bid.updatedBy = ctx.userId; - - return this.repository.save(bid); - } - - /** - * Convertir a proyecto - */ - async convertToProject(ctx: ServiceContext, id: string, projectId: string): Promise { - const bid = await this.findById(ctx, id); - if (!bid || bid.status !== 'awarded') return null; - - bid.convertedToProjectId = projectId; - bid.convertedAt = new Date(); - bid.updatedBy = ctx.userId; - - return this.repository.save(bid); - } - - /** - * Obtener próximas fechas lĆ­mite - */ - async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise { - const now = new Date(); - const future = new Date(); - future.setDate(future.getDate() + days); - - return this.repository.find({ - where: { - tenantId: ctx.tenantId, - deletedAt: undefined, - status: In(['draft', 'preparation', 'review', 'approved']), - submissionDeadline: Between(now, future), - }, - relations: ['opportunity', 'bidManager'], - order: { submissionDeadline: 'ASC' }, - }); - } - - /** - * Obtener estadĆ­sticas - */ - async getStats(ctx: ServiceContext, year?: number): Promise { - const currentYear = year || new Date().getFullYear(); - const startDate = new Date(currentYear, 0, 1); - const endDate = new Date(currentYear, 11, 31); - - const total = await this.repository - .createQueryBuilder('b') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) - .getCount(); - - const byStatus = await this.repository - .createQueryBuilder('b') - .select('b.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) - .groupBy('b.status') - .getRawMany(); - - const byType = await this.repository - .createQueryBuilder('b') - .select('b.bid_type', 'bidType') - .addSelect('COUNT(*)', 'count') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) - .groupBy('b.bid_type') - .getRawMany(); - - const valueStats = await this.repository - .createQueryBuilder('b') - .select('SUM(b.base_budget)', 'totalBudget') - .addSelect('SUM(b.our_proposal_amount)', 'totalProposed') - .addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'totalWon') - .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) - .andWhere('b.deleted_at IS NULL') - .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) - .getRawOne(); - - const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0; - const rejectedCount = byStatus.find((s) => s.status === 'rejected')?.count || 0; - const closedCount = parseInt(awardedCount) + parseInt(rejectedCount); - - return { - year: currentYear, - total, - byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })), - byType: byType.map((r) => ({ bidType: r.bidType, count: parseInt(r.count) })), - totalBudget: parseFloat(valueStats?.totalBudget) || 0, - totalProposed: parseFloat(valueStats?.totalProposed) || 0, - totalWon: parseFloat(valueStats?.totalWon) || 0, - winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0, - }; - } - - /** - * Soft delete - */ - async softDelete(ctx: ServiceContext, id: string): Promise { - const result = await this.repository.update( - { id, tenantId: ctx.tenantId }, - { deletedAt: new Date(), updatedBy: ctx.userId } - ); - return (result.affected || 0) > 0; - } -} - -export interface BidStats { - year: number; - total: number; - byStatus: { status: BidStatus; count: number }[]; - byType: { bidType: BidType; count: number }[]; - totalBudget: number; - totalProposed: number; - totalWon: number; - winRate: number; -} diff --git a/src/modules/bidding/services/index.ts b/src/modules/bidding/services/index.ts index 12cc587..f49ca6a 100644 --- a/src/modules/bidding/services/index.ts +++ b/src/modules/bidding/services/index.ts @@ -1,9 +1,64 @@ /** - * Bidding Services Index - * @module Bidding + * Bidding Services Index - MAI-018 Preconstrucción/Licitaciones + * + * Exporta todos los servicios del módulo de licitaciones. + * + * @module Bidding (MAI-018) */ -export { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, PipelineData, OpportunityStats } from './opportunity.service'; -export { BidService, CreateBidDto, UpdateBidDto, BidFilters, BidStats } from './bid.service'; -export { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters, BudgetSummary } from './bid-budget.service'; -export { BidAnalyticsService, BidDashboard, PipelineBySource, WinRateByType, MonthlyTrend, CompetitorAnalysis, FunnelAnalysis, CycleTimeAnalysis } from './bid-analytics.service'; +// Opportunity Service +export { + OpportunityService, + CreateOpportunityDto, + UpdateOpportunityDto, + OpportunityFilters, + GoNoGoDecisionDto, + PipelineData, + OpportunityStats, +} from './opportunity.service'; + +// Tender Service (Bid wrapper with tender-specific logic) +export { + TenderService, + CreateTenderDto, + UpdateTenderDto, + TenderFilters, + TenderStats, +} from './tender.service'; + +// Proposal Service (BidCompetitor wrapper for proposals) +export { + ProposalService, + CreateProposalDto, + UpdateProposalDto, + EvaluateProposalDto, + ProposalFilters, + RankedProposal, +} from './proposal.service'; + +// Vendor Service (Vendor/Supplier registry) +export { + VendorService, + CreateVendorDto, + UpdateVendorDto, + VendorFilters, + VendorRecord, + VendorPerformanceHistory, +} from './vendor.service'; + +// Bid Analytics Service +export { + BidAnalyticsService, + BidDashboard, + PipelineBySource, + WinRateByType, + MonthlyTrend, + CompetitorAnalysis, + FunnelAnalysis, + CycleTimeAnalysis, + WinRateResult, + PipelineValueResult, + CompetitorFrequency, + AveragesResult, + DateRange, +} from './bid-analytics.service'; diff --git a/src/modules/bidding/services/opportunity.service.ts b/src/modules/bidding/services/opportunity.service.ts index e0ac765..7b9042c 100644 --- a/src/modules/bidding/services/opportunity.service.ts +++ b/src/modules/bidding/services/opportunity.service.ts @@ -2,123 +2,102 @@ * OpportunityService - Gestión de Oportunidades de Negocio * * CRUD y lógica de negocio para el pipeline de oportunidades. + * Gestiona el ciclo de vida completo desde identificación hasta decisión go/no-go. * - * @module Bidding + * @module Bidding (MAI-018) */ import { Repository, In, Between } from 'typeorm'; import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; -import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from '../entities/opportunity.entity'; +import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from '../entities/opportunity.entity'; export interface CreateOpportunityDto { - code: string; - name: string; + title: string; description?: string; source: OpportunitySource; - projectType: ProjectType; + projectType: string; clientName: string; - clientContact?: string; - clientEmail?: string; - clientPhone?: string; - clientType?: string; location?: string; - state?: string; - city?: string; - estimatedValue?: number; - currency?: string; - constructionAreaM2?: number; - landAreaM2?: number; - identificationDate: Date; - deadlineDate?: Date; - expectedAwardDate?: Date; - expectedStartDate?: Date; - expectedDurationMonths?: number; - winProbability?: number; - requiresBond?: boolean; - requiresExperience?: boolean; - minimumExperienceYears?: number; - minimumCapital?: number; - requiredCertifications?: string[]; - assignedToId?: string; - sourceUrl?: string; - sourceReference?: string; - notes?: string; - metadata?: Record; + estimatedAmount?: number; + estimatedUnits?: number; + priority?: OpportunityPriority; + deadlineDate: Date; +} + +export interface GoNoGoDecisionDto { + decision: 'go' | 'no_go'; + reason: string; } export interface UpdateOpportunityDto extends Partial { status?: OpportunityStatus; - priority?: OpportunityPriority; - lossReason?: string; - winFactors?: string; } export interface OpportunityFilters { status?: OpportunityStatus | OpportunityStatus[]; source?: OpportunitySource; - projectType?: ProjectType; + projectType?: string; priority?: OpportunityPriority; - assignedToId?: string; clientName?: string; - state?: string; dateFrom?: Date; dateTo?: Date; minValue?: number; maxValue?: number; search?: string; + page?: number; + limit?: number; } export class OpportunityService { constructor(private readonly repository: Repository) {} /** - * Crear oportunidad + * Genera código automĆ”tico para oportunidad: OPP-YYYY-NNN + */ + private async generateCode(ctx: ServiceContext): Promise { + const year = new Date().getFullYear(); + const prefix = `OPP-${year}-`; + + const lastOpportunity = await this.repository + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.code LIKE :prefix', { prefix: `${prefix}%` }) + .orderBy('o.code', 'DESC') + .getOne(); + + let sequence = 1; + if (lastOpportunity) { + const lastSequence = parseInt(lastOpportunity.code.replace(prefix, ''), 10); + if (!isNaN(lastSequence)) { + sequence = lastSequence + 1; + } + } + + return `${prefix}${sequence.toString().padStart(3, '0')}`; + } + + /** + * Crear oportunidad con código auto-generado */ async create(ctx: ServiceContext, data: CreateOpportunityDto): Promise { - const weightedValue = data.estimatedValue && data.winProbability - ? data.estimatedValue * (data.winProbability / 100) - : undefined; + const code = await this.generateCode(ctx); const opportunity = this.repository.create({ tenantId: ctx.tenantId, - code: data.code, - name: data.name, + code, + title: data.title, description: data.description, source: data.source, projectType: data.projectType, clientName: data.clientName, - clientContact: data.clientContact, - clientEmail: data.clientEmail, - clientPhone: data.clientPhone, - clientType: data.clientType, location: data.location, - state: data.state, - city: data.city, - estimatedValue: data.estimatedValue, - currency: data.currency || 'MXN', - constructionAreaM2: data.constructionAreaM2, - landAreaM2: data.landAreaM2, - identificationDate: data.identificationDate, + estimatedAmount: data.estimatedAmount?.toString(), + estimatedUnits: data.estimatedUnits, deadlineDate: data.deadlineDate, - expectedAwardDate: data.expectedAwardDate, - expectedStartDate: data.expectedStartDate, - expectedDurationMonths: data.expectedDurationMonths, - winProbability: data.winProbability || 0, - requiresBond: data.requiresBond || false, - requiresExperience: data.requiresExperience || false, - minimumExperienceYears: data.minimumExperienceYears, - minimumCapital: data.minimumCapital, - requiredCertifications: data.requiredCertifications, - assignedToId: data.assignedToId, - sourceUrl: data.sourceUrl, - sourceReference: data.sourceReference, - notes: data.notes, - metadata: data.metadata, - status: 'identified', - priority: 'medium', - weightedValue, - createdBy: ctx.userId, - updatedBy: ctx.userId, + status: 'registered' as OpportunityStatus, + priority: data.priority || 'medium', + createdById: ctx.userId, + updatedById: ctx.userId, }); return this.repository.save(opportunity); @@ -130,19 +109,29 @@ export class OpportunityService { async findById(ctx: ServiceContext, id: string): Promise { return this.repository.findOne({ where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, - relations: ['assignedTo', 'bids'], + relations: ['tenders'], }); } /** - * Buscar con filtros + * Buscar por código */ - async findWithFilters( + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['tenders'], + }); + } + + /** + * Buscar con filtros y paginación + */ + async findAll( ctx: ServiceContext, - filters: OpportunityFilters, - page = 1, - limit = 20 + filters: OpportunityFilters = {} ): Promise> { + const page = filters.page || 1; + const limit = filters.limit || 20; const qb = this.repository .createQueryBuilder('o') .leftJoinAndSelect('o.assignedTo', 'u') @@ -165,15 +154,9 @@ export class OpportunityService { if (filters.priority) { qb.andWhere('o.priority = :priority', { priority: filters.priority }); } - if (filters.assignedToId) { - qb.andWhere('o.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId }); - } if (filters.clientName) { qb.andWhere('o.client_name ILIKE :clientName', { clientName: `%${filters.clientName}%` }); } - if (filters.state) { - qb.andWhere('o.state = :state', { state: filters.state }); - } if (filters.dateFrom) { qb.andWhere('o.identification_date >= :dateFrom', { dateFrom: filters.dateFrom }); } @@ -214,20 +197,21 @@ export class OpportunityService { const opportunity = await this.findById(ctx, id); if (!opportunity) return null; - // Recalcular weighted value si cambian los factores - let weightedValue = opportunity.weightedValue; - const estimatedValue = data.estimatedValue ?? opportunity.estimatedValue; - const winProbability = data.winProbability ?? opportunity.winProbability; - if (estimatedValue && winProbability) { - weightedValue = estimatedValue * (winProbability / 100); + if (data.title !== undefined) opportunity.title = data.title; + if (data.description !== undefined) opportunity.description = data.description; + if (data.source !== undefined) opportunity.source = data.source; + if (data.projectType !== undefined) opportunity.projectType = data.projectType; + if (data.clientName !== undefined) opportunity.clientName = data.clientName; + if (data.location !== undefined) opportunity.location = data.location; + if (data.estimatedAmount !== undefined) opportunity.estimatedAmount = data.estimatedAmount.toString(); + if (data.estimatedUnits !== undefined) opportunity.estimatedUnits = data.estimatedUnits; + if (data.priority !== undefined) opportunity.priority = data.priority; + if (data.deadlineDate !== undefined) opportunity.deadlineDate = data.deadlineDate; + if (data.status !== undefined) opportunity.status = data.status; + if (ctx.userId) { + opportunity.updatedById = ctx.userId; } - Object.assign(opportunity, { - ...data, - weightedValue, - updatedBy: ctx.userId, - }); - return this.repository.save(opportunity); } @@ -244,12 +228,49 @@ export class OpportunityService { if (!opportunity) return null; opportunity.status = status; - if (status === 'lost' && reason) { - opportunity.lossReason = reason; - } else if (status === 'won' && reason) { - opportunity.winFactors = reason; + if (reason) { + opportunity.goDecisionReason = reason; + } + if (ctx.userId) { + opportunity.updatedById = ctx.userId; + } + + return this.repository.save(opportunity); + } + + /** + * Evaluar decisión Go/No-Go + * @throws Error si el estado actual no es 'evaluating' + */ + async evaluateGoNoGo( + ctx: ServiceContext, + id: string, + dto: GoNoGoDecisionDto + ): Promise { + const opportunity = await this.findById(ctx, id); + if (!opportunity) { + throw new Error('Opportunity not found'); + } + + // Validar que la oportunidad estĆ© en fase de evaluación + if (opportunity.status !== 'evaluating') { + throw new Error('Opportunity must be in "evaluating" status to evaluate go/no-go decision'); + } + + // Registrar la decisión + opportunity.goDecisionDate = new Date(); + opportunity.goDecisionReason = dto.reason; + + // Cambiar estado segĆŗn decisión + if (dto.decision === 'go') { + opportunity.status = 'go'; + } else { + opportunity.status = 'no_go'; + } + + if (ctx.userId) { + opportunity.updatedById = ctx.userId; } - opportunity.updatedBy = ctx.userId; return this.repository.save(opportunity); } @@ -262,8 +283,7 @@ export class OpportunityService { .createQueryBuilder('o') .select('o.status', 'status') .addSelect('COUNT(*)', 'count') - .addSelect('SUM(o.estimated_value)', 'totalValue') - .addSelect('SUM(o.weighted_value)', 'weightedValue') + .addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') .groupBy('o.status') @@ -273,7 +293,6 @@ export class OpportunityService { status: r.status as OpportunityStatus, count: parseInt(r.count), totalValue: parseFloat(r.totalValue) || 0, - weightedValue: parseFloat(r.weightedValue) || 0, })); } @@ -289,10 +308,9 @@ export class OpportunityService { where: { tenantId: ctx.tenantId, deletedAt: undefined, - status: In(['identified', 'qualified', 'pursuing']), + status: In(['registered', 'evaluating', 'go', 'preparing']), deadlineDate: Between(now, future), }, - relations: ['assignedTo'], order: { deadlineDate: 'ASC' }, }); } @@ -309,7 +327,7 @@ export class OpportunityService { .createQueryBuilder('o') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }); + .andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }); const total = await baseQuery.getCount(); @@ -319,7 +337,7 @@ export class OpportunityService { .addSelect('COUNT(*)', 'count') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) .groupBy('o.status') .getRawMany(); @@ -329,23 +347,22 @@ export class OpportunityService { .addSelect('COUNT(*)', 'count') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) .groupBy('o.source') .getRawMany(); const valueStats = await this.repository .createQueryBuilder('o') - .select('SUM(o.estimated_value)', 'totalValue') - .addSelect('SUM(o.weighted_value)', 'weightedValue') - .addSelect('AVG(o.estimated_value)', 'avgValue') + .select('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue') + .addSelect('AVG(CAST(o.estimated_amount AS DECIMAL))', 'avgValue') .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('o.deleted_at IS NULL') - .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) .getRawOne(); - const wonCount = byStatus.find((s) => s.status === 'won')?.count || 0; - const lostCount = byStatus.find((s) => s.status === 'lost')?.count || 0; - const closedCount = parseInt(wonCount) + parseInt(lostCount); + const convertedCount = byStatus.find((s) => s.status === 'converted')?.count || 0; + const noGoCount = byStatus.find((s) => s.status === 'no_go')?.count || 0; + const closedCount = parseInt(convertedCount) + parseInt(noGoCount); return { year: currentYear, @@ -353,9 +370,8 @@ export class OpportunityService { byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })), bySource: bySource.map((r) => ({ source: r.source, count: parseInt(r.count) })), totalValue: parseFloat(valueStats?.totalValue) || 0, - weightedValue: parseFloat(valueStats?.weightedValue) || 0, avgValue: parseFloat(valueStats?.avgValue) || 0, - winRate: closedCount > 0 ? (parseInt(wonCount) / closedCount) * 100 : 0, + winRate: closedCount > 0 ? (parseInt(convertedCount) / closedCount) * 100 : 0, }; } @@ -365,7 +381,7 @@ export class OpportunityService { async softDelete(ctx: ServiceContext, id: string): Promise { const result = await this.repository.update( { id, tenantId: ctx.tenantId }, - { deletedAt: new Date(), updatedBy: ctx.userId } + { deletedAt: new Date(), updatedById: ctx.userId } ); return (result.affected || 0) > 0; } @@ -375,16 +391,14 @@ export interface PipelineData { status: OpportunityStatus; count: number; totalValue: number; - weightedValue: number; } export interface OpportunityStats { year: number; total: number; - byStatus: { status: OpportunityStatus; count: number }[]; - bySource: { source: OpportunitySource; count: number }[]; + byStatus: { status: string; count: number }[]; + bySource: { source: string; count: number }[]; totalValue: number; - weightedValue: number; avgValue: number; winRate: number; } diff --git a/src/modules/bidding/services/proposal.service.ts b/src/modules/bidding/services/proposal.service.ts new file mode 100644 index 0000000..c28ec61 --- /dev/null +++ b/src/modules/bidding/services/proposal.service.ts @@ -0,0 +1,281 @@ +/** + * ProposalService - Gestion de Propuestas de Licitacion + * + * CRUD y logica de negocio para propuestas/ofertas de proveedores. + * Gestiona evaluacion tecnica, economica y comparacion de propuestas. + * + * @module Bidding (MAI-018) + */ + +import { Repository } from 'typeorm'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Proposal, ProposalStatus } from '../entities/proposal.entity'; +import { Tender } from '../entities/tender.entity'; + +// DTOs +export interface CreateProposalDto { + tenderId: string; + vendorId: string; + proposedAmount: number; + proposedScheduleDays: number; + technicalProposalUrl?: string; + economicProposalUrl?: string; + submittedAt?: Date; +} + +export interface UpdateProposalDto extends Partial> { + status?: ProposalStatus; +} + +export interface EvaluateProposalDto { + technicalScore: number; + economicScore: number; +} + +export interface ProposalFilters { + tenderId?: string; + vendorId?: string; + status?: ProposalStatus | ProposalStatus[]; + search?: string; + page?: number; + limit?: number; +} + +export interface RankedProposal extends Proposal { + id: string; + rank: number; + totalScore: number; +} + +export class ProposalService { + constructor( + private readonly repository: Repository, + private readonly tenderRepository: Repository + ) {} + + /** + * Buscar propuestas con filtros y paginacion + */ + async findAll( + ctx: ServiceContext, + filters: ProposalFilters = {} + ): Promise> { + const page = filters.page || 1; + const limit = filters.limit || 20; + + const qb = this.repository + .createQueryBuilder('p') + .leftJoinAndSelect('p.tender', 't') + .leftJoinAndSelect('p.vendor', 'v') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.deleted_at IS NULL'); + + if (filters.tenderId) { + qb.andWhere('p.tender_id = :tenderId', { tenderId: filters.tenderId }); + } + if (filters.vendorId) { + qb.andWhere('p.vendor_id = :vendorId', { vendorId: filters.vendorId }); + } + if (filters.status) { + if (Array.isArray(filters.status)) { + qb.andWhere('p.status IN (:...statuses)', { statuses: filters.status }); + } else { + qb.andWhere('p.status = :status', { status: filters.status }); + } + } + if (filters.search) { + qb.andWhere( + '(v.business_name ILIKE :search OR v.rfc ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (page - 1) * limit; + qb.orderBy('p.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Buscar por ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['tender', 'vendor'], + }); + } + + /** + * Buscar todas las propuestas de una licitacion + */ + async findByTender(ctx: ServiceContext, tenderId: string): Promise { + return this.repository.find({ + where: { + tenderId, + tenantId: ctx.tenantId, + deletedAt: undefined, + }, + relations: ['vendor'], + order: { createdAt: 'DESC' }, + }); + } + + /** + * Crear propuesta + * @throws Error si la licitacion no esta en status de recepcion + */ + async create(ctx: ServiceContext, dto: CreateProposalDto): Promise { + // Validar que la licitacion existe y esta recibiendo propuestas + const tender = await this.tenderRepository.findOne({ + where: { + id: dto.tenderId, + tenantId: ctx.tenantId, + deletedAt: undefined, + }, + }); + + if (!tender) { + throw new Error('Tender not found'); + } + + const receivingStatuses = ['published', 'receiving']; + if (!receivingStatuses.includes(tender.status)) { + throw new Error('Tender is not currently receiving proposals. Status must be "published" or "receiving"'); + } + + const proposal = this.repository.create({ + tenantId: ctx.tenantId, + tenderId: dto.tenderId, + vendorId: dto.vendorId, + proposedAmount: dto.proposedAmount.toString(), + proposedScheduleDays: dto.proposedScheduleDays, + technicalProposalUrl: dto.technicalProposalUrl, + economicProposalUrl: dto.economicProposalUrl, + submittedAt: dto.submittedAt || new Date(), + status: 'received' as ProposalStatus, + createdById: ctx.userId, + updatedById: ctx.userId, + }); + + return this.repository.save(proposal); + } + + /** + * Actualizar propuesta + */ + async update(ctx: ServiceContext, id: string, dto: UpdateProposalDto): Promise { + const proposal = await this.findById(ctx, id); + if (!proposal) return null; + + if (dto.proposedAmount !== undefined) proposal.proposedAmount = dto.proposedAmount.toString(); + if (dto.proposedScheduleDays !== undefined) proposal.proposedScheduleDays = dto.proposedScheduleDays; + if (dto.technicalProposalUrl !== undefined) proposal.technicalProposalUrl = dto.technicalProposalUrl; + if (dto.economicProposalUrl !== undefined) proposal.economicProposalUrl = dto.economicProposalUrl; + if (dto.status !== undefined) proposal.status = dto.status; + if (ctx.userId) { + proposal.updatedById = ctx.userId; + } + + return this.repository.save(proposal); + } + + /** + * Evaluar propuesta - asigna puntuaciones tecnica y economica + */ + async evaluate( + ctx: ServiceContext, + id: string, + dto: EvaluateProposalDto + ): Promise { + const proposal = await this.findById(ctx, id); + if (!proposal) { + throw new Error('Proposal not found'); + } + + // Calcular puntuacion total (promedio simple, puede ajustarse con pesos) + const totalScore = (dto.technicalScore + dto.economicScore) / 2; + + proposal.technicalScore = dto.technicalScore; + proposal.economicScore = dto.economicScore; + proposal.totalScore = totalScore; + proposal.status = 'qualified'; + if (ctx.userId) { + proposal.updatedById = ctx.userId; + } + + return this.repository.save(proposal); + } + + /** + * Descalificar propuesta + */ + async disqualify(ctx: ServiceContext, id: string, _reason: string): Promise { + const proposal = await this.findById(ctx, id); + if (!proposal) { + throw new Error('Proposal not found'); + } + + proposal.status = 'disqualified'; + if (ctx.userId) { + proposal.updatedById = ctx.userId; + } + + return this.repository.save(proposal); + } + + /** + * Comparar propuestas de una licitacion + * Retorna propuestas ordenadas por puntuacion total (ranking) + */ + async compareProposals(ctx: ServiceContext, tenderId: string): Promise { + const proposals = await this.repository.find({ + where: { + tenderId, + tenantId: ctx.tenantId, + deletedAt: undefined, + }, + relations: ['vendor'], + }); + + // Filtrar solo las que tienen puntuacion y estan calificadas + const scoredProposals = proposals.filter( + p => p.totalScore !== undefined && p.totalScore !== null && p.status === 'qualified' + ); + + // Ordenar por puntuacion total descendente + scoredProposals.sort((a, b) => { + const scoreA = Number(a.totalScore) || 0; + const scoreB = Number(b.totalScore) || 0; + return scoreB - scoreA; + }); + + // Asignar rankings + const rankedProposals: RankedProposal[] = scoredProposals.map((proposal, index) => ({ + ...proposal, + rank: index + 1, + totalScore: Number(proposal.totalScore) || 0, + })); + + return rankedProposals; + } + + /** + * Soft delete + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), updatedById: ctx.userId } + ); + return (result.affected || 0) > 0; + } +} diff --git a/src/modules/bidding/services/tender.service.ts b/src/modules/bidding/services/tender.service.ts new file mode 100644 index 0000000..7f21e74 --- /dev/null +++ b/src/modules/bidding/services/tender.service.ts @@ -0,0 +1,371 @@ +/** + * TenderService - Gestion de Licitaciones/Convocatorias + * + * CRUD y logica de negocio para procesos de licitacion. + * Gestiona el ciclo desde publicacion hasta adjudicacion y conversion a proyecto. + * + * @module Bidding (MAI-018) + */ + +import { Repository } from 'typeorm'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Tender, TenderType, TenderStatus } from '../entities/tender.entity'; +import { Opportunity } from '../entities/opportunity.entity'; + +// DTOs +export interface CreateTenderDto { + opportunityId: string; + title: string; + description?: string; + type?: TenderType; + referenceAmount?: number; + publicationDate?: Date; + clarificationMeetingDate?: Date; + proposalDeadline: Date; + awardDate?: Date; + contractDurationDays?: number; +} + +export interface UpdateTenderDto extends Partial> { + status?: TenderStatus; +} + +export interface TenderFilters { + opportunityId?: string; + type?: TenderType; + status?: TenderStatus | TenderStatus[]; + dateFrom?: Date; + dateTo?: Date; + search?: string; + page?: number; + limit?: number; +} + +export interface TenderStats { + total: number; + byStatus: { status: string; count: number }[]; + byType: { type: string; count: number }[]; + totalBudget: number; + winRate: number; +} + +export class TenderService { + constructor( + private readonly repository: Repository, + private readonly opportunityRepository: Repository + ) {} + + /** + * Genera numero automatico para licitacion: LIC-YYYY-NNN + */ + private async generateNumber(ctx: ServiceContext): Promise { + const year = new Date().getFullYear(); + const prefix = `LIC-${year}-`; + + const lastTender = await this.repository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.number LIKE :prefix', { prefix: `${prefix}%` }) + .orderBy('t.number', 'DESC') + .getOne(); + + let sequence = 1; + if (lastTender) { + const lastSequence = parseInt(lastTender.number.replace(prefix, ''), 10); + if (!isNaN(lastSequence)) { + sequence = lastSequence + 1; + } + } + + return `${prefix}${sequence.toString().padStart(3, '0')}`; + } + + /** + * Crear licitacion + * @throws Error si la oportunidad no existe o no esta en status 'go' + */ + async create(ctx: ServiceContext, dto: CreateTenderDto): Promise { + // Validar que la oportunidad existe y esta en status 'go' + const opportunity = await this.opportunityRepository.findOne({ + where: { + id: dto.opportunityId, + tenantId: ctx.tenantId, + deletedAt: undefined + }, + }); + + if (!opportunity) { + throw new Error('Opportunity not found'); + } + + if (opportunity.status !== 'go') { + throw new Error('Opportunity must be in "go" status to create a tender'); + } + + const tenderNumber = await this.generateNumber(ctx); + + const tender = this.repository.create({ + tenantId: ctx.tenantId, + opportunityId: dto.opportunityId, + number: tenderNumber, + title: dto.title, + description: dto.description, + type: dto.type || 'public', + referenceAmount: dto.referenceAmount?.toString(), + publicationDate: dto.publicationDate, + clarificationMeetingDate: dto.clarificationMeetingDate, + proposalDeadline: dto.proposalDeadline, + awardDate: dto.awardDate, + contractDurationDays: dto.contractDurationDays, + status: 'draft' as TenderStatus, + createdById: ctx.userId, + updatedById: ctx.userId, + }); + + return this.repository.save(tender); + } + + /** + * Buscar licitaciones con filtros y paginacion + */ + async findAll( + ctx: ServiceContext, + filters: TenderFilters = {} + ): Promise> { + const page = filters.page || 1; + const limit = filters.limit || 20; + + const qb = this.repository + .createQueryBuilder('t') + .leftJoinAndSelect('t.opportunity', 'o') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL'); + + if (filters.opportunityId) { + qb.andWhere('t.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId }); + } + if (filters.type) { + qb.andWhere('t.type = :type', { type: filters.type }); + } + if (filters.status) { + if (Array.isArray(filters.status)) { + qb.andWhere('t.status IN (:...statuses)', { statuses: filters.status }); + } else { + qb.andWhere('t.status = :status', { status: filters.status }); + } + } + if (filters.dateFrom) { + qb.andWhere('t.proposal_deadline >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('t.proposal_deadline <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.search) { + qb.andWhere( + '(t.title ILIKE :search OR t.number ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (page - 1) * limit; + qb.orderBy('t.proposal_deadline', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Buscar por ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['opportunity', 'proposals', 'calendarEvents', 'documents'], + }); + } + + /** + * Buscar por numero de licitacion + */ + async findByNumber(ctx: ServiceContext, number: string): Promise { + return this.repository.findOne({ + where: { + number, + tenantId: ctx.tenantId, + deletedAt: undefined + }, + relations: ['opportunity'], + }); + } + + /** + * Actualizar licitacion + */ + async update(ctx: ServiceContext, id: string, dto: UpdateTenderDto): Promise { + const tender = await this.findById(ctx, id); + if (!tender) return null; + + if (dto.title !== undefined) tender.title = dto.title; + if (dto.description !== undefined) tender.description = dto.description; + if (dto.type !== undefined) tender.type = dto.type; + if (dto.referenceAmount !== undefined) tender.referenceAmount = dto.referenceAmount.toString(); + if (dto.publicationDate !== undefined) tender.publicationDate = dto.publicationDate; + if (dto.clarificationMeetingDate !== undefined) tender.clarificationMeetingDate = dto.clarificationMeetingDate; + if (dto.proposalDeadline !== undefined) tender.proposalDeadline = dto.proposalDeadline; + if (dto.awardDate !== undefined) tender.awardDate = dto.awardDate; + if (dto.contractDurationDays !== undefined) tender.contractDurationDays = dto.contractDurationDays; + if (dto.status !== undefined) tender.status = dto.status; + if (ctx.userId) { + tender.updatedById = ctx.userId; + } + + return this.repository.save(tender); + } + + /** + * Soft delete + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), updatedById: ctx.userId } + ); + return (result.affected || 0) > 0; + } + + /** + * Publicar licitacion - cambia status a 'published' + */ + async publish(ctx: ServiceContext, id: string): Promise { + const tender = await this.findById(ctx, id); + if (!tender) { + throw new Error('Tender not found'); + } + + if (tender.status !== 'draft') { + throw new Error('Only draft tenders can be published'); + } + + tender.status = 'published'; + tender.publicationDate = new Date(); + if (ctx.userId) { + tender.updatedById = ctx.userId; + } + + return this.repository.save(tender); + } + + /** + * Adjudicar ganador + * @param tenderId ID de la licitacion + * @param proposalId ID de la propuesta ganadora + */ + async awardWinner( + ctx: ServiceContext, + tenderId: string, + proposalId: string + ): Promise { + const tender = await this.findById(ctx, tenderId); + if (!tender) { + throw new Error('Tender not found'); + } + + tender.status = 'awarded'; + tender.awardDate = new Date(); + tender.winnerId = proposalId; + if (ctx.userId) { + tender.updatedById = ctx.userId; + } + + return this.repository.save(tender); + } + + /** + * Convertir licitacion ganada a proyecto + */ + async convertToProject(ctx: ServiceContext, tenderId: string): Promise<{ tender: Tender; projectId: string }> { + const tender = await this.findById(ctx, tenderId); + if (!tender) { + throw new Error('Tender not found'); + } + + if (tender.status !== 'awarded') { + throw new Error('Only awarded tenders can be converted to projects'); + } + + tender.status = 'converting'; + if (ctx.userId) { + tender.updatedById = ctx.userId; + } + + // Generar ID de proyecto placeholder (la creacion real la hace projects module) + const projectId = `PRJ-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + await this.repository.save(tender); + + return { tender, projectId }; + } + + /** + * Obtener estadisticas de licitaciones + */ + async getStats(ctx: ServiceContext, year?: number): Promise { + const currentYear = year || new Date().getFullYear(); + const startDate = new Date(currentYear, 0, 1); + const endDate = new Date(currentYear, 11, 31); + + const total = await this.repository + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getCount(); + + const byStatus = await this.repository + .createQueryBuilder('t') + .select('t.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('t.status') + .getRawMany(); + + const byType = await this.repository + .createQueryBuilder('t') + .select('t.type', 'type') + .addSelect('COUNT(*)', 'count') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('t.type') + .getRawMany(); + + const valueStats = await this.repository + .createQueryBuilder('t') + .select('SUM(CAST(t.reference_amount AS DECIMAL))', 'totalBudget') + .where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('t.deleted_at IS NULL') + .andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + + const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0; + const cancelledCount = byStatus.find((s) => s.status === 'cancelled')?.count || 0; + const closedCount = parseInt(awardedCount) + parseInt(cancelledCount); + + return { + total, + byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })), + byType: byType.map((r) => ({ type: r.type, count: parseInt(r.count) })), + totalBudget: parseFloat(valueStats?.totalBudget) || 0, + winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0, + }; + } +} diff --git a/src/modules/bidding/services/vendor.service.ts b/src/modules/bidding/services/vendor.service.ts new file mode 100644 index 0000000..e0fd1c1 --- /dev/null +++ b/src/modules/bidding/services/vendor.service.ts @@ -0,0 +1,378 @@ +/** + * VendorService - Gestion de Proveedores/Contratistas + * + * Registro centralizado de proveedores y contratistas que participan en licitaciones. + * Gestiona informacion de proveedores, calificaciones y historico de participacion. + * + * @module Bidding (MAI-018) + */ + +import { Repository } from 'typeorm'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Vendor, VendorCertification, VendorPerformanceEntry } from '../entities/vendor.entity'; + +// DTOs +export interface CreateVendorDto { + companyName: string; + rfc?: string; + specialties?: string[]; + rating?: number; + certifications?: VendorCertification[]; + contactName?: string; + contactEmail?: string; + contactPhone?: string; +} + +export interface UpdateVendorDto extends Partial { + isActive?: boolean; + documentationValid?: boolean; + performanceHistory?: VendorPerformanceEntry[]; +} + +export interface VendorFilters { + search?: string; + specialty?: string; + isActive?: boolean; + minRating?: number; + page?: number; + limit?: number; +} + +export interface VendorRecord { + id: string; + code: string; + businessName: string; + rfc?: string; + contactName?: string; + contactEmail?: string; + contactPhone?: string; + specialties?: string[]; + rating?: number; + totalParticipations: number; + totalWins: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface VendorPerformanceHistory { + vendor: VendorRecord; + proposals: { + tenderId: string; + tenderTitle: string; + submittedAt: Date; + status: string; + proposedAmount?: string; + technicalScore?: number; + economicScore?: number; + totalScore?: number; + isWinner: boolean; + }[]; + statistics: { + totalParticipations: number; + wins: number; + losses: number; + winRate: number; + avgTechnicalScore: number; + avgEconomicScore: number; + avgTotalScore: number; + totalProposedValue: number; + totalWonValue: number; + }; +} + +export class VendorService { + constructor(private readonly repository: Repository) {} + + /** + * Genera codigo automatico para vendor: VND-NNN + */ + private async generateCode(ctx: ServiceContext): Promise { + const prefix = 'VND-'; + + const lastVendor = await this.repository + .createQueryBuilder('v') + .where('v.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('v.code LIKE :prefix', { prefix: `${prefix}%` }) + .orderBy('v.code', 'DESC') + .getOne(); + + let sequence = 1; + if (lastVendor) { + const lastSequence = parseInt(lastVendor.code.replace(prefix, ''), 10); + if (!isNaN(lastSequence)) { + sequence = lastSequence + 1; + } + } + + return `${prefix}${sequence.toString().padStart(3, '0')}`; + } + + /** + * Validar formato RFC mexicano + */ + private validateRfc(rfc: string): boolean { + const rfcPattern = /^[A-ZƑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$/i; + return rfcPattern.test(rfc.replace(/\s/g, '').toUpperCase()); + } + + /** + * Buscar vendors con filtros y paginacion + */ + async findAll( + ctx: ServiceContext, + filters: VendorFilters = {} + ): Promise> { + const page = filters.page || 1; + const limit = filters.limit || 20; + + const qb = this.repository + .createQueryBuilder('v') + .where('v.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('v.deleted_at IS NULL'); + + if (filters.search) { + qb.andWhere( + '(v.business_name ILIKE :search OR v.rfc ILIKE :search OR v.code ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + if (filters.specialty) { + qb.andWhere(':specialty = ANY(v.specialties)', { specialty: filters.specialty }); + } + if (filters.isActive !== undefined) { + qb.andWhere('v.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.minRating !== undefined) { + qb.andWhere('v.rating >= :minRating', { minRating: filters.minRating }); + } + + const skip = (page - 1) * limit; + qb.orderBy('v.business_name', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Buscar por ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['proposals'], + }); + } + + /** + * Buscar por codigo de vendor + */ + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.repository.findOne({ + where: { + code, + tenantId: ctx.tenantId, + deletedAt: undefined, + }, + }); + } + + /** + * Buscar por RFC + */ + async findByRfc(ctx: ServiceContext, rfc: string): Promise { + const normalizedRfc = rfc.replace(/\s/g, '').toUpperCase(); + + return this.repository.findOne({ + where: { + rfc: normalizedRfc, + tenantId: ctx.tenantId, + deletedAt: undefined, + }, + }); + } + + /** + * Crear vendor + * @throws Error si el RFC ya existe o tiene formato invalido + */ + async create(ctx: ServiceContext, dto: CreateVendorDto): Promise { + // Validar formato RFC si se proporciona + if (dto.rfc && !this.validateRfc(dto.rfc)) { + throw new Error('Invalid RFC format. Must be 12 or 13 alphanumeric characters.'); + } + + const normalizedRfc = dto.rfc?.replace(/\s/g, '').toUpperCase(); + + // Verificar unicidad de RFC + if (normalizedRfc) { + const existing = await this.findByRfc(ctx, normalizedRfc); + if (existing) { + throw new Error(`Vendor with RFC ${normalizedRfc} already exists`); + } + } + + const vendorCode = await this.generateCode(ctx); + + const vendor = this.repository.create({ + tenantId: ctx.tenantId, + code: vendorCode, + businessName: dto.companyName, + rfc: normalizedRfc, + specialties: dto.specialties, + rating: dto.rating, + certifications: dto.certifications, + contactName: dto.contactName, + contactEmail: dto.contactEmail, + contactPhone: dto.contactPhone, + isActive: true, + documentationValid: false, + createdById: ctx.userId, + updatedById: ctx.userId, + }); + + return this.repository.save(vendor); + } + + /** + * Actualizar vendor + */ + async update(ctx: ServiceContext, id: string, dto: UpdateVendorDto): Promise { + const vendor = await this.findById(ctx, id); + if (!vendor) return null; + + if (dto.companyName !== undefined) vendor.businessName = dto.companyName; + if (dto.rfc !== undefined) vendor.rfc = dto.rfc?.replace(/\s/g, '').toUpperCase(); + if (dto.specialties !== undefined) vendor.specialties = dto.specialties; + if (dto.rating !== undefined) vendor.rating = dto.rating; + if (dto.certifications !== undefined) vendor.certifications = dto.certifications; + if (dto.performanceHistory !== undefined) vendor.performanceHistory = dto.performanceHistory; + if (dto.contactName !== undefined) vendor.contactName = dto.contactName; + if (dto.contactEmail !== undefined) vendor.contactEmail = dto.contactEmail; + if (dto.contactPhone !== undefined) vendor.contactPhone = dto.contactPhone; + if (dto.isActive !== undefined) vendor.isActive = dto.isActive; + if (dto.documentationValid !== undefined) vendor.documentationValid = dto.documentationValid; + if (ctx.userId) { + vendor.updatedById = ctx.userId; + } + + return this.repository.save(vendor); + } + + /** + * Soft delete - marca como inactivo + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), isActive: false, updatedById: ctx.userId } + ); + return (result.affected || 0) > 0; + } + + /** + * Actualizar rating de vendor + */ + async updateRating(ctx: ServiceContext, id: string, newRating: number): Promise { + const vendor = await this.findById(ctx, id); + if (!vendor) { + throw new Error('Vendor not found'); + } + + if (newRating < 1 || newRating > 5) { + throw new Error('Rating must be between 1 and 5'); + } + + vendor.rating = newRating; + if (ctx.userId) { + vendor.updatedById = ctx.userId; + } + + return this.repository.save(vendor); + } + + /** + * Obtener historial de rendimiento del vendor + */ + async getPerformanceHistory(ctx: ServiceContext, id: string): Promise { + const vendor = await this.repository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['proposals', 'proposals.tender'], + }); + + if (!vendor) { + throw new Error('Vendor not found'); + } + + const proposals = (vendor.proposals || []).map(p => ({ + tenderId: p.tenderId, + tenderTitle: p.tender?.title || 'Unknown', + submittedAt: p.submittedAt, + status: p.status, + proposedAmount: p.proposedAmount, + technicalScore: p.technicalScore, + economicScore: p.economicScore, + totalScore: p.totalScore, + isWinner: p.status === 'winner', + })); + + const wins = proposals.filter(p => p.isWinner).length; + const totalParticipations = proposals.length; + + const scoredProposals = proposals.filter(p => p.totalScore !== undefined && p.totalScore !== null); + const avgTechnicalScore = scoredProposals.length > 0 + ? scoredProposals.reduce((sum, p) => sum + (p.technicalScore || 0), 0) / scoredProposals.length + : 0; + const avgEconomicScore = scoredProposals.length > 0 + ? scoredProposals.reduce((sum, p) => sum + (p.economicScore || 0), 0) / scoredProposals.length + : 0; + const avgTotalScore = scoredProposals.length > 0 + ? scoredProposals.reduce((sum, p) => sum + (p.totalScore || 0), 0) / scoredProposals.length + : 0; + + const totalProposedValue = proposals.reduce((sum, p) => sum + (parseFloat(p.proposedAmount || '0') || 0), 0); + const totalWonValue = proposals + .filter(p => p.isWinner) + .reduce((sum, p) => sum + (parseFloat(p.proposedAmount || '0') || 0), 0); + + const vendorRecord: VendorRecord = { + id: vendor.id, + code: vendor.code, + businessName: vendor.businessName, + rfc: vendor.rfc, + contactName: vendor.contactName, + contactEmail: vendor.contactEmail, + contactPhone: vendor.contactPhone, + specialties: vendor.specialties, + rating: vendor.rating, + totalParticipations, + totalWins: wins, + isActive: vendor.isActive, + createdAt: vendor.createdAt, + updatedAt: vendor.updatedAt, + }; + + return { + vendor: vendorRecord, + proposals, + statistics: { + totalParticipations, + wins, + losses: totalParticipations - wins, + winRate: totalParticipations > 0 ? (wins / totalParticipations) * 100 : 0, + avgTechnicalScore, + avgEconomicScore, + avgTotalScore, + totalProposedValue, + totalWonValue, + }, + }; + } +} diff --git a/src/modules/billing-usage/entities/billing-alert.entity.ts b/src/modules/billing-usage/entities/billing-alert.entity.ts new file mode 100644 index 0000000..b6afbdc --- /dev/null +++ b/src/modules/billing-usage/entities/billing-alert.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BillingAlertType = + | 'usage_limit' + | 'payment_due' + | 'payment_failed' + | 'trial_ending' + | 'subscription_ending'; + +export type AlertSeverity = 'info' | 'warning' | 'critical'; +export type AlertStatus = 'active' | 'acknowledged' | 'resolved'; + +/** + * Entidad para alertas de facturacion y limites de uso. + * Mapea a billing.billing_alerts (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'billing_alerts', schema: 'billing' }) +export class BillingAlert { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de alerta + @Index() + @Column({ name: 'alert_type', type: 'varchar', length: 30 }) + alertType: BillingAlertType; + + // Detalles + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + message: string; + + @Column({ type: 'varchar', length: 20, default: 'info' }) + severity: AlertSeverity; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: AlertStatus; + + // Notificacion + @Column({ name: 'notified_at', type: 'timestamptz', nullable: true }) + notifiedAt: Date; + + @Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true }) + acknowledgedAt: Date; + + @Column({ name: 'acknowledged_by', type: 'uuid', nullable: true }) + acknowledgedBy: string; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/billing-usage/entities/coupon-redemption.entity.ts b/src/modules/billing-usage/entities/coupon-redemption.entity.ts new file mode 100644 index 0000000..1395ddf --- /dev/null +++ b/src/modules/billing-usage/entities/coupon-redemption.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Unique, +} from 'typeorm'; +import { Coupon } from './coupon.entity'; +import { TenantSubscription } from './tenant-subscription.entity'; + +@Entity({ name: 'coupon_redemptions', schema: 'billing' }) +@Unique(['couponId', 'tenantId']) +export class CouponRedemption { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'coupon_id', type: 'uuid' }) + couponId!: string; + + @ManyToOne(() => Coupon, (coupon) => coupon.redemptions) + @JoinColumn({ name: 'coupon_id' }) + coupon!: Coupon; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + @Column({ name: 'subscription_id', type: 'uuid', nullable: true }) + subscriptionId?: string; + + @ManyToOne(() => TenantSubscription, { nullable: true }) + @JoinColumn({ name: 'subscription_id' }) + subscription?: TenantSubscription; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2 }) + discountAmount!: number; + + @CreateDateColumn({ name: 'redeemed_at', type: 'timestamptz' }) + redeemedAt!: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date; +} diff --git a/src/modules/billing-usage/entities/coupon.entity.ts b/src/modules/billing-usage/entities/coupon.entity.ts new file mode 100644 index 0000000..b5b371e --- /dev/null +++ b/src/modules/billing-usage/entities/coupon.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import { CouponRedemption } from './coupon-redemption.entity'; + +export type DiscountType = 'percentage' | 'fixed'; +export type DurationPeriod = 'once' | 'forever' | 'months'; + +@Entity({ name: 'coupons', schema: 'billing' }) +export class Coupon { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + code!: string; + + @Column({ type: 'varchar', length: 255 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'discount_type', type: 'varchar', length: 20 }) + discountType!: DiscountType; + + @Column({ name: 'discount_value', type: 'decimal', precision: 10, scale: 2 }) + discountValue!: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency!: string; + + @Column({ name: 'applicable_plans', type: 'uuid', array: true, default: [] }) + applicablePlans!: string[]; + + @Column({ name: 'min_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + minAmount!: number; + + @Column({ name: 'duration_period', type: 'varchar', length: 20, default: 'once' }) + durationPeriod!: DurationPeriod; + + @Column({ name: 'duration_months', type: 'integer', nullable: true }) + durationMonths?: number; + + @Column({ name: 'max_redemptions', type: 'integer', nullable: true }) + maxRedemptions?: number; + + @Column({ name: 'current_redemptions', type: 'integer', default: 0 }) + currentRedemptions!: number; + + @Column({ name: 'valid_from', type: 'timestamptz', nullable: true }) + validFrom?: Date; + + @Column({ name: 'valid_until', type: 'timestamptz', nullable: true }) + validUntil?: Date; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @OneToMany(() => CouponRedemption, (redemption) => redemption.coupon) + redemptions!: CouponRedemption[]; +} diff --git a/src/modules/billing-usage/entities/index.ts b/src/modules/billing-usage/entities/index.ts new file mode 100644 index 0000000..520f364 --- /dev/null +++ b/src/modules/billing-usage/entities/index.ts @@ -0,0 +1,13 @@ +export { SubscriptionPlan, PlanType } from './subscription-plan.entity'; +export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity'; +export { UsageTracking } from './usage-tracking.entity'; +export { UsageEvent, EventCategory } from './usage-event.entity'; +export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity'; +export { InvoiceItemType } from './invoice-item.entity'; +export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity'; +export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity'; +export { PlanFeature } from './plan-feature.entity'; +export { PlanLimit, LimitType } from './plan-limit.entity'; +export { Coupon, DiscountType, DurationPeriod } from './coupon.entity'; +export { CouponRedemption } from './coupon-redemption.entity'; +export { StripeEvent } from './stripe-event.entity'; diff --git a/src/modules/billing-usage/entities/invoice-item.entity.ts b/src/modules/billing-usage/entities/invoice-item.entity.ts new file mode 100644 index 0000000..b9abd68 --- /dev/null +++ b/src/modules/billing-usage/entities/invoice-item.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Invoice } from '../../invoices/entities/invoice.entity'; + +export type InvoiceItemType = 'subscription' | 'user' | 'profile' | 'overage' | 'addon'; + +@Entity({ name: 'invoice_items', schema: 'billing' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + // Descripcion + @Column({ type: 'varchar', length: 500 }) + description: string; + + @Index() + @Column({ name: 'item_type', type: 'varchar', length: 30 }) + itemType: InvoiceItemType; + + // Cantidades + @Column({ type: 'integer', default: 1 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + // Detalles adicionales + @Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true }) + profileCode: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; + + @Column({ name: 'period_start', type: 'date', nullable: true }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date', nullable: true }) + periodEnd: Date; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; +} diff --git a/src/modules/billing-usage/entities/invoice.entity.ts b/src/modules/billing-usage/entities/invoice.entity.ts new file mode 100644 index 0000000..4c4c0b7 --- /dev/null +++ b/src/modules/billing-usage/entities/invoice.entity.ts @@ -0,0 +1,17 @@ +/** + * @deprecated Use Invoice from 'modules/invoices/entities' instead. + * + * This entity has been unified with the commercial Invoice entity. + * Both SaaS billing and commercial invoices now use the same table. + * + * Migration guide: + * - Import from: import { Invoice, InvoiceStatus, InvoiceContext } from '../../invoices/entities/invoice.entity'; + * - Set invoiceContext: 'saas' for SaaS billing invoices + * - Use subscriptionId, periodStart, periodEnd for SaaS-specific fields + */ + +// Re-export from unified invoice entity +export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType } from '../../invoices/entities/invoice.entity'; + +// Re-export InvoiceItem as well since it's used together +export { InvoiceItem } from './invoice-item.entity'; diff --git a/src/modules/billing-usage/entities/payment-method.entity.ts b/src/modules/billing-usage/entities/payment-method.entity.ts new file mode 100644 index 0000000..2f2e819 --- /dev/null +++ b/src/modules/billing-usage/entities/payment-method.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type PaymentProvider = 'stripe' | 'mercadopago' | 'bank_transfer'; +export type PaymentMethodType = 'card' | 'bank_account' | 'wallet'; + +/** + * Entidad para metodos de pago guardados por tenant. + * Almacena informacion tokenizada/encriptada de metodos de pago. + * Mapea a billing.payment_methods (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'payment_methods', schema: 'billing' }) +export class BillingPaymentMethod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Proveedor + @Index() + @Column({ type: 'varchar', length: 30 }) + provider: PaymentProvider; + + // Tipo + @Column({ name: 'method_type', type: 'varchar', length: 20 }) + methodType: PaymentMethodType; + + // Datos tokenizados del proveedor + @Column({ name: 'provider_customer_id', type: 'varchar', length: 255, nullable: true }) + providerCustomerId: string; + + @Column({ name: 'provider_method_id', type: 'varchar', length: 255, nullable: true }) + providerMethodId: string; + + // Display info (no sensible) + @Column({ name: 'display_name', type: 'varchar', length: 100, nullable: true }) + displayName: string; + + @Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true }) + cardBrand: string; + + @Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true }) + cardLastFour: string; + + @Column({ name: 'card_exp_month', type: 'integer', nullable: true }) + cardExpMonth: number; + + @Column({ name: 'card_exp_year', type: 'integer', nullable: true }) + cardExpYear: number; + + @Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true }) + bankName: string; + + @Column({ name: 'bank_last_four', type: 'varchar', length: 4, nullable: true }) + bankLastFour: string; + + // Estado + @Index() + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/billing-usage/entities/plan-feature.entity.ts b/src/modules/billing-usage/entities/plan-feature.entity.ts new file mode 100644 index 0000000..2c88839 --- /dev/null +++ b/src/modules/billing-usage/entities/plan-feature.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { SubscriptionPlan } from './subscription-plan.entity'; + +/** + * PlanFeature Entity + * Maps to billing.plan_features DDL table + * Features disponibles por plan de suscripcion + * Propagated from template-saas HU-REFACT-005 + */ +@Entity({ schema: 'billing', name: 'plan_features' }) +@Index('idx_plan_features_plan', ['planId']) +@Index('idx_plan_features_key', ['featureKey']) +export class PlanFeature { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'plan_id' }) + planId: string; + + @Column({ type: 'varchar', length: 100, nullable: false, name: 'feature_key' }) + featureKey: string; + + @Column({ type: 'varchar', length: 255, nullable: false, name: 'feature_name' }) + featureName: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + category: string | null; + + @Column({ type: 'boolean', default: true }) + enabled: boolean; + + @Column({ type: 'jsonb', default: {} }) + configuration: Record; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Relaciones + @ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'plan_id' }) + plan: SubscriptionPlan; + + // Timestamps + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/billing-usage/entities/plan-limit.entity.ts b/src/modules/billing-usage/entities/plan-limit.entity.ts new file mode 100644 index 0000000..144f909 --- /dev/null +++ b/src/modules/billing-usage/entities/plan-limit.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SubscriptionPlan } from './subscription-plan.entity'; + +export type LimitType = 'monthly' | 'daily' | 'total' | 'per_user'; + +@Entity({ name: 'plan_limits', schema: 'billing' }) +export class PlanLimit { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'plan_id', type: 'uuid' }) + planId!: string; + + @ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'plan_id' }) + plan!: SubscriptionPlan; + + @Column({ name: 'limit_key', type: 'varchar', length: 100 }) + limitKey!: string; + + @Column({ name: 'limit_name', type: 'varchar', length: 255 }) + limitName!: string; + + @Column({ name: 'limit_value', type: 'integer' }) + limitValue!: number; + + @Column({ name: 'limit_type', type: 'varchar', length: 50, default: 'monthly' }) + limitType!: LimitType; + + @Column({ name: 'allow_overage', type: 'boolean', default: false }) + allowOverage!: boolean; + + @Column({ name: 'overage_unit_price', type: 'decimal', precision: 10, scale: 4, default: 0 }) + overageUnitPrice!: number; + + @Column({ name: 'overage_currency', type: 'varchar', length: 3, default: 'MXN' }) + overageCurrency!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/billing-usage/entities/stripe-event.entity.ts b/src/modules/billing-usage/entities/stripe-event.entity.ts new file mode 100644 index 0000000..d11eb11 --- /dev/null +++ b/src/modules/billing-usage/entities/stripe-event.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'stripe_events', schema: 'billing' }) +export class StripeEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'stripe_event_id', type: 'varchar', length: 255, unique: true }) + @Index() + stripeEventId!: string; + + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + @Index() + eventType!: string; + + @Column({ name: 'api_version', type: 'varchar', length: 20, nullable: true }) + apiVersion?: string; + + @Column({ type: 'jsonb' }) + data!: Record; + + @Column({ type: 'boolean', default: false }) + @Index() + processed!: boolean; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt?: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string; + + @Column({ name: 'retry_count', type: 'integer', default: 0 }) + retryCount!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/src/modules/billing-usage/entities/subscription-plan.entity.ts b/src/modules/billing-usage/entities/subscription-plan.entity.ts new file mode 100644 index 0000000..324e7c3 --- /dev/null +++ b/src/modules/billing-usage/entities/subscription-plan.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type PlanType = 'saas' | 'on_premise' | 'hybrid'; + +@Entity({ name: 'subscription_plans', schema: 'billing' }) +export class SubscriptionPlan { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Identificacion + @Index({ unique: true }) + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Column({ name: 'plan_type', type: 'varchar', length: 20, default: 'saas' }) + planType: PlanType; + + // Precios base + @Column({ name: 'base_monthly_price', type: 'decimal', precision: 12, scale: 2, default: 0 }) + baseMonthlyPrice: number; + + @Column({ name: 'base_annual_price', type: 'decimal', precision: 12, scale: 2, nullable: true }) + baseAnnualPrice: number; + + @Column({ name: 'setup_fee', type: 'decimal', precision: 12, scale: 2, default: 0 }) + setupFee: number; + + // Limites base + @Column({ name: 'max_users', type: 'integer', default: 5 }) + maxUsers: number; + + @Column({ name: 'max_branches', type: 'integer', default: 1 }) + maxBranches: number; + + @Column({ name: 'storage_gb', type: 'integer', default: 10 }) + storageGb: number; + + @Column({ name: 'api_calls_monthly', type: 'integer', default: 10000 }) + apiCallsMonthly: number; + + // Modulos incluidos + @Column({ name: 'included_modules', type: 'text', array: true, default: [] }) + includedModules: string[]; + + // Plataformas incluidas + @Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] }) + includedPlatforms: string[]; + + // Features + @Column({ type: 'jsonb', default: {} }) + features: Record; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_public', type: 'boolean', default: true }) + isPublic: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/billing-usage/entities/tenant-subscription.entity.ts b/src/modules/billing-usage/entities/tenant-subscription.entity.ts new file mode 100644 index 0000000..1973259 --- /dev/null +++ b/src/modules/billing-usage/entities/tenant-subscription.entity.ts @@ -0,0 +1,132 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { SubscriptionPlan } from './subscription-plan.entity'; + +export type BillingCycle = 'monthly' | 'annual'; +export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended'; + +@Entity({ name: 'tenant_subscriptions', schema: 'billing' }) +@Unique(['tenantId']) +export class TenantSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'plan_id', type: 'uuid' }) + planId: string; + + // Periodo + @Column({ name: 'billing_cycle', type: 'varchar', length: 20, default: 'monthly' }) + billingCycle: BillingCycle; + + @Column({ name: 'current_period_start', type: 'timestamptz' }) + currentPeriodStart: Date; + + @Column({ name: 'current_period_end', type: 'timestamptz' }) + currentPeriodEnd: Date; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: SubscriptionStatus; + + // Trial + @Column({ name: 'trial_start', type: 'timestamptz', nullable: true }) + trialStart: Date; + + @Column({ name: 'trial_end', type: 'timestamptz', nullable: true }) + trialEnd: Date; + + // Configuracion de facturacion + @Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true }) + billingEmail: string; + + @Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true }) + billingName: string; + + @Column({ name: 'billing_address', type: 'jsonb', default: {} }) + billingAddress: Record; + + @Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true }) + taxId: string; // RFC para Mexico + + // Metodo de pago + @Column({ name: 'payment_method_id', type: 'uuid', nullable: true }) + paymentMethodId: string; + + @Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true }) + paymentProvider: string; // stripe, mercadopago, bank_transfer + + // Stripe integration + @Index() + @Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true }) + stripeCustomerId?: string; + + @Index() + @Column({ name: 'stripe_subscription_id', type: 'varchar', length: 255, nullable: true }) + stripeSubscriptionId?: string; + + @Column({ name: 'last_payment_at', type: 'timestamptz', nullable: true }) + lastPaymentAt?: Date; + + @Column({ name: 'last_payment_amount', type: 'decimal', precision: 12, scale: 2, nullable: true }) + lastPaymentAmount?: number; + + // Precios actuales + @Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 }) + currentPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_reason', type: 'varchar', length: 100, nullable: true }) + discountReason: string; + + // Uso contratado + @Column({ name: 'contracted_users', type: 'integer', nullable: true }) + contractedUsers: number; + + @Column({ name: 'contracted_branches', type: 'integer', nullable: true }) + contractedBranches: number; + + // Facturacion automatica + @Column({ name: 'auto_renew', type: 'boolean', default: true }) + autoRenew: boolean; + + @Column({ name: 'next_invoice_date', type: 'date', nullable: true }) + nextInvoiceDate: Date; + + // Cancelacion + @Column({ name: 'cancel_at_period_end', type: 'boolean', default: false }) + cancelAtPeriodEnd: boolean; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => SubscriptionPlan) + @JoinColumn({ name: 'plan_id' }) + plan: SubscriptionPlan; +} diff --git a/src/modules/billing-usage/entities/usage-event.entity.ts b/src/modules/billing-usage/entities/usage-event.entity.ts new file mode 100644 index 0000000..ab29f61 --- /dev/null +++ b/src/modules/billing-usage/entities/usage-event.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type EventCategory = 'user' | 'api' | 'storage' | 'transaction' | 'mobile'; + +/** + * Entidad para eventos de uso en tiempo real. + * Utilizada para calculo de billing y tracking granular. + * Mapea a billing.usage_events (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'usage_events', schema: 'billing' }) +export class UsageEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Evento + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 50 }) + eventType: string; // login, api_call, document_upload, sale, invoice, sync + + @Index() + @Column({ name: 'event_category', type: 'varchar', length: 30 }) + eventCategory: EventCategory; + + // Detalles + @Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true }) + profileCode: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'resource_type', type: 'varchar', length: 50, nullable: true }) + resourceType: string; + + // Metricas + @Column({ type: 'integer', default: 1 }) + quantity: number; + + @Column({ name: 'bytes_used', type: 'bigint', default: 0 }) + bytesUsed: number; + + @Column({ name: 'duration_ms', type: 'integer', nullable: true }) + durationMs: number; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/billing-usage/entities/usage-tracking.entity.ts b/src/modules/billing-usage/entities/usage-tracking.entity.ts new file mode 100644 index 0000000..d5ad4b3 --- /dev/null +++ b/src/modules/billing-usage/entities/usage-tracking.entity.ts @@ -0,0 +1,91 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'usage_tracking', schema: 'billing' }) +@Unique(['tenantId', 'periodStart']) +export class UsageTracking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Periodo + @Index() + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + // Usuarios + @Column({ name: 'active_users', type: 'integer', default: 0 }) + activeUsers: number; + + @Column({ name: 'peak_concurrent_users', type: 'integer', default: 0 }) + peakConcurrentUsers: number; + + // Por perfil + @Column({ name: 'users_by_profile', type: 'jsonb', default: {} }) + usersByProfile: Record; // {"ADM": 2, "VNT": 5, "ALM": 3} + + // Por plataforma + @Column({ name: 'users_by_platform', type: 'jsonb', default: {} }) + usersByPlatform: Record; // {"web": 8, "mobile": 5, "desktop": 0} + + // Sucursales + @Column({ name: 'active_branches', type: 'integer', default: 0 }) + activeBranches: number; + + // Storage + @Column({ name: 'storage_used_gb', type: 'decimal', precision: 10, scale: 2, default: 0 }) + storageUsedGb: number; + + @Column({ name: 'documents_count', type: 'integer', default: 0 }) + documentsCount: number; + + // API + @Column({ name: 'api_calls', type: 'integer', default: 0 }) + apiCalls: number; + + @Column({ name: 'api_errors', type: 'integer', default: 0 }) + apiErrors: number; + + // Transacciones + @Column({ name: 'sales_count', type: 'integer', default: 0 }) + salesCount: number; + + @Column({ name: 'sales_amount', type: 'decimal', precision: 14, scale: 2, default: 0 }) + salesAmount: number; + + @Column({ name: 'invoices_generated', type: 'integer', default: 0 }) + invoicesGenerated: number; + + // Mobile + @Column({ name: 'mobile_sessions', type: 'integer', default: 0 }) + mobileSessions: number; + + @Column({ name: 'offline_syncs', type: 'integer', default: 0 }) + offlineSyncs: number; + + @Column({ name: 'payment_transactions', type: 'integer', default: 0 }) + paymentTransactions: number; + + // Calculado + @Column({ name: 'total_billable_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalBillableAmount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/biometrics/entities/biometric-credential.entity.ts b/src/modules/biometrics/entities/biometric-credential.entity.ts new file mode 100644 index 0000000..c77fbce --- /dev/null +++ b/src/modules/biometrics/entities/biometric-credential.entity.ts @@ -0,0 +1,81 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Device, BiometricType } from './device.entity'; + +@Entity({ name: 'biometric_credentials', schema: 'auth' }) +@Unique(['deviceId', 'credentialId']) +export class BiometricCredential { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + // Tipo de biometrico + @Index() + @Column({ name: 'biometric_type', type: 'varchar', length: 50 }) + biometricType: BiometricType; + + // Credencial (public key para WebAuthn/FIDO2) + @Column({ name: 'credential_id', type: 'text' }) + credentialId: string; + + @Column({ name: 'public_key', type: 'text' }) + publicKey: string; + + @Column({ type: 'varchar', length: 20, default: 'ES256' }) + algorithm: string; + + // Metadata + @Column({ name: 'credential_name', type: 'varchar', length: 100, nullable: true }) + credentialName: string; // "Huella indice derecho", "Face ID iPhone" + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'use_count', type: 'integer', default: 0 }) + useCount: number; + + // Seguridad + @Column({ name: 'failed_attempts', type: 'integer', default: 0 }) + failedAttempts: number; + + @Column({ name: 'locked_until', type: 'timestamptz', nullable: true }) + lockedUntil: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @ManyToOne(() => Device, (device) => device.biometricCredentials, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'device_id' }) + device: Device; +} diff --git a/src/modules/biometrics/entities/device-activity-log.entity.ts b/src/modules/biometrics/entities/device-activity-log.entity.ts new file mode 100644 index 0000000..e245f45 --- /dev/null +++ b/src/modules/biometrics/entities/device-activity-log.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type ActivityType = 'login' | 'logout' | 'biometric_auth' | 'location_update' | 'app_open' | 'app_close'; +export type ActivityStatus = 'success' | 'failed' | 'blocked'; + +@Entity({ name: 'device_activity_log', schema: 'auth' }) +export class DeviceActivityLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + // Actividad + @Index() + @Column({ name: 'activity_type', type: 'varchar', length: 50 }) + activityType: ActivityType; + + @Column({ name: 'activity_status', type: 'varchar', length: 20 }) + activityStatus: ActivityStatus; + + // Detalles + @Column({ type: 'jsonb', default: {} }) + details: Record; + + // Ubicacion + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/biometrics/entities/device-session.entity.ts b/src/modules/biometrics/entities/device-session.entity.ts new file mode 100644 index 0000000..c94ecb4 --- /dev/null +++ b/src/modules/biometrics/entities/device-session.entity.ts @@ -0,0 +1,84 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Device } from './device.entity'; + +export type AuthMethod = 'password' | 'biometric' | 'oauth' | 'mfa'; + +@Entity({ name: 'device_sessions', schema: 'auth' }) +export class DeviceSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + // Tokens + @Index() + @Column({ name: 'access_token_hash', type: 'varchar', length: 255 }) + accessTokenHash: string; + + @Column({ name: 'refresh_token_hash', type: 'varchar', length: 255, nullable: true }) + refreshTokenHash: string; + + // Metodo de autenticacion + @Column({ name: 'auth_method', type: 'varchar', length: 50 }) + authMethod: AuthMethod; + + // Validez + @Column({ name: 'issued_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + issuedAt: Date; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'refresh_expires_at', type: 'timestamptz', nullable: true }) + refreshExpiresAt: Date; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'revoked_reason', type: 'varchar', length: 100, nullable: true }) + revokedReason: string; + + // Ubicacion + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => Device, (device) => device.sessions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'device_id' }) + device: Device; +} diff --git a/src/modules/biometrics/entities/device.entity.ts b/src/modules/biometrics/entities/device.entity.ts new file mode 100644 index 0000000..6ee5295 --- /dev/null +++ b/src/modules/biometrics/entities/device.entity.ts @@ -0,0 +1,121 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, + Unique, +} from 'typeorm'; +import { BiometricCredential } from './biometric-credential.entity'; +import { DeviceSession } from './device-session.entity'; + +export type DevicePlatform = 'ios' | 'android' | 'web' | 'desktop'; +export type BiometricType = 'fingerprint' | 'face_id' | 'face_recognition' | 'iris'; + +@Entity({ name: 'devices', schema: 'auth' }) +@Unique(['userId', 'deviceUuid']) +export class Device { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + // Identificacion del dispositivo + @Index() + @Column({ name: 'device_uuid', type: 'varchar', length: 100 }) + deviceUuid: string; + + @Column({ name: 'device_name', type: 'varchar', length: 100, nullable: true }) + deviceName: string; + + @Column({ name: 'device_model', type: 'varchar', length: 100, nullable: true }) + deviceModel: string; + + @Column({ name: 'device_brand', type: 'varchar', length: 50, nullable: true }) + deviceBrand: string; + + // Plataforma + @Index() + @Column({ type: 'varchar', length: 20 }) + platform: DevicePlatform; + + @Column({ name: 'platform_version', type: 'varchar', length: 20, nullable: true }) + platformVersion: string; + + @Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true }) + appVersion: string; + + // Estado + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_trusted', type: 'boolean', default: false }) + isTrusted: boolean; + + @Column({ name: 'trust_level', type: 'integer', default: 0 }) + trustLevel: number; // 0=none, 1=low, 2=medium, 3=high + + // Biometricos habilitados + @Column({ name: 'biometric_enabled', type: 'boolean', default: false }) + biometricEnabled: boolean; + + @Column({ name: 'biometric_type', type: 'varchar', length: 50, nullable: true }) + biometricType: BiometricType; + + // Push notifications + @Column({ name: 'push_token', type: 'text', nullable: true }) + pushToken: string; + + @Column({ name: 'push_token_updated_at', type: 'timestamptz', nullable: true }) + pushTokenUpdatedAt: Date; + + // Ubicacion ultima conocida + @Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + lastLatitude: number; + + @Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + lastLongitude: number; + + @Column({ name: 'last_location_at', type: 'timestamptz', nullable: true }) + lastLocationAt: Date; + + // Seguridad + @Column({ name: 'last_ip_address', type: 'inet', nullable: true }) + lastIpAddress: string; + + @Column({ name: 'last_user_agent', type: 'text', nullable: true }) + lastUserAgent: string; + + // Registro + @Column({ name: 'first_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + firstSeenAt: Date; + + @Column({ name: 'last_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + lastSeenAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @OneToMany(() => BiometricCredential, (credential) => credential.device) + biometricCredentials: BiometricCredential[]; + + @OneToMany(() => DeviceSession, (session) => session.device) + sessions: DeviceSession[]; +} diff --git a/src/modules/biometrics/entities/index.ts b/src/modules/biometrics/entities/index.ts new file mode 100644 index 0000000..17eca5d --- /dev/null +++ b/src/modules/biometrics/entities/index.ts @@ -0,0 +1,4 @@ +export { Device, DevicePlatform, BiometricType } from './device.entity'; +export { BiometricCredential } from './biometric-credential.entity'; +export { DeviceSession, AuthMethod } from './device-session.entity'; +export { DeviceActivityLog, ActivityType, ActivityStatus } from './device-activity-log.entity'; diff --git a/src/modules/branches/entities/branch-inventory-settings.entity.ts b/src/modules/branches/entities/branch-inventory-settings.entity.ts new file mode 100644 index 0000000..9a91f72 --- /dev/null +++ b/src/modules/branches/entities/branch-inventory-settings.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +/** + * Configuracion de inventario por sucursal. + * Mapea a core.branch_inventory_settings (DDL: 03-core-branches.sql) + */ +@Entity({ name: 'branch_inventory_settings', schema: 'core' }) +export class BranchInventorySettings { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @OneToOne(() => Branch, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; + + // Almacen asociado (referencia externa a inventory.warehouses) + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + // Configuracion de stock + @Column({ name: 'default_stock_min', type: 'integer', default: 0 }) + defaultStockMin: number; + + @Column({ name: 'default_stock_max', type: 'integer', default: 1000 }) + defaultStockMax: number; + + @Column({ name: 'auto_reorder_enabled', type: 'boolean', default: false }) + autoReorderEnabled: boolean; + + // Configuracion de precios (referencia externa a sales.price_lists) + @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) + priceListId: string; + + @Column({ name: 'allow_price_override', type: 'boolean', default: false }) + allowPriceOverride: boolean; + + @Column({ name: 'max_discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + maxDiscountPercent: number; + + // Configuracion de impuestos + @Column({ name: 'tax_config', type: 'jsonb', default: {} }) + taxConfig: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/branches/entities/branch-payment-terminal.entity.ts b/src/modules/branches/entities/branch-payment-terminal.entity.ts new file mode 100644 index 0000000..77b5708 --- /dev/null +++ b/src/modules/branches/entities/branch-payment-terminal.entity.ts @@ -0,0 +1,66 @@ +/** + * BranchPaymentTerminal Entity Stub + * TODO: Implement when branches module is created + */ + +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export type HealthStatus = 'healthy' | 'degraded' | 'offline' | 'unknown'; +export type TerminalProvider = 'clip' | 'mercadopago' | 'stripe' | 'stripe_terminal'; + +@Entity({ name: 'branch_payment_terminals' }) +export class BranchPaymentTerminal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'terminal_id', type: 'varchar', length: 100 }) + terminalId: string; + + @Column({ name: 'terminal_name', type: 'varchar', length: 100, nullable: true }) + terminalName: string | undefined; + + @Column({ name: 'terminal_provider', type: 'varchar', length: 50 }) + terminalProvider: TerminalProvider; + + @Column({ name: 'provider', type: 'varchar', length: 50 }) + provider: string; + + @Column({ name: 'credentials', type: 'jsonb', nullable: true }) + credentials: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @Column({ name: 'health_status', type: 'varchar', length: 50, default: 'unknown' }) + healthStatus: HealthStatus; + + @Column({ name: 'last_health_check_at', type: 'timestamptz', nullable: true }) + lastHealthCheckAt: Date | undefined; + + @Column({ name: 'last_transaction_at', type: 'timestamptz', nullable: true }) + lastTransactionAt: Date | undefined; + + @Column({ name: 'daily_limit', type: 'decimal', precision: 16, scale: 2, nullable: true }) + dailyLimit: number | undefined; + + @Column({ name: 'transaction_limit', type: 'decimal', precision: 16, scale: 2, nullable: true }) + transactionLimit: number | undefined; + + @Column({ name: 'config', type: 'jsonb', nullable: true }) + config: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; +} diff --git a/src/modules/branches/entities/branch-schedule.entity.ts b/src/modules/branches/entities/branch-schedule.entity.ts new file mode 100644 index 0000000..a1de7d7 --- /dev/null +++ b/src/modules/branches/entities/branch-schedule.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type ScheduleType = 'regular' | 'holiday' | 'special'; + +@Entity({ name: 'branch_schedules', schema: 'core' }) +export class BranchSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + // Identificacion + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Column({ name: 'schedule_type', type: 'varchar', length: 30, default: 'regular' }) + scheduleType: ScheduleType; + + // Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica + @Index() + @Column({ name: 'day_of_week', type: 'integer', nullable: true }) + dayOfWeek: number; + + @Index() + @Column({ name: 'specific_date', type: 'date', nullable: true }) + specificDate: Date; + + // Horarios + @Column({ name: 'open_time', type: 'time' }) + openTime: string; + + @Column({ name: 'close_time', type: 'time' }) + closeTime: string; + + // Turnos (si aplica) + @Column({ type: 'jsonb', default: [] }) + shifts: Array<{ + name: string; + start: string; + end: string; + }>; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.schedules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/branches/entities/branch.entity.ts b/src/modules/branches/entities/branch.entity.ts new file mode 100644 index 0000000..a9627eb --- /dev/null +++ b/src/modules/branches/entities/branch.entity.ts @@ -0,0 +1,158 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserBranchAssignment } from './user-branch-assignment.entity'; +import { BranchSchedule } from './branch-schedule.entity'; +import { BranchPaymentTerminal } from './branch-payment-terminal.entity'; + +export type BranchType = 'headquarters' | 'regional' | 'store' | 'warehouse' | 'office' | 'factory'; + +@Entity({ name: 'branches', schema: 'core' }) +@Unique(['tenantId', 'code']) +export class Branch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true }) + shortName: string; + + // Tipo + @Index() + @Column({ name: 'branch_type', type: 'varchar', length: 30, default: 'store' }) + branchType: BranchType; + + // Contacto + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string; + + // Direccion + @Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true }) + addressLine1: string; + + @Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true }) + addressLine2: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Column({ name: 'geofence_radius', type: 'integer', default: 100 }) + geofenceRadius: number; // Radio en metros + + @Column({ name: 'geofence_enabled', type: 'boolean', default: true }) + geofenceEnabled: boolean; + + // Configuracion + @Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_main', type: 'boolean', default: false }) + isMain: boolean; // Sucursal principal/matriz + + // Horarios de operacion + @Column({ name: 'operating_hours', type: 'jsonb', default: {} }) + operatingHours: Record; + + // Configuraciones especificas + @Column({ type: 'jsonb', default: {} }) + settings: { + allowPos?: boolean; + allowWarehouse?: boolean; + allowCheckIn?: boolean; + [key: string]: any; + }; + + // Jerarquia (path materializado) + @Index() + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'integer', default: 0 }) + hierarchyLevel: number; + + @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; + + // Relaciones + @ManyToOne(() => Branch, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent: Branch; + + @OneToMany(() => Branch, (branch) => branch.parent) + children: Branch[]; + + @OneToMany(() => UserBranchAssignment, (assignment) => assignment.branch) + userAssignments: UserBranchAssignment[]; + + @OneToMany(() => BranchSchedule, (schedule) => schedule.branch) + schedules: BranchSchedule[]; + + @OneToMany(() => BranchPaymentTerminal, (terminal) => terminal.branchId) + paymentTerminals: BranchPaymentTerminal[]; +} diff --git a/src/modules/branches/entities/index.ts b/src/modules/branches/entities/index.ts new file mode 100644 index 0000000..ce1a718 --- /dev/null +++ b/src/modules/branches/entities/index.ts @@ -0,0 +1,5 @@ +export { Branch, BranchType } from './branch.entity'; +export { UserBranchAssignment, AssignmentType, BranchRole } from './user-branch-assignment.entity'; +export { BranchSchedule, ScheduleType } from './branch-schedule.entity'; +export { BranchPaymentTerminal, TerminalProvider, HealthStatus } from './branch-payment-terminal.entity'; +export { BranchInventorySettings } from './branch-inventory-settings.entity'; diff --git a/src/modules/branches/entities/user-branch-assignment.entity.ts b/src/modules/branches/entities/user-branch-assignment.entity.ts new file mode 100644 index 0000000..d2ccd55 --- /dev/null +++ b/src/modules/branches/entities/user-branch-assignment.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type AssignmentType = 'primary' | 'secondary' | 'temporary' | 'floating'; +export type BranchRole = 'manager' | 'supervisor' | 'staff'; + +@Entity({ name: 'user_branch_assignments', schema: 'core' }) +@Unique(['userId', 'branchId', 'assignmentType']) +export class UserBranchAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de asignacion + @Column({ name: 'assignment_type', type: 'varchar', length: 30, default: 'primary' }) + assignmentType: AssignmentType; + + // Rol en la sucursal + @Column({ name: 'branch_role', type: 'varchar', length: 50, nullable: true }) + branchRole: BranchRole; + + // Permisos especificos + @Column({ type: 'jsonb', default: [] }) + permissions: string[]; + + // Vigencia (para asignaciones temporales) + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom: Date; + + @Column({ name: 'valid_until', type: 'timestamptz', nullable: true }) + validUntil: Date; + + // Estado + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @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; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.userAssignments, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/budgets/controllers/presupuesto.controller.ts b/src/modules/budgets/controllers/presupuesto.controller.ts index 1688d1b..147a148 100644 --- a/src/modules/budgets/controllers/presupuesto.controller.ts +++ b/src/modules/budgets/controllers/presupuesto.controller.ts @@ -70,7 +70,7 @@ export function createPresupuestoController(dataSource: DataSource): Router { if (fraccionamientoId) { result = await presupuestoService.findByFraccionamiento(getContext(req), fraccionamientoId, page, limit); } else { - result = await presupuestoService.findAll(getContext(req), { page, limit }); + result = await presupuestoService.findAll(getContext(req), page, limit); } res.status(200).json({ diff --git a/src/modules/contracts/dto/contract-addendum.dto.ts b/src/modules/contracts/dto/contract-addendum.dto.ts new file mode 100644 index 0000000..517226d --- /dev/null +++ b/src/modules/contracts/dto/contract-addendum.dto.ts @@ -0,0 +1,270 @@ +/** + * ContractAddendum DTOs - Data Transfer Objects para Addendas de Contratos + * + * Addendas y modificaciones a contratos. + * + * @module Contracts (MAI-012) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MinLength, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Tipo de addenda + */ +export enum AddendumTypeEnum { + EXTENSION = 'extension', + AMOUNT_INCREASE = 'amount_increase', + AMOUNT_DECREASE = 'amount_decrease', + SCOPE_CHANGE = 'scope_change', + TERMINATION = 'termination', + OTHER = 'other', +} + +/** + * Estado de la addenda + */ +export enum AddendumStatusEnum { + DRAFT = 'draft', + REVIEW = 'review', + APPROVED = 'approved', + REJECTED = 'rejected', +} + +/** + * DTO para crear una nueva addenda + */ +export class CreateAddendumDto { + @IsUUID() + contractId: string; + + @IsString() + @MinLength(3) + @MaxLength(50) + addendumNumber: string; + + @IsEnum(AddendumTypeEnum) + addendumType: AddendumTypeEnum; + + @IsString() + @MinLength(5) + @MaxLength(255) + title: string; + + @IsString() + @MinLength(10) + description: string; + + @IsDateString() + effectiveDate: string; + + @IsOptional() + @IsNumber() + adjustmentAmount?: number; + + @IsOptional() + @IsDateString() + newEndDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + newContractAmount?: number; + + @IsOptional() + @IsString() + scopeChanges?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + documentUrl?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar una addenda existente + */ +export class UpdateAddendumDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(50) + addendumNumber?: string; + + @IsOptional() + @IsEnum(AddendumTypeEnum) + addendumType?: AddendumTypeEnum; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsString() + @MinLength(10) + description?: string; + + @IsOptional() + @IsDateString() + effectiveDate?: string; + + @IsOptional() + @IsNumber() + adjustmentAmount?: number; + + @IsOptional() + @IsDateString() + newEndDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + newContractAmount?: number; + + @IsOptional() + @IsString() + scopeChanges?: string; + + @IsOptional() + @IsEnum(AddendumStatusEnum) + status?: AddendumStatusEnum; + + @IsOptional() + @IsString() + @MaxLength(500) + documentUrl?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para aprobar una addenda + */ +export class ApproveAddendumDto { + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para rechazar una addenda + */ +export class RejectAddendumDto { + @IsString() + @MinLength(10) + rejectionReason: string; +} + +/** + * DTO para filtrar addendas en listados + */ +export class AddendumFiltersDto { + @IsOptional() + @IsUUID() + contractId?: string; + + @IsOptional() + @IsEnum(AddendumTypeEnum) + addendumType?: AddendumTypeEnum; + + @IsOptional() + @IsEnum(AddendumStatusEnum) + status?: AddendumStatusEnum; + + @IsOptional() + @IsDateString() + effectiveDateFrom?: string; + + @IsOptional() + @IsDateString() + effectiveDateTo?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una addenda + */ +export class AddendumResponseDto { + id: string; + tenantId: string; + contractId: string; + contract?: { + id: string; + contractNumber: string; + name: string; + }; + addendumNumber: string; + addendumType: AddendumTypeEnum; + title: string; + description: string; + effectiveDate: Date; + newEndDate?: Date; + amountChange: string; + newContractAmount?: string; + scopeChanges?: string; + status: AddendumStatusEnum; + approvedAt?: Date; + approvedById?: string; + approvedBy?: { + id: string; + firstName: string; + lastName: string; + }; + rejectionReason?: string; + documentUrl?: string; + notes?: string; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; +} diff --git a/src/modules/contracts/dto/contract.dto.ts b/src/modules/contracts/dto/contract.dto.ts new file mode 100644 index 0000000..09d4252 --- /dev/null +++ b/src/modules/contracts/dto/contract.dto.ts @@ -0,0 +1,466 @@ +/** + * Contract DTOs - Data Transfer Objects para Contratos + * + * Contratos con clientes y subcontratistas. + * + * @module Contracts (MAI-012) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MinLength, + MaxLength, + Min, + Max, +} from 'class-validator'; + +/** + * Tipo de contrato + */ +export enum ContractTypeEnum { + CLIENT = 'client', + SUBCONTRACTOR = 'subcontractor', +} + +/** + * Estado del contrato + */ +export enum ContractStatusEnum { + DRAFT = 'draft', + REVIEW = 'review', + APPROVED = 'approved', + ACTIVE = 'active', + COMPLETED = 'completed', + TERMINATED = 'terminated', +} + +/** + * Tipo de contrato cliente + */ +export enum ClientContractTypeEnum { + DESARROLLO = 'desarrollo', + LLAVE_EN_MANO = 'llave_en_mano', + ADMINISTRACION = 'administracion', +} + +/** + * DTO para crear un nuevo contrato + */ +export class CreateContractDto { + @IsString() + @MinLength(3) + @MaxLength(50) + contractNumber: string; + + @IsEnum(ContractTypeEnum) + type: ContractTypeEnum; + + @IsString() + @MinLength(5) + @MaxLength(255) + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + partnerId?: string; + + @IsOptional() + @IsUUID() + fraccionamientoId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsUUID() + subcontractorId?: string; + + @IsDateString() + startDate: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsNumber() + @Min(0) + contractAmount: number; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsEnum(ClientContractTypeEnum) + clientContractType?: ClientContractTypeEnum; + + @IsOptional() + @IsString() + @MaxLength(255) + clientName?: string; + + @IsOptional() + @IsString() + @MaxLength(13) + clientRfc?: string; + + @IsOptional() + @IsString() + clientAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + specialty?: string; + + @IsOptional() + @IsString() + paymentTerms?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + retentionPercentage?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + advancePercentage?: number; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar un contrato existente + */ +export class UpdateContractDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(50) + contractNumber?: string; + + @IsOptional() + @IsEnum(ContractTypeEnum) + type?: ContractTypeEnum; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + partnerId?: string; + + @IsOptional() + @IsUUID() + fraccionamientoId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsUUID() + subcontractorId?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + contractAmount?: number; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsEnum(ClientContractTypeEnum) + clientContractType?: ClientContractTypeEnum; + + @IsOptional() + @IsString() + @MaxLength(255) + clientName?: string; + + @IsOptional() + @IsString() + @MaxLength(13) + clientRfc?: string; + + @IsOptional() + @IsString() + clientAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + specialty?: string; + + @IsOptional() + @IsString() + paymentTerms?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + retentionPercentage?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + advancePercentage?: number; + + @IsOptional() + @IsEnum(ContractStatusEnum) + status?: ContractStatusEnum; + + @IsOptional() + @IsString() + @MaxLength(500) + documentUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + signedDocumentUrl?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + terminationReason?: string; +} + +/** + * DTO para filtrar contratos en listados + */ +export class ContractFiltersDto { + @IsOptional() + @IsEnum(ContractTypeEnum) + type?: ContractTypeEnum; + + @IsOptional() + @IsEnum(ContractStatusEnum) + status?: ContractStatusEnum; + + @IsOptional() + @IsUUID() + partnerId?: string; + + @IsOptional() + @IsUUID() + fraccionamientoId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsUUID() + subcontractorId?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsDateString() + startDateFrom?: string; + + @IsOptional() + @IsDateString() + startDateTo?: string; + + @IsOptional() + @IsDateString() + endDateFrom?: string; + + @IsOptional() + @IsDateString() + endDateTo?: string; + + @IsOptional() + @IsNumber() + @Min(0) + amountMin?: number; + + @IsOptional() + @IsNumber() + @Min(0) + amountMax?: number; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO para aprobar un contrato + */ +export class ApproveContractDto { + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsDateString() + approvalDate?: string; +} + +/** + * DTO para aprobacion legal del contrato + */ +export class LegalApproveContractDto { + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para terminar un contrato + */ +export class TerminateContractDto { + @IsString() + @MinLength(10) + reason: string; + + @IsOptional() + @IsDateString() + terminationDate?: string; +} + +/** + * DTO para firmar un contrato + */ +export class SignContractDto { + @IsOptional() + @IsString() + @MaxLength(500) + signedDocumentUrl?: string; + + @IsOptional() + @IsDateString() + signedDate?: string; +} + +/** + * DTO de respuesta para un contrato + */ +export class ContractResponseDto { + id: string; + tenantId: string; + projectId?: string; + project?: { + id: string; + code: string; + name: string; + }; + fraccionamientoId?: string; + fraccionamiento?: { + id: string; + code: string; + name: string; + }; + contractNumber: string; + contractType: ContractTypeEnum; + clientContractType?: ClientContractTypeEnum; + name: string; + description?: string; + clientName?: string; + clientRfc?: string; + clientAddress?: string; + subcontractorId?: string; + subcontractor?: { + id: string; + code: string; + businessName: string; + }; + specialty?: string; + startDate: Date; + endDate: Date; + contractAmount: string; + currency: string; + paymentTerms?: string; + retentionPercentage: string; + advancePercentage: string; + status: ContractStatusEnum; + submittedAt?: Date; + legalApprovedAt?: Date; + approvedAt?: Date; + signedAt?: Date; + terminatedAt?: Date; + terminationReason?: string; + documentUrl?: string; + signedDocumentUrl?: string; + progressPercentage: string; + invoicedAmount: string; + paidAmount: string; + remainingAmount?: string; + isExpiring?: boolean; + notes?: string; + addendumsCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; +} diff --git a/src/modules/contracts/dto/index.ts b/src/modules/contracts/dto/index.ts new file mode 100644 index 0000000..8c770f8 --- /dev/null +++ b/src/modules/contracts/dto/index.ts @@ -0,0 +1,58 @@ +/** + * Contracts DTOs Index + * Barrel file exporting all contracts module DTOs and Enums. + * + * @module Contracts (MAI-012) + */ + +// ============================================================================ +// CONTRACT DTOs +// ============================================================================ +export { + // Enums + ContractTypeEnum, + ContractStatusEnum, + ClientContractTypeEnum, + // DTOs + CreateContractDto, + UpdateContractDto, + ContractFiltersDto, + ApproveContractDto, + LegalApproveContractDto, + TerminateContractDto, + SignContractDto, + ContractResponseDto, +} from './contract.dto'; + +// ============================================================================ +// CONTRACT ADDENDUM DTOs +// ============================================================================ +export { + // Enums + AddendumTypeEnum, + AddendumStatusEnum, + // DTOs + CreateAddendumDto, + UpdateAddendumDto, + ApproveAddendumDto, + RejectAddendumDto, + AddendumFiltersDto, + AddendumResponseDto, +} from './contract-addendum.dto'; + +// ============================================================================ +// SUBCONTRACTOR DTOs +// ============================================================================ +export { + // Enums + SubcontractorSpecialtyEnum, + SubcontractorStatusEnum, + // DTOs + CreateSubcontractorDto, + UpdateSubcontractorDto, + UpdateSubcontractorRatingDto, + RegisterIncidentDto, + BlacklistSubcontractorDto, + SubcontractorFiltersDto, + SubcontractorResponseDto, +} from './subcontractor.dto'; diff --git a/src/modules/contracts/dto/subcontractor.dto.ts b/src/modules/contracts/dto/subcontractor.dto.ts new file mode 100644 index 0000000..a89fddb --- /dev/null +++ b/src/modules/contracts/dto/subcontractor.dto.ts @@ -0,0 +1,329 @@ +/** + * Subcontractor DTOs - Data Transfer Objects para Subcontratistas + * + * Catalogo de subcontratistas. + * + * @module Contracts (MAI-012) + */ + +import { + IsString, + IsOptional, + IsEnum, + IsNumber, + IsEmail, + IsArray, + MinLength, + MaxLength, + Min, + Max, +} from 'class-validator'; + +/** + * Especialidad del subcontratista + */ +export enum SubcontractorSpecialtyEnum { + CIMENTACION = 'cimentacion', + ESTRUCTURA = 'estructura', + INSTALACIONES_ELECTRICAS = 'instalaciones_electricas', + INSTALACIONES_HIDRAULICAS = 'instalaciones_hidraulicas', + ACABADOS = 'acabados', + URBANIZACION = 'urbanizacion', + CARPINTERIA = 'carpinteria', + HERRERIA = 'herreria', + OTROS = 'otros', +} + +/** + * Estado del subcontratista + */ +export enum SubcontractorStatusEnum { + ACTIVE = 'active', + INACTIVE = 'inactive', + BLACKLISTED = 'blacklisted', +} + +/** + * DTO para crear un nuevo subcontratista + */ +export class CreateSubcontractorDto { + @IsString() + @MinLength(3) + @MaxLength(30) + code: string; + + @IsString() + @MinLength(5) + @MaxLength(255) + businessName: string; + + @IsOptional() + @IsString() + @MaxLength(255) + tradeName?: string; + + @IsString() + @MinLength(12) + @MaxLength(13) + rfc: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + contactName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + contactPhone?: string; + + @IsEnum(SubcontractorSpecialtyEnum) + primarySpecialty: SubcontractorSpecialtyEnum; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + secondarySpecialties?: string[]; + + @IsOptional() + @IsString() + @MaxLength(100) + bankName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + bankAccount?: string; + + @IsOptional() + @IsString() + @MaxLength(18) + clabe?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar un subcontratista existente + */ +export class UpdateSubcontractorDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(30) + code?: string; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(255) + businessName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + tradeName?: string; + + @IsOptional() + @IsString() + @MinLength(12) + @MaxLength(13) + rfc?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + contactName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + contactPhone?: string; + + @IsOptional() + @IsEnum(SubcontractorSpecialtyEnum) + primarySpecialty?: SubcontractorSpecialtyEnum; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + secondarySpecialties?: string[]; + + @IsOptional() + @IsEnum(SubcontractorStatusEnum) + status?: SubcontractorStatusEnum; + + @IsOptional() + @IsString() + @MaxLength(100) + bankName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + bankAccount?: string; + + @IsOptional() + @IsString() + @MaxLength(18) + clabe?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar rating del subcontratista + */ +export class UpdateSubcontractorRatingDto { + @IsNumber() + @Min(0) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + comments?: string; +} + +/** + * DTO para registrar incidente del subcontratista + */ +export class RegisterIncidentDto { + @IsString() + @MinLength(10) + description: string; + + @IsOptional() + @IsString() + severity?: string; +} + +/** + * DTO para marcar subcontratista en lista negra + */ +export class BlacklistSubcontractorDto { + @IsString() + @MinLength(10) + reason: string; +} + +/** + * DTO para filtrar subcontratistas en listados + */ +export class SubcontractorFiltersDto { + @IsOptional() + @IsEnum(SubcontractorSpecialtyEnum) + primarySpecialty?: SubcontractorSpecialtyEnum; + + @IsOptional() + @IsEnum(SubcontractorStatusEnum) + status?: SubcontractorStatusEnum; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(5) + minRating?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(5) + maxRating?: number; + + @IsOptional() + @IsNumber() + @Min(0) + minContracts?: number; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un subcontratista + */ +export class SubcontractorResponseDto { + id: string; + tenantId: string; + code: string; + businessName: string; + tradeName?: string; + rfc: string; + address?: string; + phone?: string; + email?: string; + contactName?: string; + contactPhone?: string; + primarySpecialty: SubcontractorSpecialtyEnum; + secondarySpecialties?: string[]; + status: SubcontractorStatusEnum; + totalContracts: number; + completedContracts: number; + averageRating: string; + totalIncidents: number; + bankName?: string; + bankAccount?: string; + clabe?: string; + notes?: string; + activeContractsCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; +} diff --git a/src/modules/core/entities/country.entity.ts b/src/modules/core/entities/country.entity.ts new file mode 100644 index 0000000..e3a6384 --- /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 0000000..30f4f65 --- /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'; + +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 0000000..f322222 --- /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 0000000..fb2b36c --- /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 +} + +/** + * Aplicacion del descuento + */ +export enum DiscountAppliesTo { + ALL = 'all', // Todos los productos + CATEGORY = 'category', // Categoria especifica + PRODUCT = 'product', // Producto especifico + CUSTOMER = 'customer', // Cliente especifico + CUSTOMER_GROUP = 'customer_group', // Grupo de clientes +} + +/** + * Condicion de activacion + */ +export enum DiscountCondition { + NONE = 'none', // Sin condicion + MIN_QUANTITY = 'min_quantity', // Cantidad minima + MIN_AMOUNT = 'min_amount', // Monto minimo + 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 e828c0e..584325c 100644 --- a/src/modules/core/entities/index.ts +++ b/src/modules/core/entities/index.ts @@ -2,5 +2,18 @@ * Core Entities Index */ +// Existing entities export { Tenant } from './tenant.entity'; export { User } from './user.entity'; + +// Catalog entities (propagated from erp-core) +export { Country } from './country.entity'; +export { Currency } from './currency.entity'; +export { CurrencyRate, RateSource } from './currency-rate.entity'; +export { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition } from './discount-rule.entity'; +export { PaymentTerm, PaymentTermLine, PaymentTermLineType } from './payment-term.entity'; +export { ProductCategory } from './product-category.entity'; +export { Sequence, ResetPeriod } from './sequence.entity'; +export { State } from './state.entity'; +export { Uom, UomType } from './uom.entity'; +export { UomCategory } from './uom-category.entity'; 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 0000000..733e3f8 --- /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 calculo para la linea del termino de pago + */ +export enum PaymentTermLineType { + BALANCE = 'balance', // Saldo restante + PERCENT = 'percent', // Porcentaje del total + FIXED = 'fixed', // Monto fijo +} + +/** + * Linea de termino de pago (para terminos con multiples 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; +} + +/** + * Termino 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 0000000..d9fdd08 --- /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 0000000..cc28829 --- /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 0000000..a7d36c5 --- /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'; + +@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 0000000..6b7e95c --- /dev/null +++ b/src/modules/core/entities/uom-category.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Uom } from './uom.entity'; +import { Tenant } from './tenant.entity'; + +@Entity({ schema: 'core', name: 'uom_categories' }) +@Index('idx_uom_categories_tenant', ['tenantId']) +@Index('idx_uom_categories_tenant_name', ['tenantId', 'name'], { unique: true }) +export class UomCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @OneToMany(() => Uom, (uom) => uom.category) + uoms: Uom[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/core/entities/uom.entity.ts b/src/modules/core/entities/uom.entity.ts new file mode 100644 index 0000000..070370a --- /dev/null +++ b/src/modules/core/entities/uom.entity.ts @@ -0,0 +1,89 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { UomCategory } from './uom-category.entity'; +import { Tenant } from './tenant.entity'; + +export enum UomType { + REFERENCE = 'reference', + BIGGER = 'bigger', + SMALLER = 'smaller', +} + +@Entity({ schema: 'core', name: 'uom' }) +@Index('idx_uom_tenant', ['tenantId']) +@Index('idx_uom_category_id', ['categoryId']) +@Index('idx_uom_code', ['code']) +@Index('idx_uom_active', ['active']) +@Index('idx_uom_tenant_category_name', ['tenantId', 'categoryId', 'name'], { unique: true }) +export class Uom { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: 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(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => UomCategory, (category) => category.uoms, { + nullable: false, + }) + @JoinColumn({ name: 'category_id' }) + category: UomCategory; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/documents/dto/approval.dto.ts b/src/modules/documents/dto/approval.dto.ts new file mode 100644 index 0000000..cf0aa0d --- /dev/null +++ b/src/modules/documents/dto/approval.dto.ts @@ -0,0 +1,400 @@ +/** + * Approval DTOs - Data Transfer Objects para Flujos de Aprobacion + * + * Gestiona workflows de aprobacion para documentos. + * + * @module Documents (MAE-016) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsArray, + IsBoolean, + IsDateString, + ValidateNested, + MinLength, + MaxLength, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Tipo de paso de aprobacion + */ +export enum ApprovalStepTypeEnum { + REVIEW = 'review', + APPROVAL = 'approval', + SIGNATURE = 'signature', + COMMENT = 'comment', +} + +/** + * Estado del workflow + */ +export enum WorkflowStatusEnum { + DRAFT = 'draft', + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', +} + +/** + * Accion de aprobacion + */ +export enum ApprovalActionEnum { + APPROVE = 'approve', + REJECT = 'reject', + REQUEST_CHANGES = 'request_changes', +} + +/** + * DTO para definir un paso de aprobacion dentro de un workflow + */ +export class CreateApprovalStepDto { + @IsNumber() + @Min(1) + stepNumber!: number; + + @IsString() + @MinLength(2) + @MaxLength(255) + name!: string; + + @IsEnum(ApprovalStepTypeEnum) + type!: ApprovalStepTypeEnum; + + @IsArray() + @IsUUID('4', { each: true }) + approvers!: string[]; + + @IsOptional() + @IsNumber() + @Min(1) + requiredCount?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + role?: string; +} + +/** + * DTO para crear un nuevo workflow de aprobacion + */ +export class CreateApprovalWorkflowDto { + @IsString() + @MinLength(3) + @MaxLength(50) + workflowCode!: string; + + @IsString() + @MinLength(3) + @MaxLength(255) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + documentType?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateApprovalStepDto) + steps?: CreateApprovalStepDto[]; + + @IsOptional() + @IsBoolean() + allowParallel?: boolean; + + @IsOptional() + @IsBoolean() + allowSkip?: boolean; + + @IsOptional() + @IsBoolean() + autoArchiveOnApproval?: boolean; +} + +/** + * DTO para actualizar un workflow de aprobacion existente + */ +export class UpdateApprovalWorkflowDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + documentType?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateApprovalStepDto) + steps?: CreateApprovalStepDto[]; + + @IsOptional() + @IsBoolean() + allowParallel?: boolean; + + @IsOptional() + @IsBoolean() + allowSkip?: boolean; + + @IsOptional() + @IsBoolean() + autoArchiveOnApproval?: boolean; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +/** + * DTO para enviar un documento a aprobacion + */ +export class SubmitForApprovalDto { + @IsUUID() + documentId!: string; + + @IsUUID() + workflowId!: string; + + @IsOptional() + @IsUUID() + versionId?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; + + @IsOptional() + @IsDateString() + dueDate?: string; +} + +/** + * DTO para aprobar o rechazar un documento + */ +export class ApproveRejectDto { + @IsEnum(ApprovalActionEnum) + action!: ApprovalActionEnum; + + @IsOptional() + @IsString() + @MaxLength(2000) + comments?: string; +} + +/** + * DTO para cancelar una instancia de aprobacion + */ +export class CancelApprovalDto { + @IsString() + @MinLength(5) + @MaxLength(2000) + reason!: string; +} + +/** + * DTO para filtrar workflows de aprobacion + */ +export class WorkflowFiltersDto { + @IsOptional() + @IsString() + @MaxLength(255) + search?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + documentType?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO para filtrar instancias de aprobacion + */ +export class ApprovalInstanceFiltersDto { + @IsOptional() + @IsUUID() + documentId?: string; + + @IsOptional() + @IsUUID() + workflowId?: string; + + @IsOptional() + @IsEnum(WorkflowStatusEnum) + status?: WorkflowStatusEnum; + + @IsOptional() + @IsUUID() + initiatedById?: string; + + @IsOptional() + @IsDateString() + startedFrom?: string; + + @IsOptional() + @IsDateString() + startedTo?: string; + + @IsOptional() + @IsDateString() + dueFrom?: string; + + @IsOptional() + @IsDateString() + dueTo?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un workflow de aprobacion + */ +export class ApprovalWorkflowResponseDto { + id!: string; + tenantId!: string; + workflowCode!: string; + name!: string; + description?: string; + categoryId?: string; + category?: { + id: string; + name: string; + code: string; + }; + documentType?: string; + steps!: Array<{ + stepNumber: number; + name: string; + type: ApprovalStepTypeEnum; + approvers: string[]; + requiredCount: number; + }>; + allowParallel!: boolean; + allowSkip!: boolean; + autoArchiveOnApproval!: boolean; + isActive!: boolean; + createdBy?: string; + createdAt!: Date; + updatedBy?: string; + updatedAt!: Date; +} + +/** + * DTO de respuesta para una instancia de aprobacion + */ +export class ApprovalInstanceResponseDto { + id!: string; + tenantId!: string; + workflowId!: string; + workflow?: { + id: string; + name: string; + workflowCode: string; + }; + documentId!: string; + document?: { + id: string; + documentCode: string; + title: string; + }; + versionId?: string; + status!: WorkflowStatusEnum; + currentStep!: number; + totalSteps!: number; + startedAt?: Date; + completedAt?: Date; + dueDate?: Date; + initiatedById?: string; + initiatedByName?: string; + finalAction?: ApprovalActionEnum; + finalComments?: string; + finalApproverId?: string; + notes?: string; + steps?: Array<{ + id: string; + stepNumber: number; + stepName: string; + stepType: ApprovalStepTypeEnum; + status: WorkflowStatusEnum; + startedAt?: Date; + completedAt?: Date; + actionTaken?: ApprovalActionEnum; + }>; + createdAt!: Date; + updatedAt!: Date; +} diff --git a/src/modules/documents/dto/document-version.dto.ts b/src/modules/documents/dto/document-version.dto.ts new file mode 100644 index 0000000..c58df1f --- /dev/null +++ b/src/modules/documents/dto/document-version.dto.ts @@ -0,0 +1,233 @@ +/** + * Document Version DTOs - Data Transfer Objects para Versiones de Documentos + * + * Gestiona la creacion y filtrado de versiones de documentos. + * + * @module Documents (MAE-016) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsUrl, + IsDateString, + MinLength, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Estado de la version + */ +export enum VersionStatusEnum { + CURRENT = 'current', + SUPERSEDED = 'superseded', + ARCHIVED = 'archived', +} + +/** + * DTO para crear una nueva version de documento + */ +export class CreateVersionDto { + @IsUUID() + documentId!: string; + + @IsString() + @MinLength(1) + @MaxLength(20) + versionNumber!: string; + + @IsOptional() + @IsString() + @MaxLength(100) + versionLabel?: string; + + @IsString() + @MinLength(1) + @MaxLength(500) + fileName!: string; + + @IsString() + @MinLength(1) + @MaxLength(20) + fileExtension!: string; + + @IsNumber() + @Min(1) + fileSizeBytes!: number; + + @IsOptional() + @IsString() + @MaxLength(100) + mimeType?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + checksumMd5?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + checksumSha256?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + storageProvider?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + storageBucket?: string; + + @IsString() + @MinLength(1) + @MaxLength(1000) + storageKey!: string; + + @IsOptional() + @IsUrl() + @MaxLength(2000) + storageUrl?: string; + + @IsOptional() + @IsUrl() + @MaxLength(2000) + thumbnailUrl?: string; + + @IsOptional() + @IsUrl() + @MaxLength(2000) + previewUrl?: string; + + @IsOptional() + @IsString() + changeSummary?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + changeType?: string; + + @IsOptional() + @IsNumber() + @Min(1) + pageCount?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + uploadSource?: string; +} + +/** + * DTO para filtrar versiones de documentos + */ +export class VersionFiltersDto { + @IsOptional() + @IsUUID() + documentId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + versionNumber?: string; + + @IsOptional() + @IsEnum(VersionStatusEnum) + status?: VersionStatusEnum; + + @IsOptional() + @IsUUID() + uploadedById?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + mimeType?: string; + + @IsOptional() + @IsDateString() + createdFrom?: string; + + @IsOptional() + @IsDateString() + createdTo?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una version de documento + */ +export class VersionResponseDto { + id!: string; + tenantId!: string; + documentId!: string; + versionNumber!: string; + versionLabel?: string; + status!: VersionStatusEnum; + fileName!: string; + fileExtension!: string; + fileSizeBytes!: number; + mimeType?: string; + checksumMd5?: string; + checksumSha256?: string; + storageProvider!: string; + storageBucket?: string; + storageKey!: string; + storageUrl?: string; + thumbnailUrl?: string; + previewUrl?: string; + isProcessed!: boolean; + ocrText?: string; + pageCount?: number; + changeSummary?: string; + changeType?: string; + uploadedById?: string; + uploadedByName?: string; + uploadSource?: string; + createdAt!: Date; + supersededAt?: Date; + supersededByVersionId?: string; +} + +/** + * DTO para marcar una version como actual + */ +export class SetCurrentVersionDto { + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} + +/** + * DTO para archivar una version + */ +export class ArchiveVersionDto { + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/src/modules/documents/dto/document.dto.ts b/src/modules/documents/dto/document.dto.ts new file mode 100644 index 0000000..bb740a5 --- /dev/null +++ b/src/modules/documents/dto/document.dto.ts @@ -0,0 +1,447 @@ +/** + * Document DTOs - Data Transfer Objects para Documentos + * + * Gestiona la creacion, actualizacion y filtrado de documentos. + * + * @module Documents (MAE-016) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsArray, + IsUrl, + IsDateString, + IsBoolean, + MinLength, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Tipo de documento + */ +export enum DocumentTypeEnum { + PLAN = 'plan', + SPECIFICATION = 'specification', + CONTRACT = 'contract', + PERMIT = 'permit', + REPORT = 'report', + PHOTOGRAPH = 'photograph', + DRAWING = 'drawing', + MANUAL = 'manual', + PROCEDURE = 'procedure', + FORM = 'form', + CORRESPONDENCE = 'correspondence', + INVOICE = 'invoice', + ESTIMATE = 'estimate', + OTHER = 'other', +} + +/** + * Estado del documento + */ +export enum DocumentStatusEnum { + DRAFT = 'draft', + PENDING_REVIEW = 'pending_review', + IN_REVIEW = 'in_review', + APPROVED = 'approved', + REJECTED = 'rejected', + OBSOLETE = 'obsolete', + ARCHIVED = 'archived', +} + +/** + * Nivel de acceso del documento + */ +export enum AccessLevelEnum { + PUBLIC = 'public', + INTERNAL = 'internal', + CONFIDENTIAL = 'confidential', + RESTRICTED = 'restricted', +} + +/** + * DTO para crear un nuevo documento + */ +export class CreateDocumentDto { + @IsString() + @MinLength(3) + @MaxLength(500) + title!: string; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(100) + documentCode?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @IsEnum(DocumentTypeEnum) + documentType!: DocumentTypeEnum; + + @IsOptional() + @IsEnum(AccessLevelEnum) + accessLevel?: AccessLevelEnum; + + @IsOptional() + @IsUrl() + fileUrl?: string; + + @IsOptional() + @IsNumber() + @Min(0) + fileSize?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + mimeType?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + projectCode?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + projectName?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + keywords?: string[]; + + @IsOptional() + @IsString() + @MaxLength(255) + author?: string; + + @IsOptional() + @IsDateString() + documentDate?: string; + + @IsOptional() + @IsDateString() + effectiveDate?: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + source?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + externalReference?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + originalFilename?: string; + + @IsOptional() + @IsUUID() + parentDocumentId?: string; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + relatedDocuments?: string[]; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsBoolean() + isTemplate?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para actualizar un documento existente + */ +export class UpdateDocumentDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(500) + title?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsEnum(DocumentTypeEnum) + documentType?: DocumentTypeEnum; + + @IsOptional() + @IsEnum(DocumentStatusEnum) + status?: DocumentStatusEnum; + + @IsOptional() + @IsEnum(AccessLevelEnum) + accessLevel?: AccessLevelEnum; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + projectCode?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + projectName?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + keywords?: string[]; + + @IsOptional() + @IsString() + @MaxLength(255) + author?: string; + + @IsOptional() + @IsDateString() + documentDate?: string; + + @IsOptional() + @IsDateString() + effectiveDate?: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; + + @IsOptional() + @IsDateString() + reviewDate?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + source?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + externalReference?: string; + + @IsOptional() + @IsUUID() + parentDocumentId?: string; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + relatedDocuments?: string[]; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsBoolean() + isTemplate?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para filtrar documentos en listados + */ +export class DocumentFiltersDto { + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + search?: string; + + @IsOptional() + @IsEnum(DocumentTypeEnum) + documentType?: DocumentTypeEnum; + + @IsOptional() + @IsEnum(DocumentStatusEnum) + status?: DocumentStatusEnum; + + @IsOptional() + @IsEnum(AccessLevelEnum) + accessLevel?: AccessLevelEnum; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + @MaxLength(255) + author?: string; + + @IsOptional() + @IsDateString() + createdFrom?: string; + + @IsOptional() + @IsDateString() + createdTo?: string; + + @IsOptional() + @IsDateString() + documentDateFrom?: string; + + @IsOptional() + @IsDateString() + documentDateTo?: string; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsBoolean() + isTemplate?: boolean; + + @IsOptional() + @IsUUID() + parentDocumentId?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un documento + */ +export class DocumentResponseDto { + id!: string; + tenantId!: string; + documentCode!: string; + title!: string; + description?: string; + categoryId?: string; + category?: { + id: string; + name: string; + code: string; + }; + documentType!: DocumentTypeEnum; + status!: DocumentStatusEnum; + accessLevel!: AccessLevelEnum; + currentVersionId?: string; + currentVersionNumber!: string; + projectId?: string; + projectCode?: string; + projectName?: string; + author?: string; + keywords?: string[]; + tags?: string[]; + documentDate?: Date; + effectiveDate?: Date; + expiryDate?: Date; + reviewDate?: Date; + source?: string; + externalReference?: string; + originalFilename?: string; + parentDocumentId?: string; + relatedDocuments?: string[]; + requiresApproval!: boolean; + currentWorkflowId?: string; + approvedById?: string; + approvedAt?: Date; + viewCount!: number; + downloadCount!: number; + lastAccessedAt?: Date; + isTemplate!: boolean; + isLocked!: boolean; + lockedById?: string; + lockedAt?: Date; + notes?: string; + versionsCount?: number; + annotationsCount?: number; + createdBy?: string; + createdAt!: Date; + updatedBy?: string; + updatedAt!: Date; +} + +/** + * DTO para bloquear/desbloquear un documento + */ +export class LockDocumentDto { + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/src/modules/documents/dto/index.ts b/src/modules/documents/dto/index.ts new file mode 100644 index 0000000..f722bc9 --- /dev/null +++ b/src/modules/documents/dto/index.ts @@ -0,0 +1,46 @@ +/** + * Documents DTO Barrel Export + * + * Re-exports all DTOs for the Documents module. + * + * @module Documents (MAE-016) + */ + +// Document DTOs +export { + DocumentTypeEnum, + DocumentStatusEnum, + AccessLevelEnum, + CreateDocumentDto, + UpdateDocumentDto, + DocumentFiltersDto, + DocumentResponseDto, + LockDocumentDto, +} from './document.dto'; + +// Document Version DTOs +export { + VersionStatusEnum, + CreateVersionDto, + VersionFiltersDto, + VersionResponseDto, + SetCurrentVersionDto, + ArchiveVersionDto, +} from './document-version.dto'; + +// Approval DTOs +export { + ApprovalStepTypeEnum, + WorkflowStatusEnum, + ApprovalActionEnum, + CreateApprovalStepDto, + CreateApprovalWorkflowDto, + UpdateApprovalWorkflowDto, + SubmitForApprovalDto, + ApproveRejectDto, + CancelApprovalDto, + WorkflowFiltersDto, + ApprovalInstanceFiltersDto, + ApprovalWorkflowResponseDto, + ApprovalInstanceResponseDto, +} from './approval.dto'; diff --git a/src/modules/estimates/controllers/fondo-garantia.controller.ts b/src/modules/estimates/controllers/fondo-garantia.controller.ts index 9a81e92..bea4108 100644 --- a/src/modules/estimates/controllers/fondo-garantia.controller.ts +++ b/src/modules/estimates/controllers/fondo-garantia.controller.ts @@ -60,7 +60,7 @@ export function createFondoGarantiaController(dataSource: DataSource): Router { const page = parseInt(req.query.page as string) || 1; const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); - const result = await fondoGarantiaService.findAll(getContext(req), { page, limit }); + const result = await fondoGarantiaService.findAll(getContext(req), page, limit); res.status(200).json({ success: true, diff --git a/src/modules/estimates/services/anticipo.service.ts b/src/modules/estimates/services/anticipo.service.ts index f1c395d..4ff316a 100644 --- a/src/modules/estimates/services/anticipo.service.ts +++ b/src/modules/estimates/services/anticipo.service.ts @@ -131,11 +131,11 @@ export class AnticipoService { grossAmount: dto.grossAmount, taxAmount: dto.taxAmount, netAmount: dto.netAmount, - amortizationPercentage: dto.amortizationPercentage, + amortizationPercentage: dto.amortizationPercentage ?? 0, amortizedAmount: 0, isFullyAmortized: false, - notes: dto.notes, - createdBy: ctx.userId, + notes: dto.notes ?? null, + createdById: ctx.userId ?? null, }); return this.repository.save(anticipo); @@ -155,7 +155,7 @@ export class AnticipoService { } Object.assign(anticipo, data); - anticipo.updatedById = ctx.userId; + anticipo.updatedById = ctx.userId ?? null; return this.repository.save(anticipo); } @@ -170,7 +170,7 @@ export class AnticipoService { } anticipo.deletedAt = new Date(); - anticipo.deletedById = ctx.userId; + anticipo.deletedById = ctx.userId ?? null; await this.repository.save(anticipo); return true; } @@ -202,7 +202,7 @@ export class AnticipoService { : `Aprobación: ${notes}`; } - anticipo.updatedById = ctx.userId; + anticipo.updatedById = ctx.userId ?? null; return this.repository.save(anticipo); } @@ -231,7 +231,7 @@ export class AnticipoService { anticipo.paidAt = paidAt || new Date(); anticipo.paymentReference = paymentReference; - anticipo.updatedById = ctx.userId; + anticipo.updatedById = ctx.userId ?? null; return this.repository.save(anticipo); } @@ -398,7 +398,7 @@ export class AnticipoService { anticipo.amortizedAmount = newAmortizedAmount; anticipo.isFullyAmortized = isFullyAmortized; - anticipo.updatedById = ctx.userId; + anticipo.updatedById = ctx.userId ?? null; return this.repository.save(anticipo); } diff --git a/src/modules/estimates/services/estimacion.service.ts b/src/modules/estimates/services/estimacion.service.ts index 704f35d..e817df4 100644 --- a/src/modules/estimates/services/estimacion.service.ts +++ b/src/modules/estimates/services/estimacion.service.ts @@ -110,7 +110,7 @@ export class EstimacionService { } Object.assign(estimacion, data); - estimacion.updatedById = ctx.userId; + estimacion.updatedById = ctx.userId ?? null; return this.repository.save(estimacion); } @@ -125,7 +125,7 @@ export class EstimacionService { } estimacion.deletedAt = new Date(); - estimacion.deletedById = ctx.userId; + estimacion.deletedById = ctx.userId ?? null; await this.repository.save(estimacion); return true; } diff --git a/src/modules/feature-flags/entities/flag-evaluation.entity.ts b/src/modules/feature-flags/entities/flag-evaluation.entity.ts new file mode 100644 index 0000000..3f8e1b8 --- /dev/null +++ b/src/modules/feature-flags/entities/flag-evaluation.entity.ts @@ -0,0 +1,54 @@ +/** + * FlagEvaluation Entity + * Feature flag evaluation history for analytics + * Compatible with erp-core flag-evaluation.entity + * + * @module FeatureFlags + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +@Entity({ schema: 'feature_flags', name: 'flag_evaluations' }) +@Index('idx_flag_evaluations_flag', ['flagId']) +@Index('idx_flag_evaluations_tenant', ['tenantId']) +@Index('idx_flag_evaluations_date', ['evaluatedAt']) +export class FlagEvaluation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'flag_id' }) + flagId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'user_id' }) + userId: string | null; + + @Column({ type: 'boolean', nullable: false }) + result: boolean; + + @Column({ type: 'varchar', length: 100, nullable: true }) + variant: string | null; + + @Column({ type: 'jsonb', default: {}, name: 'evaluation_context' }) + evaluationContext: Record; + + @Column({ type: 'varchar', length: 100, nullable: true, name: 'evaluation_reason' }) + evaluationReason: string | null; + + @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP', name: 'evaluated_at' }) + evaluatedAt: Date; + + @ManyToOne(() => Flag, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/feature-flags/entities/flag.entity.ts b/src/modules/feature-flags/entities/flag.entity.ts new file mode 100644 index 0000000..69579de --- /dev/null +++ b/src/modules/feature-flags/entities/flag.entity.ts @@ -0,0 +1,65 @@ +/** + * Flag Entity + * Feature flag definition with rollout control + * Compatible with erp-core flag.entity + * + * @module FeatureFlags + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + OneToMany, +} from 'typeorm'; +import { TenantOverride } from './tenant-override.entity'; + +@Entity({ name: 'flags', schema: 'feature_flags' }) +@Unique(['code']) +export class Flag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'enabled', type: 'boolean', default: false }) + enabled: boolean; + + @Column({ name: 'rollout_percentage', type: 'int', default: 100 }) + rolloutPercentage: number; + + @Column({ name: 'tags', type: 'text', array: true, nullable: true }) + tags: string[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @OneToMany(() => TenantOverride, (override) => override.flag) + overrides: TenantOverride[]; +} diff --git a/src/modules/feature-flags/entities/index.ts b/src/modules/feature-flags/entities/index.ts new file mode 100644 index 0000000..8cf3637 --- /dev/null +++ b/src/modules/feature-flags/entities/index.ts @@ -0,0 +1,7 @@ +/** + * Feature Flags Entities - Export + */ + +export { Flag } from './flag.entity'; +export { TenantOverride } from './tenant-override.entity'; +export { FlagEvaluation } from './flag-evaluation.entity'; diff --git a/src/modules/feature-flags/entities/tenant-override.entity.ts b/src/modules/feature-flags/entities/tenant-override.entity.ts new file mode 100644 index 0000000..d4dee9b --- /dev/null +++ b/src/modules/feature-flags/entities/tenant-override.entity.ts @@ -0,0 +1,58 @@ +/** + * TenantOverride Entity + * Per-tenant feature flag override + * Compatible with erp-core tenant-override.entity + * + * @module FeatureFlags + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +@Entity({ name: 'tenant_overrides', schema: 'feature_flags' }) +@Unique(['flagId', 'tenantId']) +export class TenantOverride { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'flag_id', type: 'uuid' }) + flagId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled: boolean; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => Flag, (flag) => flag.overrides, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/fiscal/entities/cfdi-use.entity.ts b/src/modules/fiscal/entities/cfdi-use.entity.ts new file mode 100644 index 0000000..0b8a0d9 --- /dev/null +++ b/src/modules/fiscal/entities/cfdi-use.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { PersonType } from './fiscal-regime.entity'; + +@Entity({ schema: 'fiscal', name: 'cfdi_uses' }) +@Index('idx_cfdi_uses_code', ['code'], { unique: true }) +@Index('idx_cfdi_uses_applies', ['appliesTo']) +export class CfdiUse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 10, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: PersonType, + nullable: false, + default: PersonType.BOTH, + name: 'applies_to', + }) + appliesTo: PersonType; + + @Column({ type: 'simple-array', nullable: true, name: 'allowed_regimes' }) + allowedRegimes: string[] | null; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/fiscal/entities/fiscal-regime.entity.ts b/src/modules/fiscal/entities/fiscal-regime.entity.ts new file mode 100644 index 0000000..b24ee36 --- /dev/null +++ b/src/modules/fiscal/entities/fiscal-regime.entity.ts @@ -0,0 +1,49 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum PersonType { + NATURAL = 'natural', // Persona fisica + LEGAL = 'legal', // Persona moral + BOTH = 'both', +} + +@Entity({ schema: 'fiscal', name: 'fiscal_regimes' }) +@Index('idx_fiscal_regimes_code', ['code'], { unique: true }) +@Index('idx_fiscal_regimes_applies', ['appliesTo']) +export class FiscalRegime { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 10, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: PersonType, + nullable: false, + default: PersonType.BOTH, + name: 'applies_to', + }) + appliesTo: PersonType; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/fiscal/entities/index.ts b/src/modules/fiscal/entities/index.ts new file mode 100644 index 0000000..72f467f --- /dev/null +++ b/src/modules/fiscal/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Fiscal Entities Index + */ + +export { TaxCategory, TaxNature } from './tax-category.entity'; +export { FiscalRegime, PersonType } from './fiscal-regime.entity'; +export { CfdiUse } from './cfdi-use.entity'; +export { PaymentMethod } from './payment-method.entity'; +export { PaymentType } from './payment-type.entity'; +export { WithholdingType } from './withholding-type.entity'; diff --git a/src/modules/fiscal/entities/payment-method.entity.ts b/src/modules/fiscal/entities/payment-method.entity.ts new file mode 100644 index 0000000..d9fa946 --- /dev/null +++ b/src/modules/fiscal/entities/payment-method.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'fiscal', name: 'payment_methods' }) +@Index('idx_payment_methods_code', ['code'], { unique: true }) +export class PaymentMethod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 10, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'requires_bank_info' }) + requiresBankInfo: boolean; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/fiscal/entities/payment-type.entity.ts b/src/modules/fiscal/entities/payment-type.entity.ts new file mode 100644 index 0000000..31e9fbc --- /dev/null +++ b/src/modules/fiscal/entities/payment-type.entity.ts @@ -0,0 +1,33 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'fiscal', name: 'payment_types' }) +@Index('idx_payment_types_code', ['code'], { unique: true }) +export class PaymentType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 10, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/fiscal/entities/tax-category.entity.ts b/src/modules/fiscal/entities/tax-category.entity.ts new file mode 100644 index 0000000..9841c68 --- /dev/null +++ b/src/modules/fiscal/entities/tax-category.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum TaxNature { + TAX = 'tax', + WITHHOLDING = 'withholding', + BOTH = 'both', +} + +@Entity({ schema: 'fiscal', name: 'tax_categories' }) +@Index('idx_tax_categories_code', ['code'], { unique: true }) +@Index('idx_tax_categories_sat', ['satCode']) +export class TaxCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 20, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: TaxNature, + nullable: false, + default: TaxNature.TAX, + name: 'tax_nature', + }) + taxNature: TaxNature; + + @Column({ type: 'varchar', length: 10, nullable: true, name: 'sat_code' }) + satCode: string | null; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/fiscal/entities/withholding-type.entity.ts b/src/modules/fiscal/entities/withholding-type.entity.ts new file mode 100644 index 0000000..57e01bd --- /dev/null +++ b/src/modules/fiscal/entities/withholding-type.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { TaxCategory } from './tax-category.entity'; + +@Entity({ schema: 'fiscal', name: 'withholding_types' }) +@Index('idx_withholding_types_code', ['code'], { unique: true }) +@Index('idx_withholding_types_category', ['taxCategoryId']) +export class WithholdingType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 20, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'decimal', + precision: 5, + scale: 2, + nullable: false, + default: 0, + name: 'default_rate', + }) + defaultRate: number; + + @Column({ type: 'uuid', nullable: true, name: 'tax_category_id' }) + taxCategoryId: string | null; + + @ManyToOne(() => TaxCategory, { nullable: true }) + @JoinColumn({ name: 'tax_category_id' }) + taxCategory: TaxCategory | null; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts new file mode 100644 index 0000000..f7467a4 --- /dev/null +++ b/src/modules/health/health.controller.ts @@ -0,0 +1,50 @@ +/** + * Health Controller + * REST endpoints for health, liveness and readiness probes + * Compatible with erp-core health.controller + * + * @module Health + */ + +import { Router, Request, Response } from 'express'; +import { HealthService } from './health.service'; + +export class HealthController { + public router: Router; + + constructor(private healthService: HealthService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + this.router.get('/', this.getHealth.bind(this)); + this.router.get('/live', this.getLiveness.bind(this)); + this.router.get('/ready', this.getReadiness.bind(this)); + } + + private async getHealth(_req: Request, res: Response): Promise { + try { + const health = await this.healthService.getHealth(); + const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503; + res.status(statusCode).json(health); + } catch (error) { + res.status(503).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + private async getLiveness(_req: Request, res: Response): Promise { + const result = await this.healthService.getLiveness(); + res.status(200).json(result); + } + + private async getReadiness(_req: Request, res: Response): Promise { + const result = await this.healthService.getReadiness(); + const statusCode = result.status === 'ready' ? 200 : 503; + res.status(statusCode).json(result); + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..6621262 --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,42 @@ +/** + * Health Module + * Initializes health check service and routes + * Compatible with erp-core health.module + * + * @module Health + */ + +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { HealthService } from './health.service'; +import { HealthController } from './health.controller'; + +export interface HealthModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class HealthModule { + public router: Router; + public healthService: HealthService; + private basePath: string; + + constructor(options: HealthModuleOptions) { + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(options.dataSource); + this.initializeRoutes(); + } + + private initializeServices(dataSource: DataSource): void { + this.healthService = new HealthService(dataSource); + } + + private initializeRoutes(): void { + const healthController = new HealthController(this.healthService); + this.router.use(`${this.basePath}/health`, healthController.router); + } +} + +export { HealthService } from './health.service'; +export { HealthController } from './health.controller'; diff --git a/src/modules/health/health.service.ts b/src/modules/health/health.service.ts new file mode 100644 index 0000000..c91d4a0 --- /dev/null +++ b/src/modules/health/health.service.ts @@ -0,0 +1,108 @@ +/** + * Health Service + * Database, memory, liveness and readiness checks + * Compatible with erp-core health.service + * + * @module Health + */ + +import { DataSource } from 'typeorm'; + +export interface HealthStatus { + status: 'healthy' | 'unhealthy' | 'degraded'; + timestamp: string; + version: string; + uptime: number; + checks: { + database: HealthCheck; + memory: HealthCheck; + }; +} + +export interface HealthCheck { + status: 'up' | 'down'; + responseTime?: number; + details?: Record; +} + +export class HealthService { + private startTime: number; + + constructor(private dataSource: DataSource) { + this.startTime = Date.now(); + } + + async getHealth(): Promise { + const databaseCheck = await this.checkDatabase(); + const memoryCheck = this.checkMemory(); + + const isHealthy = databaseCheck.status === 'up' && memoryCheck.status === 'up'; + const isDegraded = databaseCheck.status === 'up' || memoryCheck.status === 'up'; + + return { + status: isHealthy ? 'healthy' : isDegraded ? 'degraded' : 'unhealthy', + timestamp: new Date().toISOString(), + version: process.env.APP_VERSION || '1.0.0', + uptime: Math.floor((Date.now() - this.startTime) / 1000), + checks: { + database: databaseCheck, + memory: memoryCheck, + }, + }; + } + + async checkDatabase(): Promise { + const startTime = Date.now(); + try { + if (!this.dataSource.isInitialized) { + return { + status: 'down', + details: { error: 'Database not initialized' }, + }; + } + + await this.dataSource.query('SELECT 1'); + return { + status: 'up', + responseTime: Date.now() - startTime, + }; + } catch (error) { + return { + status: 'down', + responseTime: Date.now() - startTime, + details: { error: error instanceof Error ? error.message : 'Unknown error' }, + }; + } + } + + checkMemory(): HealthCheck { + const memoryUsage = process.memoryUsage(); + const heapUsedPercent = (memoryUsage.heapUsed / memoryUsage.heapTotal) * 100; + + return { + status: heapUsedPercent < 90 ? 'up' : 'down', + details: { + heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024), + heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024), + external: Math.round(memoryUsage.external / 1024 / 1024), + rss: Math.round(memoryUsage.rss / 1024 / 1024), + heapUsedPercent: Math.round(heapUsedPercent * 100) / 100, + }, + }; + } + + async getLiveness(): Promise<{ status: 'ok' }> { + return { status: 'ok' }; + } + + async getReadiness(): Promise<{ status: 'ready' | 'not_ready'; details?: Record }> { + const databaseCheck = await this.checkDatabase(); + if (databaseCheck.status !== 'up') { + return { + status: 'not_ready', + details: { database: databaseCheck }, + }; + } + return { status: 'ready' }; + } +} diff --git a/src/modules/health/index.ts b/src/modules/health/index.ts new file mode 100644 index 0000000..1af742c --- /dev/null +++ b/src/modules/health/index.ts @@ -0,0 +1,7 @@ +/** + * Health Module Exports + */ + +export { HealthModule, HealthModuleOptions } from './health.module'; +export { HealthService, HealthStatus, HealthCheck } from './health.service'; +export { HealthController } from './health.controller'; diff --git a/src/modules/hr/entities/contract.entity.ts b/src/modules/hr/entities/contract.entity.ts new file mode 100644 index 0000000..a5a6a21 --- /dev/null +++ b/src/modules/hr/entities/contract.entity.ts @@ -0,0 +1,161 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Department } from './department.entity'; + +/** + * Contract Type Enum + */ +export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time'; + +/** + * Contract Status Enum + */ +export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled'; + +/** + * Wage Type for payment frequency + */ +export type WageType = 'hourly' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'annual'; + +/** + * Contract Entity (schema: hr.contracts) + * + * Employment contracts with details about compensation, duration, + * and terms of employment. Tracks contract lifecycle from draft to termination. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'contracts', schema: 'hr' }) +export class Contract { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + // Note: ManyToOne to Employee removed - construction Employee + // has a different structure and does not have contracts back-reference. + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'reference', type: 'varchar', length: 50, nullable: true }) + reference: string | null; + + @Index() + @Column({ + name: 'contract_type', + type: 'enum', + enum: ['permanent', 'temporary', 'contractor', 'internship', 'part_time'], + enumName: 'hr_contract_type', + default: 'permanent', + }) + contractType: ContractType; + + @Index() + @Column({ + name: 'status', + type: 'enum', + enum: ['draft', 'active', 'expired', 'terminated', 'cancelled'], + enumName: 'hr_contract_status', + default: 'draft', + }) + status: ContractStatus; + + @Index() + @Column({ name: 'date_start', type: 'date' }) + dateStart: Date; + + @Column({ name: 'date_end', type: 'date', nullable: true }) + dateEnd: Date | null; + + @Column({ name: 'job_position_id', type: 'uuid', nullable: true }) + jobPositionId: string | null; + + // Note: ManyToOne to JobPosition removed - construction uses Puesto entity instead. + + @Column({ name: 'department_id', type: 'uuid', nullable: true }) + departmentId: string | null; + + @ManyToOne(() => Department, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'department_id' }) + department: Department | null; + + // Compensation + @Column({ name: 'wage', type: 'decimal', precision: 15, scale: 2 }) + wage: number; + + @Column({ + name: 'wage_type', + type: 'varchar', + length: 20, + default: 'monthly', + }) + wageType: WageType; + + @Column({ name: 'currency', type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Schedule + @Column({ + name: 'hours_per_week', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + default: 48, + }) + hoursPerWeek: number | null; + + @Column({ name: 'schedule_type', type: 'varchar', length: 50, nullable: true }) + scheduleType: string | null; + + // Trial period + @Column({ name: 'trial_period_months', type: 'integer', nullable: true, default: 0 }) + trialPeriodMonths: number | null; + + @Column({ name: 'trial_date_end', type: 'date', nullable: true }) + trialDateEnd: Date | null; + + // Metadata + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'document_url', type: 'text', nullable: true }) + documentUrl: string | null; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'activated_at', type: 'timestamptz', nullable: true }) + activatedAt: Date | null; + + @Column({ name: 'terminated_at', type: 'timestamptz', nullable: true }) + terminatedAt: Date | null; + + @Column({ name: 'terminated_by', type: 'uuid', nullable: true }) + terminatedBy: string | null; + + @Column({ name: 'termination_reason', type: 'text', nullable: true }) + terminationReason: string | null; +} diff --git a/src/modules/hr/entities/department.entity.ts b/src/modules/hr/entities/department.entity.ts new file mode 100644 index 0000000..22207a4 --- /dev/null +++ b/src/modules/hr/entities/department.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; + +/** + * Department Entity (schema: hr.departments) + * + * Organizational departments with self-referential hierarchy. + * Supports parent/child relationships for department structure. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'departments', schema: 'hr' }) +@Index(['tenantId', 'companyId', 'code'], { unique: true }) +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'code', type: 'varchar', length: 50, nullable: true }) + code: string | null; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @ManyToOne(() => Department, (department) => department.children, { + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'parent_id' }) + parent: Department | null; + + @OneToMany(() => Department, (department) => department.parent) + children: Department[]; + + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string | null; + + // Note: manager ManyToOne to Employee removed - construction Employee + // has a different structure and does not map this back-reference. + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'color', type: 'varchar', length: 7, nullable: true }) + color: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/index.ts b/src/modules/hr/entities/index.ts index 48752f0..aa6b6aa 100644 --- a/src/modules/hr/entities/index.ts +++ b/src/modules/hr/entities/index.ts @@ -3,6 +3,14 @@ * @module HR */ +// Existing construction-specific entities export * from './puesto.entity'; export * from './employee.entity'; export * from './employee-fraccionamiento.entity'; + +// Entities propagated from erp-core +export { Department } from './department.entity'; +export { Contract, ContractType, ContractStatus, WageType } from './contract.entity'; +export { LeaveType, LeaveTypeCategory, AllocationType } from './leave-type.entity'; +export { LeaveAllocation } from './leave-allocation.entity'; +export { Leave, LeaveStatus, HalfDayType } from './leave.entity'; diff --git a/src/modules/hr/entities/leave-allocation.entity.ts b/src/modules/hr/entities/leave-allocation.entity.ts new file mode 100644 index 0000000..3aa9a0b --- /dev/null +++ b/src/modules/hr/entities/leave-allocation.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { LeaveType } from './leave-type.entity'; + +/** + * Leave Allocation Entity (schema: hr.leave_allocations) + * + * Tracks allocated leave days per employee and leave type. + * Supports period-based allocations with used/remaining tracking. + * + * Note: days_remaining is a computed column in PostgreSQL + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leave_allocations', schema: 'hr' }) +export class LeaveAllocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + // Note: ManyToOne to Employee removed - construction Employee + // has a different structure and does not have leaveAllocations back-reference. + + @Index() + @Column({ name: 'leave_type_id', type: 'uuid' }) + leaveTypeId: string; + + @ManyToOne(() => LeaveType, (leaveType) => leaveType.allocations, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'leave_type_id' }) + leaveType: LeaveType; + + @Column({ name: 'days_allocated', type: 'decimal', precision: 5, scale: 2 }) + daysAllocated: number; + + @Column({ name: 'days_used', type: 'decimal', precision: 5, scale: 2, default: 0 }) + daysUsed: number; + + /** + * Generated column in PostgreSQL: days_allocated - days_used + * Mark as insert: false, update: false since it's computed by DB + */ + @Column({ + name: 'days_remaining', + type: 'decimal', + precision: 5, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + daysRemaining: number; + + @Index() + @Column({ name: 'date_from', type: 'date' }) + dateFrom: Date; + + @Column({ name: 'date_to', type: 'date' }) + dateTo: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/leave-type.entity.ts b/src/modules/hr/entities/leave-type.entity.ts new file mode 100644 index 0000000..f182b35 --- /dev/null +++ b/src/modules/hr/entities/leave-type.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { Leave } from './leave.entity'; +import { LeaveAllocation } from './leave-allocation.entity'; + +/** + * Leave Type Category Enum + */ +export type LeaveTypeCategory = + | 'vacation' + | 'sick' + | 'personal' + | 'maternity' + | 'paternity' + | 'bereavement' + | 'unpaid' + | 'other'; + +/** + * Allocation Type Enum + */ +export type AllocationType = 'fixed' | 'accrual' | 'unlimited'; + +/** + * Leave Type Entity (schema: hr.leave_types) + * + * Configurable leave/absence types for the organization. + * Defines rules for approval, allocation, and payment. + * + * Examples: Vacation, Sick Leave, Maternity, etc. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leave_types', schema: 'hr' }) +@Index(['tenantId', 'companyId', 'code'], { unique: true }) +export class LeaveType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'color', type: 'varchar', length: 7, default: '#3B82F6' }) + color: string; + + @Index() + @Column({ + name: 'leave_category', + type: 'enum', + enum: ['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other'], + enumName: 'hr_leave_type_category', + default: 'other', + }) + leaveCategory: LeaveTypeCategory; + + @Column({ + name: 'allocation_type', + type: 'enum', + enum: ['fixed', 'accrual', 'unlimited'], + enumName: 'hr_allocation_type', + default: 'fixed', + }) + allocationType: AllocationType; + + @Column({ name: 'requires_approval', type: 'boolean', default: true }) + requiresApproval: boolean; + + @Column({ name: 'requires_document', type: 'boolean', default: false }) + requiresDocument: boolean; + + // Limits + @Column({ name: 'max_days_per_request', type: 'integer', nullable: true }) + maxDaysPerRequest: number | null; + + @Column({ name: 'max_days_per_year', type: 'integer', nullable: true }) + maxDaysPerYear: number | null; + + @Column({ name: 'min_days_notice', type: 'integer', default: 0 }) + minDaysNotice: number; + + // Payment + @Column({ name: 'is_paid', type: 'boolean', default: true }) + isPaid: boolean; + + @Column({ + name: 'pay_percentage', + type: 'decimal', + precision: 5, + scale: 2, + default: 100, + }) + payPercentage: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Relations + @OneToMany(() => Leave, (leave) => leave.leaveType) + leaves: Leave[]; + + @OneToMany(() => LeaveAllocation, (allocation) => allocation.leaveType) + allocations: LeaveAllocation[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/leave.entity.ts b/src/modules/hr/entities/leave.entity.ts new file mode 100644 index 0000000..d26b784 --- /dev/null +++ b/src/modules/hr/entities/leave.entity.ts @@ -0,0 +1,138 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { LeaveType } from './leave-type.entity'; +import { LeaveAllocation } from './leave-allocation.entity'; + +/** + * Leave Status Enum + */ +export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled'; + +/** + * Half Day Type + */ +export type HalfDayType = 'morning' | 'afternoon'; + +/** + * Leave Entity (schema: hr.leaves) + * + * Leave/absence requests from employees. + * Tracks the full lifecycle from draft to approval/rejection. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leaves', schema: 'hr' }) +export class Leave { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + // Note: ManyToOne to Employee removed - construction Employee + // has a different structure and does not have leaves back-reference. + + @Index() + @Column({ name: 'leave_type_id', type: 'uuid' }) + leaveTypeId: string; + + @ManyToOne(() => LeaveType, (leaveType) => leaveType.leaves, { + onDelete: 'RESTRICT', + }) + @JoinColumn({ name: 'leave_type_id' }) + leaveType: LeaveType; + + @Column({ name: 'allocation_id', type: 'uuid', nullable: true }) + allocationId: string | null; + + @ManyToOne(() => LeaveAllocation, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'allocation_id' }) + allocation: LeaveAllocation | null; + + // Period + @Index() + @Column({ name: 'date_from', type: 'date' }) + dateFrom: Date; + + @Column({ name: 'date_to', type: 'date' }) + dateTo: Date; + + @Column({ name: 'days_requested', type: 'decimal', precision: 5, scale: 2 }) + daysRequested: number; + + // Half day support + @Column({ name: 'is_half_day', type: 'boolean', default: false }) + isHalfDay: boolean; + + @Column({ + name: 'half_day_type', + type: 'varchar', + length: 20, + nullable: true, + }) + halfDayType: HalfDayType | null; + + @Index() + @Column({ + name: 'status', + type: 'enum', + enum: ['draft', 'submitted', 'approved', 'rejected', 'cancelled'], + enumName: 'hr_leave_status', + default: 'draft', + }) + status: LeaveStatus; + + // Approval + @Index() + @Column({ name: 'approver_id', type: 'uuid', nullable: true }) + approverId: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string | null; + + // Metadata + @Column({ name: 'request_reason', type: 'text', nullable: true }) + requestReason: string | null; + + @Column({ name: 'document_url', type: 'text', nullable: true }) + documentUrl: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date | null; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string | null; +} diff --git a/src/modules/hr/services/employee.service.ts b/src/modules/hr/services/employee.service.ts index 82a03d2..b60e4ca 100644 --- a/src/modules/hr/services/employee.service.ts +++ b/src/modules/hr/services/employee.service.ts @@ -6,10 +6,24 @@ * @module HR */ -import { Repository, FindOptionsWhere } from 'typeorm'; +import { DataSource, Repository, FindOptionsWhere } from 'typeorm'; import { Employee, EstadoEmpleado, Genero } from '../entities/employee.entity'; import { EmployeeFraccionamiento } from '../entities/employee-fraccionamiento.entity'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export { Genero }; export interface CreateEmployeeDto { codigo: string; @@ -75,10 +89,13 @@ export interface EmployeeStats { } export class EmployeeService { - constructor( - private readonly employeeRepository: Repository, - private readonly asignacionRepository: Repository - ) {} + private employeeRepository: Repository; + private asignacionRepository: Repository; + + constructor(dataSource: DataSource) { + this.employeeRepository = dataSource.getRepository(Employee); + this.asignacionRepository = dataSource.getRepository(EmployeeFraccionamiento); + } async findWithFilters( ctx: ServiceContext, @@ -181,12 +198,30 @@ export class EmployeeService { const employee = this.employeeRepository.create({ tenantId: ctx.tenantId, - createdById: ctx.userId, + createdById: ctx.userId ?? null, estado: 'activo', - ...dto, - }); + codigo: dto.codigo, + nombre: dto.nombre, + apellidoPaterno: dto.apellidoPaterno, + apellidoMaterno: dto.apellidoMaterno, + curp: dto.curp, + rfc: dto.rfc, + nss: dto.nss, + fechaNacimiento: dto.fechaNacimiento, + genero: dto.genero, + email: dto.email, + telefono: dto.telefono, + direccion: dto.direccion, + fechaIngreso: dto.fechaIngreso, + puestoId: dto.puestoId, + departamento: dto.departamento, + tipoContrato: dto.tipoContrato, + salarioDiario: dto.salarioDiario, + fotoUrl: dto.fotoUrl, + } as Partial); - return this.employeeRepository.save(employee); + const saved = await this.employeeRepository.save(employee); + return Array.isArray(saved) ? saved[0] : saved; } async update(ctx: ServiceContext, id: string, dto: UpdateEmployeeDto): Promise { diff --git a/src/modules/hr/services/puesto.service.ts b/src/modules/hr/services/puesto.service.ts index d180a81..9a5c85e 100644 --- a/src/modules/hr/services/puesto.service.ts +++ b/src/modules/hr/services/puesto.service.ts @@ -6,9 +6,21 @@ * @module HR */ -import { Repository, FindOptionsWhere } from 'typeorm'; +import { DataSource, Repository, FindOptionsWhere } from 'typeorm'; import { Puesto } from '../entities/puesto.entity'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} export interface CreatePuestoDto { codigo: string; @@ -33,7 +45,11 @@ export interface PuestoFilters { } export class PuestoService { - constructor(private readonly repository: Repository) {} + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(Puesto); + } async findAll( ctx: ServiceContext, diff --git a/src/modules/hse/controllers/permiso-trabajo.controller.ts b/src/modules/hse/controllers/permiso-trabajo.controller.ts index a5c13a1..cd168b4 100644 --- a/src/modules/hse/controllers/permiso-trabajo.controller.ts +++ b/src/modules/hse/controllers/permiso-trabajo.controller.ts @@ -122,7 +122,12 @@ export function createPermisoTrabajoController(dataSource: DataSource): Router { res.status(200).json({ success: true, data: result.data, - pagination: result.meta, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, }); } catch (error) { next(error); diff --git a/src/modules/hse/controllers/stps.controller.ts b/src/modules/hse/controllers/stps.controller.ts index 974d197..26e0142 100644 --- a/src/modules/hse/controllers/stps.controller.ts +++ b/src/modules/hse/controllers/stps.controller.ts @@ -167,7 +167,12 @@ export function createStpsController(dataSource: DataSource): Router { res.status(200).json({ success: true, data: result.data, - pagination: result.meta, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, }); } catch (error) { next(error); @@ -691,7 +696,12 @@ export function createStpsController(dataSource: DataSource): Router { res.status(200).json({ success: true, data: result.data, - pagination: result.meta, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, }); } catch (error) { next(error); diff --git a/src/modules/infonavit/controllers/asignacion.controller.ts b/src/modules/infonavit/controllers/asignacion.controller.ts index c31d18c..ede1b25 100644 --- a/src/modules/infonavit/controllers/asignacion.controller.ts +++ b/src/modules/infonavit/controllers/asignacion.controller.ts @@ -10,9 +10,6 @@ import { Router, Request, Response, NextFunction } from 'express'; import { DataSource } from 'typeorm'; import { AsignacionService, AsignacionFilters } from '../services/asignacion.service'; -import { AsignacionVivienda } from '../entities/asignacion-vivienda.entity'; -import { OfertaVivienda } from '../entities/oferta-vivienda.entity'; -import { Derechohabiente } from '../entities/derechohabiente.entity'; import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; import { AuthService } from '../../auth/services/auth.service'; import { User } from '../../core/entities/user.entity'; @@ -30,16 +27,13 @@ interface ServiceContext { export function createAsignacionController(dataSource: DataSource): Router { const router = Router(); - // Repositories - const asignacionRepo = dataSource.getRepository(AsignacionVivienda); - const ofertaRepo = dataSource.getRepository(OfertaVivienda); - const derechohabienteRepo = dataSource.getRepository(Derechohabiente); + // Repositories for auth const userRepository = dataSource.getRepository(User); const tenantRepository = dataSource.getRepository(Tenant); const refreshTokenRepository = dataSource.getRepository(RefreshToken); // Services - const service = new AsignacionService(asignacionRepo, ofertaRepo, derechohabienteRepo); + const service = new AsignacionService(dataSource); const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); const authMiddleware = new AuthMiddleware(authService, dataSource); @@ -81,10 +75,10 @@ export function createAsignacionController(dataSource: DataSource): Router { success: true, data: result.data, pagination: { - total: result.meta.total, - page: result.meta.page, - limit: result.meta.limit, - totalPages: result.meta.totalPages, + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, }, }); } catch (error) { diff --git a/src/modules/infonavit/controllers/derechohabiente.controller.ts b/src/modules/infonavit/controllers/derechohabiente.controller.ts index 5fd1dde..d5afb53 100644 --- a/src/modules/infonavit/controllers/derechohabiente.controller.ts +++ b/src/modules/infonavit/controllers/derechohabiente.controller.ts @@ -10,8 +10,6 @@ import { Router, Request, Response, NextFunction } from 'express'; import { DataSource } from 'typeorm'; import { DerechohabienteService, DerechohabienteFilters } from '../services/derechohabiente.service'; -import { Derechohabiente } from '../entities/derechohabiente.entity'; -import { HistoricoPuntos } from '../entities/historico-puntos.entity'; import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; import { AuthService } from '../../auth/services/auth.service'; import { User } from '../../core/entities/user.entity'; @@ -29,15 +27,13 @@ interface ServiceContext { export function createDerechohabienteController(dataSource: DataSource): Router { const router = Router(); - // Repositories - const derechohabienteRepo = dataSource.getRepository(Derechohabiente); - const historicoPuntosRepo = dataSource.getRepository(HistoricoPuntos); + // Repositories for auth const userRepository = dataSource.getRepository(User); const tenantRepository = dataSource.getRepository(Tenant); const refreshTokenRepository = dataSource.getRepository(RefreshToken); // Services - const service = new DerechohabienteService(derechohabienteRepo, historicoPuntosRepo); + const service = new DerechohabienteService(dataSource); const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); const authMiddleware = new AuthMiddleware(authService, dataSource); @@ -79,10 +75,10 @@ export function createDerechohabienteController(dataSource: DataSource): Router success: true, data: result.data, pagination: { - total: result.meta.total, - page: result.meta.page, - limit: result.meta.limit, - totalPages: result.meta.totalPages, + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, }, }); } catch (error) { diff --git a/src/modules/infonavit/services/asignacion.service.ts b/src/modules/infonavit/services/asignacion.service.ts index c61f5ea..67065e8 100644 --- a/src/modules/infonavit/services/asignacion.service.ts +++ b/src/modules/infonavit/services/asignacion.service.ts @@ -1,16 +1,34 @@ /** * AsignacionService - Servicio de asignaciones de vivienda INFONAVIT * - * Gestión de asignaciones de vivienda a derechohabientes. + * Gestion de asignaciones de vivienda a derechohabientes. * * @module Infonavit */ -import { Repository, FindOptionsWhere } from 'typeorm'; +import { DataSource, Repository, FindOptionsWhere } from 'typeorm'; import { AsignacionVivienda, AsignacionStatus } from '../entities/asignacion-vivienda.entity'; import { OfertaVivienda } from '../entities/oferta-vivienda.entity'; import { Derechohabiente } from '../entities/derechohabiente.entity'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +/** + * Service context for multi-tenant operations + */ +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} export interface CreateAsignacionDto { ofertaId: string; @@ -40,11 +58,15 @@ export interface AsignacionFilters { } export class AsignacionService { - constructor( - private readonly asignacionRepository: Repository, - private readonly ofertaRepository: Repository, - private readonly derechohabienteRepository: Repository - ) {} + private readonly asignacionRepository: Repository; + private readonly ofertaRepository: Repository; + private readonly derechohabienteRepository: Repository; + + constructor(dataSource: DataSource) { + this.asignacionRepository = dataSource.getRepository(AsignacionVivienda); + this.ofertaRepository = dataSource.getRepository(OfertaVivienda); + this.derechohabienteRepository = dataSource.getRepository(Derechohabiente); + } private generateAssignmentNumber(): string { const now = new Date(); @@ -100,12 +122,10 @@ export class AsignacionService { return { data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, + total, + page, + limit, + totalPages: Math.ceil(total / limit), }; } diff --git a/src/modules/infonavit/services/derechohabiente.service.ts b/src/modules/infonavit/services/derechohabiente.service.ts index 6cdc874..954f1b7 100644 --- a/src/modules/infonavit/services/derechohabiente.service.ts +++ b/src/modules/infonavit/services/derechohabiente.service.ts @@ -1,15 +1,33 @@ /** - * DerechohabienteService - Servicio de gestión de derechohabientes INFONAVIT + * DerechohabienteService - Servicio de gestion de derechohabientes INFONAVIT * - * Gestión de trabajadores con crĆ©dito INFONAVIT. + * Gestion de trabajadores con credito INFONAVIT. * * @module Infonavit */ -import { Repository, FindOptionsWhere } from 'typeorm'; +import { DataSource, Repository, FindOptionsWhere } from 'typeorm'; import { Derechohabiente, DerechohabienteStatus } from '../entities/derechohabiente.entity'; import { HistoricoPuntos } from '../entities/historico-puntos.entity'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +/** + * Service context for multi-tenant operations + */ +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} export interface CreateDerechohabienteDto { nss: string; @@ -48,10 +66,13 @@ export interface DerechohabienteFilters { } export class DerechohabienteService { - constructor( - private readonly derechohabienteRepository: Repository, - private readonly historicoPuntosRepository: Repository - ) {} + private readonly derechohabienteRepository: Repository; + private readonly historicoPuntosRepository: Repository; + + constructor(dataSource: DataSource) { + this.derechohabienteRepository = dataSource.getRepository(Derechohabiente); + this.historicoPuntosRepository = dataSource.getRepository(HistoricoPuntos); + } async findWithFilters( ctx: ServiceContext, @@ -100,12 +121,10 @@ export class DerechohabienteService { return { data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, + total, + page, + limit, + totalPages: Math.ceil(total / limit), }; } diff --git a/src/modules/infonavit/services/index.ts b/src/modules/infonavit/services/index.ts index eb79b54..fad65d7 100644 --- a/src/modules/infonavit/services/index.ts +++ b/src/modules/infonavit/services/index.ts @@ -3,5 +3,8 @@ * @module Infonavit */ -export * from './derechohabiente.service'; -export * from './asignacion.service'; +export { DerechohabienteService } from './derechohabiente.service'; +export type { CreateDerechohabienteDto, UpdateDerechohabienteDto, PrecalificationDto, DerechohabienteFilters } from './derechohabiente.service'; + +export { AsignacionService } from './asignacion.service'; +export type { CreateAsignacionDto, FormalizeDto, AsignacionFilters } from './asignacion.service'; diff --git a/src/modules/invoices/entities/index.ts b/src/modules/invoices/entities/index.ts new file mode 100644 index 0000000..527fe39 --- /dev/null +++ b/src/modules/invoices/entities/index.ts @@ -0,0 +1,8 @@ +/** + * Invoices Entities - Export + */ + +export { Invoice, InvoiceType, InvoiceStatus, InvoiceContext } from './invoice.entity'; +export { InvoiceItem } from './invoice-item.entity'; +export { Payment } from './payment.entity'; +export { PaymentAllocation } from './payment-allocation.entity'; diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts new file mode 100644 index 0000000..3d46a73 --- /dev/null +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -0,0 +1,95 @@ +/** + * InvoiceItem Entity + * Line items for unified invoices + * Compatible with erp-core invoice-item.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'invoice_items', schema: 'billing' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @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; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode?: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode?: 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({ name: 'withholding_rate', type: 'decimal', precision: 5, scale: 2, default: 0 }) + withholdingRate: number; + + @Column({ name: 'withholding_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingAmount: 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/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts new file mode 100644 index 0000000..36ad901 --- /dev/null +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -0,0 +1,187 @@ +/** + * Unified Invoice Entity + * Combines commercial and SaaS billing invoices + * Compatible with erp-core invoice.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { InvoiceItem } from './invoice-item.entity'; + +export type InvoiceType = 'sale' | 'purchase' | 'credit_note' | 'debit_note'; +export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided'; +export type InvoiceContext = 'commercial' | 'saas'; + +@Entity({ name: 'invoices', schema: 'billing' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index({ unique: true }) + @Column({ name: 'invoice_number', type: 'varchar', length: 30 }) + invoiceNumber: string; + + @Index() + @Column({ name: 'invoice_type', type: 'varchar', length: 20, default: 'sale' }) + invoiceType: InvoiceType; + + @Index() + @Column({ name: 'invoice_context', type: 'varchar', length: 20, default: 'commercial' }) + invoiceContext: InvoiceContext; + + // Commercial fields + @Column({ name: 'sales_order_id', type: 'uuid', nullable: true }) + salesOrderId: string | null; + + @Column({ name: 'purchase_order_id', type: 'uuid', nullable: true }) + purchaseOrderId: string | null; + + @Index() + @Column({ name: 'partner_id', type: 'uuid', nullable: true }) + partnerId: string | null; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string | null; + + @Column({ name: 'partner_tax_id', type: 'varchar', length: 50, nullable: true }) + partnerTaxId: string | null; + + // SaaS billing fields + @Index() + @Column({ name: 'subscription_id', type: 'uuid', nullable: true }) + subscriptionId: string | null; + + @Column({ name: 'period_start', type: 'date', nullable: true }) + periodStart: Date | null; + + @Column({ name: 'period_end', type: 'date', nullable: true }) + periodEnd: Date | null; + + // Billing information + @Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true }) + billingName: string | null; + + @Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true }) + billingEmail: string | null; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: Record | null; + + @Column({ name: 'tax_id', type: 'varchar', length: 50, nullable: true }) + taxId: string | null; + + // Dates + @Index() + @Column({ name: 'invoice_date', type: 'date', default: () => 'CURRENT_DATE' }) + invoiceDate: Date; + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate: Date | null; + + @Column({ name: 'payment_date', type: 'date', nullable: true }) + paymentDate: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + // Amounts + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @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: 'withholding_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingTax: 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: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountPaid: number; + + @Column({ name: 'amount_due', type: 'decimal', precision: 15, scale: 2, insert: false, update: false, nullable: true }) + amountDue: number | null; + + @Column({ name: 'paid_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + paidAmount: number; + + // Payment details + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string | null; + + @Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true }) + paymentReference: string | null; + + // Status + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: InvoiceStatus; + + // CFDI (Mexico) + @Index() + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string | null; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: string | null; + + @Column({ name: 'cfdi_xml', type: 'text', nullable: true }) + cfdiXml: string | null; + + @Column({ name: 'cfdi_pdf_url', type: 'varchar', length: 500, nullable: true }) + cfdiPdfUrl: string | null; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string | null; + + // Audit + @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; + + // Relations + @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true }) + items: InvoiceItem[]; +} diff --git a/src/modules/invoices/entities/payment-allocation.entity.ts b/src/modules/invoices/entities/payment-allocation.entity.ts new file mode 100644 index 0000000..66e9001 --- /dev/null +++ b/src/modules/invoices/entities/payment-allocation.entity.ts @@ -0,0 +1,53 @@ +/** + * PaymentAllocation Entity + * Links payments to invoices + * Compatible with erp-core payment-allocation.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Payment } from './payment.entity'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'payment_allocations', schema: 'billing' }) +export class PaymentAllocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'payment_id', type: 'uuid' }) + paymentId: string; + + @ManyToOne(() => Payment, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'payment_id' }) + payment: Payment; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'allocation_date', type: 'date', default: () => 'CURRENT_DATE' }) + allocationDate: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/invoices/entities/payment.entity.ts b/src/modules/invoices/entities/payment.entity.ts new file mode 100644 index 0000000..1e6416a --- /dev/null +++ b/src/modules/invoices/entities/payment.entity.ts @@ -0,0 +1,89 @@ +/** + * Payment Entity + * Payment received or made + * Compatible with erp-core payment.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'payments', schema: 'billing' }) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'payment_number', type: 'varchar', length: 30 }) + paymentNumber: string; + + @Index() + @Column({ name: 'payment_type', type: 'varchar', length: 20, default: 'received' }) + paymentType: 'received' | 'made'; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @Column({ name: 'payment_date', type: 'date', default: () => 'CURRENT_DATE' }) + paymentDate: Date; + + @Index() + @Column({ name: 'payment_method', type: 'varchar', length: 50 }) + paymentMethod: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + reference: string; + + @Column({ name: 'bank_account_id', type: 'uuid', nullable: true }) + bankAccountId: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'reconciled' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: 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/mcp/controllers/mcp.controller.ts b/src/modules/mcp/controllers/mcp.controller.ts index 306ae92..0d68d36 100644 --- a/src/modules/mcp/controllers/mcp.controller.ts +++ b/src/modules/mcp/controllers/mcp.controller.ts @@ -30,7 +30,7 @@ export class McpController { // TOOLS // ============================================ - private async listTools(req: Request, res: Response, next: NextFunction): Promise { + private async listTools(_req: Request, res: Response, next: NextFunction): Promise { try { const tools = this.mcpService.listTools(); res.json({ data: tools, total: tools.length }); @@ -93,7 +93,7 @@ export class McpController { // RESOURCES // ============================================ - private async listResources(req: Request, res: Response, next: NextFunction): Promise { + private async listResources(_req: Request, res: Response, next: NextFunction): Promise { try { const resources = this.mcpService.listResources(); res.json({ data: resources, total: resources.length }); diff --git a/src/modules/mcp/interfaces/mcp-context.interface.ts b/src/modules/mcp/interfaces/mcp-context.interface.ts index 69488c4..0cd0c98 100644 --- a/src/modules/mcp/interfaces/mcp-context.interface.ts +++ b/src/modules/mcp/interfaces/mcp-context.interface.ts @@ -11,6 +11,7 @@ export interface McpContext { userId?: string; agentId?: string; conversationId?: string; + branchId?: string; callerType: CallerType; permissions: string[]; metadata?: Record; diff --git a/src/modules/mcp/interfaces/mcp-tool.interface.ts b/src/modules/mcp/interfaces/mcp-tool.interface.ts index 155f8d7..f5cca40 100644 --- a/src/modules/mcp/interfaces/mcp-tool.interface.ts +++ b/src/modules/mcp/interfaces/mcp-tool.interface.ts @@ -39,6 +39,9 @@ export type ToolCategory = | 'orders' | 'customers' | 'fiados' + | 'sales' + | 'financial' + | 'branches' | 'system'; export interface McpToolDefinition { diff --git a/src/modules/mcp/tools/branch-tools.service.ts b/src/modules/mcp/tools/branch-tools.service.ts index d352893..30b4b76 100644 --- a/src/modules/mcp/tools/branch-tools.service.ts +++ b/src/modules/mcp/tools/branch-tools.service.ts @@ -225,11 +225,10 @@ export class BranchToolsService implements McpToolProvider { } private async getEmployeeSchedule( - params: { branch_id?: string; date?: string; employee_id?: string }, - context: McpContext + _params: { branch_id?: string; date?: string; employee_id?: string }, + _context: McpContext ): Promise { // TODO: Connect to ScheduleService - const date = params.date || new Date().toISOString().split('T')[0]; return [ { employee: 'Ana Garcia', shift: 'morning', start: '09:00', end: '15:00', status: 'confirmed' }, { employee: 'Carlos Lopez', shift: 'afternoon', start: '15:00', end: '21:00', status: 'confirmed' }, @@ -238,8 +237,8 @@ export class BranchToolsService implements McpToolProvider { } private async getBranchHours( - params: { branch_id?: string }, - context: McpContext + _params: { branch_id?: string }, + _context: McpContext ): Promise { // TODO: Connect to BranchesService return { @@ -262,8 +261,8 @@ export class BranchToolsService implements McpToolProvider { } private async getPromotions( - params: { branch_id?: string; active_only?: boolean }, - context: McpContext + _params: { branch_id?: string; active_only?: boolean }, + _context: McpContext ): Promise { // TODO: Connect to PromotionsService return [ diff --git a/src/modules/mcp/tools/customers-tools.service.ts b/src/modules/mcp/tools/customers-tools.service.ts index daa5298..3873183 100644 --- a/src/modules/mcp/tools/customers-tools.service.ts +++ b/src/modules/mcp/tools/customers-tools.service.ts @@ -59,8 +59,8 @@ export class CustomersToolsService implements McpToolProvider { } private async searchCustomers( - params: { query: string; limit?: number }, - context: McpContext + _params: { query: string; limit?: number }, + _context: McpContext ): Promise { // TODO: Connect to actual customers service return [ @@ -78,7 +78,7 @@ export class CustomersToolsService implements McpToolProvider { private async getCustomerBalance( params: { customer_id: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual customers service return { diff --git a/src/modules/mcp/tools/fiados-tools.service.ts b/src/modules/mcp/tools/fiados-tools.service.ts index 6e34982..ae46a39 100644 --- a/src/modules/mcp/tools/fiados-tools.service.ts +++ b/src/modules/mcp/tools/fiados-tools.service.ts @@ -95,7 +95,7 @@ export class FiadosToolsService implements McpToolProvider { private async getFiadoBalance( params: { customer_id: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual fiados service return { @@ -164,7 +164,7 @@ export class FiadosToolsService implements McpToolProvider { private async checkFiadoEligibility( params: { customer_id: string; amount: number }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual fiados service const mockBalance = 1500.00; diff --git a/src/modules/mcp/tools/financial-tools.service.ts b/src/modules/mcp/tools/financial-tools.service.ts index 50b2ba8..7ec2516 100644 --- a/src/modules/mcp/tools/financial-tools.service.ts +++ b/src/modules/mcp/tools/financial-tools.service.ts @@ -161,7 +161,7 @@ export class FinancialToolsService implements McpToolProvider { private async getFinancialReport( params: { type: string; start_date?: string; end_date?: string; branch_id?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to FinancialService return { @@ -187,7 +187,7 @@ export class FinancialToolsService implements McpToolProvider { private async getAccountsReceivable( params: { status?: string; min_amount?: number; limit?: number }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to AccountsService return { @@ -217,7 +217,7 @@ export class FinancialToolsService implements McpToolProvider { private async getAccountsPayable( params: { status?: string; due_date_before?: string; limit?: number }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to AccountsService return { @@ -246,7 +246,7 @@ export class FinancialToolsService implements McpToolProvider { private async getCashFlow( params: { period?: string; branch_id?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to FinancialService return { @@ -269,7 +269,7 @@ export class FinancialToolsService implements McpToolProvider { private async getKPIs( params: { period?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to AnalyticsService return { diff --git a/src/modules/mcp/tools/inventory-tools.service.ts b/src/modules/mcp/tools/inventory-tools.service.ts index 76a45ca..63d5455 100644 --- a/src/modules/mcp/tools/inventory-tools.service.ts +++ b/src/modules/mcp/tools/inventory-tools.service.ts @@ -89,7 +89,7 @@ export class InventoryToolsService implements McpToolProvider { private async checkStock( params: { product_ids?: string[]; warehouse_id?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual inventory service return [ @@ -105,7 +105,7 @@ export class InventoryToolsService implements McpToolProvider { private async getLowStockProducts( params: { threshold?: number }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual inventory service const threshold = params.threshold || 10; @@ -140,7 +140,7 @@ export class InventoryToolsService implements McpToolProvider { private async getInventoryValue( params: { warehouse_id?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual inventory service return { diff --git a/src/modules/mcp/tools/orders-tools.service.ts b/src/modules/mcp/tools/orders-tools.service.ts index facc0b0..5348a08 100644 --- a/src/modules/mcp/tools/orders-tools.service.ts +++ b/src/modules/mcp/tools/orders-tools.service.ts @@ -108,7 +108,7 @@ export class OrdersToolsService implements McpToolProvider { private async getOrderStatus( params: { order_id: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual orders service return { diff --git a/src/modules/mcp/tools/products-tools.service.ts b/src/modules/mcp/tools/products-tools.service.ts index 92c3e44..bda609d 100644 --- a/src/modules/mcp/tools/products-tools.service.ts +++ b/src/modules/mcp/tools/products-tools.service.ts @@ -80,7 +80,7 @@ export class ProductsToolsService implements McpToolProvider { private async listProducts( params: { category?: string; search?: string; min_price?: number; max_price?: number; limit?: number }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual products service return [ @@ -97,7 +97,7 @@ export class ProductsToolsService implements McpToolProvider { private async getProductDetails( params: { product_id: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual products service return { @@ -113,7 +113,7 @@ export class ProductsToolsService implements McpToolProvider { private async checkProductAvailability( params: { product_id: string; quantity: number }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to actual inventory service const mockStock = 50; diff --git a/src/modules/mcp/tools/sales-tools.service.ts b/src/modules/mcp/tools/sales-tools.service.ts index d65ceb9..52760a4 100644 --- a/src/modules/mcp/tools/sales-tools.service.ts +++ b/src/modules/mcp/tools/sales-tools.service.ts @@ -210,7 +210,7 @@ export class SalesToolsService implements McpToolProvider { private async getSalesSummary( params: { period?: string; branch_id?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to SalesService const period = params.period || 'today'; @@ -230,7 +230,7 @@ export class SalesToolsService implements McpToolProvider { private async getSalesReport( params: { start_date: string; end_date: string; group_by?: string; branch_id?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to SalesService return [ @@ -251,7 +251,7 @@ export class SalesToolsService implements McpToolProvider { private async getTopProducts( params: { period?: string; limit?: number; branch_id?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to SalesService return [ @@ -263,7 +263,7 @@ export class SalesToolsService implements McpToolProvider { private async getTopCustomers( params: { period?: string; limit?: number; order_by?: string }, - context: McpContext + _context: McpContext ): Promise { // TODO: Connect to CustomersService return [ @@ -273,8 +273,8 @@ export class SalesToolsService implements McpToolProvider { } private async getSalesByBranch( - params: { period?: string }, - context: McpContext + _params: { period?: string }, + _context: McpContext ): Promise { // TODO: Connect to SalesService + BranchesService return [ diff --git a/src/modules/mobile/entities/index.ts b/src/modules/mobile/entities/index.ts new file mode 100644 index 0000000..96e5432 --- /dev/null +++ b/src/modules/mobile/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Mobile Entities - Export + */ + +export { MobileSession, MobileSessionStatus } from './mobile-session.entity'; +export { OfflineSyncQueue, SyncOperation, SyncStatus, ConflictResolution } from './offline-sync-queue.entity'; +export { SyncConflict, ConflictType, ConflictResolutionType } from './sync-conflict.entity'; +export { PushToken, PushProvider } from './push-token.entity'; +export { PushNotificationLog, PushNotificationStatus, PushNotificationCategory } from './push-notification-log.entity'; +export { PaymentTransaction, PaymentSourceType, PaymentMethod, PaymentStatus, CardType } from './payment-transaction.entity'; diff --git a/src/modules/mobile/entities/mobile-session.entity.ts b/src/modules/mobile/entities/mobile-session.entity.ts new file mode 100644 index 0000000..eb8018e --- /dev/null +++ b/src/modules/mobile/entities/mobile-session.entity.ts @@ -0,0 +1,98 @@ +/** + * MobileSession Entity + * Mobile app session with offline mode and location tracking + * Compatible with erp-core mobile-session.entity + * + * @module Mobile + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type MobileSessionStatus = 'active' | 'paused' | 'expired' | 'terminated'; + +@Entity({ name: 'mobile_sessions', schema: 'mobile' }) +export class MobileSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: MobileSessionStatus; + + @Column({ name: 'active_profile_id', type: 'uuid', nullable: true }) + activeProfileId: string; + + @Column({ name: 'active_profile_code', type: 'varchar', length: 10, nullable: true }) + activeProfileCode: string; + + @Column({ name: 'is_offline_mode', type: 'boolean', default: false }) + isOfflineMode: boolean; + + @Column({ name: 'offline_since', type: 'timestamptz', nullable: true }) + offlineSince: Date; + + @Column({ name: 'last_sync_at', type: 'timestamptz', nullable: true }) + lastSyncAt: Date; + + @Column({ name: 'pending_sync_count', type: 'integer', default: 0 }) + pendingSyncCount: number; + + @Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + lastLatitude: number; + + @Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + lastLongitude: number; + + @Column({ name: 'last_location_at', type: 'timestamptz', nullable: true }) + lastLocationAt: Date; + + @Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true }) + appVersion: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; + + @Column({ name: 'os_version', type: 'varchar', length: 20, nullable: true }) + osVersion: string; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + startedAt: Date; + + @Column({ name: 'last_activity_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + lastActivityAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ name: 'ended_at', type: 'timestamptz', nullable: true }) + endedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/offline-sync-queue.entity.ts b/src/modules/mobile/entities/offline-sync-queue.entity.ts new file mode 100644 index 0000000..e144f50 --- /dev/null +++ b/src/modules/mobile/entities/offline-sync-queue.entity.ts @@ -0,0 +1,95 @@ +/** + * OfflineSyncQueue Entity + * Queue for offline data synchronization + * Compatible with erp-core offline-sync-queue.entity + * + * @module Mobile + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type SyncOperation = 'create' | 'update' | 'delete'; +export type SyncStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'conflict'; +export type ConflictResolution = 'local_wins' | 'server_wins' | 'merged' | 'manual'; + +@Entity({ name: 'offline_sync_queue', schema: 'mobile' }) +export class OfflineSyncQueue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Column({ name: 'entity_type', type: 'varchar', length: 50 }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ type: 'varchar', length: 20 }) + operation: SyncOperation; + + @Column({ type: 'jsonb' }) + payload: Record; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @Column({ name: 'sequence_number', type: 'bigint' }) + sequenceNumber: number; + + @Column({ name: 'depends_on', type: 'uuid', nullable: true }) + dependsOn: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: SyncStatus; + + @Column({ name: 'retry_count', type: 'integer', default: 0 }) + retryCount: number; + + @Column({ name: 'max_retries', type: 'integer', default: 3 }) + maxRetries: number; + + @Column({ name: 'last_error', type: 'text', nullable: true }) + lastError: string; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date; + + @Column({ name: 'conflict_data', type: 'jsonb', nullable: true }) + conflictData: Record; + + @Column({ name: 'conflict_resolved_at', type: 'timestamptz', nullable: true }) + conflictResolvedAt: Date; + + @Column({ name: 'conflict_resolution', type: 'varchar', length: 20, nullable: true }) + conflictResolution: ConflictResolution; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/payment-transaction.entity.ts b/src/modules/mobile/entities/payment-transaction.entity.ts new file mode 100644 index 0000000..49f975c --- /dev/null +++ b/src/modules/mobile/entities/payment-transaction.entity.ts @@ -0,0 +1,116 @@ +/** + * PaymentTransaction Entity + * Mobile payment processing and tracking + * Compatible with erp-core payment-transaction.entity + * + * @module Mobile + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +// erp-core base + construction-specific POS values +export type PaymentSourceType = 'sale' | 'invoice' | 'subscription' | 'pos' | 'mobile' | 'web' | 'api'; +export type PaymentMethod = 'card' | 'contactless' | 'qr' | 'link' | 'cash' | 'transfer'; +export type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' | 'cancelled'; +export type CardType = 'credit' | 'debit'; + +@Entity({ name: 'payment_transactions', schema: 'mobile' }) +export class PaymentTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Index() + @Column({ name: 'source_type', type: 'varchar', length: 30 }) + sourceType: PaymentSourceType; + + @Column({ name: 'source_id', type: 'uuid' }) + sourceId: string; + + @Column({ name: 'terminal_provider', type: 'varchar', length: 30 }) + terminalProvider: string; + + @Column({ name: 'terminal_id', type: 'varchar', length: 100, nullable: true }) + terminalId: string; + + @Index() + @Column({ name: 'external_transaction_id', type: 'varchar', length: 255, nullable: true }) + externalTransactionId: string; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'tip_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + tipAmount: number; + + @Column({ name: 'total_amount', type: 'decimal', precision: 12, scale: 2 }) + totalAmount: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 30 }) + paymentMethod: PaymentMethod; + + @Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true }) + cardBrand: string; + + @Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true }) + cardLastFour: string; + + @Column({ name: 'card_type', type: 'varchar', length: 20, nullable: true }) + cardType: CardType; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: PaymentStatus; + + @Column({ name: 'failure_reason', type: 'text', nullable: true }) + failureReason: string; + + @Column({ name: 'initiated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + initiatedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'provider_response', type: 'jsonb', default: {} }) + providerResponse: Record; + + @Column({ name: 'receipt_url', type: 'text', nullable: true }) + receiptUrl: string; + + @Column({ name: 'receipt_sent', type: 'boolean', default: false }) + receiptSent: boolean; + + @Column({ name: 'receipt_sent_to', type: 'varchar', length: 255, nullable: true }) + receiptSentTo: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/push-notification-log.entity.ts b/src/modules/mobile/entities/push-notification-log.entity.ts new file mode 100644 index 0000000..ab024c9 --- /dev/null +++ b/src/modules/mobile/entities/push-notification-log.entity.ts @@ -0,0 +1,81 @@ +/** + * PushNotificationLog Entity + * Push notification delivery log + * Compatible with erp-core push-notification-log.entity + * + * @module Mobile + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PushToken } from './push-token.entity'; + +export type PushNotificationStatus = 'sent' | 'delivered' | 'failed' | 'read'; +export type PushNotificationCategory = 'attendance' | 'sale' | 'inventory' | 'alert' | 'system'; + +@Entity({ name: 'push_notifications_log', schema: 'mobile' }) +export class PushNotificationLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'push_token_id', type: 'uuid', nullable: true }) + pushTokenId: string; + + @ManyToOne(() => PushToken) + @JoinColumn({ name: 'push_token_id' }) + pushToken: PushToken; + + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + body: string; + + @Column({ type: 'jsonb', default: {} }) + data: Record; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + category: PushNotificationCategory; + + @Column({ name: 'sent_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + sentAt: Date; + + @Column({ name: 'provider_message_id', type: 'varchar', length: 255, nullable: true }) + providerMessageId: string; + + @Column({ type: 'varchar', length: 20, default: 'sent' }) + status: PushNotificationStatus; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/mobile/entities/push-token.entity.ts b/src/modules/mobile/entities/push-token.entity.ts new file mode 100644 index 0000000..d793641 --- /dev/null +++ b/src/modules/mobile/entities/push-token.entity.ts @@ -0,0 +1,69 @@ +/** + * PushToken Entity + * Push notification token management per device + * Compatible with erp-core push-token.entity + * + * @module Mobile + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type PushProvider = 'firebase' | 'apns' | 'fcm'; + +@Entity({ name: 'push_tokens', schema: 'mobile' }) +@Unique(['deviceId', 'platform']) +export class PushToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'text' }) + token: string; + + @Column({ type: 'varchar', length: 20 }) + platform: string; + + @Column({ type: 'varchar', length: 30, default: 'firebase' }) + provider: PushProvider; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_valid', type: 'boolean', default: true }) + isValid: boolean; + + @Column({ name: 'invalid_reason', type: 'text', nullable: true }) + invalidReason: string; + + @Column({ name: 'subscribed_topics', type: 'text', array: true, default: [] }) + subscribedTopics: string[]; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/sync-conflict.entity.ts b/src/modules/mobile/entities/sync-conflict.entity.ts new file mode 100644 index 0000000..6419e3e --- /dev/null +++ b/src/modules/mobile/entities/sync-conflict.entity.ts @@ -0,0 +1,67 @@ +/** + * SyncConflict Entity + * Offline synchronization conflict tracking and resolution + * Compatible with erp-core sync-conflict.entity + * + * @module Mobile + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { OfflineSyncQueue } from './offline-sync-queue.entity'; + +export type ConflictType = 'data_conflict' | 'version_conflict' | 'delete_conflict' | 'constraint_conflict'; +export type ConflictResolutionType = 'local_wins' | 'server_wins' | 'merged' | 'manual'; + +@Entity({ name: 'sync_conflicts', schema: 'mobile' }) +export class SyncConflict { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'sync_queue_id', type: 'uuid' }) + syncQueueId: string; + + @ManyToOne(() => OfflineSyncQueue, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sync_queue_id' }) + syncQueue: OfflineSyncQueue; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'conflict_type', type: 'varchar', length: 30 }) + conflictType: ConflictType; + + @Column({ name: 'local_data', type: 'jsonb' }) + localData: Record; + + @Column({ name: 'server_data', type: 'jsonb' }) + serverData: Record; + + @Column({ type: 'varchar', length: 20, nullable: true }) + resolution: ConflictResolutionType; + + @Column({ name: 'merged_data', type: 'jsonb', nullable: true }) + mergedData: Record; + + @Column({ name: 'resolved_by', type: 'uuid', nullable: true }) + resolvedBy: string; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/notifications/entities/channel.entity.ts b/src/modules/notifications/entities/channel.entity.ts new file mode 100644 index 0000000..4baf2b1 --- /dev/null +++ b/src/modules/notifications/entities/channel.entity.ts @@ -0,0 +1,65 @@ +/** + * Channel Entity + * Notification delivery channel configuration + * Compatible with erp-core channel.entity + * + * @module Notifications + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type ChannelType = 'email' | 'sms' | 'push' | 'whatsapp' | 'in_app' | 'webhook'; + +@Entity({ name: 'channels', schema: 'notifications' }) +export class Channel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 30, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ type: 'varchar', length: 50, nullable: true }) + provider: string; + + @Column({ name: 'provider_config', type: 'jsonb', default: {} }) + providerConfig: Record; + + @Column({ name: 'rate_limit_per_minute', type: 'int', nullable: true }) + rateLimitPerMinute: number; + + @Column({ name: 'rate_limit_per_hour', type: 'int', nullable: true }) + rateLimitPerHour: number; + + @Column({ name: 'rate_limit_per_day', type: 'int', nullable: true }) + rateLimitPerDay: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/in-app-notification.entity.ts b/src/modules/notifications/entities/in-app-notification.entity.ts new file mode 100644 index 0000000..77703fe --- /dev/null +++ b/src/modules/notifications/entities/in-app-notification.entity.ts @@ -0,0 +1,85 @@ +/** + * InAppNotification Entity + * In-app notification with read/archive tracking + * Compatible with erp-core in-app-notification.entity + * + * @module Notifications + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type InAppCategory = 'info' | 'success' | 'warning' | 'error' | 'task'; +export type InAppPriority = 'low' | 'normal' | 'high' | 'urgent'; +export type InAppActionType = 'link' | 'modal' | 'function'; + +@Entity({ name: 'in_app_notifications', schema: 'notifications' }) +export class InAppNotification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text' }) + message: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ name: 'action_type', type: 'varchar', length: 20, nullable: true }) + actionType: InAppActionType; + + @Column({ name: 'action_url', type: 'text', nullable: true }) + actionUrl: string; + + @Column({ name: 'action_data', type: 'jsonb', nullable: true }) + actionData: Record; + + @Column({ type: 'varchar', length: 30, default: 'info' }) + category: InAppCategory; + + @Column({ name: 'context_type', type: 'varchar', length: 100, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'is_read', type: 'boolean', default: false }) + isRead: boolean; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'is_archived', type: 'boolean', default: false }) + isArchived: boolean; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt: Date; + + @Column({ type: 'varchar', length: 20, default: 'normal' }) + priority: InAppPriority; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/notifications/entities/index.ts b/src/modules/notifications/entities/index.ts new file mode 100644 index 0000000..e4621a7 --- /dev/null +++ b/src/modules/notifications/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Notifications Entities - Export + */ + +export { Channel, ChannelType } from './channel.entity'; +export { NotificationTemplate, TemplateTranslation, TemplateCategory } from './template.entity'; +export { NotificationPreference, DigestFrequency } from './preference.entity'; +export { Notification, NotificationStatus, NotificationPriority } from './notification.entity'; +export { NotificationBatch, BatchStatus, AudienceType } from './notification-batch.entity'; +export { InAppNotification, InAppCategory, InAppPriority, InAppActionType } from './in-app-notification.entity'; diff --git a/src/modules/notifications/entities/notification-batch.entity.ts b/src/modules/notifications/entities/notification-batch.entity.ts new file mode 100644 index 0000000..12afda4 --- /dev/null +++ b/src/modules/notifications/entities/notification-batch.entity.ts @@ -0,0 +1,96 @@ +/** + * NotificationBatch Entity + * Batch notification campaigns with audience targeting + * Compatible with erp-core notification-batch.entity + * + * @module Notifications + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { NotificationTemplate } from './template.entity'; +import { ChannelType } from './channel.entity'; + +export type BatchStatus = 'draft' | 'scheduled' | 'processing' | 'completed' | 'failed' | 'cancelled'; +export type AudienceType = 'all_users' | 'segment' | 'custom'; + +@Entity({ name: 'notification_batches', schema: 'notifications' }) +export class NotificationBatch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'audience_type', type: 'varchar', length: 30 }) + audienceType: AudienceType; + + @Column({ name: 'audience_filter', type: 'jsonb', default: {} }) + audienceFilter: Record; + + @Column({ type: 'jsonb', default: {} }) + variables: Record; + + @Index() + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt: Date; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: BatchStatus; + + @Column({ name: 'total_recipients', type: 'int', default: 0 }) + totalRecipients: number; + + @Column({ name: 'sent_count', type: 'int', default: 0 }) + sentCount: number; + + @Column({ name: 'delivered_count', type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ name: 'failed_count', type: 'int', default: 0 }) + failedCount: number; + + @Column({ name: 'read_count', type: 'int', default: 0 }) + readCount: number; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @ManyToOne(() => NotificationTemplate, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; +} diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/modules/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..63a086e --- /dev/null +++ b/src/modules/notifications/entities/notification.entity.ts @@ -0,0 +1,138 @@ +/** + * Notification Entity + * Individual notification with delivery tracking + * Compatible with erp-core notification.entity + * + * @module Notifications + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { NotificationTemplate } from './template.entity'; +import { Channel, ChannelType } from './channel.entity'; + +export type NotificationStatus = 'pending' | 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'cancelled'; +export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +@Entity({ name: 'notifications', schema: 'notifications' }) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'recipient_email', type: 'varchar', length: 255, nullable: true }) + recipientEmail: string; + + @Column({ name: 'recipient_phone', type: 'varchar', length: 20, nullable: true }) + recipientPhone: string; + + @Column({ name: 'recipient_device_id', type: 'varchar', length: 255, nullable: true }) + recipientDeviceId: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'template_code', type: 'varchar', length: 100, nullable: true }) + templateCode: string; + + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Index() + @Column({ name: 'channel_id', type: 'uuid', nullable: true }) + channelId: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ type: 'text', nullable: true }) + body: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ type: 'jsonb', default: {} }) + variables: Record; + + @Column({ name: 'context_type', type: 'varchar', length: 100, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ type: 'varchar', length: 20, default: 'normal' }) + priority: NotificationPriority; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: NotificationStatus; + + @Column({ name: 'queued_at', type: 'timestamptz', nullable: true }) + queuedAt: Date; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'failed_at', type: 'timestamptz', nullable: true }) + failedAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'retry_count', type: 'int', default: 0 }) + retryCount: number; + + @Column({ name: 'max_retries', type: 'int', default: 3 }) + maxRetries: number; + + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'provider_message_id', type: 'varchar', length: 255, nullable: true }) + providerMessageId: string; + + @Column({ name: 'provider_response', type: 'jsonb', nullable: true }) + providerResponse: Record; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @ManyToOne(() => NotificationTemplate, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate | null; + + @ManyToOne(() => Channel, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'channel_id' }) + channel: Channel | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/preference.entity.ts b/src/modules/notifications/entities/preference.entity.ts new file mode 100644 index 0000000..fa129bb --- /dev/null +++ b/src/modules/notifications/entities/preference.entity.ts @@ -0,0 +1,82 @@ +/** + * NotificationPreference Entity + * User notification preferences per tenant + * Compatible with erp-core preference.entity + * + * @module Notifications + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly'; + +@Entity({ name: 'notification_preferences', schema: 'notifications' }) +@Unique(['userId', 'tenantId']) +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'global_enabled', type: 'boolean', default: true }) + globalEnabled: boolean; + + @Column({ name: 'quiet_hours_start', type: 'time', nullable: true }) + quietHoursStart: string; + + @Column({ name: 'quiet_hours_end', type: 'time', nullable: true }) + quietHoursEnd: string; + + @Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ name: 'email_enabled', type: 'boolean', default: true }) + emailEnabled: boolean; + + @Column({ name: 'sms_enabled', type: 'boolean', default: false }) + smsEnabled: boolean; + + @Column({ name: 'push_enabled', type: 'boolean', default: true }) + pushEnabled: boolean; + + @Column({ name: 'whatsapp_enabled', type: 'boolean', default: false }) + whatsappEnabled: boolean; + + @Column({ name: 'in_app_enabled', type: 'boolean', default: true }) + inAppEnabled: boolean; + + @Column({ name: 'category_preferences', type: 'jsonb', default: {} }) + categoryPreferences: Record; + + @Column({ name: 'digest_frequency', type: 'varchar', length: 20, default: 'instant' }) + digestFrequency: DigestFrequency; + + @Column({ name: 'digest_day', type: 'int', nullable: true }) + digestDay: number; + + @Column({ name: 'digest_hour', type: 'int', nullable: true }) + digestHour: number; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/template.entity.ts b/src/modules/notifications/entities/template.entity.ts new file mode 100644 index 0000000..3638f84 --- /dev/null +++ b/src/modules/notifications/entities/template.entity.ts @@ -0,0 +1,126 @@ +/** + * NotificationTemplate + TemplateTranslation Entities + * Template system with i18n support + * Compatible with erp-core template.entity + * + * @module Notifications + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ChannelType } from './channel.entity'; + +export type TemplateCategory = 'system' | 'marketing' | 'transactional' | 'alert'; + +@Entity({ name: 'notification_templates', schema: 'notifications' }) +@Unique(['tenantId', 'code', 'channelType']) +export class NotificationTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string | null; + + @Index() + @Column({ type: 'varchar', length: 100 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + category: TemplateCategory; + + @Index() + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body_template', type: 'text', nullable: true }) + bodyTemplate: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'available_variables', type: 'jsonb', default: [] }) + availableVariables: string[]; + + @Column({ name: 'default_locale', type: 'varchar', length: 10, default: 'es-MX' }) + defaultLocale: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ type: 'int', default: 1 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string | null; + + @OneToMany(() => TemplateTranslation, (t) => t.template) + translations: TemplateTranslation[]; +} + +@Entity({ name: 'template_translations', schema: 'notifications' }) +@Unique(['templateId', 'locale']) +export class TemplateTranslation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @Column({ type: 'varchar', length: 10 }) + locale: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body_template', type: 'text', nullable: true }) + bodyTemplate: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => NotificationTemplate, (t) => t.translations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate; +} diff --git a/src/modules/partners/entities/index.ts b/src/modules/partners/entities/index.ts new file mode 100644 index 0000000..6ad9fef --- /dev/null +++ b/src/modules/partners/entities/index.ts @@ -0,0 +1,14 @@ +/** + * Partners Entities Index + * @module Partners + */ + +export { Partner } from './partner.entity'; +export { PartnerAddress } from './partner-address.entity'; +export { PartnerContact } from './partner-contact.entity'; +export { PartnerBankAccount } from './partner-bank-account.entity'; +export { PartnerTaxInfo } from './partner-tax-info.entity'; +export { PartnerSegment } from './partner-segment.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 0000000..203abb9 --- /dev/null +++ b/src/modules/partners/entities/partner-address.entity.ts @@ -0,0 +1,90 @@ +/** + * Partner Address Entity + * Direcciones de facturacion y envio de partners + * + * @module Partners + * @table partners.partner_addresses + */ + +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 0000000..14a5102 --- /dev/null +++ b/src/modules/partners/entities/partner-bank-account.entity.ts @@ -0,0 +1,85 @@ +/** + * Partner Bank Account Entity + * Cuentas bancarias asociadas a partners + * + * @module Partners + * @table partners.partner_bank_accounts + */ + +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 0000000..6888553 --- /dev/null +++ b/src/modules/partners/entities/partner-contact.entity.ts @@ -0,0 +1,80 @@ +/** + * Partner Contact Entity + * Contactos asociados a un partner + * + * @module Partners + * @table partners.partner_contacts + */ + +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-segment.entity.ts b/src/modules/partners/entities/partner-segment.entity.ts new file mode 100644 index 0000000..3da6664 --- /dev/null +++ b/src/modules/partners/entities/partner-segment.entity.ts @@ -0,0 +1,81 @@ +/** + * Partner Segment Entity + * Segmentos para agrupacion y analitica de partners + * + * @module Partners + * @table partners.partner_segments + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'partner_segments', schema: 'partners' }) +export class PartnerSegment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Segment type + @Column({ name: 'segment_type', type: 'varchar', length: 20, default: 'customer' }) + segmentType: 'customer' | 'supplier' | 'both'; + + // Styling + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + // Rules for auto-assignment (stored as JSON) + @Column({ type: 'jsonb', nullable: true }) + rules: Record; + + // Benefits/conditions + @Column({ name: 'default_discount', type: 'decimal', precision: 5, scale: 2, default: 0 }) + defaultDiscount: number; + + @Column({ name: 'default_payment_terms', type: 'int', default: 0 }) + defaultPaymentTerms: number; + + @Column({ name: 'priority', type: 'int', default: 0 }) + priority: number; + + // State + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + // 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; +} diff --git a/src/modules/partners/entities/partner-tax-info.entity.ts b/src/modules/partners/entities/partner-tax-info.entity.ts new file mode 100644 index 0000000..8485220 --- /dev/null +++ b/src/modules/partners/entities/partner-tax-info.entity.ts @@ -0,0 +1,80 @@ +/** + * Partner Tax Info Entity + * Informacion fiscal extendida de partners + * + * @module Partners + * @table partners.partner_tax_info + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Partner } from './partner.entity'; + +@Entity({ name: 'partner_tax_info', schema: 'partners' }) +export class PartnerTaxInfo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @ManyToOne(() => Partner, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'partner_id' }) + partner: Partner; + + // Fiscal identification + @Column({ name: 'tax_id_type', type: 'varchar', length: 20, nullable: true }) + taxIdType: string; // RFC, CURP, EIN, VAT + + @Column({ name: 'tax_id_country', type: 'varchar', length: 3, default: 'MEX' }) + taxIdCountry: string; + + // SAT (Mexico) specific + @Column({ name: 'sat_regime', type: 'varchar', length: 10, nullable: true }) + satRegime: string; // 601, 603, 612, etc. + + @Column({ name: 'sat_regime_name', type: 'varchar', length: 200, nullable: true }) + satRegimeName: string; + + @Column({ name: 'cfdi_use', type: 'varchar', length: 10, nullable: true }) + cfdiUse: string; // G01, G02, G03, etc. + + @Column({ name: 'cfdi_use_name', type: 'varchar', length: 200, nullable: true }) + cfdiUseName: string; + + @Column({ name: 'fiscal_zip_code', type: 'varchar', length: 10, nullable: true }) + fiscalZipCode: string; + + // Withholding taxes + @Column({ name: 'withholding_isr', type: 'decimal', precision: 5, scale: 2, default: 0 }) + withholdingIsr: number; + + @Column({ name: 'withholding_iva', type: 'decimal', precision: 5, scale: 2, default: 0 }) + withholdingIva: number; + + // Validation + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'verification_source', type: 'varchar', length: 50, nullable: true }) + verificationSource: string; // SAT, MANUAL, API + + // 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 0000000..990c882 --- /dev/null +++ b/src/modules/partners/entities/partner.entity.ts @@ -0,0 +1,123 @@ +/** + * Partner Entity + * Clientes, proveedores y socios comerciales + * + * @module Partners + * @table partners.partners + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} 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/payment-terminals/controllers/clip-webhook.controller.ts b/src/modules/payment-terminals/controllers/clip-webhook.controller.ts index 88a968f..dcaa518 100644 --- a/src/modules/payment-terminals/controllers/clip-webhook.controller.ts +++ b/src/modules/payment-terminals/controllers/clip-webhook.controller.ts @@ -12,7 +12,7 @@ export class ClipWebhookController { public router: Router; private clipService: ClipService; - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.router = Router(); this.clipService = new ClipService(dataSource); this.initializeRoutes(); @@ -27,7 +27,7 @@ export class ClipWebhookController { * POST /webhooks/clip/:tenantId * Recibir notificaciones de Clip */ - private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise { + private async handleWebhook(req: Request, res: Response, _next: NextFunction): Promise { try { const tenantId = req.params.tenantId; const eventType = req.body.event || req.body.type; diff --git a/src/modules/payment-terminals/controllers/clip.controller.ts b/src/modules/payment-terminals/controllers/clip.controller.ts index 1ce4ad9..263b2dd 100644 --- a/src/modules/payment-terminals/controllers/clip.controller.ts +++ b/src/modules/payment-terminals/controllers/clip.controller.ts @@ -12,7 +12,7 @@ export class ClipController { public router: Router; private clipService: ClipService; - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.router = Router(); this.clipService = new ClipService(dataSource); this.initializeRoutes(); diff --git a/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts b/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts index cf07aa4..405b946 100644 --- a/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts +++ b/src/modules/payment-terminals/controllers/mercadopago-webhook.controller.ts @@ -12,7 +12,7 @@ export class MercadoPagoWebhookController { public router: Router; private mercadoPagoService: MercadoPagoService; - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.router = Router(); this.mercadoPagoService = new MercadoPagoService(dataSource); this.initializeRoutes(); @@ -27,7 +27,7 @@ export class MercadoPagoWebhookController { * POST /webhooks/mercadopago/:tenantId * Recibir notificaciones IPN de MercadoPago */ - private async handleWebhook(req: Request, res: Response, next: NextFunction): Promise { + private async handleWebhook(req: Request, res: Response, _next: NextFunction): Promise { try { const tenantId = req.params.tenantId; const eventType = req.body.type || req.body.action; diff --git a/src/modules/payment-terminals/controllers/mercadopago.controller.ts b/src/modules/payment-terminals/controllers/mercadopago.controller.ts index 357a2db..c296096 100644 --- a/src/modules/payment-terminals/controllers/mercadopago.controller.ts +++ b/src/modules/payment-terminals/controllers/mercadopago.controller.ts @@ -12,7 +12,7 @@ export class MercadoPagoController { public router: Router; private mercadoPagoService: MercadoPagoService; - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.router = Router(); this.mercadoPagoService = new MercadoPagoService(dataSource); this.initializeRoutes(); diff --git a/src/modules/payment-terminals/dto/terminal.dto.ts b/src/modules/payment-terminals/dto/terminal.dto.ts index 00b8fca..ca05d8e 100644 --- a/src/modules/payment-terminals/dto/terminal.dto.ts +++ b/src/modules/payment-terminals/dto/terminal.dto.ts @@ -2,7 +2,9 @@ * Terminal DTOs */ -import { TerminalProvider, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity'; +// Define types locally instead of importing from non-existent modules +export type TerminalProvider = 'mercadopago' | 'clip' | 'stripe_terminal'; +export type HealthStatus = 'healthy' | 'degraded' | 'offline' | 'unknown'; export class CreateTerminalDto { branchId: string; diff --git a/src/modules/payment-terminals/dto/transaction.dto.ts b/src/modules/payment-terminals/dto/transaction.dto.ts index 0a1bfe5..e0a9132 100644 --- a/src/modules/payment-terminals/dto/transaction.dto.ts +++ b/src/modules/payment-terminals/dto/transaction.dto.ts @@ -2,7 +2,10 @@ * Transaction DTOs */ -import { PaymentSourceType, PaymentMethod, PaymentStatus } from '../../mobile/entities/payment-transaction.entity'; +// Define types locally instead of importing from non-existent modules +export type PaymentSourceType = 'pos' | 'mobile' | 'web' | 'api'; +export type PaymentMethod = 'card' | 'contactless' | 'qr' | 'link'; +export type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' | 'cancelled'; export class ProcessPaymentDto { terminalId: string; diff --git a/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts b/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts index 7b96834..8ab6270 100644 --- a/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts +++ b/src/modules/payment-terminals/entities/tenant-terminal-config.entity.ts @@ -5,8 +5,6 @@ import { CreateDateColumn, UpdateDateColumn, Index, - ManyToOne, - JoinColumn, } from 'typeorm'; export type TerminalProvider = 'mercadopago' | 'clip' | 'stripe_terminal'; diff --git a/src/modules/payment-terminals/services/clip.service.ts b/src/modules/payment-terminals/services/clip.service.ts index 3e47c5d..16f37ce 100644 --- a/src/modules/payment-terminals/services/clip.service.ts +++ b/src/modules/payment-terminals/services/clip.service.ts @@ -75,7 +75,7 @@ export class ClipService { private paymentRepository: Repository; private webhookRepository: Repository; - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.configRepository = dataSource.getRepository(TenantTerminalConfig); this.paymentRepository = dataSource.getRepository(TerminalPayment); this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent); @@ -183,24 +183,24 @@ export class ClipService { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json() as Record; throw new ClipError(error.message || 'Payment failed', response.status, error); } - return response.json(); + return response.json() as Promise>; }); // Actualizar registro local - savedPayment.externalId = clipPayment.id; - savedPayment.externalStatus = clipPayment.status; - savedPayment.status = this.mapClipStatus(clipPayment.status); - savedPayment.providerResponse = clipPayment; + savedPayment.externalId = (clipPayment as any).id; + savedPayment.externalStatus = (clipPayment as any).status; + savedPayment.status = this.mapClipStatus((clipPayment as any).status); + savedPayment.providerResponse = clipPayment as Record; savedPayment.processedAt = new Date(); - if (clipPayment.card) { - savedPayment.cardLastFour = clipPayment.card.last_four; - savedPayment.cardBrand = clipPayment.card.brand; - savedPayment.cardType = clipPayment.card.type; + if ((clipPayment as any).card) { + savedPayment.cardLastFour = (clipPayment as any).card.last_four; + savedPayment.cardBrand = (clipPayment as any).card.brand; + savedPayment.cardType = (clipPayment as any).card.type; } return this.paymentRepository.save(savedPayment); @@ -252,7 +252,7 @@ export class ClipService { }); if (response.ok) { - const clipPayment = await response.json(); + const clipPayment = await response.json() as Record; payment.externalStatus = clipPayment.status; payment.status = this.mapClipStatus(clipPayment.status); payment.providerResponse = clipPayment; @@ -291,7 +291,7 @@ export class ClipService { const { credentials } = await this.getCredentials(tenantId); const refundAmount = dto.amount || Number(payment.amount); - const clipRefund = await this.executeWithRetry(async () => { + await this.executeWithRetry(async () => { const response = await fetch( `${CLIP_API_BASE}/v1/payments/${payment.externalId}/refund`, { @@ -309,16 +309,16 @@ export class ClipService { ); if (!response.ok) { - const error = await response.json(); + const error = await response.json() as Record; throw new ClipError(error.message || 'Refund failed', response.status, error); } - return response.json(); + return response.json() as Promise>; }); // Actualizar pago payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount; - payment.refundReason = dto.reason; + payment.refundReason = dto.reason ?? null; payment.refundedAt = new Date(); if (payment.refundedAmount >= Number(payment.amount)) { @@ -336,7 +336,7 @@ export class ClipService { async createPaymentLink( tenantId: string, dto: CreateClipLinkDto, - createdBy?: string + _createdBy?: string ): Promise<{ url: string; id: string }> { const { credentials } = await this.getCredentials(tenantId); @@ -361,7 +361,7 @@ export class ClipService { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json() as Record; throw new ClipError( error.message || 'Failed to create payment link', response.status, @@ -369,12 +369,12 @@ export class ClipService { ); } - return response.json(); + return response.json() as Promise>; }); return { - url: paymentLink.url, - id: paymentLink.id, + url: (paymentLink as any).url, + id: (paymentLink as any).id, }; } diff --git a/src/modules/payment-terminals/services/mercadopago.service.ts b/src/modules/payment-terminals/services/mercadopago.service.ts index 0ea1775..9152b1f 100644 --- a/src/modules/payment-terminals/services/mercadopago.service.ts +++ b/src/modules/payment-terminals/services/mercadopago.service.ts @@ -72,7 +72,7 @@ export class MercadoPagoService { private paymentRepository: Repository; private webhookRepository: Repository; - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.configRepository = dataSource.getRepository(TenantTerminalConfig); this.paymentRepository = dataSource.getRepository(TerminalPayment); this.webhookRepository = dataSource.getRepository(TerminalWebhookEvent); @@ -170,34 +170,34 @@ export class MercadoPagoService { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json() as Record; throw new MercadoPagoError(error.message || 'Payment failed', response.status, error); } - return response.json(); + return response.json() as Promise>; }); // Actualizar registro local - savedPayment.externalId = mpPayment.id?.toString(); - savedPayment.externalStatus = mpPayment.status; - savedPayment.status = this.mapMPStatus(mpPayment.status); - savedPayment.providerResponse = mpPayment; + savedPayment.externalId = (mpPayment as any).id?.toString(); + savedPayment.externalStatus = (mpPayment as any).status; + savedPayment.status = this.mapMPStatus((mpPayment as any).status); + savedPayment.providerResponse = mpPayment as Record; savedPayment.processedAt = new Date(); - if (mpPayment.fee_details?.length > 0) { - const totalFee = mpPayment.fee_details.reduce( + if ((mpPayment as any).fee_details?.length > 0) { + const totalFee = (mpPayment as any).fee_details.reduce( (sum: number, fee: any) => sum + fee.amount, 0 ); savedPayment.feeAmount = totalFee; - savedPayment.feeDetails = mpPayment.fee_details; + savedPayment.feeDetails = (mpPayment as any).fee_details; savedPayment.netAmount = dto.amount - totalFee; } - if (mpPayment.card) { - savedPayment.cardLastFour = mpPayment.card.last_four_digits; - savedPayment.cardBrand = mpPayment.card.payment_method?.name; - savedPayment.cardType = mpPayment.card.cardholder?.identification?.type; + if ((mpPayment as any).card) { + savedPayment.cardLastFour = (mpPayment as any).card.last_four_digits; + savedPayment.cardBrand = (mpPayment as any).card.payment_method?.name; + savedPayment.cardType = (mpPayment as any).card.cardholder?.identification?.type; } return this.paymentRepository.save(savedPayment); @@ -249,7 +249,7 @@ export class MercadoPagoService { }); if (response.ok) { - const mpPayment = await response.json(); + const mpPayment = await response.json() as Record; payment.externalStatus = mpPayment.status; payment.status = this.mapMPStatus(mpPayment.status); payment.providerResponse = mpPayment; @@ -288,7 +288,7 @@ export class MercadoPagoService { const { credentials } = await this.getCredentials(tenantId); const refundAmount = dto.amount || Number(payment.amount); - const mpRefund = await this.executeWithRetry(async () => { + await this.executeWithRetry(async () => { const response = await fetch( `${MP_API_BASE}/v1/payments/${payment.externalId}/refunds`, { @@ -304,16 +304,16 @@ export class MercadoPagoService { ); if (!response.ok) { - const error = await response.json(); + const error = await response.json() as Record; throw new MercadoPagoError(error.message || 'Refund failed', response.status, error); } - return response.json(); + return response.json() as Promise>; }); // Actualizar pago payment.refundedAmount = Number(payment.refundedAmount || 0) + refundAmount; - payment.refundReason = dto.reason; + payment.refundReason = dto.reason ?? null; payment.refundedAt = new Date(); if (payment.refundedAmount >= Number(payment.amount)) { @@ -331,7 +331,7 @@ export class MercadoPagoService { async createPaymentLink( tenantId: string, dto: CreatePaymentLinkDto, - createdBy?: string + _createdBy?: string ): Promise<{ url: string; id: string }> { const { credentials, config } = await this.getCredentials(tenantId); @@ -370,7 +370,7 @@ export class MercadoPagoService { }); if (!response.ok) { - const error = await response.json(); + const error = await response.json() as Record; throw new MercadoPagoError( error.message || 'Failed to create payment link', response.status, @@ -378,12 +378,12 @@ export class MercadoPagoService { ); } - return response.json(); + return response.json() as Promise>; }); return { - url: preference.init_point, - id: preference.id, + url: (preference as any).init_point, + id: (preference as any).id, }; } @@ -465,10 +465,10 @@ export class MercadoPagoService { if (!response.ok) return; - const mpPayment = await response.json(); + const mpPayment = await response.json() as Record; // Buscar pago local por external_reference o external_id - let payment = await this.paymentRepository.findOne({ + const payment = await this.paymentRepository.findOne({ where: [ { externalId: mpPaymentId.toString(), tenantId }, { id: mpPayment.external_reference, tenantId }, @@ -494,7 +494,7 @@ export class MercadoPagoService { /** * Procesar webhook de reembolso */ - private async handleRefundWebhook(tenantId: string, refundId: string): Promise { + private async handleRefundWebhook(_tenantId: string, _refundId: string): Promise { // Implementación similar a handlePaymentWebhook } diff --git a/src/modules/payment-terminals/services/terminals.service.ts b/src/modules/payment-terminals/services/terminals.service.ts index 16ed00e..3263701 100644 --- a/src/modules/payment-terminals/services/terminals.service.ts +++ b/src/modules/payment-terminals/services/terminals.service.ts @@ -11,14 +11,14 @@ import { CreateTerminalDto, UpdateTerminalDto, TerminalResponseDto } from '../dt export class TerminalsService { private terminalRepository: Repository; - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal); } /** * Create a new terminal */ - async create(tenantId: string, dto: CreateTerminalDto): Promise { + async create(_tenantId: string, dto: CreateTerminalDto): Promise { // If setting as primary, unset other primary terminals for this branch if (dto.isPrimary) { await this.terminalRepository.update( @@ -209,14 +209,14 @@ export class TerminalsService { return { id: terminal.id, branchId: terminal.branchId, - terminalProvider: terminal.terminalProvider, + terminalProvider: terminal.terminalProvider as TerminalResponseDto['terminalProvider'], terminalId: terminal.terminalId, terminalName: terminal.terminalName, isPrimary: terminal.isPrimary, isActive: terminal.isActive, dailyLimit: terminal.dailyLimit ? Number(terminal.dailyLimit) : undefined, transactionLimit: terminal.transactionLimit ? Number(terminal.transactionLimit) : undefined, - healthStatus: terminal.healthStatus, + healthStatus: terminal.healthStatus as TerminalResponseDto['healthStatus'], lastTransactionAt: terminal.lastTransactionAt, lastHealthCheckAt: terminal.lastHealthCheckAt, }; diff --git a/src/modules/payment-terminals/services/transactions.service.ts b/src/modules/payment-terminals/services/transactions.service.ts index 146fde5..1bd9e56 100644 --- a/src/modules/payment-terminals/services/transactions.service.ts +++ b/src/modules/payment-terminals/services/transactions.service.ts @@ -4,7 +4,7 @@ * Service for processing and managing payment transactions */ -import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { Repository, DataSource } from 'typeorm'; import { PaymentTransaction, PaymentStatus, @@ -27,7 +27,7 @@ export class TransactionsService { private terminalRepository: Repository; private circuitBreakers: Map = new Map(); - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { this.transactionRepository = dataSource.getRepository(PaymentTransaction); this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal); } @@ -115,14 +115,14 @@ export class TransactionsService { transactionId: transaction.id, externalTransactionId: providerResult.externalTransactionId, amount: dto.amount, - totalAmount: transaction.totalAmount, + totalAmount: transaction.totalAmount ?? dto.amount, tipAmount: transaction.tipAmount, currency: transaction.currency, status: transaction.status, - paymentMethod: transaction.paymentMethod, - cardBrand: transaction.cardBrand, - cardLastFour: transaction.cardLastFour, - receiptUrl: transaction.receiptUrl, + paymentMethod: (transaction.paymentMethod ?? undefined) as PaymentResultDto['paymentMethod'], + cardBrand: transaction.cardBrand ?? undefined, + cardLastFour: transaction.cardLastFour ?? undefined, + receiptUrl: transaction.receiptUrl ?? undefined, error: providerResult.error, }; } catch (error: any) { @@ -152,7 +152,7 @@ export class TransactionsService { */ async processRefund( tenantId: string, - userId: string, + _userId: string, dto: ProcessRefundDto ): Promise { const transaction = await this.transactionRepository.findOne({ @@ -184,11 +184,6 @@ export class TransactionsService { } try { - // Get terminal for provider info - const terminal = await this.terminalRepository.findOne({ - where: { terminalProvider: transaction.terminalProvider as any }, - }); - // Process refund with provider // In production, this would call the actual provider API const refundResult = await this.processRefundWithProvider(transaction, refundAmount, dto.reason); @@ -345,6 +340,8 @@ export class TransactionsService { const byProvider: Record = {}; const byPaymentMethod: Record = { card: 0, + cash: 0, + transfer: 0, contactless: 0, qr: 0, link: 0, @@ -356,16 +353,19 @@ export class TransactionsService { for (const tx of transactions) { byStatus[tx.status]++; - if (!byProvider[tx.terminalProvider]) { - byProvider[tx.terminalProvider] = { count: 0, amount: 0 }; + const provider = tx.terminalProvider ?? 'unknown'; + if (!byProvider[provider]) { + byProvider[provider] = { count: 0, amount: 0 }; } - byProvider[tx.terminalProvider].count++; + byProvider[provider].count++; if (tx.status === 'completed') { totalAmount += Number(tx.totalAmount); completedCount++; - byProvider[tx.terminalProvider].amount += Number(tx.totalAmount); - byPaymentMethod[tx.paymentMethod]++; + byProvider[provider].amount += Number(tx.totalAmount); + if (tx.paymentMethod) { + byPaymentMethod[tx.paymentMethod]++; + } } } @@ -406,7 +406,7 @@ export class TransactionsService { private async processWithProvider( terminal: BranchPaymentTerminal, transaction: PaymentTransaction, - dto: ProcessPaymentDto + _dto: ProcessPaymentDto ): Promise<{ status: PaymentStatus; externalTransactionId?: string; @@ -457,9 +457,9 @@ export class TransactionsService { * Process refund with provider (simulated) */ private async processRefundWithProvider( - transaction: PaymentTransaction, - amount: number, - reason?: string + _transaction: PaymentTransaction, + _amount: number, + _reason?: string ): Promise<{ success: boolean; refundId?: string; error?: string }> { // In production, this would call the actual provider API diff --git a/src/modules/products/entities/index.ts b/src/modules/products/entities/index.ts new file mode 100644 index 0000000..55118e7 --- /dev/null +++ b/src/modules/products/entities/index.ts @@ -0,0 +1,7 @@ +export { ProductCategory } from './product-category.entity'; +export { Product } from './product.entity'; +export { ProductPrice } from './product-price.entity'; +export { ProductSupplier } from './product-supplier.entity'; +export { ProductAttribute } from './product-attribute.entity'; +export { ProductAttributeValue } from './product-attribute-value.entity'; +export { ProductVariant } from './product-variant.entity'; diff --git a/src/modules/products/entities/product-attribute-value.entity.ts b/src/modules/products/entities/product-attribute-value.entity.ts new file mode 100644 index 0000000..0a5f63b --- /dev/null +++ b/src/modules/products/entities/product-attribute-value.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProductAttribute } from './product-attribute.entity'; + +/** + * Product Attribute Value Entity (schema: products.product_attribute_values) + * + * Represents specific values for product attributes. + * Example: For attribute "Color", values could be "Red", "Blue", "Green". + */ +@Entity({ name: 'product_attribute_values', schema: 'products' }) +export class ProductAttributeValue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'attribute_id', type: 'uuid' }) + attributeId: string; + + @ManyToOne(() => ProductAttribute, (attribute) => attribute.values, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'attribute_id' }) + attribute: ProductAttribute; + + @Column({ type: 'varchar', length: 50, nullable: true }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'html_color', type: 'varchar', length: 20, nullable: true }) + htmlColor: string; + + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/products/entities/product-attribute.entity.ts b/src/modules/products/entities/product-attribute.entity.ts new file mode 100644 index 0000000..2460ef0 --- /dev/null +++ b/src/modules/products/entities/product-attribute.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { ProductAttributeValue } from './product-attribute-value.entity'; + +/** + * Product Attribute Entity (schema: products.product_attributes) + * + * Represents configurable attributes for products like color, size, material. + * Each attribute can have multiple values (e.g., Color: Red, Blue, Green). + */ +@Entity({ name: 'product_attributes', schema: 'products' }) +export class ProductAttribute { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'display_type', type: 'varchar', length: 20, default: 'radio' }) + displayType: 'radio' | 'select' | 'color' | 'pills'; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + @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; + + @OneToMany(() => ProductAttributeValue, (value) => value.attribute) + values: ProductAttributeValue[]; +} diff --git a/src/modules/products/entities/product-category.entity.ts b/src/modules/products/entities/product-category.entity.ts new file mode 100644 index 0000000..4de6df7 --- /dev/null +++ b/src/modules/products/entities/product-category.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +@Entity({ name: 'product_categories', schema: 'products' }) +export class ProductCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: ProductCategory; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Imagen + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + // Orden + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/products/entities/product-price.entity.ts b/src/modules/products/entities/product-price.entity.ts new file mode 100644 index 0000000..c768e2b --- /dev/null +++ b/src/modules/products/entities/product-price.entity.ts @@ -0,0 +1,48 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_prices', schema: 'products' }) +export class ProductPrice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'price_type', type: 'varchar', length: 30, default: 'standard' }) + priceType: 'standard' | 'wholesale' | 'retail' | 'promo'; + + @Column({ name: 'price_list_name', type: 'varchar', length: 100, nullable: true }) + priceListName?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4 }) + price: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_quantity', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minQuantity: number; + + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom: Date; + + @Column({ name: 'valid_to', type: 'timestamptz', nullable: true }) + validTo?: Date; + + @Index() + @Column({ name: 'is_active', type: 'boolean', 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/products/entities/product-supplier.entity.ts b/src/modules/products/entities/product-supplier.entity.ts new file mode 100644 index 0000000..0cfbe24 --- /dev/null +++ b/src/modules/products/entities/product-supplier.entity.ts @@ -0,0 +1,51 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_suppliers', schema: 'products' }) +export class ProductSupplier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_sku', type: 'varchar', length: 50, nullable: true }) + supplierSku?: string; + + @Column({ name: 'supplier_name', type: 'varchar', length: 200, nullable: true }) + supplierName?: string; + + @Column({ name: 'purchase_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + purchasePrice?: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_order_qty', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minOrderQty: number; + + @Column({ name: 'lead_time_days', type: 'int', default: 0 }) + leadTimeDays: number; + + @Index() + @Column({ name: 'is_preferred', type: 'boolean', default: false }) + isPreferred: boolean; + + @Column({ name: 'is_active', type: 'boolean', 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/products/entities/product-variant.entity.ts b/src/modules/products/entities/product-variant.entity.ts new file mode 100644 index 0000000..5c677fe --- /dev/null +++ b/src/modules/products/entities/product-variant.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Product } from './product.entity'; + +/** + * Product Variant Entity (schema: products.product_variants) + * + * Represents product variants generated from attribute combinations. + * Example: "Blue T-Shirt - Size M" is a variant of product "T-Shirt". + */ +@Entity({ name: 'product_variants', schema: 'products' }) +export class ProductVariant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50 }) + sku: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'price_extra', type: 'decimal', precision: 15, scale: 4, default: 0 }) + priceExtra: number; + + @Column({ name: 'cost_extra', type: 'decimal', precision: 15, scale: 4, default: 0 }) + costExtra: number; + + @Column({ name: 'stock_qty', type: 'decimal', precision: 15, scale: 4, default: 0 }) + stockQty: number; + + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @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; +} diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts new file mode 100644 index 0000000..d665b2c --- /dev/null +++ b/src/modules/products/entities/product.entity.ts @@ -0,0 +1,206 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProductCategory } from './product-category.entity'; + +/** + * Commerce Product Entity (schema: products.products) + * + * NOTE: This is NOT a duplicate of inventory/entities/product.entity.ts + * + * Key differences: + * - This entity: products.products - Commerce/retail focused + * - Has: SAT codes, tax rates, detailed dimensions, min/max stock, reorder points + * - Used by: Sales, purchases, invoicing, POS + * + * - Inventory Product: 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 + * + * These are intentionally separate by domain. This commerce product entity handles + * pricing, tax compliance (SAT/CFDI), and business rules. For physical stock tracking, + * use the inventory module's product entity. + */ +@Entity({ name: 'products', schema: 'products' }) +export class Product { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'category_id', type: 'uuid', nullable: true }) + categoryId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'category_id' }) + category: ProductCategory; + + /** + * Optional link to inventory.products for unified stock management. + * This allows the commerce product to be linked to its inventory counterpart + * for stock tracking, valuation (FIFO/AVERAGE), and warehouse operations. + * + * The inventory product handles: stock levels, lot/serial tracking, valuation layers + * This commerce product handles: pricing, taxes, SAT compliance, commercial data + */ + @Index() + @Column({ name: 'inventory_product_id', type: 'uuid', nullable: true }) + inventoryProductId: string | null; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 50 }) + sku: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true }) + shortName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'product_type', type: 'varchar', length: 20, default: 'product' }) + productType: 'product' | 'service' | 'consumable' | 'kit'; + + // Precios + @Column({ name: 'sale_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + salePrice: number; + + @Column({ name: 'cost_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + costPrice: number; + + @Column({ name: 'min_sale_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + minSalePrice: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Impuestos + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16 }) + taxRate: number; + + @Column({ name: 'tax_included', type: 'boolean', default: false }) + taxIncluded: boolean; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode: string; + + // Unidad de medida + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'uom_purchase', type: 'varchar', length: 20, nullable: true }) + uomPurchase: string; + + @Column({ name: 'conversion_factor', type: 'decimal', precision: 10, scale: 4, default: 1 }) + conversionFactor: number; + + // Inventario + @Column({ name: 'track_inventory', type: 'boolean', default: true }) + trackInventory: boolean; + + @Column({ name: 'min_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) + minStock: number; + + @Column({ name: 'max_stock', type: 'decimal', precision: 15, scale: 4, nullable: true }) + maxStock: number; + + @Column({ name: 'reorder_point', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderPoint: number; + + @Column({ name: 'reorder_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderQuantity: number; + + // Lotes y series + @Column({ name: 'track_lots', type: 'boolean', default: false }) + trackLots: boolean; + + @Column({ name: 'track_serials', type: 'boolean', default: false }) + trackSerials: boolean; + + @Column({ name: 'track_expiry', type: 'boolean', default: false }) + trackExpiry: boolean; + + // Dimensiones + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + weight: number; + + @Column({ name: 'weight_unit', type: 'varchar', length: 10, default: 'kg' }) + weightUnit: string; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + length: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + width: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + height: number; + + @Column({ name: 'dimension_unit', type: 'varchar', length: 10, default: 'cm' }) + dimensionUnit: string; + + // Imagenes + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @Column({ type: 'text', array: true, default: '{}' }) + images: string[]; + + // Tags + @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_sellable', type: 'boolean', default: true }) + isSellable: boolean; + + @Column({ name: 'is_purchasable', type: 'boolean', default: true }) + isPurchasable: boolean; + + // 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/profiles/entities/index.ts b/src/modules/profiles/entities/index.ts new file mode 100644 index 0000000..31b3568 --- /dev/null +++ b/src/modules/profiles/entities/index.ts @@ -0,0 +1,9 @@ +/** + * Profiles Entities - Export + */ + +export { Person } from './person.entity'; +export { UserProfile } from './user-profile.entity'; +export { ProfileTool } from './profile-tool.entity'; +export { ProfileModule } from './profile-module.entity'; +export { UserProfileAssignment } from './user-profile-assignment.entity'; diff --git a/src/modules/profiles/entities/person.entity.ts b/src/modules/profiles/entities/person.entity.ts new file mode 100644 index 0000000..195f63e --- /dev/null +++ b/src/modules/profiles/entities/person.entity.ts @@ -0,0 +1,78 @@ +/** + * Person Entity + * Contact/person information with identity verification + * Compatible with erp-core person.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'persons', schema: 'auth' }) +export class Person { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'full_name', type: 'varchar', length: 200 }) + fullName: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100, nullable: true }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true }) + lastName: string; + + @Column({ name: 'maternal_name', type: 'varchar', length: 100, nullable: true }) + maternalName: string; + + @Index() + @Column({ type: 'varchar', length: 255 }) + email: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ name: 'mobile_phone', type: 'varchar', length: 20, nullable: true }) + mobilePhone: string; + + @Column({ name: 'identification_type', type: 'varchar', length: 50, nullable: true }) + identificationType: string; + + @Column({ name: 'identification_number', type: 'varchar', length: 50, nullable: true }) + identificationNumber: string; + + @Column({ name: 'identification_expiry', type: 'date', nullable: true }) + identificationExpiry: Date; + + @Column({ type: 'jsonb', default: {} }) + address: Record; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy: string; + + @Column({ name: 'is_responsible_for_tenant', type: 'boolean', default: false }) + isResponsibleForTenant: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/profiles/entities/profile-module.entity.ts b/src/modules/profiles/entities/profile-module.entity.ts new file mode 100644 index 0000000..764fbf0 --- /dev/null +++ b/src/modules/profiles/entities/profile-module.entity.ts @@ -0,0 +1,45 @@ +/** + * ProfileModule Entity + * Module-level access control per profile + * Compatible with erp-core profile-module.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Unique, + Index, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_modules', schema: 'auth' }) +@Unique(['profileId', 'moduleCode']) +export class ProfileModule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'module_code', type: 'varchar', length: 50 }) + moduleCode: string; + + @Column({ name: 'access_level', type: 'varchar', length: 20, default: 'read' }) + accessLevel: 'read' | 'write' | 'admin'; + + @Column({ name: 'can_export', type: 'boolean', default: false }) + canExport: boolean; + + @Column({ name: 'can_print', type: 'boolean', default: true }) + canPrint: boolean; + + @ManyToOne(() => UserProfile, (profile) => profile.modules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/profile-tool.entity.ts b/src/modules/profiles/entities/profile-tool.entity.ts new file mode 100644 index 0000000..c0992f2 --- /dev/null +++ b/src/modules/profiles/entities/profile-tool.entity.ts @@ -0,0 +1,68 @@ +/** + * ProfileTool Entity + * Tool/permission assignments per profile + * Compatible with erp-core profile-tool.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_tools', schema: 'auth' }) +@Unique(['profileId', 'toolCode']) +export class ProfileTool { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Index() + @Column({ name: 'tool_code', type: 'varchar', length: 50 }) + toolCode: string; + + @Column({ name: 'tool_name', type: 'varchar', length: 100 }) + toolName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'is_mobile_only', type: 'boolean', default: false }) + isMobileOnly: boolean; + + @Column({ name: 'is_web_only', type: 'boolean', default: false }) + isWebOnly: boolean; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ type: 'jsonb', default: {} }) + configuration: Record; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => UserProfile, (profile) => profile.tools, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile-assignment.entity.ts b/src/modules/profiles/entities/user-profile-assignment.entity.ts new file mode 100644 index 0000000..bcfdeff --- /dev/null +++ b/src/modules/profiles/entities/user-profile-assignment.entity.ts @@ -0,0 +1,50 @@ +/** + * UserProfileAssignment Entity + * Links users to profiles with expiration support + * Compatible with erp-core user-profile-assignment.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'user_profile_assignments', schema: 'auth' }) +@Unique(['userId', 'profileId']) +export class UserProfileAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' }) + assignedAt: Date; + + @Column({ name: 'assigned_by', type: 'uuid', nullable: true }) + assignedBy: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @ManyToOne(() => UserProfile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile.entity.ts b/src/modules/profiles/entities/user-profile.entity.ts new file mode 100644 index 0000000..85438bd --- /dev/null +++ b/src/modules/profiles/entities/user-profile.entity.ts @@ -0,0 +1,90 @@ +/** + * UserProfile Entity + * Role-based profile with module access, tools and pricing + * Compatible with erp-core user-profile.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, + Unique, +} from 'typeorm'; +import { ProfileTool } from './profile-tool.entity'; +import { ProfileModule } from './profile-module.entity'; + +@Entity({ name: 'user_profiles', schema: 'auth' }) +@Unique(['tenantId', 'code']) +export class UserProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 10 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'base_permissions', type: 'jsonb', default: [] }) + basePermissions: string[]; + + @Column({ name: 'available_modules', type: 'text', array: true, default: [] }) + availableModules: string[]; + + @Column({ name: 'monthly_price', type: 'decimal', precision: 10, scale: 2, default: 0 }) + monthlyPrice: number; + + @Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] }) + includedPlatforms: string[]; + + @Column({ name: 'default_tools', type: 'text', array: true, default: [] }) + defaultTools: string[]; + + @Column({ name: 'feature_flags', type: 'jsonb', default: {} }) + featureFlags: Record; + + @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; + + @OneToMany(() => ProfileTool, (tool) => tool.profile, { cascade: true }) + tools: ProfileTool[]; + + @OneToMany(() => ProfileModule, (module) => module.profile, { cascade: true }) + modules: ProfileModule[]; +} diff --git a/src/modules/progress/controllers/avance-obra.controller.ts b/src/modules/progress/controllers/avance-obra.controller.ts index 4ab12a3..c2e9344 100644 --- a/src/modules/progress/controllers/avance-obra.controller.ts +++ b/src/modules/progress/controllers/avance-obra.controller.ts @@ -12,8 +12,6 @@ import { DataSource } from 'typeorm'; import { AvanceObraService, CreateAvanceDto, AddFotoDto, AvanceFilters } from '../services/avance-obra.service'; import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; import { AuthService } from '../../auth/services/auth.service'; -import { AvanceObra } from '../entities/avance-obra.entity'; -import { FotoAvance } from '../entities/foto-avance.entity'; import { User } from '../../core/entities/user.entity'; import { Tenant } from '../../core/entities/tenant.entity'; import { RefreshToken } from '../../auth/entities/refresh-token.entity'; @@ -32,15 +30,13 @@ interface ServiceContext { export function createAvanceObraController(dataSource: DataSource): Router { const router = Router(); - // Repositorios - const avanceRepository = dataSource.getRepository(AvanceObra); - const fotoRepository = dataSource.getRepository(FotoAvance); + // Repositorios for auth const userRepository = dataSource.getRepository(User); const tenantRepository = dataSource.getRepository(Tenant); const refreshTokenRepository = dataSource.getRepository(RefreshToken); // Servicios - const avanceService = new AvanceObraService(avanceRepository, fotoRepository); + const avanceService = new AvanceObraService(dataSource); const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); const authMiddleware = new AuthMiddleware(authService, dataSource); @@ -85,10 +81,10 @@ export function createAvanceObraController(dataSource: DataSource): Router { success: true, data: result.data, pagination: { - total: result.meta.total, - page: result.meta.page, - limit: result.meta.limit, - totalPages: result.meta.totalPages, + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, }, }); } catch (error) { diff --git a/src/modules/progress/controllers/bitacora-obra.controller.ts b/src/modules/progress/controllers/bitacora-obra.controller.ts index 4756e79..b10e46d 100644 --- a/src/modules/progress/controllers/bitacora-obra.controller.ts +++ b/src/modules/progress/controllers/bitacora-obra.controller.ts @@ -11,7 +11,6 @@ import { DataSource } from 'typeorm'; import { BitacoraObraService, CreateBitacoraDto, UpdateBitacoraDto, BitacoraFilters } from '../services/bitacora-obra.service'; import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; import { AuthService } from '../../auth/services/auth.service'; -import { BitacoraObra } from '../entities/bitacora-obra.entity'; import { User } from '../../core/entities/user.entity'; import { Tenant } from '../../core/entities/tenant.entity'; import { RefreshToken } from '../../auth/entities/refresh-token.entity'; @@ -25,19 +24,18 @@ interface ServiceContext { } /** - * Crear router de bitĆ”cora de obra + * Crear router de bitacora de obra */ export function createBitacoraObraController(dataSource: DataSource): Router { const router = Router(); - // Repositorios - const bitacoraRepository = dataSource.getRepository(BitacoraObra); + // Repositorios for auth const userRepository = dataSource.getRepository(User); const tenantRepository = dataSource.getRepository(Tenant); const refreshTokenRepository = dataSource.getRepository(RefreshToken); // Servicios - const bitacoraService = new BitacoraObraService(bitacoraRepository); + const bitacoraService = new BitacoraObraService(dataSource); const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); const authMiddleware = new AuthMiddleware(authService, dataSource); @@ -85,10 +83,10 @@ export function createBitacoraObraController(dataSource: DataSource): Router { success: true, data: result.data, pagination: { - total: result.meta.total, - page: result.meta.page, - limit: result.meta.limit, - totalPages: result.meta.totalPages, + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, }, }); } catch (error) { diff --git a/src/modules/progress/services/avance-obra.service.ts b/src/modules/progress/services/avance-obra.service.ts index 266a7c2..13ff49b 100644 --- a/src/modules/progress/services/avance-obra.service.ts +++ b/src/modules/progress/services/avance-obra.service.ts @@ -1,17 +1,35 @@ /** - * AvanceObraService - Gestión de Avances de Obra + * AvanceObraService - Gestion de Avances de Obra * - * Gestiona el registro y aprobación de avances fĆ­sicos de obra. - * Incluye workflow de captura -> revisión -> aprobación. + * Gestiona el registro y aprobacion de avances fisicos de obra. + * Incluye workflow de captura -> revision -> aprobacion. * * @module Progress */ -import { Repository } from 'typeorm'; -import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { DataSource, Repository, IsNull } from 'typeorm'; import { AvanceObra, AdvanceStatus } from '../entities/avance-obra.entity'; import { FotoAvance } from '../entities/foto-avance.entity'; +/** + * Service context for multi-tenant operations + */ +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + export interface CreateAvanceDto { loteId?: string; departamentoId?: string; @@ -40,12 +58,64 @@ export interface AvanceFilters { dateTo?: Date; } -export class AvanceObraService extends BaseService { - constructor( - repository: Repository, - private readonly fotoRepository: Repository - ) { - super(repository); +export class AvanceObraService { + private readonly repository: Repository; + private readonly fotoRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(AvanceObra); + this.fotoRepository = dataSource.getRepository(FotoAvance); + } + + /** + * Find by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Create entity + */ + async create(ctx: ServiceContext, data: Partial): Promise { + const entity = this.repository.create({ + ...data, + tenantId: ctx.tenantId, + createdById: ctx.userId, + }); + return this.repository.save(entity); + } + + /** + * Update entity + */ + async update(ctx: ServiceContext, id: string, data: Partial): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + Object.assign(entity, data, { updatedById: ctx.userId }); + return this.repository.save(entity); + } + + /** + * Soft delete entity + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + entity.deletedAt = new Date(); + entity.deletedById = ctx.userId ?? null; + await this.repository.save(entity); + return true; } /** @@ -79,11 +149,25 @@ export class AvanceObraService extends BaseService { page = 1, limit = 20 ): Promise> { - return this.findAll(ctx, { + const skip = (page - 1) * limit; + const [data, total] = await this.repository.findAndCount({ + where: { + tenantId: ctx.tenantId, + loteId, + deletedAt: IsNull(), + }, + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + data, + total, page, limit, - where: { loteId } as any, - }); + totalPages: Math.ceil(total / limit), + }; } /** @@ -95,11 +179,25 @@ export class AvanceObraService extends BaseService { page = 1, limit = 20 ): Promise> { - return this.findAll(ctx, { + const skip = (page - 1) * limit; + const [data, total] = await this.repository.findAndCount({ + where: { + tenantId: ctx.tenantId, + departamentoId, + deletedAt: IsNull(), + }, + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + data, + total, page, limit, - where: { departamentoId } as any, - }); + totalPages: Math.ceil(total / limit), + }; } /** @@ -142,12 +240,10 @@ export class AvanceObraService extends BaseService { return { data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, + total, + page, + limit, + totalPages: Math.ceil(total / limit), }; } @@ -159,8 +255,8 @@ export class AvanceObraService extends BaseService { where: { id, tenantId: ctx.tenantId, - deletedAt: null, - } as any, + deletedAt: IsNull(), + }, relations: ['fotos', 'concepto', 'capturedBy'], }); } diff --git a/src/modules/progress/services/bitacora-obra.service.ts b/src/modules/progress/services/bitacora-obra.service.ts index 676de68..b288793 100644 --- a/src/modules/progress/services/bitacora-obra.service.ts +++ b/src/modules/progress/services/bitacora-obra.service.ts @@ -1,16 +1,34 @@ /** - * BitacoraObraService - BitĆ”cora de Obra + * BitacoraObraService - Bitacora de Obra * - * Gestiona el registro diario de bitĆ”cora de obra. - * Genera automĆ”ticamente el nĆŗmero de entrada secuencial. + * Gestiona el registro diario de bitacora de obra. + * Genera automaticamente el numero de entrada secuencial. * * @module Progress */ -import { Repository } from 'typeorm'; -import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { DataSource, Repository, IsNull } from 'typeorm'; import { BitacoraObra } from '../entities/bitacora-obra.entity'; +/** + * Service context for multi-tenant operations + */ +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + export interface CreateBitacoraDto { fraccionamientoId: string; entryDate: Date; @@ -39,13 +57,107 @@ export interface BitacoraFilters { hasIncidents?: boolean; } -export class BitacoraObraService extends BaseService { - constructor(repository: Repository) { - super(repository); +export class BitacoraObraService { + private readonly repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(BitacoraObra); } /** - * Crear nueva entrada de bitĆ”cora + * Find by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Create entity + */ + async create(ctx: ServiceContext, data: Partial): Promise { + const entity = this.repository.create({ + ...data, + tenantId: ctx.tenantId, + createdById: ctx.userId, + }); + return this.repository.save(entity); + } + + /** + * Update entity + */ + async update(ctx: ServiceContext, id: string, data: Partial): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return null; + } + Object.assign(entity, data, { updatedById: ctx.userId }); + return this.repository.save(entity); + } + + /** + * Soft delete entity + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const entity = await this.findById(ctx, id); + if (!entity) { + return false; + } + entity.deletedAt = new Date(); + entity.deletedById = ctx.userId ?? null; + await this.repository.save(entity); + return true; + } + + /** + * Count entities + */ + async count(ctx: ServiceContext, where: Partial): Promise { + return this.repository.count({ + where: { + ...where, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + } as any, + }); + } + + /** + * Find entities + */ + async find(ctx: ServiceContext, options: { where?: any; order?: any; take?: number }): Promise { + return this.repository.find({ + where: { + ...options.where, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + order: options.order, + take: options.take, + }); + } + + /** + * Find one entity + */ + async findOne(ctx: ServiceContext, where: Partial): Promise { + return this.repository.findOne({ + where: { + ...where, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + } as any, + }); + } + + /** + * Crear nueva entrada de bitacora */ async createEntry( ctx: ServiceContext, @@ -61,7 +173,7 @@ export class BitacoraObraService extends BaseService { } /** - * Obtener siguiente nĆŗmero de entrada + * Obtener siguiente numero de entrada */ private async getNextEntryNumber( ctx: ServiceContext, @@ -78,7 +190,7 @@ export class BitacoraObraService extends BaseService { } /** - * Obtener bitĆ”cora por fraccionamiento + * Obtener bitacora por fraccionamiento */ async findByFraccionamiento( ctx: ServiceContext, @@ -86,15 +198,29 @@ export class BitacoraObraService extends BaseService { page = 1, limit = 20 ): Promise> { - return this.findAll(ctx, { + const skip = (page - 1) * limit; + const [data, total] = await this.repository.findAndCount({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + deletedAt: IsNull(), + }, + skip, + take: limit, + order: { entryNumber: 'DESC' }, + }); + + return { + data, + total, page, limit, - where: { fraccionamientoId } as any, - }); + totalPages: Math.ceil(total / limit), + }; } /** - * Obtener bitĆ”cora con filtros + * Obtener bitacora con filtros */ async findWithFilters( ctx: ServiceContext, @@ -130,12 +256,10 @@ export class BitacoraObraService extends BaseService { return { data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, + total, + page, + limit, + totalPages: Math.ceil(total / limit), }; } @@ -154,7 +278,7 @@ export class BitacoraObraService extends BaseService { } /** - * Obtener Ćŗltima entrada + * Obtener ultima entrada */ async findLatest( ctx: ServiceContext, @@ -170,7 +294,7 @@ export class BitacoraObraService extends BaseService { } /** - * Obtener estadĆ­sticas de bitĆ”cora + * Obtener estadisticas de bitacora */ async getStats( ctx: ServiceContext, diff --git a/src/modules/progress/services/index.ts b/src/modules/progress/services/index.ts index 89c3b16..47e5790 100644 --- a/src/modules/progress/services/index.ts +++ b/src/modules/progress/services/index.ts @@ -3,5 +3,8 @@ * MAI-005: Control de Obra */ -export * from './avance-obra.service'; -export * from './bitacora-obra.service'; +export { AvanceObraService } from './avance-obra.service'; +export type { CreateAvanceDto, AddFotoDto, AvanceFilters } from './avance-obra.service'; + +export { BitacoraObraService } from './bitacora-obra.service'; +export type { CreateBitacoraDto, UpdateBitacoraDto, BitacoraFilters } from './bitacora-obra.service'; diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts new file mode 100644 index 0000000..dbc3634 --- /dev/null +++ b/src/modules/projects/entities/index.ts @@ -0,0 +1 @@ +export * from './timesheet.entity'; diff --git a/src/modules/projects/entities/timesheet.entity.ts b/src/modules/projects/entities/timesheet.entity.ts new file mode 100644 index 0000000..3bdb6b3 --- /dev/null +++ b/src/modules/projects/entities/timesheet.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum TimesheetStatus { + DRAFT = 'draft', + SUBMITTED = 'submitted', + APPROVED = 'approved', + REJECTED = 'rejected', +} + +@Entity({ schema: 'projects', name: 'timesheets' }) +@Index('idx_timesheets_tenant', ['tenantId']) +@Index('idx_timesheets_company', ['companyId']) +@Index('idx_timesheets_project', ['projectId']) +@Index('idx_timesheets_task', ['taskId']) +@Index('idx_timesheets_user', ['userId']) +@Index('idx_timesheets_user_date', ['userId', 'date']) +@Index('idx_timesheets_date', ['date']) +@Index('idx_timesheets_status', ['status']) +export class TimesheetEntity { + @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: 'uuid', nullable: false, name: 'project_id' }) + projectId: string; + + @Column({ type: 'uuid', nullable: true, name: 'task_id' }) + taskId: string | null; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'date', nullable: false }) + date: Date; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: false }) + hours: number; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'boolean', default: true, nullable: false }) + billable: boolean; + + @Column({ type: 'boolean', default: false, nullable: false }) + invoiced: boolean; + + @Column({ type: 'uuid', nullable: true, name: 'invoice_id' }) + invoiceId: string | null; + + @Column({ + type: 'enum', + enum: TimesheetStatus, + default: TimesheetStatus.DRAFT, + nullable: false, + }) + status: TimesheetStatus; + + @Column({ type: 'uuid', nullable: true, name: 'approved_by' }) + approvedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'approved_at' }) + approvedAt: Date | null; + + // 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/purchase/dto/comparativo.dto.ts b/src/modules/purchase/dto/comparativo.dto.ts new file mode 100644 index 0000000..7d191ee --- /dev/null +++ b/src/modules/purchase/dto/comparativo.dto.ts @@ -0,0 +1,408 @@ +/** + * Comparativo DTOs - Data Transfer Objects para Cuadros Comparativos de Cotizaciones + * + * Gestiona comparativos de cotizaciones para seleccion de proveedores. + * + * @module Purchase (MAI-004) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsBoolean, + IsNumber, + IsDateString, + IsEnum, + IsArray, + ValidateNested, + MinLength, + MaxLength, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Estado del comparativo + */ +export enum ComparativoStatus { + DRAFT = 'draft', + IN_EVALUATION = 'in_evaluation', + APPROVED = 'approved', + CANCELLED = 'cancelled', +} + +/** + * DTO para producto en cotizacion de proveedor + */ +export class ComparativoProductoDto { + @IsUUID() + productId: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsNumber() + @Min(0) + unitPrice: number; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para proveedor en comparativo + */ +export class ComparativoProveedorDto { + @IsUUID() + supplierId: string; + + @IsOptional() + @IsString() + @MaxLength(50) + quotationNumber?: string; + + @IsOptional() + @IsDateString() + quotationDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + deliveryDays?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + paymentConditions?: string; + + @IsOptional() + @IsNumber() + @Min(0) + totalAmount?: number; + + @IsOptional() + @IsString() + evaluationNotes?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ComparativoProductoDto) + productos?: ComparativoProductoDto[]; +} + +/** + * DTO para crear un nuevo comparativo de cotizaciones + */ +export class CreateComparativoDto { + @IsOptional() + @IsUUID() + requisicionId?: string; + + @IsString() + @MinLength(3) + @MaxLength(30) + code: string; + + @IsString() + @MinLength(3) + @MaxLength(255) + name: string; + + @IsDateString() + comparisonDate: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ComparativoProveedorDto) + proveedores?: ComparativoProveedorDto[]; +} + +/** + * DTO para actualizar un comparativo existente + */ +export class UpdateComparativoDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(30) + code?: string; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsDateString() + comparisonDate?: string; + + @IsOptional() + @IsEnum(ComparativoStatus) + status?: ComparativoStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para agregar un proveedor al comparativo + */ +export class AddProveedorToComparativoDto { + @IsUUID() + supplierId: string; + + @IsOptional() + @IsString() + @MaxLength(50) + quotationNumber?: string; + + @IsOptional() + @IsDateString() + quotationDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + deliveryDays?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + paymentConditions?: string; +} + +/** + * DTO para actualizar un proveedor en el comparativo + */ +export class UpdateProveedorInComparativoDto { + @IsOptional() + @IsString() + @MaxLength(50) + quotationNumber?: string; + + @IsOptional() + @IsDateString() + quotationDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + deliveryDays?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + paymentConditions?: string; + + @IsOptional() + @IsNumber() + @Min(0) + totalAmount?: number; + + @IsOptional() + @IsBoolean() + isSelected?: boolean; + + @IsOptional() + @IsString() + evaluationNotes?: string; +} + +/** + * DTO para agregar un producto a la cotizacion de un proveedor + */ +export class AddProductoToProveedorDto { + @IsUUID() + productId: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsNumber() + @Min(0) + unitPrice: number; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO para aprobar un comparativo y seleccionar proveedor ganador + */ +export class ApproveComparativoDto { + @IsUUID() + winnerSupplierId: string; + + @IsOptional() + @IsString() + approvalNotes?: string; +} + +/** + * DTO para cancelar un comparativo + */ +export class CancelComparativoDto { + @IsString() + @MinLength(10) + reason: string; +} + +/** + * DTO para filtrar comparativos en listados + */ +export class ComparativoFiltersDto { + @IsOptional() + @IsUUID() + requisicionId?: string; + + @IsOptional() + @IsEnum(ComparativoStatus) + status?: ComparativoStatus; + + @IsOptional() + @IsUUID() + winnerSupplierId?: string; + + @IsOptional() + @IsDateString() + comparisonDateFrom?: string; + + @IsOptional() + @IsDateString() + comparisonDateTo?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un producto en cotizacion + */ +export class ComparativoProductoResponseDto { + id: string; + tenantId: string; + comparativoProveedorId: string; + productId: string; + product?: { + id: string; + code: string; + name: string; + unit: string; + }; + quantity: number; + unitPrice: number; + totalPrice: number; + notes?: string; + createdAt: Date; + createdById?: string; +} + +/** + * DTO de respuesta para un proveedor en comparativo + */ +export class ComparativoProveedorResponseDto { + id: string; + tenantId: string; + comparativoId: string; + supplierId: string; + supplier?: { + id: string; + code: string; + name: string; + rfc?: string; + }; + quotationNumber?: string; + quotationDate?: Date; + deliveryDays?: number; + paymentConditions?: string; + totalAmount?: number; + isSelected: boolean; + evaluationNotes?: string; + productos?: ComparativoProductoResponseDto[]; + createdAt: Date; + createdById?: string; +} + +/** + * DTO de respuesta para un comparativo de cotizaciones + */ +export class ComparativoResponseDto { + id: string; + tenantId: string; + requisicionId?: string; + requisicion?: { + id: string; + code: string; + }; + code: string; + name: string; + comparisonDate: Date; + status: ComparativoStatus; + winnerSupplierId?: string; + winnerSupplier?: { + id: string; + code: string; + name: string; + }; + approvedById?: string; + approvedBy?: { + id: string; + firstName: string; + lastName: string; + }; + approvedAt?: Date; + notes?: string; + proveedores?: ComparativoProveedorResponseDto[]; + proveedoresCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/purchase/dto/index.ts b/src/modules/purchase/dto/index.ts new file mode 100644 index 0000000..2e190a4 --- /dev/null +++ b/src/modules/purchase/dto/index.ts @@ -0,0 +1,52 @@ +/** + * Purchase DTOs Index + * Barrel file exporting all purchase module DTOs and Enums. + * + * @module Purchase (MAI-004) + */ + +// ============================================================================ +// PURCHASE ORDER CONSTRUCTION DTOs +// ============================================================================ +export { + CreatePurchaseOrderConstructionDto, + UpdatePurchaseOrderConstructionDto, + RegisterReceptionDto, + PurchaseOrderConstructionFiltersDto, + PurchaseOrderConstructionResponseDto, +} from './purchase-order-construction.dto'; + +// ============================================================================ +// SUPPLIER CONSTRUCTION DTOs +// ============================================================================ +export { + CreateSupplierConstructionDto, + UpdateSupplierConstructionDto, + UpdateSupplierEvaluationDto, + SupplierConstructionFiltersDto, + SupplierConstructionResponseDto, +} from './supplier-construction.dto'; + +// ============================================================================ +// COMPARATIVO DTOs +// ============================================================================ +export { + // Enums + ComparativoStatus, + // Supporting DTOs + ComparativoProductoDto, + ComparativoProveedorDto, + // Main DTOs + CreateComparativoDto, + UpdateComparativoDto, + AddProveedorToComparativoDto, + UpdateProveedorInComparativoDto, + AddProductoToProveedorDto, + ApproveComparativoDto, + CancelComparativoDto, + ComparativoFiltersDto, + // Response DTOs + ComparativoProductoResponseDto, + ComparativoProveedorResponseDto, + ComparativoResponseDto, +} from './comparativo.dto'; diff --git a/src/modules/purchase/dto/purchase-order-construction.dto.ts b/src/modules/purchase/dto/purchase-order-construction.dto.ts new file mode 100644 index 0000000..c45bc43 --- /dev/null +++ b/src/modules/purchase/dto/purchase-order-construction.dto.ts @@ -0,0 +1,185 @@ +/** + * PurchaseOrderConstruction DTOs - Data Transfer Objects para Ordenes de Compra Construccion + * + * Extension de ordenes de compra para proyectos de construccion. + * + * @module Purchase (MAI-004) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsBoolean, + IsDateString, + IsNumber, + IsEnum, + MaxLength, + Min, + Max, +} from 'class-validator'; + +/** + * DTO para crear una nueva extension de orden de compra para construccion + */ +export class CreatePurchaseOrderConstructionDto { + @IsUUID() + purchaseOrderId: string; + + @IsOptional() + @IsUUID() + fraccionamientoId?: string; + + @IsOptional() + @IsUUID() + requisicionId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + deliveryLocation?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + deliveryContact?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + deliveryPhone?: string; +} + +/** + * DTO para actualizar una orden de compra de construccion existente + */ +export class UpdatePurchaseOrderConstructionDto { + @IsOptional() + @IsUUID() + fraccionamientoId?: string; + + @IsOptional() + @IsUUID() + requisicionId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + deliveryLocation?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + deliveryContact?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + deliveryPhone?: string; +} + +/** + * DTO para registrar la recepcion de una orden de compra + */ +export class RegisterReceptionDto { + @IsOptional() + @IsUUID() + receivedById?: string; + + @IsOptional() + @IsBoolean() + qualityApproved?: boolean; + + @IsOptional() + @IsString() + qualityNotes?: string; +} + +/** + * DTO para filtrar ordenes de compra de construccion en listados + */ +export class PurchaseOrderConstructionFiltersDto { + @IsOptional() + @IsUUID() + fraccionamientoId?: string; + + @IsOptional() + @IsUUID() + requisicionId?: string; + + @IsOptional() + @IsBoolean() + qualityApproved?: boolean; + + @IsOptional() + @IsDateString() + receivedFrom?: string; + + @IsOptional() + @IsDateString() + receivedTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una orden de compra de construccion + */ +export class PurchaseOrderConstructionResponseDto { + id: string; + tenantId: string; + purchaseOrderId: string; + fraccionamientoId?: string; + fraccionamiento?: { + id: string; + code: string; + name: string; + }; + requisicionId?: string; + requisicion?: { + id: string; + code: string; + }; + deliveryLocation?: string; + deliveryContact?: string; + deliveryPhone?: string; + receivedById?: string; + receivedBy?: { + id: string; + firstName: string; + lastName: string; + }; + receivedAt?: Date; + qualityApproved?: boolean; + qualityNotes?: string; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/purchase/dto/supplier-construction.dto.ts b/src/modules/purchase/dto/supplier-construction.dto.ts new file mode 100644 index 0000000..515e183 --- /dev/null +++ b/src/modules/purchase/dto/supplier-construction.dto.ts @@ -0,0 +1,261 @@ +/** + * SupplierConstruction DTOs - Data Transfer Objects para Proveedores de Construccion + * + * Extension de proveedores con informacion especifica para construccion. + * + * @module Purchase (MAI-004) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsDateString, + IsEnum, + Min, + Max, +} from 'class-validator'; + +/** + * DTO para crear una nueva extension de proveedor para construccion + */ +export class CreateSupplierConstructionDto { + @IsUUID() + supplierId: string; + + @IsOptional() + @IsBoolean() + isMaterialsSupplier?: boolean; + + @IsOptional() + @IsBoolean() + isServicesSupplier?: boolean; + + @IsOptional() + @IsBoolean() + isEquipmentSupplier?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + specialties?: string[]; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + qualityRating?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + deliveryRating?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + priceRating?: number; + + @IsOptional() + @IsNumber() + @Min(0) + creditLimit?: number; + + @IsOptional() + @IsNumber() + @Min(0) + paymentDays?: number; + + @IsOptional() + @IsBoolean() + hasValidDocuments?: boolean; + + @IsOptional() + @IsDateString() + documentsExpiryDate?: string; +} + +/** + * DTO para actualizar un proveedor de construccion existente + */ +export class UpdateSupplierConstructionDto { + @IsOptional() + @IsBoolean() + isMaterialsSupplier?: boolean; + + @IsOptional() + @IsBoolean() + isServicesSupplier?: boolean; + + @IsOptional() + @IsBoolean() + isEquipmentSupplier?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + specialties?: string[]; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + qualityRating?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + deliveryRating?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + priceRating?: number; + + @IsOptional() + @IsNumber() + @Min(0) + creditLimit?: number; + + @IsOptional() + @IsNumber() + @Min(0) + paymentDays?: number; + + @IsOptional() + @IsBoolean() + hasValidDocuments?: boolean; + + @IsOptional() + @IsDateString() + documentsExpiryDate?: string; + + @IsOptional() + @IsDateString() + lastEvaluationDate?: string; +} + +/** + * DTO para actualizar la evaluacion de un proveedor + */ +export class UpdateSupplierEvaluationDto { + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + qualityRating?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + deliveryRating?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + priceRating?: number; + + @IsOptional() + @IsString() + evaluationNotes?: string; +} + +/** + * DTO para filtrar proveedores de construccion en listados + */ +export class SupplierConstructionFiltersDto { + @IsOptional() + @IsBoolean() + isMaterialsSupplier?: boolean; + + @IsOptional() + @IsBoolean() + isServicesSupplier?: boolean; + + @IsOptional() + @IsBoolean() + isEquipmentSupplier?: boolean; + + @IsOptional() + @IsString() + specialty?: string; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(5) + minOverallRating?: number; + + @IsOptional() + @IsBoolean() + hasValidDocuments?: boolean; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un proveedor de construccion + */ +export class SupplierConstructionResponseDto { + id: string; + tenantId: string; + supplierId: string; + supplier?: { + id: string; + code: string; + name: string; + rfc?: string; + }; + isMaterialsSupplier: boolean; + isServicesSupplier: boolean; + isEquipmentSupplier: boolean; + specialties?: string[]; + qualityRating?: number; + deliveryRating?: number; + priceRating?: number; + overallRating?: number; + lastEvaluationDate?: Date; + creditLimit?: number; + paymentDays: number; + hasValidDocuments: boolean; + documentsExpiryDate?: Date; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/purchase/entities/index.ts b/src/modules/purchase/entities/index.ts index 129f99c..408c775 100644 --- a/src/modules/purchase/entities/index.ts +++ b/src/modules/purchase/entities/index.ts @@ -2,11 +2,19 @@ * Purchase Entities Index * @module Purchase * - * Extensiones de compras para construcción (MAI-004) + * Extensiones de compras para construccion (MAI-004) */ +// Construction-specific entities export * from './purchase-order-construction.entity'; export * from './supplier-construction.entity'; export * from './comparativo-cotizaciones.entity'; export * from './comparativo-proveedor.entity'; export * from './comparativo-producto.entity'; + +// Core purchase entities (from erp-core) +export * from './purchase-receipt.entity'; +export * from './purchase-receipt-item.entity'; +export * from './purchase-order-matching.entity'; +export * from './purchase-matching-line.entity'; +export * from './matching-exception.entity'; diff --git a/src/modules/purchase/entities/matching-exception.entity.ts b/src/modules/purchase/entities/matching-exception.entity.ts new file mode 100644 index 0000000..a1ffb37 --- /dev/null +++ b/src/modules/purchase/entities/matching-exception.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PurchaseOrderMatching } from './purchase-order-matching.entity'; +import { PurchaseMatchingLine } from './purchase-matching-line.entity'; + +export type ExceptionType = + | 'over_receipt' + | 'short_receipt' + | 'over_invoice' + | 'short_invoice' + | 'price_variance'; + +export type ExceptionStatus = 'pending' | 'approved' | 'rejected'; + +@Entity({ name: 'matching_exceptions', schema: 'purchases' }) +export class MatchingException { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'matching_id', type: 'uuid', nullable: true }) + matchingId?: string; + + @ManyToOne(() => PurchaseOrderMatching, (matching) => matching.exceptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_id' }) + matching?: PurchaseOrderMatching; + + @Index() + @Column({ name: 'matching_line_id', type: 'uuid', nullable: true }) + matchingLineId?: string; + + @ManyToOne(() => PurchaseMatchingLine, (line) => line.exceptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_line_id' }) + matchingLine?: PurchaseMatchingLine; + + @Index() + @Column({ name: 'exception_type', type: 'varchar', length: 50 }) + exceptionType: ExceptionType; + + @Column({ name: 'expected_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + expectedValue?: number; + + @Column({ name: 'actual_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + actualValue?: number; + + @Column({ name: 'variance_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + varianceValue?: number; + + @Column({ name: 'variance_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + variancePercent?: number; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ExceptionStatus; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt?: Date; + + @Column({ name: 'resolved_by', type: 'uuid', nullable: true }) + resolvedBy?: string; + + @Column({ name: 'resolution_notes', type: 'text', nullable: true }) + resolutionNotes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/purchase/entities/purchase-matching-line.entity.ts b/src/modules/purchase/entities/purchase-matching-line.entity.ts new file mode 100644 index 0000000..7074368 --- /dev/null +++ b/src/modules/purchase/entities/purchase-matching-line.entity.ts @@ -0,0 +1,98 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { PurchaseOrderMatching } from './purchase-order-matching.entity'; +import { MatchingException } from './matching-exception.entity'; + +export type MatchingLineStatus = 'pending' | 'partial' | 'matched' | 'mismatch'; + +@Entity({ name: 'purchase_matching_lines', schema: 'purchases' }) +export class PurchaseMatchingLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'matching_id', type: 'uuid' }) + matchingId: string; + + @ManyToOne(() => PurchaseOrderMatching, (matching) => matching.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_id' }) + matching: PurchaseOrderMatching; + + @Index() + @Column({ name: 'order_item_id', type: 'uuid' }) + orderItemId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Quantities + @Column({ name: 'qty_ordered', type: 'decimal', precision: 15, scale: 4 }) + qtyOrdered: number; + + @Column({ name: 'qty_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + qtyReceived: number; + + @Column({ name: 'qty_invoiced', type: 'decimal', precision: 15, scale: 4, default: 0 }) + qtyInvoiced: number; + + // Prices + @Column({ name: 'price_ordered', type: 'decimal', precision: 15, scale: 2 }) + priceOrdered: number; + + @Column({ name: 'price_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 }) + priceInvoiced: number; + + // Generated columns (read-only in TypeORM) + @Column({ + name: 'qty_variance', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + nullable: true, + }) + qtyVariance: number; + + @Column({ + name: 'invoice_qty_variance', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + nullable: true, + }) + invoiceQtyVariance: number; + + @Column({ + name: 'price_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + priceVariance: number; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: MatchingLineStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @OneToMany(() => MatchingException, (exception) => exception.matchingLine) + exceptions: MatchingException[]; +} diff --git a/src/modules/purchase/entities/purchase-order-matching.entity.ts b/src/modules/purchase/entities/purchase-order-matching.entity.ts new file mode 100644 index 0000000..4b59740 --- /dev/null +++ b/src/modules/purchase/entities/purchase-order-matching.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { PurchaseReceipt } from './purchase-receipt.entity'; +import { PurchaseMatchingLine } from './purchase-matching-line.entity'; +import { MatchingException } from './matching-exception.entity'; + +export type MatchingStatus = + | 'pending' + | 'partial_receipt' + | 'received' + | 'partial_invoice' + | 'matched' + | 'mismatch'; + +@Entity({ name: 'purchase_order_matching', schema: 'purchases' }) +export class PurchaseOrderMatching { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'purchase_order_id', type: 'uuid' }) + purchaseOrderId: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: MatchingStatus; + + @Column({ name: 'total_ordered', type: 'decimal', precision: 15, scale: 2 }) + totalOrdered: number; + + @Column({ name: 'total_received', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalReceived: number; + + @Column({ name: 'total_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalInvoiced: number; + + // Generated columns (read-only in TypeORM) + @Column({ + name: 'receipt_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + receiptVariance: number; + + @Column({ + name: 'invoice_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + invoiceVariance: number; + + @Index() + @Column({ name: 'last_receipt_id', type: 'uuid', nullable: true }) + lastReceiptId?: string; + + @ManyToOne(() => PurchaseReceipt, { nullable: true }) + @JoinColumn({ name: 'last_receipt_id' }) + lastReceipt?: PurchaseReceipt; + + @Column({ name: 'last_invoice_id', type: 'uuid', nullable: true }) + lastInvoiceId?: string; + + @Column({ name: 'matched_at', type: 'timestamptz', nullable: true }) + matchedAt?: Date; + + @Column({ name: 'matched_by', type: 'uuid', nullable: true }) + matchedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @OneToMany(() => PurchaseMatchingLine, (line) => line.matching) + lines: PurchaseMatchingLine[]; + + @OneToMany(() => MatchingException, (exception) => exception.matching) + exceptions: MatchingException[]; +} diff --git a/src/modules/purchase/entities/purchase-receipt-item.entity.ts b/src/modules/purchase/entities/purchase-receipt-item.entity.ts new file mode 100644 index 0000000..8cd3eeb --- /dev/null +++ b/src/modules/purchase/entities/purchase-receipt-item.entity.ts @@ -0,0 +1,57 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { PurchaseReceipt } from './purchase-receipt.entity'; + +@Entity({ name: 'purchase_receipt_items', schema: 'purchases' }) +export class PurchaseReceiptItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'receipt_id', type: 'uuid' }) + receiptId: string; + + @ManyToOne(() => PurchaseReceipt, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'receipt_id' }) + receipt: PurchaseReceipt; + + @Column({ name: 'order_item_id', type: 'uuid', nullable: true }) + orderItemId?: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'quantity_expected', type: 'decimal', precision: 15, scale: 4, nullable: true }) + quantityExpected?: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4 }) + quantityReceived: number; + + @Column({ name: 'quantity_rejected', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityRejected: number; + + @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; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'quality_status', type: 'varchar', length: 20, default: 'pending' }) + qualityStatus: 'pending' | 'approved' | 'rejected' | 'quarantine'; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/purchase/entities/purchase-receipt.entity.ts b/src/modules/purchase/entities/purchase-receipt.entity.ts new file mode 100644 index 0000000..03da5cc --- /dev/null +++ b/src/modules/purchase/entities/purchase-receipt.entity.ts @@ -0,0 +1,52 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'purchase_receipts', schema: 'purchases' }) +export class PurchaseReceipt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'receipt_number', type: 'varchar', length: 30 }) + receiptNumber: string; + + @Column({ name: 'receipt_date', type: 'date', default: () => 'CURRENT_DATE' }) + receiptDate: Date; + + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedBy?: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'supplier_delivery_note', type: 'varchar', length: 100, nullable: true }) + supplierDeliveryNote?: string; + + @Column({ name: 'supplier_invoice_number', type: 'varchar', length: 100, nullable: true }) + supplierInvoiceNumber?: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | '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; +} diff --git a/src/modules/quality/dto/checklist.dto.ts b/src/modules/quality/dto/checklist.dto.ts new file mode 100644 index 0000000..6b6adf7 --- /dev/null +++ b/src/modules/quality/dto/checklist.dto.ts @@ -0,0 +1,264 @@ +/** + * Checklist DTOs - Data Transfer Objects para Checklists de Calidad + * + * Representa templates de inspeccion de calidad. + * + * @module Quality (MAI-009) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsBoolean, + IsArray, + ValidateNested, + MaxLength, + MinLength, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Etapa de construccion del checklist + */ +export enum ChecklistStage { + FOUNDATION = 'foundation', + STRUCTURE = 'structure', + INSTALLATIONS = 'installations', + FINISHES = 'finishes', + DELIVERY = 'delivery', + CUSTOM = 'custom', +} + +/** + * DTO para crear un item de checklist + */ +export class CreateChecklistItemDto { + @IsNumber() + @Min(1) + sequenceNumber: number; + + @IsString() + @MaxLength(100) + category: string; + + @IsString() + @MinLength(5) + @MaxLength(2000) + description: string; + + @IsOptional() + @IsBoolean() + isCritical?: boolean; + + @IsOptional() + @IsBoolean() + requiresPhoto?: boolean; + + @IsOptional() + @IsString() + @MaxLength(2000) + acceptanceCriteria?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +/** + * DTO para actualizar un item de checklist + */ +export class UpdateChecklistItemDto { + @IsOptional() + @IsNumber() + @Min(1) + sequenceNumber?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + category?: string; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsBoolean() + isCritical?: boolean; + + @IsOptional() + @IsBoolean() + requiresPhoto?: boolean; + + @IsOptional() + @IsString() + @MaxLength(2000) + acceptanceCriteria?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +/** + * DTO para crear un nuevo checklist + */ +export class CreateChecklistDto { + @IsString() + @MinLength(2) + @MaxLength(30) + code: string; + + @IsString() + @MinLength(3) + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @IsEnum(ChecklistStage) + stage: ChecklistStage; + + @IsOptional() + @IsUUID() + prototypeId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateChecklistItemDto) + items?: CreateChecklistItemDto[]; +} + +/** + * DTO para actualizar un checklist existente + */ +export class UpdateChecklistDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(30) + code?: string; + + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsEnum(ChecklistStage) + stage?: ChecklistStage; + + @IsOptional() + @IsUUID() + prototypeId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +/** + * DTO para filtrar checklists en listados + */ +export class ChecklistFiltersDto { + @IsOptional() + @IsEnum(ChecklistStage) + stage?: ChecklistStage; + + @IsOptional() + @IsUUID() + prototypeId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un item de checklist + */ +export class ChecklistItemResponseDto { + id: string; + tenantId: string; + checklistId: string; + sequenceNumber: number; + category: string; + description: string; + isCritical: boolean; + requiresPhoto: boolean; + acceptanceCriteria?: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** + * DTO de respuesta para un checklist + */ +export class ChecklistResponseDto { + id: string; + tenantId: string; + code: string; + name: string; + description?: string; + stage: ChecklistStage; + prototypeId?: string; + isActive: boolean; + version: number; + itemsCount?: number; + items?: ChecklistItemResponseDto[]; + inspectionsCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; + deletedAt?: Date; + deletedById?: string; +} diff --git a/src/modules/quality/dto/corrective-action.dto.ts b/src/modules/quality/dto/corrective-action.dto.ts new file mode 100644 index 0000000..3a9f26a --- /dev/null +++ b/src/modules/quality/dto/corrective-action.dto.ts @@ -0,0 +1,218 @@ +/** + * Corrective Action DTOs - Data Transfer Objects para Acciones Correctivas (CAPA) + * + * Representa acciones correctivas y preventivas asociadas a no conformidades. + * + * @module Quality (MAI-009) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + IsBoolean, + MaxLength, + MinLength, + Min, +} from 'class-validator'; + +/** + * Tipo de accion correctiva + */ +export enum ActionType { + CORRECTIVE = 'corrective', + PREVENTIVE = 'preventive', + IMPROVEMENT = 'improvement', +} + +/** + * Estado de la accion correctiva + */ +export enum ActionStatus { + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + VERIFIED = 'verified', +} + +/** + * DTO para crear una nueva accion correctiva + */ +export class CreateCorrectiveActionDto { + @IsUUID() + nonConformityId: string; + + @IsEnum(ActionType) + actionType: ActionType; + + @IsString() + @MinLength(10) + @MaxLength(2000) + description: string; + + @IsUUID() + responsibleId: string; + + @IsDateString() + dueDate: string; +} + +/** + * DTO para actualizar una accion correctiva existente + */ +export class UpdateCorrectiveActionDto { + @IsOptional() + @IsEnum(ActionType) + actionType?: ActionType; + + @IsOptional() + @IsString() + @MinLength(10) + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsUUID() + responsibleId?: string; + + @IsOptional() + @IsDateString() + dueDate?: string; + + @IsOptional() + @IsEnum(ActionStatus) + status?: ActionStatus; +} + +/** + * DTO para completar una accion correctiva + */ +export class CompleteCorrectiveActionDto { + @IsString() + @MinLength(10) + @MaxLength(2000) + completionNotes: string; +} + +/** + * DTO para verificar efectividad de una accion correctiva + */ +export class VerifyCorrectiveActionDto { + @IsBoolean() + effectivenessVerified: boolean; + + @IsOptional() + @IsString() + @MaxLength(2000) + verificationNotes?: string; +} + +/** + * DTO para filtrar acciones correctivas en listados + */ +export class CorrectiveActionFiltersDto { + @IsOptional() + @IsUUID() + nonConformityId?: string; + + @IsOptional() + @IsUUID() + responsibleId?: string; + + @IsOptional() + @IsEnum(ActionType) + actionType?: ActionType; + + @IsOptional() + @IsEnum(ActionStatus) + status?: ActionStatus; + + @IsOptional() + @IsBoolean() + effectivenessVerified?: boolean; + + @IsOptional() + @IsDateString() + dueDateFrom?: string; + + @IsOptional() + @IsDateString() + dueDateTo?: string; + + @IsOptional() + @IsDateString() + createdFrom?: string; + + @IsOptional() + @IsDateString() + createdTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una accion correctiva + */ +export class CorrectiveActionResponseDto { + id: string; + tenantId: string; + nonConformityId: string; + nonConformity?: { + id: string; + ncNumber: string; + category: string; + severity: string; + }; + actionType: ActionType; + description: string; + responsibleId: string; + responsible?: { + id: string; + firstName: string; + lastName: string; + }; + dueDate: Date; + status: ActionStatus; + completedAt?: Date; + completionNotes?: string; + verifiedAt?: Date; + verifiedById?: string; + verifiedBy?: { + id: string; + firstName: string; + lastName: string; + }; + effectivenessVerified: boolean; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/quality/dto/index.ts b/src/modules/quality/dto/index.ts new file mode 100644 index 0000000..078a349 --- /dev/null +++ b/src/modules/quality/dto/index.ts @@ -0,0 +1,79 @@ +/** + * Quality DTOs Index + * Barrel file exporting all quality module DTOs and Enums. + * + * @module Quality (MAI-009) + */ + +// ============================================================================ +// INSPECTION DTOs +// ============================================================================ +export { + // Enums + InspectionStatus, + // DTOs + CreateInspectionDto, + UpdateInspectionDto, + ApproveInspectionDto, + RejectInspectionDto, + InspectionFiltersDto, + InspectionResponseDto, +} from './inspection.dto'; + +// ============================================================================ +// CHECKLIST DTOs +// ============================================================================ +export { + // Enums + ChecklistStage, + // DTOs + CreateChecklistItemDto, + UpdateChecklistItemDto, + CreateChecklistDto, + UpdateChecklistDto, + ChecklistFiltersDto, + ChecklistItemResponseDto, + ChecklistResponseDto, +} from './checklist.dto'; + +// ============================================================================ +// TICKET / NON-CONFORMITY DTOs +// ============================================================================ +export { + // Enums + NCSeverity, + NCStatus, + TicketPriority, + TicketStatus, + TicketCategory, + // Non-Conformity DTOs + CreateNonConformityDto, + UpdateNonConformityDto, + CloseNonConformityDto, + VerifyNonConformityDto, + NonConformityFiltersDto, + NonConformityResponseDto, + // Post-Sale Ticket DTOs + CreateTicketDto, + UpdateTicketDto, + ResolveTicketDto, + RecordSatisfactionDto, + TicketFiltersDto, + TicketResponseDto, +} from './ticket.dto'; + +// ============================================================================ +// CORRECTIVE ACTION DTOs +// ============================================================================ +export { + // Enums + ActionType, + ActionStatus, + // DTOs + CreateCorrectiveActionDto, + UpdateCorrectiveActionDto, + CompleteCorrectiveActionDto, + VerifyCorrectiveActionDto, + CorrectiveActionFiltersDto, + CorrectiveActionResponseDto, +} from './corrective-action.dto'; diff --git a/src/modules/quality/dto/inspection.dto.ts b/src/modules/quality/dto/inspection.dto.ts new file mode 100644 index 0000000..3d9b824 --- /dev/null +++ b/src/modules/quality/dto/inspection.dto.ts @@ -0,0 +1,240 @@ +/** + * Inspection DTOs - Data Transfer Objects para Inspecciones de Calidad + * + * Representa una inspeccion de calidad realizada en obra. + * + * @module Quality (MAI-009) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MaxLength, + Min, + Max, +} from 'class-validator'; + +/** + * Estado de la inspeccion + */ +export enum InspectionStatus { + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + APPROVED = 'approved', + REJECTED = 'rejected', +} + +/** + * DTO para crear una nueva inspeccion + */ +export class CreateInspectionDto { + @IsUUID() + checklistId: string; + + @IsUUID() + loteId: string; + + @IsDateString() + inspectionDate: string; + + @IsUUID() + inspectorId: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; +} + +/** + * DTO para actualizar una inspeccion existente + */ +export class UpdateInspectionDto { + @IsOptional() + @IsUUID() + checklistId?: string; + + @IsOptional() + @IsUUID() + loteId?: string; + + @IsOptional() + @IsDateString() + inspectionDate?: string; + + @IsOptional() + @IsUUID() + inspectorId?: string; + + @IsOptional() + @IsEnum(InspectionStatus) + status?: InspectionStatus; + + @IsOptional() + @IsNumber() + @Min(0) + totalItems?: number; + + @IsOptional() + @IsNumber() + @Min(0) + passedItems?: number; + + @IsOptional() + @IsNumber() + @Min(0) + failedItems?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + passRate?: number; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + rejectionReason?: string; +} + +/** + * DTO para aprobar una inspeccion + */ +export class ApproveInspectionDto { + @IsOptional() + @IsString() + @MaxLength(500) + notes?: string; +} + +/** + * DTO para rechazar una inspeccion + */ +export class RejectInspectionDto { + @IsString() + @MaxLength(2000) + rejectionReason: string; +} + +/** + * DTO para filtrar inspecciones en listados + */ +export class InspectionFiltersDto { + @IsOptional() + @IsEnum(InspectionStatus) + status?: InspectionStatus; + + @IsOptional() + @IsUUID() + inspectorId?: string; + + @IsOptional() + @IsUUID() + checklistId?: string; + + @IsOptional() + @IsUUID() + loteId?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + minPassRate?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + maxPassRate?: number; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una inspeccion + */ +export class InspectionResponseDto { + id: string; + tenantId: string; + checklistId: string; + checklist?: { + id: string; + code: string; + name: string; + stage: string; + }; + loteId: string; + inspectionNumber: string; + inspectionDate: Date; + inspectorId: string; + inspector?: { + id: string; + firstName: string; + lastName: string; + }; + status: InspectionStatus; + totalItems: number; + passedItems: number; + failedItems: number; + passRate?: number; + completedAt?: Date; + approvedById?: string; + approvedBy?: { + id: string; + firstName: string; + lastName: string; + }; + approvedAt?: Date; + notes?: string; + rejectionReason?: string; + resultsCount?: number; + nonConformitiesCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/quality/dto/ticket.dto.ts b/src/modules/quality/dto/ticket.dto.ts new file mode 100644 index 0000000..70cd16c --- /dev/null +++ b/src/modules/quality/dto/ticket.dto.ts @@ -0,0 +1,543 @@ +/** + * Ticket DTOs - Data Transfer Objects para Tickets de No Conformidad + * + * Representa no conformidades detectadas en inspecciones de calidad. + * + * @module Quality (MAI-009) + */ + +import { + IsString, + IsUUID, + IsOptional, + IsEnum, + IsNumber, + IsDateString, + MaxLength, + MinLength, + Min, +} from 'class-validator'; + +/** + * Severidad de la no conformidad + */ +export enum NCSeverity { + MINOR = 'minor', + MAJOR = 'major', + CRITICAL = 'critical', +} + +/** + * Estado de la no conformidad + */ +export enum NCStatus { + OPEN = 'open', + IN_PROGRESS = 'in_progress', + CLOSED = 'closed', + VERIFIED = 'verified', +} + +/** + * Prioridad del ticket postventa + */ +export enum TicketPriority { + URGENT = 'urgent', + HIGH = 'high', + MEDIUM = 'medium', + LOW = 'low', +} + +/** + * Estado del ticket postventa + */ +export enum TicketStatus { + CREATED = 'created', + ASSIGNED = 'assigned', + IN_PROGRESS = 'in_progress', + RESOLVED = 'resolved', + CLOSED = 'closed', + CANCELLED = 'cancelled', +} + +/** + * Categoria del ticket postventa + */ +export enum TicketCategory { + PLUMBING = 'plumbing', + ELECTRICAL = 'electrical', + FINISHES = 'finishes', + CARPENTRY = 'carpentry', + STRUCTURAL = 'structural', + OTHER = 'other', +} + +// ============================================================================ +// NON-CONFORMITY DTOs (Internal Quality Issues) +// ============================================================================ + +/** + * DTO para crear una nueva no conformidad + */ +export class CreateNonConformityDto { + @IsOptional() + @IsUUID() + inspectionId?: string; + + @IsUUID() + loteId: string; + + @IsDateString() + detectionDate: string; + + @IsString() + @MaxLength(100) + category: string; + + @IsEnum(NCSeverity) + severity: NCSeverity; + + @IsString() + @MinLength(10) + @MaxLength(2000) + description: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + rootCause?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + photoUrl?: string; + + @IsOptional() + @IsUUID() + contractorId?: string; + + @IsOptional() + @IsDateString() + dueDate?: string; +} + +/** + * DTO para actualizar una no conformidad existente + */ +export class UpdateNonConformityDto { + @IsOptional() + @IsUUID() + loteId?: string; + + @IsOptional() + @IsDateString() + detectionDate?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + category?: string; + + @IsOptional() + @IsEnum(NCSeverity) + severity?: NCSeverity; + + @IsOptional() + @IsString() + @MinLength(10) + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + rootCause?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + photoUrl?: string; + + @IsOptional() + @IsUUID() + contractorId?: string; + + @IsOptional() + @IsEnum(NCStatus) + status?: NCStatus; + + @IsOptional() + @IsDateString() + dueDate?: string; +} + +/** + * DTO para cerrar una no conformidad + */ +export class CloseNonConformityDto { + @IsOptional() + @IsString() + @MaxLength(500) + closurePhotoUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + closureNotes?: string; +} + +/** + * DTO para verificar cierre de una no conformidad + */ +export class VerifyNonConformityDto { + @IsOptional() + @IsString() + @MaxLength(2000) + verificationNotes?: string; +} + +/** + * DTO para filtrar no conformidades en listados + */ +export class NonConformityFiltersDto { + @IsOptional() + @IsUUID() + inspectionId?: string; + + @IsOptional() + @IsUUID() + loteId?: string; + + @IsOptional() + @IsUUID() + contractorId?: string; + + @IsOptional() + @IsEnum(NCSeverity) + severity?: NCSeverity; + + @IsOptional() + @IsEnum(NCStatus) + status?: NCStatus; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsDateString() + detectionDateFrom?: string; + + @IsOptional() + @IsDateString() + detectionDateTo?: string; + + @IsOptional() + @IsDateString() + dueDateFrom?: string; + + @IsOptional() + @IsDateString() + dueDateTo?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para una no conformidad + */ +export class NonConformityResponseDto { + id: string; + tenantId: string; + inspectionId?: string; + inspection?: { + id: string; + inspectionNumber: string; + inspectionDate: Date; + }; + loteId: string; + ncNumber: string; + detectionDate: Date; + category: string; + severity: NCSeverity; + description: string; + rootCause?: string; + photoUrl?: string; + contractorId?: string; + status: NCStatus; + dueDate?: Date; + closedAt?: Date; + closedById?: string; + closedBy?: { + id: string; + firstName: string; + lastName: string; + }; + verifiedAt?: Date; + verifiedById?: string; + verifiedBy?: { + id: string; + firstName: string; + lastName: string; + }; + closurePhotoUrl?: string; + closureNotes?: string; + correctiveActionsCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} + +// ============================================================================ +// POST-SALE TICKET DTOs (External Customer Issues) +// ============================================================================ + +/** + * DTO para crear un nuevo ticket postventa + */ +export class CreateTicketDto { + @IsUUID() + loteId: string; + + @IsOptional() + @IsUUID() + derechohabienteId?: string; + + @IsEnum(TicketCategory) + category: TicketCategory; + + @IsEnum(TicketPriority) + priority: TicketPriority; + + @IsString() + @MinLength(5) + @MaxLength(255) + title: string; + + @IsString() + @MinLength(10) + @MaxLength(2000) + description: string; + + @IsOptional() + @IsString() + @MaxLength(500) + photoUrl?: string; + + @IsNumber() + @Min(1) + slaHours: number; + + @IsOptional() + @IsString() + @MaxLength(200) + contactName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + contactPhone?: string; +} + +/** + * DTO para actualizar un ticket postventa existente + */ +export class UpdateTicketDto { + @IsOptional() + @IsEnum(TicketCategory) + category?: TicketCategory; + + @IsOptional() + @IsEnum(TicketPriority) + priority?: TicketPriority; + + @IsOptional() + @IsString() + @MinLength(5) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsString() + @MinLength(10) + @MaxLength(2000) + description?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + photoUrl?: string; + + @IsOptional() + @IsEnum(TicketStatus) + status?: TicketStatus; + + @IsOptional() + @IsString() + @MaxLength(200) + contactName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + contactPhone?: string; +} + +/** + * DTO para resolver un ticket postventa + */ +export class ResolveTicketDto { + @IsString() + @MinLength(10) + @MaxLength(2000) + resolutionNotes: string; + + @IsOptional() + @IsString() + @MaxLength(500) + resolutionPhotoUrl?: string; +} + +/** + * DTO para registrar satisfaccion del cliente + */ +export class RecordSatisfactionDto { + @IsNumber() + @Min(1) + satisfactionRating: number; + + @IsOptional() + @IsString() + @MaxLength(2000) + satisfactionComment?: string; +} + +/** + * DTO para filtrar tickets postventa en listados + */ +export class TicketFiltersDto { + @IsOptional() + @IsUUID() + loteId?: string; + + @IsOptional() + @IsUUID() + derechohabienteId?: string; + + @IsOptional() + @IsEnum(TicketCategory) + category?: TicketCategory; + + @IsOptional() + @IsEnum(TicketPriority) + priority?: TicketPriority; + + @IsOptional() + @IsEnum(TicketStatus) + status?: TicketStatus; + + @IsOptional() + @IsDateString() + createdFrom?: string; + + @IsOptional() + @IsDateString() + createdTo?: string; + + @IsOptional() + @IsDateString() + slaDueFrom?: string; + + @IsOptional() + @IsDateString() + slaDueTo?: string; + + @IsOptional() + slaBreached?: boolean; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * DTO de respuesta para un ticket postventa + */ +export class TicketResponseDto { + id: string; + tenantId: string; + loteId: string; + derechohabienteId?: string; + ticketNumber: string; + category: TicketCategory; + priority: TicketPriority; + title: string; + description: string; + photoUrl?: string; + status: TicketStatus; + slaHours: number; + slaDueAt: Date; + slaBreached: boolean; + assignedAt?: Date; + resolvedAt?: Date; + closedAt?: Date; + resolutionNotes?: string; + resolutionPhotoUrl?: string; + satisfactionRating?: number; + satisfactionComment?: string; + contactName?: string; + contactPhone?: string; + assignmentsCount?: number; + createdAt: Date; + createdById?: string; + createdBy?: { + id: string; + firstName: string; + lastName: string; + }; + updatedAt: Date; + updatedById?: string; +} diff --git a/src/modules/reports/controllers/earned-value.controller.ts b/src/modules/reports/controllers/earned-value.controller.ts new file mode 100644 index 0000000..bdc71d9 --- /dev/null +++ b/src/modules/reports/controllers/earned-value.controller.ts @@ -0,0 +1,280 @@ +/** + * EarnedValueController - Endpoints para Earned Value Management + * + * Expone mĆ©tricas de Curva S, SPI, CPI para proyectos de construcción. + * + * @module Reports (Construction-specific) + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { EarnedValueService } from '../services/earned-value.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Create the earned value controller with routes + */ +export function createEarnedValueController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new EarnedValueService(dataSource); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper to create service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /earned-value/metrics/:fraccionamientoId + * Get current EVM metrics for a project + */ + router.get( + '/metrics/:fraccionamientoId', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { fraccionamientoId } = req.params; + const asOfDate = req.query.asOfDate + ? new Date(req.query.asOfDate as string) + : undefined; + + const metrics = await service.getCurrentMetrics(ctx, fraccionamientoId, asOfDate); + res.json(metrics); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /earned-value/curva-s/:fraccionamientoId + * Get Curva S data for a project + */ + router.get( + '/curva-s/:fraccionamientoId', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { fraccionamientoId } = req.params; + const filters = { + dateFrom: req.query.dateFrom + ? new Date(req.query.dateFrom as string) + : undefined, + dateTo: req.query.dateTo + ? new Date(req.query.dateTo as string) + : undefined, + periodType: (req.query.periodType as 'daily' | 'weekly' | 'monthly') || 'weekly', + }; + + const curvaS = await service.getCurvaS(ctx, fraccionamientoId, filters); + res.json({ + fraccionamientoId, + periodType: filters.periodType, + dataPoints: curvaS.length, + data: curvaS, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /earned-value/spi/:fraccionamientoId + * Get Schedule Performance Index for a project + */ + router.get( + '/spi/:fraccionamientoId', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { fraccionamientoId } = req.params; + const asOfDate = req.query.asOfDate + ? new Date(req.query.asOfDate as string) + : undefined; + + const spi = await service.calculateSPI(ctx, fraccionamientoId, asOfDate); + res.json({ + fraccionamientoId, + asOfDate: asOfDate || new Date(), + ...spi, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /earned-value/cpi/:fraccionamientoId + * Get Cost Performance Index for a project + */ + router.get( + '/cpi/:fraccionamientoId', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { fraccionamientoId } = req.params; + const asOfDate = req.query.asOfDate + ? new Date(req.query.asOfDate as string) + : undefined; + + const cpi = await service.calculateCPI(ctx, fraccionamientoId, asOfDate); + res.json({ + fraccionamientoId, + asOfDate: asOfDate || new Date(), + ...cpi, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /earned-value/portfolio + * Get EVM summary for all active projects + */ + router.get( + '/portfolio', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const fraccionamientoIds = req.query.fraccionamientoIds + ? (req.query.fraccionamientoIds as string).split(',') + : undefined; + + const summary = await service.getPortfolioSummary(ctx, fraccionamientoIds); + res.json(summary); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /earned-value/trend/:fraccionamientoId + * Get EVM trend over time for a project + */ + router.get( + '/trend/:fraccionamientoId', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { fraccionamientoId } = req.params; + const periodCount = req.query.periods + ? parseInt(req.query.periods as string, 10) + : 12; + const periodType = + (req.query.periodType as 'weekly' | 'monthly') || 'monthly'; + + const trend = await service.getEvmTrend(ctx, fraccionamientoId, periodCount, periodType); + res.json({ + fraccionamientoId, + periodType, + periodCount, + trend, + }); + } catch (error) { + next(error); + } + } + ); + + /** + * POST /earned-value/snapshot/:fraccionamientoId + * Save current EVM metrics as KPI snapshot + */ + router.post( + '/snapshot/:fraccionamientoId', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { fraccionamientoId } = req.params; + const asOfDate = req.body.asOfDate + ? new Date(req.body.asOfDate) + : undefined; + + await service.saveEvmSnapshot(ctx, fraccionamientoId, asOfDate); + res.status(201).json({ + message: 'EVM snapshot saved successfully', + fraccionamientoId, + snapshotDate: asOfDate || new Date(), + }); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /earned-value/dashboard/:fraccionamientoId + * Get comprehensive EVM dashboard data + */ + router.get( + '/dashboard/:fraccionamientoId', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { fraccionamientoId } = req.params; + + // Fetch all data in parallel + const [metrics, curvaS, trend] = await Promise.all([ + service.getCurrentMetrics(ctx, fraccionamientoId), + service.getCurvaS(ctx, fraccionamientoId, { periodType: 'weekly' }), + service.getEvmTrend(ctx, fraccionamientoId, 6, 'monthly'), + ]); + + res.json({ + fraccionamientoId, + currentMetrics: metrics, + curvaS, + trend, + summary: { + percentComplete: metrics.percentComplete, + spi: metrics.spi, + cpi: metrics.cpi, + status: metrics.status, + projectedCompletion: metrics.eac, + variance: metrics.vac, + }, + }); + } catch (error) { + next(error); + } + } + ); + + return router; +} diff --git a/src/modules/reports/controllers/index.ts b/src/modules/reports/controllers/index.ts index e2047cd..42071d2 100644 --- a/src/modules/reports/controllers/index.ts +++ b/src/modules/reports/controllers/index.ts @@ -6,3 +6,4 @@ export { createReportController } from './report.controller'; export { createDashboardController } from './dashboard.controller'; export { createKpiController } from './kpi.controller'; +export { createEarnedValueController } from './earned-value.controller'; diff --git a/src/modules/reports/entities/custom-report.entity.ts b/src/modules/reports/entities/custom-report.entity.ts new file mode 100644 index 0000000..9626d81 --- /dev/null +++ b/src/modules/reports/entities/custom-report.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Report } from './report.entity'; + +/** + * Custom Report Entity (schema: reports.custom_reports) + * + * User-personalized reports based on existing definitions. + * Stores custom columns, filters, grouping, and sorting preferences. + */ +@Entity({ name: 'custom_reports', schema: 'reports' }) +export class CustomReport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'owner_id', type: 'uuid' }) + ownerId: string; + + @Column({ name: 'base_definition_id', type: 'uuid', nullable: true }) + baseDefinitionId: string | null; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'custom_columns', type: 'jsonb', default: '[]' }) + customColumns: Record[]; + + @Column({ name: 'custom_filters', type: 'jsonb', default: '[]' }) + customFilters: Record[]; + + @Column({ name: 'custom_grouping', type: 'jsonb', default: '[]' }) + customGrouping: Record[]; + + @Column({ name: 'custom_sorting', type: 'jsonb', default: '[]' }) + customSorting: Record[]; + + @Index() + @Column({ name: 'is_favorite', type: 'boolean', default: false }) + isFavorite: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Report, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'base_definition_id' }) + baseDefinition: Report | null; +} diff --git a/src/modules/reports/entities/data-model-entity.entity.ts b/src/modules/reports/entities/data-model-entity.entity.ts new file mode 100644 index 0000000..913eb76 --- /dev/null +++ b/src/modules/reports/entities/data-model-entity.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { DataModelField } from './data-model-field.entity'; +import { DataModelRelationship } from './data-model-relationship.entity'; + +/** + * Data Model Entity (schema: reports.data_model_entities) + * + * Represents database tables/entities available for report building. + * Used by the report builder UI to construct dynamic queries. + */ +@Entity({ name: 'data_model_entities', schema: 'reports' }) +@Index(['name'], { unique: true }) +export class DataModelEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 255 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Index() + @Column({ name: 'schema_name', type: 'varchar', length: 100 }) + schemaName: string; + + @Column({ name: 'table_name', type: 'varchar', length: 100 }) + tableName: string; + + @Column({ name: 'primary_key_column', type: 'varchar', length: 100, default: 'id' }) + primaryKeyColumn: string; + + @Column({ name: 'tenant_column', type: 'varchar', length: 100, nullable: true, default: 'tenant_id' }) + tenantColumn: string | null; + + @Column({ name: 'is_multi_tenant', type: 'boolean', default: true }) + isMultiTenant: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @OneToMany(() => DataModelField, (field) => field.entity) + fields: DataModelField[]; + + @OneToMany(() => DataModelRelationship, (rel) => rel.sourceEntity) + sourceRelationships: DataModelRelationship[]; + + @OneToMany(() => DataModelRelationship, (rel) => rel.targetEntity) + targetRelationships: DataModelRelationship[]; +} diff --git a/src/modules/reports/entities/data-model-field.entity.ts b/src/modules/reports/entities/data-model-field.entity.ts new file mode 100644 index 0000000..f81c988 --- /dev/null +++ b/src/modules/reports/entities/data-model-field.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { DataModelEntity } from './data-model-entity.entity'; + +/** + * Data Model Field Entity (schema: reports.data_model_fields) + * + * Represents columns/fields within a data model entity. + * Includes metadata for filtering, sorting, grouping, and formatting. + */ +@Entity({ name: 'data_model_fields', schema: 'reports' }) +@Index(['entityId', 'name'], { unique: true }) +export class DataModelField { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'entity_id', type: 'uuid' }) + entityId: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 255 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'data_type', type: 'varchar', length: 50 }) + dataType: string; + + @Column({ name: 'is_nullable', type: 'boolean', default: true }) + isNullable: boolean; + + @Column({ name: 'is_filterable', type: 'boolean', default: true }) + isFilterable: boolean; + + @Column({ name: 'is_sortable', type: 'boolean', default: true }) + isSortable: boolean; + + @Column({ name: 'is_groupable', type: 'boolean', default: false }) + isGroupable: boolean; + + @Column({ name: 'is_aggregatable', type: 'boolean', default: false }) + isAggregatable: boolean; + + @Column({ name: 'aggregation_functions', type: 'text', array: true, default: '{}' }) + aggregationFunctions: string[]; + + @Column({ name: 'format_pattern', type: 'varchar', length: 100, nullable: true }) + formatPattern: string | null; + + @Column({ name: 'display_format', type: 'varchar', length: 50, nullable: true }) + displayFormat: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => DataModelEntity, (entity) => entity.fields, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'entity_id' }) + entity: DataModelEntity; +} diff --git a/src/modules/reports/entities/data-model-relationship.entity.ts b/src/modules/reports/entities/data-model-relationship.entity.ts new file mode 100644 index 0000000..cca04dd --- /dev/null +++ b/src/modules/reports/entities/data-model-relationship.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { DataModelEntity } from './data-model-entity.entity'; + +/** + * Relationship type enum + */ +export enum RelationshipType { + ONE_TO_ONE = 'one_to_one', + ONE_TO_MANY = 'one_to_many', + MANY_TO_ONE = 'many_to_one', + MANY_TO_MANY = 'many_to_many', +} + +/** + * Data Model Relationship Entity (schema: reports.data_model_relationships) + * + * Defines relationships between data model entities for join operations + * in the report builder. + */ +@Entity({ name: 'data_model_relationships', schema: 'reports' }) +@Index(['sourceEntityId', 'targetEntityId', 'name'], { unique: true }) +export class DataModelRelationship { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'source_entity_id', type: 'uuid' }) + sourceEntityId: string; + + @Index() + @Column({ name: 'target_entity_id', type: 'uuid' }) + targetEntityId: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ + name: 'relationship_type', + type: 'varchar', + length: 20, + }) + relationshipType: RelationshipType; + + @Column({ name: 'source_column', type: 'varchar', length: 100 }) + sourceColumn: string; + + @Column({ name: 'target_column', type: 'varchar', length: 100 }) + targetColumn: string; + + @Column({ name: 'join_condition', type: 'text', nullable: true }) + joinCondition: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => DataModelEntity, (entity) => entity.sourceRelationships, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'source_entity_id' }) + sourceEntity: DataModelEntity; + + @ManyToOne(() => DataModelEntity, (entity) => entity.targetRelationships, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'target_entity_id' }) + targetEntity: DataModelEntity; +} diff --git a/src/modules/reports/entities/index.ts b/src/modules/reports/entities/index.ts index baef642..c3accac 100644 --- a/src/modules/reports/entities/index.ts +++ b/src/modules/reports/entities/index.ts @@ -3,8 +3,19 @@ * MAI-006: Reportes y Analytics */ +// Existing construction entities export * from './report.entity'; export * from './report-execution.entity'; export * from './dashboard.entity'; export * from './dashboard-widget.entity'; export * from './kpi-snapshot.entity'; + +// Core report entities (from erp-core) +export * from './report-schedule.entity'; +export * from './report-recipient.entity'; +export * from './schedule-execution.entity'; +export * from './widget-query.entity'; +export * from './custom-report.entity'; +export * from './data-model-entity.entity'; +export * from './data-model-field.entity'; +export * from './data-model-relationship.entity'; diff --git a/src/modules/reports/entities/report-recipient.entity.ts b/src/modules/reports/entities/report-recipient.entity.ts new file mode 100644 index 0000000..ff7d96f --- /dev/null +++ b/src/modules/reports/entities/report-recipient.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ReportSchedule } from './report-schedule.entity'; + +/** + * Report Recipient Entity (schema: reports.report_recipients) + * + * Stores recipients for scheduled reports. Can reference internal users + * or external email addresses. + */ +@Entity({ name: 'report_recipients', schema: 'reports' }) +export class ReportRecipient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'schedule_id', type: 'uuid' }) + scheduleId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; + + @Column({ name: 'email', type: 'varchar', length: 255, nullable: true }) + email: string | null; + + @Column({ name: 'name', type: 'varchar', length: 255, nullable: true }) + name: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => ReportSchedule, (schedule) => schedule.recipients, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'schedule_id' }) + schedule: ReportSchedule; +} diff --git a/src/modules/reports/entities/report-schedule.entity.ts b/src/modules/reports/entities/report-schedule.entity.ts new file mode 100644 index 0000000..8c20a20 --- /dev/null +++ b/src/modules/reports/entities/report-schedule.entity.ts @@ -0,0 +1,142 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Report } from './report.entity'; +import { ReportRecipient } from './report-recipient.entity'; +import { ScheduleExecution } from './schedule-execution.entity'; + +/** + * Delivery method enum + */ +export enum DeliveryMethod { + NONE = 'none', + EMAIL = 'email', + STORAGE = 'storage', + WEBHOOK = 'webhook', +} + +/** + * Schedule execution status enum + */ +export enum ScheduleExecutionStatus { + PENDING = 'pending', + RUNNING = 'running', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +/** + * Schedule export format enum + */ +export enum ScheduleExportFormat { + PDF = 'pdf', + EXCEL = 'excel', + CSV = 'csv', + JSON = 'json', + HTML = 'html', +} + +/** + * Report Schedule Entity (schema: reports.report_schedules) + * + * Configures scheduled report execution with cron expressions, + * delivery methods, and default parameters. + */ +@Entity({ name: 'report_schedules', schema: 'reports' }) +export class ReportSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'report_definition_id', type: 'uuid' }) + reportDefinitionId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'cron_expression', type: 'varchar', length: 100 }) + cronExpression: string; + + @Column({ name: 'timezone', type: 'varchar', length: 100, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ name: 'parameters', type: 'jsonb', default: '{}' }) + parameters: Record; + + @Column({ + name: 'delivery_method', + type: 'enum', + enum: DeliveryMethod, + enumName: 'delivery_method', + default: DeliveryMethod.EMAIL, + }) + deliveryMethod: DeliveryMethod; + + @Column({ name: 'delivery_config', type: 'jsonb', default: '{}' }) + deliveryConfig: Record; + + @Column({ + name: 'export_format', + type: 'enum', + enum: ScheduleExportFormat, + enumName: 'schedule_export_format', + default: ScheduleExportFormat.PDF, + }) + exportFormat: ScheduleExportFormat; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_run_at', type: 'timestamptz', nullable: true }) + lastRunAt: Date | null; + + @Column({ + name: 'last_run_status', + type: 'enum', + enum: ScheduleExecutionStatus, + enumName: 'schedule_execution_status', + nullable: true, + }) + lastRunStatus: ScheduleExecutionStatus | null; + + @Index() + @Column({ name: 'next_run_at', type: 'timestamptz', nullable: true }) + nextRunAt: Date | null; + + @Column({ name: 'run_count', type: 'int', default: 0 }) + runCount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + // Relations + @ManyToOne(() => Report, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'report_definition_id' }) + reportDefinition: Report; + + @OneToMany(() => ReportRecipient, (recipient) => recipient.schedule) + recipients: ReportRecipient[]; + + @OneToMany(() => ScheduleExecution, (scheduleExec) => scheduleExec.schedule) + scheduleExecutions: ScheduleExecution[]; +} diff --git a/src/modules/reports/entities/schedule-execution.entity.ts b/src/modules/reports/entities/schedule-execution.entity.ts new file mode 100644 index 0000000..4d37a2f --- /dev/null +++ b/src/modules/reports/entities/schedule-execution.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ReportSchedule, ScheduleExecutionStatus } from './report-schedule.entity'; +import { ReportExecution } from './report-execution.entity'; + +/** + * Schedule Execution Entity (schema: reports.schedule_executions) + * + * Links scheduled reports to their actual executions, + * tracking delivery status and recipient notifications. + */ +@Entity({ name: 'schedule_executions', schema: 'reports' }) +export class ScheduleExecution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'schedule_id', type: 'uuid' }) + scheduleId: string; + + @Column({ name: 'execution_id', type: 'uuid', nullable: true }) + executionId: string | null; + + @Column({ + name: 'status', + type: 'enum', + enum: ScheduleExecutionStatus, + enumName: 'schedule_execution_status', + }) + status: ScheduleExecutionStatus; + + @Column({ name: 'recipients_notified', type: 'int', default: 0 }) + recipientsNotified: number; + + @Column({ name: 'delivery_status', type: 'jsonb', default: '{}' }) + deliveryStatus: Record; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string | null; + + @Index() + @Column({ name: 'executed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + executedAt: Date; + + // Relations + @ManyToOne(() => ReportSchedule, (schedule) => schedule.scheduleExecutions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'schedule_id' }) + schedule: ReportSchedule; + + @ManyToOne(() => ReportExecution, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'execution_id' }) + execution: ReportExecution | null; +} diff --git a/src/modules/reports/entities/widget-query.entity.ts b/src/modules/reports/entities/widget-query.entity.ts new file mode 100644 index 0000000..2f96d98 --- /dev/null +++ b/src/modules/reports/entities/widget-query.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { DashboardWidget } from './dashboard-widget.entity'; + +/** + * Widget Query Entity (schema: reports.widget_queries) + * + * Data source queries for dashboard widgets. + * Supports both raw SQL and function-based queries with caching. + */ +@Entity({ name: 'widget_queries', schema: 'reports' }) +export class WidgetQuery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'widget_id', type: 'uuid' }) + widgetId: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'query_text', type: 'text', nullable: true }) + queryText: string | null; + + @Column({ name: 'query_function', type: 'varchar', length: 255, nullable: true }) + queryFunction: string | null; + + @Column({ name: 'parameters', type: 'jsonb', default: '{}' }) + parameters: Record; + + @Column({ name: 'result_mapping', type: 'jsonb', default: '{}' }) + resultMapping: Record; + + @Column({ name: 'cache_ttl_seconds', type: 'int', nullable: true, default: 300 }) + cacheTtlSeconds: number | null; + + @Column({ name: 'last_cached_at', type: 'timestamptz', nullable: true }) + lastCachedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => DashboardWidget, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'widget_id' }) + widget: DashboardWidget; +} diff --git a/src/modules/reports/services/dashboard.service.ts b/src/modules/reports/services/dashboard.service.ts index 82d0d54..11aa97c 100644 --- a/src/modules/reports/services/dashboard.service.ts +++ b/src/modules/reports/services/dashboard.service.ts @@ -179,12 +179,10 @@ export class DashboardService { return { data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, + total, + page, + limit, + totalPages: Math.ceil(total / limit), }; } diff --git a/src/modules/reports/services/earned-value.service.ts b/src/modules/reports/services/earned-value.service.ts new file mode 100644 index 0000000..6af9479 --- /dev/null +++ b/src/modules/reports/services/earned-value.service.ts @@ -0,0 +1,621 @@ +/** + * EarnedValueService - Earned Value Management (EVM) para Construcción + * + * Calcula indicadores de Curva S, SPI, CPI y proyecciones. + * Basado en PMI PMBOK Earned Value Management. + * + * @module Reports (Construction-specific) + */ + +import { DataSource, Repository, LessThanOrEqual } from 'typeorm'; +import { Estimacion } from '../../estimates/entities/estimacion.entity'; +import { KpiSnapshot, KpiCategory } from '../entities/kpi-snapshot.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Earned Value metrics at a point in time + */ +export interface EarnedValueMetrics { + date: Date; + /** Budget at Completion - total presupuesto */ + bac: number; + /** Planned Value - valor planeado acumulado */ + pv: number; + /** Earned Value - valor ganado (% avance * BAC) */ + ev: number; + /** Actual Cost - costo real acumulado */ + ac: number; + /** Schedule Performance Index = EV / PV */ + spi: number; + /** Cost Performance Index = EV / AC */ + cpi: number; + /** Schedule Variance = EV - PV */ + sv: number; + /** Cost Variance = EV - AC */ + cv: number; + /** Estimate at Completion = BAC / CPI */ + eac: number; + /** Estimate to Complete = EAC - AC */ + etc: number; + /** Variance at Completion = BAC - EAC */ + vac: number; + /** To Complete Performance Index = (BAC - EV) / (BAC - AC) */ + tcpi: number; + /** Percent complete = EV / BAC */ + percentComplete: number; + /** Status color based on SPI and CPI thresholds */ + status: 'green' | 'yellow' | 'red'; +} + +/** + * Curva S data point + */ +export interface CurvaSPoint { + date: Date; + pv: number; + ev: number; + ac: number; + percentPv: number; + percentEv: number; + percentAc: number; +} + +/** + * Project budget data for EVM calculations + */ +export interface ProjectBudget { + fraccionamientoId: string; + bac: number; + startDate: Date; + endDate: Date; + plannedCurve: { date: Date; cumulativeValue: number }[]; +} + +/** + * Filters for EVM queries + */ +export interface EvmFilters { + fraccionamientoId?: string; + dateFrom?: Date; + dateTo?: Date; + periodType?: 'daily' | 'weekly' | 'monthly'; +} + +export class EarnedValueService { + private estimacionRepo: Repository; + private kpiRepo: Repository; + + constructor(dataSource: DataSource) { + this.estimacionRepo = dataSource.getRepository(Estimacion); + this.kpiRepo = dataSource.getRepository(KpiSnapshot); + } + + /** + * Calculate current Earned Value metrics for a project + */ + async getCurrentMetrics( + ctx: ServiceContext, + fraccionamientoId: string, + asOfDate?: Date + ): Promise { + const date = asOfDate || new Date(); + + // Get budget data (BAC) + const budget = await this.getProjectBudget(ctx, fraccionamientoId); + + // Get Earned Value (approved estimates sum) + const ev = await this.calculateEarnedValue(ctx, fraccionamientoId, date); + + // Get Planned Value (scheduled value to date) + const pv = this.calculatePlannedValue(budget, date); + + // Get Actual Cost (real costs incurred) + const ac = await this.calculateActualCost(ctx, fraccionamientoId, date); + + // Calculate all EVM indicators + return this.computeEvmMetrics(budget.bac, pv, ev, ac, date); + } + + /** + * Generate Curva S data for a project + */ + async getCurvaS( + ctx: ServiceContext, + fraccionamientoId: string, + filters?: EvmFilters + ): Promise { + const budget = await this.getProjectBudget(ctx, fraccionamientoId); + const periodType = filters?.periodType || 'weekly'; + const dateFrom = filters?.dateFrom || budget.startDate; + const dateTo = filters?.dateTo || new Date(); + + const points: CurvaSPoint[] = []; + const dates = this.generateDateSeries(dateFrom, dateTo, periodType); + + for (const date of dates) { + const pv = this.calculatePlannedValue(budget, date); + const ev = await this.calculateEarnedValue(ctx, fraccionamientoId, date); + const ac = await this.calculateActualCost(ctx, fraccionamientoId, date); + + points.push({ + date, + pv, + ev, + ac, + percentPv: budget.bac > 0 ? (pv / budget.bac) * 100 : 0, + percentEv: budget.bac > 0 ? (ev / budget.bac) * 100 : 0, + percentAc: budget.bac > 0 ? (ac / budget.bac) * 100 : 0, + }); + } + + return points; + } + + /** + * Calculate SPI for a specific date + */ + async calculateSPI( + ctx: ServiceContext, + fraccionamientoId: string, + asOfDate?: Date + ): Promise<{ spi: number; status: 'green' | 'yellow' | 'red'; interpretation: string }> { + const metrics = await this.getCurrentMetrics(ctx, fraccionamientoId, asOfDate); + + let interpretation: string; + if (metrics.spi >= 1.0) { + interpretation = 'Proyecto adelantado respecto al cronograma'; + } else if (metrics.spi >= 0.95) { + interpretation = 'Proyecto ligeramente atrasado (tolerable)'; + } else if (metrics.spi >= 0.85) { + interpretation = 'Proyecto con atraso moderado, requiere atención'; + } else { + interpretation = 'Proyecto con atraso crĆ­tico, requiere acción inmediata'; + } + + return { + spi: metrics.spi, + status: metrics.spi >= 0.95 ? 'green' : metrics.spi >= 0.85 ? 'yellow' : 'red', + interpretation, + }; + } + + /** + * Calculate CPI for a specific date + */ + async calculateCPI( + ctx: ServiceContext, + fraccionamientoId: string, + asOfDate?: Date + ): Promise<{ cpi: number; status: 'green' | 'yellow' | 'red'; interpretation: string }> { + const metrics = await this.getCurrentMetrics(ctx, fraccionamientoId, asOfDate); + + let interpretation: string; + if (metrics.cpi >= 1.0) { + interpretation = 'Proyecto bajo presupuesto (eficiente)'; + } else if (metrics.cpi >= 0.95) { + interpretation = 'Proyecto ligeramente sobre presupuesto (tolerable)'; + } else if (metrics.cpi >= 0.85) { + interpretation = 'Proyecto con sobrecosto moderado, requiere control'; + } else { + interpretation = 'Proyecto con sobrecosto crĆ­tico, requiere acción inmediata'; + } + + return { + cpi: metrics.cpi, + status: metrics.cpi >= 0.95 ? 'green' : metrics.cpi >= 0.85 ? 'yellow' : 'red', + interpretation, + }; + } + + /** + * Get EVM summary for multiple projects + */ + async getPortfolioSummary( + ctx: ServiceContext, + fraccionamientoIds?: string[] + ): Promise<{ + projects: Array<{ + fraccionamientoId: string; + name: string; + bac: number; + percentComplete: number; + spi: number; + cpi: number; + status: 'green' | 'yellow' | 'red'; + }>; + totals: { + totalBac: number; + avgSpi: number; + avgCpi: number; + projectsOnTrack: number; + projectsAtRisk: number; + projectsCritical: number; + }; + }> { + // Get all active projects for tenant + const projects = await this.getActiveProjects(ctx, fraccionamientoIds); + + const projectMetrics = await Promise.all( + projects.map(async (p) => { + const metrics = await this.getCurrentMetrics(ctx, p.fraccionamientoId); + return { + fraccionamientoId: p.fraccionamientoId, + name: p.name, + bac: metrics.bac, + percentComplete: metrics.percentComplete, + spi: metrics.spi, + cpi: metrics.cpi, + status: metrics.status, + }; + }) + ); + + const totals = { + totalBac: projectMetrics.reduce((sum, p) => sum + p.bac, 0), + avgSpi: + projectMetrics.length > 0 + ? projectMetrics.reduce((sum, p) => sum + p.spi, 0) / projectMetrics.length + : 0, + avgCpi: + projectMetrics.length > 0 + ? projectMetrics.reduce((sum, p) => sum + p.cpi, 0) / projectMetrics.length + : 0, + projectsOnTrack: projectMetrics.filter((p) => p.status === 'green').length, + projectsAtRisk: projectMetrics.filter((p) => p.status === 'yellow').length, + projectsCritical: projectMetrics.filter((p) => p.status === 'red').length, + }; + + return { projects: projectMetrics, totals }; + } + + /** + * Get EVM trend over time + */ + async getEvmTrend( + ctx: ServiceContext, + fraccionamientoId: string, + periodCount: number = 12, + periodType: 'weekly' | 'monthly' = 'monthly' + ): Promise< + Array<{ + period: Date; + spi: number; + cpi: number; + percentComplete: number; + }> + > { + const trend: Array<{ + period: Date; + spi: number; + cpi: number; + percentComplete: number; + }> = []; + + const now = new Date(); + for (let i = periodCount - 1; i >= 0; i--) { + const periodDate = new Date(now); + if (periodType === 'weekly') { + periodDate.setDate(periodDate.getDate() - i * 7); + } else { + periodDate.setMonth(periodDate.getMonth() - i); + } + + const metrics = await this.getCurrentMetrics(ctx, fraccionamientoId, periodDate); + trend.push({ + period: periodDate, + spi: metrics.spi, + cpi: metrics.cpi, + percentComplete: metrics.percentComplete, + }); + } + + return trend; + } + + /** + * Save EVM metrics as KPI snapshots + */ + async saveEvmSnapshot( + ctx: ServiceContext, + fraccionamientoId: string, + asOfDate?: Date + ): Promise { + const metrics = await this.getCurrentMetrics(ctx, fraccionamientoId, asOfDate); + const date = asOfDate || new Date(); + + const kpis = [ + { + kpiCode: 'EVM_SPI', + kpiName: 'Schedule Performance Index', + category: 'progress' as KpiCategory, + value: metrics.spi, + targetValue: 1.0, + }, + { + kpiCode: 'EVM_CPI', + kpiName: 'Cost Performance Index', + category: 'financial' as KpiCategory, + value: metrics.cpi, + targetValue: 1.0, + }, + { + kpiCode: 'EVM_EV', + kpiName: 'Earned Value', + category: 'progress' as KpiCategory, + value: metrics.ev, + targetValue: metrics.pv, + }, + { + kpiCode: 'EVM_PERCENT', + kpiName: 'Percent Complete', + category: 'progress' as KpiCategory, + value: metrics.percentComplete, + targetValue: this.calculatePlannedPercent( + await this.getProjectBudget(ctx, fraccionamientoId), + date + ), + }, + ]; + + for (const kpi of kpis) { + const snapshot = this.kpiRepo.create({ + tenantId: ctx.tenantId, + kpiCode: kpi.kpiCode, + kpiName: kpi.kpiName, + category: kpi.category, + snapshotDate: date, + periodType: 'daily', + fraccionamientoId, + value: kpi.value, + targetValue: kpi.targetValue, + isOnTarget: kpi.value >= (kpi.targetValue || 0) * 0.95, + statusColor: + kpi.value >= (kpi.targetValue || 0) * 0.95 + ? 'green' + : kpi.value >= (kpi.targetValue || 0) * 0.85 + ? 'yellow' + : 'red', + }); + await this.kpiRepo.save(snapshot); + } + } + + // ============ Private Helper Methods ============ + + /** + * Get project budget data (BAC and planned curve) + * This would typically come from the budgets/presupuestos module + */ + private async getProjectBudget( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + // In a real implementation, this would query the presupuesto module + // For now, we'll calculate from approved estimates as a proxy + const estimates = await this.estimacionRepo.find({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + status: 'approved' as any, + }, + order: { periodEnd: 'DESC' }, + }); + + // Calculate BAC from contract value (placeholder - would come from contracts module) + const totalEstimated = estimates.reduce((sum, e) => sum + Number(e.totalAmount), 0); + const bac = totalEstimated * 1.2; // Assume estimates are ~80% of total budget + + // Determine project dates + const startDate = + estimates.length > 0 + ? new Date(Math.min(...estimates.map((e) => new Date(e.periodStart).getTime()))) + : new Date(); + const endDate = new Date(startDate); + endDate.setMonth(endDate.getMonth() + 12); // Default 12 month project + + // Generate planned curve (S-curve typically) + const plannedCurve = this.generatePlannedCurve(startDate, endDate, bac); + + return { fraccionamientoId, bac, startDate, endDate, plannedCurve }; + } + + /** + * Calculate Earned Value (sum of approved estimate values to date) + */ + private async calculateEarnedValue( + ctx: ServiceContext, + fraccionamientoId: string, + asOfDate: Date + ): Promise { + const estimates = await this.estimacionRepo.find({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + status: 'approved' as any, + approvedAt: LessThanOrEqual(asOfDate), + }, + }); + + return estimates.reduce((sum, e) => sum + Number(e.subtotal), 0); + } + + /** + * Calculate Planned Value from the planned curve + */ + private calculatePlannedValue(budget: ProjectBudget, asOfDate: Date): number { + const point = budget.plannedCurve.find( + (p) => p.date.getTime() >= asOfDate.getTime() + ); + if (point) { + return point.cumulativeValue; + } + // If past end date, return full BAC + return budget.bac; + } + + /** + * Calculate planned percent complete + */ + private calculatePlannedPercent(budget: ProjectBudget, asOfDate: Date): number { + const pv = this.calculatePlannedValue(budget, asOfDate); + return budget.bac > 0 ? (pv / budget.bac) * 100 : 0; + } + + /** + * Calculate Actual Cost (real costs incurred to date) + * In a real implementation, this would query the financial/costs module + */ + private async calculateActualCost( + ctx: ServiceContext, + fraccionamientoId: string, + asOfDate: Date + ): Promise { + // Sum of paid estimates as a proxy for actual cost + const estimates = await this.estimacionRepo.find({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + status: 'paid' as any, + paidAt: LessThanOrEqual(asOfDate), + }, + }); + + return estimates.reduce((sum, e) => sum + Number(e.totalAmount), 0); + } + + /** + * Compute all EVM metrics from base values + */ + private computeEvmMetrics( + bac: number, + pv: number, + ev: number, + ac: number, + date: Date + ): EarnedValueMetrics { + // Avoid division by zero + const spi = pv > 0 ? ev / pv : 0; + const cpi = ac > 0 ? ev / ac : 0; + const sv = ev - pv; + const cv = ev - ac; + const eac = cpi > 0 ? bac / cpi : bac; + const etc = eac - ac; + const vac = bac - eac; + const tcpi = bac - ac > 0 ? (bac - ev) / (bac - ac) : 0; + const percentComplete = bac > 0 ? (ev / bac) * 100 : 0; + + // Determine status based on both SPI and CPI + const minIndex = Math.min(spi, cpi); + const status: 'green' | 'yellow' | 'red' = + minIndex >= 0.95 ? 'green' : minIndex >= 0.85 ? 'yellow' : 'red'; + + return { + date, + bac, + pv, + ev, + ac, + spi: Number(spi.toFixed(3)), + cpi: Number(cpi.toFixed(3)), + sv: Number(sv.toFixed(2)), + cv: Number(cv.toFixed(2)), + eac: Number(eac.toFixed(2)), + etc: Number(etc.toFixed(2)), + vac: Number(vac.toFixed(2)), + tcpi: Number(tcpi.toFixed(3)), + percentComplete: Number(percentComplete.toFixed(2)), + status, + }; + } + + /** + * Generate S-curve for planned value (typical construction project) + */ + private generatePlannedCurve( + startDate: Date, + endDate: Date, + bac: number + ): { date: Date; cumulativeValue: number }[] { + const curve: { date: Date; cumulativeValue: number }[] = []; + const totalDays = + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24); + const weekCount = Math.ceil(totalDays / 7); + + for (let week = 0; week <= weekCount; week++) { + const date = new Date(startDate); + date.setDate(date.getDate() + week * 7); + + // S-curve formula: cumulative % = 1 / (1 + e^(-k*(t - t0))) + // Simplified: use sigmoid function + const t = week / weekCount; // normalized time 0 to 1 + const k = 8; // steepness factor + const sigmoid = 1 / (1 + Math.exp(-k * (t - 0.5))); + const cumulativeValue = bac * sigmoid; + + curve.push({ date, cumulativeValue }); + } + + return curve; + } + + /** + * Generate date series based on period type + */ + private generateDateSeries( + from: Date, + to: Date, + periodType: 'daily' | 'weekly' | 'monthly' + ): Date[] { + const dates: Date[] = []; + const current = new Date(from); + + while (current <= to) { + dates.push(new Date(current)); + + switch (periodType) { + case 'daily': + current.setDate(current.getDate() + 1); + break; + case 'weekly': + current.setDate(current.getDate() + 7); + break; + case 'monthly': + current.setMonth(current.getMonth() + 1); + break; + } + } + + return dates; + } + + /** + * Get active projects for the tenant + */ + private async getActiveProjects( + ctx: ServiceContext, + fraccionamientoIds?: string[] + ): Promise<{ fraccionamientoId: string; name: string }[]> { + // Query distinct fraccionamientos from estimates + const qb = this.estimacionRepo + .createQueryBuilder('e') + .select('DISTINCT e.fraccionamiento_id', 'fraccionamientoId') + .addSelect('f.nombre', 'name') + .leftJoin('construction.fraccionamientos', 'f', 'f.id = e.fraccionamiento_id') + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.deleted_at IS NULL'); + + if (fraccionamientoIds && fraccionamientoIds.length > 0) { + qb.andWhere('e.fraccionamiento_id IN (:...ids)', { ids: fraccionamientoIds }); + } + + const results = await qb.getRawMany(); + return results.map((r) => ({ + fraccionamientoId: r.fraccionamientoId, + name: r.name || 'Unknown Project', + })); + } +} diff --git a/src/modules/reports/services/index.ts b/src/modules/reports/services/index.ts index ddefa06..64f6928 100644 --- a/src/modules/reports/services/index.ts +++ b/src/modules/reports/services/index.ts @@ -6,3 +6,4 @@ export * from './report.service'; export * from './dashboard.service'; export * from './kpi.service'; +export * from './earned-value.service'; diff --git a/src/modules/sales/entities/index.ts b/src/modules/sales/entities/index.ts new file mode 100644 index 0000000..cca5d8f --- /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 0000000..95928bd --- /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 0000000..bb2e52b --- /dev/null +++ b/src/modules/sales/entities/quotation.entity.ts @@ -0,0 +1,101 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } 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 0000000..3a38976 --- /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 0000000..1528295 --- /dev/null +++ b/src/modules/sales/entities/sales-order.entity.ts @@ -0,0 +1,138 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +/** + * 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; + + // 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/settings/entities/index.ts b/src/modules/settings/entities/index.ts new file mode 100644 index 0000000..17c9fe0 --- /dev/null +++ b/src/modules/settings/entities/index.ts @@ -0,0 +1,8 @@ +/** + * Settings Entities - Export + */ + +export { SystemSetting } from './system-setting.entity'; +export { PlanSetting } from './plan-setting.entity'; +export { TenantSetting } from './tenant-setting.entity'; +export { UserPreference } from './user-preference.entity'; diff --git a/src/modules/settings/entities/plan-setting.entity.ts b/src/modules/settings/entities/plan-setting.entity.ts new file mode 100644 index 0000000..61c733c --- /dev/null +++ b/src/modules/settings/entities/plan-setting.entity.ts @@ -0,0 +1,42 @@ +/** + * Plan Setting Entity + * Default configuration per subscription plan + * Hierarchy: system_settings < plan_settings < tenant_settings + * Compatible with erp-core plan-setting.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'plan_settings', schema: 'core_settings' }) +@Unique(['planId', 'key']) +export class PlanSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'plan_id', type: 'uuid' }) + planId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/settings/entities/system-setting.entity.ts b/src/modules/settings/entities/system-setting.entity.ts new file mode 100644 index 0000000..080b9c4 --- /dev/null +++ b/src/modules/settings/entities/system-setting.entity.ts @@ -0,0 +1,67 @@ +/** + * System Setting Entity + * Global system configuration settings across all tenants + * Compatible with erp-core system-setting.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'system_settings', schema: 'core_settings' }) +@Unique(['key']) +export class SystemSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ + name: 'data_type', + type: 'varchar', + length: 20, + default: 'string', + }) + dataType: 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret'; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50 }) + category: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'is_editable', type: 'boolean', default: true }) + isEditable: boolean; + + @Column({ name: 'default_value', type: 'jsonb', nullable: true }) + defaultValue: any; + + @Column({ name: 'validation_rules', type: 'jsonb', default: '{}' }) + validationRules: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string | null; +} diff --git a/src/modules/settings/entities/tenant-setting.entity.ts b/src/modules/settings/entities/tenant-setting.entity.ts new file mode 100644 index 0000000..d99446c --- /dev/null +++ b/src/modules/settings/entities/tenant-setting.entity.ts @@ -0,0 +1,53 @@ +/** + * Tenant Setting Entity + * Custom configuration per tenant, overrides system and plan defaults + * Hierarchy: system_settings < plan_settings < tenant_settings + * Compatible with erp-core tenant-setting.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'tenant_settings', schema: 'core_settings' }) +@Unique(['tenantId', 'key']) +export class TenantSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ + name: 'inherited_from', + type: 'varchar', + length: 20, + default: 'custom', + }) + inheritedFrom: 'system' | 'plan' | 'custom'; + + @Column({ name: 'is_overridden', type: 'boolean', default: true }) + isOverridden: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/settings/entities/user-preference.entity.ts b/src/modules/settings/entities/user-preference.entity.ts new file mode 100644 index 0000000..1191d32 --- /dev/null +++ b/src/modules/settings/entities/user-preference.entity.ts @@ -0,0 +1,44 @@ +/** + * User Preference Entity + * Personal preferences per user (theme, language, notifications, etc.) + * Compatible with erp-core user-preference.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'user_preferences', schema: 'core_settings' }) +@Unique(['userId', 'key']) +export class UserPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ name: 'synced_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + syncedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/storage/controllers/index.ts b/src/modules/storage/controllers/index.ts new file mode 100644 index 0000000..5a58ef9 --- /dev/null +++ b/src/modules/storage/controllers/index.ts @@ -0,0 +1,5 @@ +/** + * Storage Module - Controller Exports + */ + +export { createStorageController } from './storage.controller'; diff --git a/src/modules/storage/controllers/storage.controller.ts b/src/modules/storage/controllers/storage.controller.ts new file mode 100644 index 0000000..dfccabd --- /dev/null +++ b/src/modules/storage/controllers/storage.controller.ts @@ -0,0 +1,317 @@ +/** + * StorageController - Endpoints para gestión de archivos + * + * @swagger + * tags: + * name: Storage + * description: Gestión de almacenamiento de archivos + * + * @module Storage + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { StorageService } from '../services/storage.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +/** + * Create the storage controller with routes + * + * @swagger + * /api/storage/files: + * get: + * tags: [Storage] + * summary: List files + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: bucketId + * schema: + * type: string + * - in: query + * name: folderId + * schema: + * type: string + * - in: query + * name: search + * schema: + * type: string + * responses: + * 200: + * description: List of files + */ +export function createStorageController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const storageService = new StorageService(dataSource); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper to create service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * @swagger + * /api/storage/files: + * get: + * tags: [Storage] + * summary: List files with filters + */ + router.get( + '/files', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const filters = { + bucketId: req.query.bucketId as string, + folderId: req.query.folderId as string, + mimeType: req.query.mimeType as string, + search: req.query.search as string, + status: req.query.status as string, + entityType: req.query.entityType as string, + entityId: req.query.entityId as string, + createdFrom: req.query.createdFrom + ? new Date(req.query.createdFrom as string) + : undefined, + createdTo: req.query.createdTo + ? new Date(req.query.createdTo as string) + : undefined, + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + }; + + const result = await storageService.findAll(ctx, filters); + res.json(result); + } catch (error) { + next(error); + } + } + ); + + /** + * @swagger + * /api/storage/files/{id}: + * get: + * tags: [Storage] + * summary: Get file by ID + */ + router.get( + '/files/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const file = await storageService.findById(ctx, req.params.id); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.json(file); + } catch (error) { + next(error); + } + } + ); + + /** + * @swagger + * /api/storage/upload/request: + * post: + * tags: [Storage] + * summary: Request upload URL + */ + router.post( + '/upload/request', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { bucketId, fileName, mimeType, sizeBytes } = req.body; + + if (!bucketId || !fileName || !mimeType || !sizeBytes) { + res.status(400).json({ + error: 'Bad Request', + message: 'bucketId, fileName, mimeType, and sizeBytes are required', + }); + return; + } + + const result = await storageService.generateUploadUrl( + ctx, + bucketId, + fileName, + mimeType, + sizeBytes + ); + + res.json(result); + } catch (error) { + next(error); + } + } + ); + + /** + * @swagger + * /api/storage/upload/{uploadId}/complete: + * post: + * tags: [Storage] + * summary: Complete upload + */ + router.post( + '/upload/:uploadId/complete', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { storageKey, actualSize, checksum } = req.body; + + const file = await storageService.completeUpload( + ctx, + req.params.uploadId, + storageKey, + actualSize, + checksum + ); + + res.status(201).json(file); + } catch (error) { + next(error); + } + } + ); + + /** + * @swagger + * /api/storage/files/{id}/download-url: + * get: + * tags: [Storage] + * summary: Get download URL for file + */ + router.get( + '/files/:id/download-url', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const expiresInMinutes = parseInt(req.query.expires as string) || 60; + + const downloadUrl = await storageService.generateDownloadUrl( + ctx, + req.params.id, + expiresInMinutes + ); + + res.json({ downloadUrl, expiresInMinutes }); + } catch (error) { + next(error); + } + } + ); + + /** + * @swagger + * /api/storage/files/{id}: + * delete: + * tags: [Storage] + * summary: Delete file + */ + router.delete( + '/files/:id', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const deleted = await storageService.deleteFile(ctx, req.params.id); + + if (!deleted) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + ); + + /** + * @swagger + * /api/storage/files/{id}/copy: + * post: + * tags: [Storage] + * summary: Copy file + */ + router.post( + '/files/:id/copy', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const { targetFolderId } = req.body; + + const copiedFile = await storageService.copyFile( + ctx, + req.params.id, + targetFolderId + ); + + res.status(201).json(copiedFile); + } catch (error) { + next(error); + } + } + ); + + /** + * @swagger + * /api/storage/usage: + * get: + * tags: [Storage] + * summary: Get tenant storage usage + */ + router.get( + '/usage', + authMiddleware.authenticate, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const ctx = getContext(req); + const bucketId = req.query.bucketId as string; + const usage = await storageService.getTenantUsage(ctx, bucketId); + res.json(usage); + } catch (error) { + next(error); + } + } + ); + + return router; +} diff --git a/src/modules/storage/entities/bucket.entity.ts b/src/modules/storage/entities/bucket.entity.ts new file mode 100644 index 0000000..c33ecbb --- /dev/null +++ b/src/modules/storage/entities/bucket.entity.ts @@ -0,0 +1,74 @@ +/** + * StorageBucket Entity + * Storage bucket configuration with provider and quota settings + * Compatible with erp-core bucket.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BucketType = 'public' | 'private' | 'protected'; +export type StorageProvider = 'local' | 's3' | 'gcs' | 'azure'; + +@Entity({ name: 'buckets', schema: 'storage' }) +export class StorageBucket { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'bucket_type', type: 'varchar', length: 30, default: 'private' }) + bucketType: BucketType; + + @Column({ name: 'max_file_size_mb', type: 'int', default: 50 }) + maxFileSizeMb: number; + + @Column({ name: 'allowed_mime_types', type: 'text', array: true, default: [] }) + allowedMimeTypes: string[]; + + @Column({ name: 'allowed_extensions', type: 'text', array: true, default: [] }) + allowedExtensions: string[]; + + @Column({ name: 'auto_delete_days', type: 'int', nullable: true }) + autoDeleteDays: number; + + @Column({ name: 'versioning_enabled', type: 'boolean', default: false }) + versioningEnabled: boolean; + + @Column({ name: 'max_versions', type: 'int', default: 5 }) + maxVersions: number; + + @Column({ name: 'storage_provider', type: 'varchar', length: 30, default: 'local' }) + storageProvider: StorageProvider; + + @Column({ name: 'storage_config', type: 'jsonb', default: {} }) + storageConfig: Record; + + @Column({ name: 'quota_per_tenant_gb', type: 'int', nullable: true }) + quotaPerTenantGb: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/storage/entities/file-access-token.entity.ts b/src/modules/storage/entities/file-access-token.entity.ts new file mode 100644 index 0000000..3ac6170 --- /dev/null +++ b/src/modules/storage/entities/file-access-token.entity.ts @@ -0,0 +1,71 @@ +/** + * FileAccessToken Entity + * Temporary access tokens for file downloads + * Compatible with erp-core file-access-token.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_access_tokens', schema: 'storage' }) +export class FileAccessToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'token', type: 'varchar', length: 255, unique: true }) + token: string; + + @Column({ name: 'permissions', type: 'text', array: true, default: ['read'] }) + permissions: string[]; + + @Column({ name: 'allowed_ips', type: 'inet', array: true, nullable: true }) + allowedIps: string[]; + + @Column({ name: 'max_downloads', type: 'int', nullable: true }) + maxDownloads: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'created_for', type: 'varchar', length: 255, nullable: true }) + createdFor: string; + + @Column({ name: 'purpose', type: 'text', nullable: true }) + purpose: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file-share.entity.ts b/src/modules/storage/entities/file-share.entity.ts new file mode 100644 index 0000000..f9647a0 --- /dev/null +++ b/src/modules/storage/entities/file-share.entity.ts @@ -0,0 +1,96 @@ +/** + * FileShare Entity + * File sharing with granular permissions + * Compatible with erp-core file-share.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_shares', schema: 'storage' }) +export class FileShare { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'shared_with_user_id', type: 'uuid', nullable: true }) + sharedWithUserId: string; + + @Column({ name: 'shared_with_email', type: 'varchar', length: 255, nullable: true }) + sharedWithEmail: string; + + @Column({ name: 'shared_with_role', type: 'varchar', length: 50, nullable: true }) + sharedWithRole: string; + + @Column({ name: 'can_view', type: 'boolean', default: true }) + canView: boolean; + + @Column({ name: 'can_download', type: 'boolean', default: true }) + canDownload: boolean; + + @Column({ name: 'can_edit', type: 'boolean', default: false }) + canEdit: boolean; + + @Column({ name: 'can_delete', type: 'boolean', default: false }) + canDelete: boolean; + + @Column({ name: 'can_share', type: 'boolean', default: false }) + canShare: boolean; + + @Index() + @Column({ name: 'public_link', type: 'varchar', length: 255, unique: true, nullable: true }) + publicLink: string; + + @Column({ name: 'public_link_password', type: 'varchar', length: 255, nullable: true }) + publicLinkPassword: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'view_count', type: 'int', default: 0 }) + viewCount: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Column({ name: 'notify_on_access', type: 'boolean', default: false }) + notifyOnAccess: boolean; + + @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; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file.entity.ts b/src/modules/storage/entities/file.entity.ts new file mode 100644 index 0000000..1fb8e4a --- /dev/null +++ b/src/modules/storage/entities/file.entity.ts @@ -0,0 +1,162 @@ +/** + * StorageFile Entity + * File metadata with versioning, checksums, and processing status + * Compatible with erp-core file.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; + +export type FileCategory = 'image' | 'document' | 'video' | 'audio' | 'archive' | 'other'; +export type FileStatus = 'active' | 'processing' | 'archived' | 'deleted'; +export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +@Entity({ name: 'files', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path', 'version']) +export class StorageFile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName: string; + + @Column({ name: 'path', type: 'text' }) + path: string; + + @Index() + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType: string; + + @Column({ name: 'extension', type: 'varchar', length: 20, nullable: true }) + extension: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30, nullable: true }) + category: FileCategory; + + @Column({ name: 'size_bytes', type: 'bigint' }) + sizeBytes: number; + + @Column({ name: 'checksum_md5', type: 'varchar', length: 32, nullable: true }) + checksumMd5: string; + + @Index() + @Column({ name: 'checksum_sha256', type: 'varchar', length: 64, nullable: true }) + checksumSha256: string; + + @Column({ name: 'storage_key', type: 'text' }) + storageKey: string; + + @Column({ name: 'storage_url', type: 'text', nullable: true }) + storageUrl: string; + + @Column({ name: 'cdn_url', type: 'text', nullable: true }) + cdnUrl: string; + + @Column({ name: 'width', type: 'int', nullable: true }) + width: number; + + @Column({ name: 'height', type: 'int', nullable: true }) + height: number; + + @Column({ name: 'thumbnail_url', type: 'text', nullable: true }) + thumbnailUrl: string; + + @Column({ name: 'thumbnails', type: 'jsonb', default: {} }) + thumbnails: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'alt_text', type: 'text', nullable: true }) + altText: string; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'parent_version_id', type: 'uuid', nullable: true }) + parentVersionId: string; + + @Column({ name: 'is_latest', type: 'boolean', default: true }) + isLatest: boolean; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'access_count', type: 'int', default: 0 }) + accessCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'active' }) + status: FileStatus; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'processing_status', type: 'varchar', length: 20, nullable: true }) + processingStatus: ProcessingStatus; + + @Column({ name: 'processing_error', type: 'text', nullable: true }) + processingError: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) + uploadedBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; +} diff --git a/src/modules/storage/entities/folder.entity.ts b/src/modules/storage/entities/folder.entity.ts new file mode 100644 index 0000000..0295474 --- /dev/null +++ b/src/modules/storage/entities/folder.entity.ts @@ -0,0 +1,91 @@ +/** + * StorageFolder Entity + * Hierarchical folder structure within buckets + * Compatible with erp-core folder.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'folders', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path']) +export class StorageFolder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @Index() + @Column({ name: 'path', type: 'text' }) + path: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'depth', type: 'int', default: 0 }) + depth: number; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'color', type: 'varchar', length: 7, nullable: true }) + color: string; + + @Column({ name: 'icon', type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'is_private', type: 'boolean', default: false }) + isPrivate: boolean; + + @Column({ name: 'owner_id', type: 'uuid', nullable: true }) + ownerId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'parent_id' }) + parent: StorageFolder; + + @OneToMany(() => StorageFolder, (folder) => folder.parent) + children: StorageFolder[]; +} diff --git a/src/modules/storage/entities/index.ts b/src/modules/storage/entities/index.ts new file mode 100644 index 0000000..2e12efd --- /dev/null +++ b/src/modules/storage/entities/index.ts @@ -0,0 +1,11 @@ +/** + * Storage Entities - Export + */ + +export { StorageBucket, BucketType, StorageProvider } from './bucket.entity'; +export { StorageFolder } from './folder.entity'; +export { StorageFile, FileCategory, FileStatus, ProcessingStatus } from './file.entity'; +export { FileAccessToken } from './file-access-token.entity'; +export { StorageUpload, UploadStatus } from './upload.entity'; +export { FileShare } from './file-share.entity'; +export { TenantUsage } from './tenant-usage.entity'; diff --git a/src/modules/storage/entities/tenant-usage.entity.ts b/src/modules/storage/entities/tenant-usage.entity.ts new file mode 100644 index 0000000..5c63781 --- /dev/null +++ b/src/modules/storage/entities/tenant-usage.entity.ts @@ -0,0 +1,65 @@ +/** + * TenantUsage Entity + * Per-tenant storage usage and quota tracking + * Compatible with erp-core tenant-usage.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'tenant_usage', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'monthYear']) +export class TenantUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @Column({ name: 'quota_bytes', type: 'bigint', nullable: true }) + quotaBytes: number; + + @Column({ name: 'quota_file_count', type: 'int', nullable: true }) + quotaFileCount: number; + + @Column({ name: 'usage_by_category', type: 'jsonb', default: {} }) + usageByCategory: Record; + + @Column({ name: 'monthly_upload_bytes', type: 'bigint', default: 0 }) + monthlyUploadBytes: number; + + @Column({ name: 'monthly_download_bytes', type: 'bigint', default: 0 }) + monthlyDownloadBytes: number; + + @Column({ name: 'month_year', type: 'varchar', length: 7 }) + monthYear: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; +} diff --git a/src/modules/storage/entities/upload.entity.ts b/src/modules/storage/entities/upload.entity.ts new file mode 100644 index 0000000..bd3113e --- /dev/null +++ b/src/modules/storage/entities/upload.entity.ts @@ -0,0 +1,110 @@ +/** + * StorageUpload Entity + * Chunked upload tracking + * Compatible with erp-core upload.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; +import { StorageFile } from './file.entity'; + +export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +@Entity({ name: 'uploads', schema: 'storage' }) +export class StorageUpload { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'file_name', type: 'varchar', length: 255 }) + fileName: string; + + @Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true }) + mimeType: string; + + @Column({ name: 'total_size_bytes', type: 'bigint', nullable: true }) + totalSizeBytes: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: UploadStatus; + + @Column({ name: 'uploaded_bytes', type: 'bigint', default: 0 }) + uploadedBytes: number; + + @Column({ name: 'upload_progress', type: 'decimal', precision: 5, scale: 2, default: 0 }) + uploadProgress: number; + + @Column({ name: 'total_chunks', type: 'int', nullable: true }) + totalChunks: number; + + @Column({ name: 'completed_chunks', type: 'int', default: 0 }) + completedChunks: number; + + @Column({ name: 'chunk_size_bytes', type: 'int', nullable: true }) + chunkSizeBytes: number; + + @Column({ name: 'chunks_status', type: 'jsonb', default: {} }) + chunksStatus: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'file_id', type: 'uuid', nullable: true }) + fileId: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + startedAt: Date; + + @Column({ name: 'last_chunk_at', type: 'timestamptz', nullable: true }) + lastChunkAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; + + @ManyToOne(() => StorageFile, { nullable: true }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/services/index.ts b/src/modules/storage/services/index.ts new file mode 100644 index 0000000..43aa3f5 --- /dev/null +++ b/src/modules/storage/services/index.ts @@ -0,0 +1,5 @@ +/** + * Storage Module - Service Exports + */ + +export * from './storage.service'; diff --git a/src/modules/storage/services/storage.service.ts b/src/modules/storage/services/storage.service.ts new file mode 100644 index 0000000..761df9e --- /dev/null +++ b/src/modules/storage/services/storage.service.ts @@ -0,0 +1,477 @@ +/** + * StorageService - Gestión de almacenamiento de archivos + * + * Servicio para subir, descargar y gestionar archivos. + * Soporta mĆŗltiples providers (local, S3, GCS, Azure). + * + * @module Storage + */ + +import { DataSource, Repository } from 'typeorm'; +import { StorageFile, StorageBucket, StorageUpload, TenantUsage, StorageProvider } from '../entities'; +import * as crypto from 'crypto'; +import * as path from 'path'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateFileDto { + bucketId: string; + folderId?: string; + name: string; + originalName: string; + mimeType: string; + sizeBytes: number; + storageKey: string; + checksumMd5?: string; + checksumSha256?: string; + metadata?: Record; + tags?: string[]; + entityType?: string; + entityId?: string; + isPublic?: boolean; +} + +export interface FileFilters { + bucketId?: string; + folderId?: string; + mimeType?: string; + search?: string; + status?: string; + entityType?: string; + entityId?: string; + createdFrom?: Date; + createdTo?: Date; + page?: number; + limit?: number; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class StorageService { + private fileRepo: Repository; + private uploadRepo: Repository; + private bucketRepo: Repository; + private usageRepo: Repository; + + constructor(dataSource: DataSource) { + this.fileRepo = dataSource.getRepository(StorageFile); + this.uploadRepo = dataSource.getRepository(StorageUpload); + this.bucketRepo = dataSource.getRepository(StorageBucket); + this.usageRepo = dataSource.getRepository(TenantUsage); + } + + /** + * Create a file record after upload + */ + async createFile(ctx: ServiceContext, dto: CreateFileDto): Promise { + const uniqueName = dto.name || this.generateUniqueFileName(dto.originalName); + const ext = path.extname(dto.originalName).substring(1).toLowerCase(); + const category = this.categorizeFile(dto.mimeType); + + const file = this.fileRepo.create({ + tenantId: ctx.tenantId, + bucketId: dto.bucketId, + folderId: dto.folderId, + name: uniqueName, + originalName: dto.originalName, + path: dto.storageKey, + mimeType: dto.mimeType, + extension: ext, + category, + sizeBytes: dto.sizeBytes, + storageKey: dto.storageKey, + checksumMd5: dto.checksumMd5, + checksumSha256: dto.checksumSha256, + metadata: dto.metadata || {}, + tags: dto.tags || [], + entityType: dto.entityType, + entityId: dto.entityId, + isPublic: dto.isPublic || false, + uploadedBy: ctx.userId, + status: 'active', + }); + + const savedFile = await this.fileRepo.save(file); + + // Update tenant usage + await this.updateTenantUsage(ctx.tenantId, dto.bucketId, dto.sizeBytes, 1); + + return savedFile; + } + + /** + * Find file by ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.fileRepo.findOne({ + where: { + id, + tenantId: ctx.tenantId, + status: 'active', + }, + }); + } + + /** + * Find files with filters + */ + async findAll( + ctx: ServiceContext, + filters: FileFilters = {} + ): Promise> { + const page = filters.page || 1; + const limit = Math.min(filters.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.fileRepo + .createQueryBuilder('f') + .where('f.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('f.status = :status', { status: filters.status || 'active' }); + + if (filters.bucketId) { + qb.andWhere('f.bucket_id = :bucketId', { bucketId: filters.bucketId }); + } + if (filters.folderId) { + qb.andWhere('f.folder_id = :folderId', { folderId: filters.folderId }); + } + if (filters.mimeType) { + qb.andWhere('f.mime_type LIKE :mimeType', { mimeType: `${filters.mimeType}%` }); + } + if (filters.entityType) { + qb.andWhere('f.entity_type = :entityType', { entityType: filters.entityType }); + } + if (filters.entityId) { + qb.andWhere('f.entity_id = :entityId', { entityId: filters.entityId }); + } + if (filters.search) { + qb.andWhere('(f.name ILIKE :search OR f.original_name ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + if (filters.createdFrom) { + qb.andWhere('f.created_at >= :createdFrom', { createdFrom: filters.createdFrom }); + } + if (filters.createdTo) { + qb.andWhere('f.created_at <= :createdTo', { createdTo: filters.createdTo }); + } + + qb.orderBy('f.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Generate upload URL (for direct uploads) + */ + async generateUploadUrl( + ctx: ServiceContext, + bucketId: string, + fileName: string, + mimeType: string, + sizeBytes: number + ): Promise<{ uploadId: string; uploadUrl: string; expiresAt: Date }> { + const bucket = await this.bucketRepo.findOne({ where: { id: bucketId, isActive: true } }); + if (!bucket) { + throw new Error('Bucket not found or inactive'); + } + + const uploadId = crypto.randomUUID(); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); + + const upload = this.uploadRepo.create({ + id: uploadId, + tenantId: ctx.tenantId, + bucketId, + fileName, + mimeType, + totalSizeBytes: sizeBytes, + status: 'pending', + expiresAt, + createdBy: ctx.userId, + }); + + await this.uploadRepo.save(upload); + + // Generate presigned URL based on provider + const uploadUrl = await this.generatePresignedUrl(bucket, fileName, mimeType, uploadId); + + return { uploadId, uploadUrl, expiresAt }; + } + + /** + * Complete upload after file is received + */ + async completeUpload( + ctx: ServiceContext, + uploadId: string, + storageKey: string, + actualSize: number, + checksum?: string + ): Promise { + const upload = await this.uploadRepo.findOne({ + where: { id: uploadId, tenantId: ctx.tenantId }, + }); + + if (!upload) { + throw new Error('Upload not found'); + } + + if (upload.status !== 'pending' && upload.status !== 'uploading') { + throw new Error(`Upload is ${upload.status}, cannot complete`); + } + + // Create file record + const file = await this.createFile(ctx, { + bucketId: upload.bucketId, + folderId: upload.folderId, + name: upload.fileName, + originalName: upload.fileName, + mimeType: upload.mimeType || 'application/octet-stream', + sizeBytes: actualSize, + storageKey, + checksumSha256: checksum, + }); + + // Update upload status + await this.uploadRepo.update( + { id: uploadId }, + { status: 'completed', completedAt: new Date(), fileId: file.id } + ); + + return file; + } + + /** + * Generate download URL for a file + */ + async generateDownloadUrl( + ctx: ServiceContext, + fileId: string, + expiresInMinutes: number = 60 + ): Promise { + const file = await this.findById(ctx, fileId); + if (!file) { + throw new Error('File not found'); + } + + const bucket = await this.bucketRepo.findOne({ where: { id: file.bucketId } }); + if (!bucket) { + throw new Error('Bucket not found'); + } + + return this.generateDownloadPresignedUrl(bucket, file.storageKey, expiresInMinutes); + } + + /** + * Soft delete a file + */ + async deleteFile(ctx: ServiceContext, fileId: string): Promise { + const file = await this.findById(ctx, fileId); + if (!file) { + return false; + } + + await this.fileRepo.update( + { id: fileId, tenantId: ctx.tenantId }, + { status: 'deleted', deletedAt: new Date() } + ); + + // Update tenant usage (subtract file size) + await this.updateTenantUsage(ctx.tenantId, file.bucketId, -file.sizeBytes, -1); + + return true; + } + + /** + * Get tenant storage usage + */ + async getTenantUsage(ctx: ServiceContext, bucketId?: string): Promise<{ + totalSizeBytes: number; + fileCount: number; + quotaBytes: number | null; + usagePercentage: number; + }> { + const monthYear = this.getCurrentMonthYear(); + + const whereClause: Record = { + tenantId: ctx.tenantId, + monthYear, + }; + + if (bucketId) { + whereClause.bucketId = bucketId; + } + + const usages = await this.usageRepo.find({ where: whereClause }); + + if (usages.length === 0) { + return { + totalSizeBytes: 0, + fileCount: 0, + quotaBytes: null, + usagePercentage: 0, + }; + } + + const totalSizeBytes = usages.reduce((sum, u) => sum + (Number(u.totalSizeBytes) || 0), 0); + const fileCount = usages.reduce((sum, u) => sum + (u.fileCount || 0), 0); + const quotaBytes = usages[0].quotaBytes ? Number(usages[0].quotaBytes) : null; + + return { + totalSizeBytes, + fileCount, + quotaBytes, + usagePercentage: quotaBytes ? (totalSizeBytes / quotaBytes) * 100 : 0, + }; + } + + /** + * Copy file + */ + async copyFile( + ctx: ServiceContext, + sourceFileId: string, + targetFolderId?: string + ): Promise { + const sourceFile = await this.findById(ctx, sourceFileId); + if (!sourceFile) { + throw new Error('Source file not found'); + } + + const newStorageKey = this.generateStoragePath(sourceFile.originalName); + + return this.createFile(ctx, { + bucketId: sourceFile.bucketId, + folderId: targetFolderId || sourceFile.folderId, + name: this.generateUniqueFileName(sourceFile.originalName), + originalName: sourceFile.originalName, + mimeType: sourceFile.mimeType, + sizeBytes: sourceFile.sizeBytes, + storageKey: newStorageKey, + checksumMd5: sourceFile.checksumMd5, + checksumSha256: sourceFile.checksumSha256, + metadata: { ...sourceFile.metadata, copiedFrom: sourceFileId }, + tags: [...sourceFile.tags], + }); + } + + // ============ Private Helper Methods ============ + + private generateUniqueFileName(originalName: string): string { + const ext = path.extname(originalName); + const base = path.basename(originalName, ext); + const timestamp = Date.now(); + const random = crypto.randomBytes(4).toString('hex'); + return `${base}_${timestamp}_${random}${ext}`; + } + + private generateStoragePath(fileName: string): string { + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}/${month}/${day}/${this.generateUniqueFileName(fileName)}`; + } + + private getCurrentMonthYear(): string { + const date = new Date(); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + } + + private categorizeFile(mimeType: string): 'image' | 'document' | 'video' | 'audio' | 'archive' | 'other' { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('video/')) return 'video'; + if (mimeType.startsWith('audio/')) return 'audio'; + if (mimeType.includes('pdf') || mimeType.includes('document') || mimeType.includes('spreadsheet') || mimeType.includes('text/')) return 'document'; + if (mimeType.includes('zip') || mimeType.includes('tar') || mimeType.includes('rar') || mimeType.includes('7z')) return 'archive'; + return 'other'; + } + + private async updateTenantUsage( + tenantId: string, + bucketId: string, + sizeChange: number, + countChange: number + ): Promise { + const monthYear = this.getCurrentMonthYear(); + + let usage = await this.usageRepo.findOne({ + where: { tenantId, bucketId, monthYear }, + }); + + if (!usage) { + usage = this.usageRepo.create({ + tenantId, + bucketId, + monthYear, + totalSizeBytes: Math.max(0, sizeChange), + fileCount: Math.max(0, countChange), + }); + } else { + usage.totalSizeBytes = Math.max(0, Number(usage.totalSizeBytes || 0) + sizeChange); + usage.fileCount = Math.max(0, (usage.fileCount || 0) + countChange); + } + + await this.usageRepo.save(usage); + } + + private async generatePresignedUrl( + bucket: StorageBucket, + fileName: string, + _mimeType: string, + uploadId: string + ): Promise { + const provider = bucket.storageProvider as StorageProvider; + const config = bucket.storageConfig || {}; + + switch (provider) { + case 's3': + return `https://${config.bucket || 'default'}.s3.${config.region || 'us-east-1'}.amazonaws.com/uploads/${fileName}`; + case 'gcs': + return `https://storage.googleapis.com/${config.bucket || 'default'}/uploads/${fileName}`; + case 'azure': + return `https://${config.account || 'default'}.blob.core.windows.net/${config.container || 'uploads'}/${fileName}`; + default: + // Local: return internal upload endpoint + return `/api/storage/upload/${uploadId}`; + } + } + + private async generateDownloadPresignedUrl( + bucket: StorageBucket, + storageKey: string, + _expiresInMinutes: number + ): Promise { + const provider = bucket.storageProvider as StorageProvider; + const config = bucket.storageConfig || {}; + + switch (provider) { + case 's3': + return `https://${config.bucket || 'default'}.s3.${config.region || 'us-east-1'}.amazonaws.com/${storageKey}`; + case 'gcs': + return `https://storage.googleapis.com/${config.bucket || 'default'}/${storageKey}`; + case 'azure': + return `https://${config.account || 'default'}.blob.core.windows.net/${config.container || 'uploads'}/${storageKey}`; + default: + return `/api/storage/download/${storageKey}`; + } + } +} diff --git a/src/modules/warehouses/entities/index.ts b/src/modules/warehouses/entities/index.ts new file mode 100644 index 0000000..fb6b6e3 --- /dev/null +++ b/src/modules/warehouses/entities/index.ts @@ -0,0 +1,3 @@ +export { Warehouse } from './warehouse.entity'; +export { WarehouseLocation } from './warehouse-location.entity'; +export { WarehouseZone } from './warehouse-zone.entity'; diff --git a/src/modules/warehouses/entities/warehouse-location.entity.ts b/src/modules/warehouses/entities/warehouse-location.entity.ts new file mode 100644 index 0000000..030ff0a --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-location.entity.ts @@ -0,0 +1,111 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_locations', schema: 'inventory' }) +export class WarehouseLocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => WarehouseLocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: WarehouseLocation; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + // Tipo de ubicacion + @Index() + @Column({ name: 'location_type', type: 'varchar', length: 20, default: 'shelf' }) + locationType: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Coordenadas dentro del almacen + @Column({ type: 'varchar', length: 10, nullable: true }) + aisle: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + rack: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + shelf: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + bin: string; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Restricciones + @Column({ name: 'allowed_product_types', type: 'text', array: true, default: '{}' }) + allowedProductTypes: string[]; + + @Column({ name: 'temperature_range', type: 'jsonb', nullable: true }) + temperatureRange: { min?: number; max?: number }; + + @Column({ name: 'humidity_range', type: 'jsonb', nullable: true }) + humidityRange: { min?: number; max?: number }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_pickable', type: 'boolean', default: true }) + isPickable: boolean; + + @Column({ name: 'is_receivable', type: 'boolean', default: true }) + isReceivable: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/warehouses/entities/warehouse-zone.entity.ts b/src/modules/warehouses/entities/warehouse-zone.entity.ts new file mode 100644 index 0000000..d710cc5 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -0,0 +1,41 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_zones', schema: 'inventory' }) +export class WarehouseZone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color?: string; + + @Index() + @Column({ name: 'zone_type', type: 'varchar', length: 20, default: 'storage' }) + zoneType: 'storage' | 'picking' | 'packing' | 'shipping' | 'receiving' | 'quarantine'; + + @Column({ name: 'is_active', type: 'boolean', 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/warehouses/entities/warehouse.entity.ts b/src/modules/warehouses/entities/warehouse.entity.ts new file mode 100644 index 0000000..0343d30 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse.entity.ts @@ -0,0 +1,138 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity'; + +/** + * Warehouse Entity (schema: inventory.warehouses) + * + * This is the CANONICAL warehouse entity for the ERP system. + * All warehouse-related imports should use this entity. + * + * Note: The deprecated entity at inventory/entities/warehouse.entity.ts + * has been superseded by this one and should not be used for new code. + */ +@Entity({ name: 'warehouses', schema: 'inventory' }) +@Index('idx_warehouses_tenant_id', ['tenantId']) +@Index('idx_warehouses_company_id', ['companyId']) +@Index('idx_warehouses_code_company', ['companyId', 'code'], { unique: true }) +export class Warehouse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId: string | null; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'warehouse_type', type: 'varchar', length: 20, default: 'standard' }) + warehouseType: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + // Direccion + @Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true }) + addressLine1: string; + + @Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true }) + addressLine2: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Contacto + @Column({ name: 'manager_name', type: 'varchar', length: 100, nullable: true }) + managerName: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Configuracion + @Column({ type: 'jsonb', default: {} }) + settings: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + // 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/webhooks/entities/delivery.entity.ts b/src/modules/webhooks/entities/delivery.entity.ts new file mode 100644 index 0000000..47d82d0 --- /dev/null +++ b/src/modules/webhooks/entities/delivery.entity.ts @@ -0,0 +1,105 @@ +/** + * WebhookDelivery Entity + * Delivery tracking with retry logic + * Compatible with erp-core delivery.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type DeliveryStatus = 'pending' | 'sending' | 'delivered' | 'failed' | 'retrying' | 'cancelled'; + +@Entity({ name: 'deliveries', schema: 'webhooks' }) +export class WebhookDelivery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'event_id', type: 'uuid' }) + eventId: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'payload_hash', type: 'varchar', length: 64, nullable: true }) + payloadHash: string; + + @Column({ name: 'request_url', type: 'text' }) + requestUrl: string; + + @Column({ name: 'request_method', type: 'varchar', length: 10 }) + requestMethod: string; + + @Column({ name: 'request_headers', type: 'jsonb', default: {} }) + requestHeaders: Record; + + @Column({ name: 'response_status', type: 'int', nullable: true }) + responseStatus: number; + + @Column({ name: 'response_headers', type: 'jsonb', default: {} }) + responseHeaders: Record; + + @Column({ name: 'response_body', type: 'text', nullable: true }) + responseBody: string; + + @Column({ name: 'response_time_ms', type: 'int', nullable: true }) + responseTimeMs: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: DeliveryStatus; + + @Column({ name: 'attempt_number', type: 'int', default: 1 }) + attemptNumber: number; + + @Column({ name: 'max_attempts', type: 'int', default: 5 }) + maxAttempts: number; + + @Index() + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode: string; + + @Column({ name: 'scheduled_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + scheduledAt: Date; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint-log.entity.ts b/src/modules/webhooks/entities/endpoint-log.entity.ts new file mode 100644 index 0000000..cde11c2 --- /dev/null +++ b/src/modules/webhooks/entities/endpoint-log.entity.ts @@ -0,0 +1,54 @@ +/** + * WebhookEndpointLog Entity + * Activity log for webhook endpoints + * Compatible with erp-core endpoint-log.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type WebhookLogType = 'config_changed' | 'activated' | 'deactivated' | 'verified' | 'error' | 'rate_limited' | 'created'; + +@Entity({ name: 'endpoint_logs', schema: 'webhooks' }) +export class WebhookEndpointLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'log_type', type: 'varchar', length: 30 }) + logType: WebhookLogType; + + @Column({ name: 'message', type: 'text', nullable: true }) + message: string; + + @Column({ name: 'details', type: 'jsonb', default: {} }) + details: Record; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint.entity.ts b/src/modules/webhooks/entities/endpoint.entity.ts new file mode 100644 index 0000000..a7ab0ad --- /dev/null +++ b/src/modules/webhooks/entities/endpoint.entity.ts @@ -0,0 +1,118 @@ +/** + * WebhookEndpoint Entity + * Outbound webhook endpoint configuration with retry and rate limiting + * Compatible with erp-core endpoint.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AuthType = 'none' | 'basic' | 'bearer' | 'hmac' | 'oauth2'; + +@Entity({ name: 'endpoints', schema: 'webhooks' }) +@Unique(['tenantId', 'url']) +export class WebhookEndpoint { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'url', type: 'text' }) + url: string; + + @Column({ name: 'http_method', type: 'varchar', length: 10, default: 'POST' }) + httpMethod: string; + + @Column({ name: 'auth_type', type: 'varchar', length: 30, default: 'none' }) + authType: AuthType; + + @Column({ name: 'auth_config', type: 'jsonb', default: {} }) + authConfig: Record; + + @Column({ name: 'custom_headers', type: 'jsonb', default: {} }) + customHeaders: Record; + + @Column({ name: 'subscribed_events', type: 'text', array: true, default: [] }) + subscribedEvents: string[]; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'retry_enabled', type: 'boolean', default: true }) + retryEnabled: boolean; + + @Column({ name: 'max_retries', type: 'int', default: 5 }) + maxRetries: number; + + @Column({ name: 'retry_delay_seconds', type: 'int', default: 60 }) + retryDelaySeconds: number; + + @Column({ name: 'retry_backoff_multiplier', type: 'decimal', precision: 3, scale: 1, default: 2.0 }) + retryBackoffMultiplier: number; + + @Column({ name: 'timeout_seconds', type: 'int', default: 30 }) + timeoutSeconds: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'signing_secret', type: 'varchar', length: 255, nullable: true }) + signingSecret: string; + + @Column({ name: 'total_deliveries', type: 'int', default: 0 }) + totalDeliveries: number; + + @Column({ name: 'successful_deliveries', type: 'int', default: 0 }) + successfulDeliveries: number; + + @Column({ name: 'failed_deliveries', type: 'int', default: 0 }) + failedDeliveries: number; + + @Column({ name: 'last_delivery_at', type: 'timestamptz', nullable: true }) + lastDeliveryAt: Date; + + @Column({ name: 'last_success_at', type: 'timestamptz', nullable: true }) + lastSuccessAt: Date; + + @Column({ name: 'last_failure_at', type: 'timestamptz', nullable: true }) + lastFailureAt: Date; + + @Column({ name: 'rate_limit_per_minute', type: 'int', default: 60 }) + rateLimitPerMinute: number; + + @Column({ name: 'rate_limit_per_hour', type: 'int', default: 1000 }) + rateLimitPerHour: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/webhooks/entities/event-type.entity.ts b/src/modules/webhooks/entities/event-type.entity.ts new file mode 100644 index 0000000..5874773 --- /dev/null +++ b/src/modules/webhooks/entities/event-type.entity.ts @@ -0,0 +1,56 @@ +/** + * WebhookEventType Entity + * Event type definitions for webhook subscriptions + * Compatible with erp-core event-type.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type EventCategory = 'sales' | 'inventory' | 'customers' | 'auth' | 'billing' | 'system'; + +@Entity({ name: 'event_types', schema: 'webhooks' }) +export class WebhookEventType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: EventCategory; + + @Column({ name: 'payload_schema', type: 'jsonb', default: {} }) + payloadSchema: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_internal', type: 'boolean', default: false }) + isInternal: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/webhooks/entities/event.entity.ts b/src/modules/webhooks/entities/event.entity.ts new file mode 100644 index 0000000..987fa3e --- /dev/null +++ b/src/modules/webhooks/entities/event.entity.ts @@ -0,0 +1,69 @@ +/** + * WebhookEvent Entity + * Individual webhook events with dispatch tracking + * Compatible with erp-core event.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type WebhookEventStatus = 'pending' | 'processing' | 'dispatched' | 'failed'; + +@Entity({ name: 'events', schema: 'webhooks' }) +export class WebhookEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'resource_type', type: 'varchar', length: 100, nullable: true }) + resourceType: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'triggered_by', type: 'uuid', nullable: true }) + triggeredBy: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: WebhookEventStatus; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date; + + @Column({ name: 'dispatched_endpoints', type: 'int', default: 0 }) + dispatchedEndpoints: number; + + @Column({ name: 'failed_endpoints', type: 'int', default: 0 }) + failedEndpoints: number; + + @Index() + @Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true }) + idempotencyKey: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; +} diff --git a/src/modules/webhooks/entities/index.ts b/src/modules/webhooks/entities/index.ts new file mode 100644 index 0000000..c247310 --- /dev/null +++ b/src/modules/webhooks/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Webhooks Entities - Export + */ + +export { WebhookEventType, EventCategory } from './event-type.entity'; +export { WebhookEndpoint, AuthType } from './endpoint.entity'; +export { WebhookDelivery, DeliveryStatus } from './delivery.entity'; +export { WebhookEvent, WebhookEventStatus } from './event.entity'; +export { WebhookSubscription } from './subscription.entity'; +export { WebhookEndpointLog, WebhookLogType } from './endpoint-log.entity'; diff --git a/src/modules/webhooks/entities/subscription.entity.ts b/src/modules/webhooks/entities/subscription.entity.ts new file mode 100644 index 0000000..87993e8 --- /dev/null +++ b/src/modules/webhooks/entities/subscription.entity.ts @@ -0,0 +1,63 @@ +/** + * WebhookSubscription Entity + * Links endpoints to specific event types + * Compatible with erp-core subscription.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; +import { WebhookEventType } from './event-type.entity'; + +@Entity({ name: 'subscriptions', schema: 'webhooks' }) +@Unique(['endpointId', 'eventTypeId']) +export class WebhookSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'event_type_id', type: 'uuid' }) + eventTypeId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'payload_template', type: 'jsonb', nullable: true }) + payloadTemplate: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; + + @ManyToOne(() => WebhookEventType, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'event_type_id' }) + eventType: WebhookEventType; +} diff --git a/src/modules/whatsapp/entities/account.entity.ts b/src/modules/whatsapp/entities/account.entity.ts new file mode 100644 index 0000000..85893f2 --- /dev/null +++ b/src/modules/whatsapp/entities/account.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AccountStatus = 'pending' | 'active' | 'suspended' | 'disconnected'; + +@Entity({ name: 'accounts', schema: 'whatsapp' }) +@Unique(['tenantId', 'phoneNumber']) +export class WhatsAppAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'phone_number_id', type: 'varchar', length: 50 }) + phoneNumberId: string; + + @Column({ name: 'business_account_id', type: 'varchar', length: 50 }) + businessAccountId: string; + + @Column({ name: 'access_token', type: 'text', nullable: true }) + accessToken: string; + + @Column({ name: 'webhook_verify_token', type: 'varchar', length: 255, nullable: true }) + webhookVerifyToken: string; + + @Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true }) + webhookSecret: string; + + @Column({ name: 'business_name', type: 'varchar', length: 200, nullable: true }) + businessName: string; + + @Column({ name: 'business_description', type: 'text', nullable: true }) + businessDescription: string; + + @Column({ name: 'business_category', type: 'varchar', length: 100, nullable: true }) + businessCategory: string; + + @Column({ name: 'business_website', type: 'text', nullable: true }) + businessWebsite: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'default_language', type: 'varchar', length: 10, default: 'es_MX' }) + defaultLanguage: string; + + @Column({ name: 'auto_reply_enabled', type: 'boolean', default: false }) + autoReplyEnabled: boolean; + + @Column({ name: 'auto_reply_message', type: 'text', nullable: true }) + autoReplyMessage: string; + + @Column({ name: 'business_hours', type: 'jsonb', default: {} }) + businessHours: Record; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: AccountStatus; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'daily_message_limit', type: 'int', default: 1000 }) + dailyMessageLimit: number; + + @Column({ name: 'messages_sent_today', type: 'int', default: 0 }) + messagesSentToday: number; + + @Column({ name: 'last_limit_reset', type: 'timestamptz', nullable: true }) + lastLimitReset: Date; + + @Column({ name: 'total_messages_sent', type: 'bigint', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'bigint', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/whatsapp/entities/automation.entity.ts b/src/modules/whatsapp/entities/automation.entity.ts new file mode 100644 index 0000000..a8aeb81 --- /dev/null +++ b/src/modules/whatsapp/entities/automation.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type AutomationTriggerType = 'keyword' | 'first_message' | 'after_hours' | 'no_response' | 'webhook'; +export type AutomationActionType = 'send_message' | 'send_template' | 'assign_agent' | 'add_tag' | 'create_ticket'; + +@Entity({ name: 'automations', schema: 'whatsapp' }) +export class WhatsAppAutomation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'trigger_type', type: 'varchar', length: 30 }) + triggerType: AutomationTriggerType; + + @Column({ name: 'trigger_config', type: 'jsonb', default: {} }) + triggerConfig: Record; + + @Column({ name: 'action_type', type: 'varchar', length: 30 }) + actionType: AutomationActionType; + + @Column({ name: 'action_config', type: 'jsonb', default: {} }) + actionConfig: Record; + + @Column({ name: 'conditions', type: 'jsonb', default: [] }) + conditions: Record[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'priority', type: 'int', default: 0 }) + priority: number; + + @Column({ name: 'trigger_count', type: 'int', default: 0 }) + triggerCount: number; + + @Column({ name: 'last_triggered_at', type: 'timestamptz', nullable: true }) + lastTriggeredAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/broadcast-recipient.entity.ts b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts new file mode 100644 index 0000000..71fb0ad --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Broadcast } from './broadcast.entity'; +import { WhatsAppContact } from './contact.entity'; +import { WhatsAppMessage } from './message.entity'; + +export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; + +@Entity({ name: 'broadcast_recipients', schema: 'whatsapp' }) +@Unique(['broadcastId', 'contactId']) +export class BroadcastRecipient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'broadcast_id', type: 'uuid' }) + broadcastId: string; + + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: any[]; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: RecipientStatus; + + @Column({ name: 'message_id', type: 'uuid', nullable: true }) + messageId: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => Broadcast, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'broadcast_id' }) + broadcast: Broadcast; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; + + @ManyToOne(() => WhatsAppMessage, { nullable: true }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/broadcast.entity.ts b/src/modules/whatsapp/entities/broadcast.entity.ts new file mode 100644 index 0000000..94f11cc --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppTemplate } from './template.entity'; + +export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'completed' | 'cancelled' | 'failed'; +export type AudienceType = 'all' | 'segment' | 'custom' | 'file'; + +@Entity({ name: 'broadcasts', schema: 'whatsapp' }) +export class Broadcast { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @Column({ name: 'audience_type', type: 'varchar', length: 30 }) + audienceType: AudienceType; + + @Column({ name: 'audience_filter', type: 'jsonb', default: {} }) + audienceFilter: Record; + + @Column({ name: 'recipient_count', type: 'int', default: 0 }) + recipientCount: number; + + @Index() + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt: Date; + + @Column({ name: 'timezone', type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'draft' }) + status: BroadcastStatus; + + @Column({ name: 'sent_count', type: 'int', default: 0 }) + sentCount: number; + + @Column({ name: 'delivered_count', type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ name: 'read_count', type: 'int', default: 0 }) + readCount: number; + + @Column({ name: 'failed_count', type: 'int', default: 0 }) + failedCount: number; + + @Column({ name: 'reply_count', type: 'int', default: 0 }) + replyCount: number; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'estimated_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + estimatedCost: number; + + @Column({ name: 'actual_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + actualCost: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppTemplate) + @JoinColumn({ name: 'template_id' }) + template: WhatsAppTemplate; +} diff --git a/src/modules/whatsapp/entities/contact.entity.ts b/src/modules/whatsapp/entities/contact.entity.ts new file mode 100644 index 0000000..b3b6726 --- /dev/null +++ b/src/modules/whatsapp/entities/contact.entity.ts @@ -0,0 +1,99 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type ConversationStatus = 'active' | 'waiting' | 'resolved' | 'blocked'; +export type MessageDirection = 'inbound' | 'outbound'; + +@Entity({ name: 'contacts', schema: 'whatsapp' }) +@Unique(['accountId', 'phoneNumber']) +export class WhatsAppContact { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'wa_id', type: 'varchar', length: 50, nullable: true }) + waId: string; + + @Column({ name: 'profile_name', type: 'varchar', length: 200, nullable: true }) + profileName: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'conversation_status', type: 'varchar', length: 20, default: 'active' }) + conversationStatus: ConversationStatus; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + lastMessageAt: Date; + + @Column({ name: 'last_message_direction', type: 'varchar', length: 10, nullable: true }) + lastMessageDirection: MessageDirection; + + @Column({ name: 'conversation_window_expires_at', type: 'timestamptz', nullable: true }) + conversationWindowExpiresAt: Date; + + @Column({ name: 'can_send_template_only', type: 'boolean', default: true }) + canSendTemplateOnly: boolean; + + @Index() + @Column({ name: 'opted_in', type: 'boolean', default: false }) + optedIn: boolean; + + @Column({ name: 'opted_in_at', type: 'timestamptz', nullable: true }) + optedInAt: Date; + + @Column({ name: 'opted_out', type: 'boolean', default: false }) + optedOut: boolean; + + @Column({ name: 'opted_out_at', type: 'timestamptz', nullable: true }) + optedOutAt: Date; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'total_messages_sent', type: 'int', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'int', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/conversation.entity.ts b/src/modules/whatsapp/entities/conversation.entity.ts new file mode 100644 index 0000000..7eef57b --- /dev/null +++ b/src/modules/whatsapp/entities/conversation.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type WAConversationStatus = 'open' | 'pending' | 'resolved' | 'closed'; +export type WAConversationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +@Entity({ name: 'conversations', schema: 'whatsapp' }) +export class WhatsAppConversation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'open' }) + status: WAConversationStatus; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: WAConversationPriority; + + @Index() + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', nullable: true }) + assignedAt: Date; + + @Column({ name: 'team_id', type: 'uuid', nullable: true }) + teamId: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'first_response_at', type: 'timestamptz', nullable: true }) + firstResponseAt: Date; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @Column({ name: 'message_count', type: 'int', default: 0 }) + messageCount: number; + + @Column({ name: 'unread_count', type: 'int', default: 0 }) + unreadCount: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/index.ts b/src/modules/whatsapp/entities/index.ts new file mode 100644 index 0000000..4eafbfe --- /dev/null +++ b/src/modules/whatsapp/entities/index.ts @@ -0,0 +1,10 @@ +export { WhatsAppAccount, AccountStatus } from './account.entity'; +export { WhatsAppContact, ConversationStatus } from './contact.entity'; +export { WhatsAppMessage, MessageType, MessageStatus, MessageDirection, CostCategory } from './message.entity'; +export { WhatsAppTemplate, TemplateCategory, TemplateStatus, HeaderType } from './template.entity'; +export { WhatsAppConversation, WAConversationStatus, WAConversationPriority } from './conversation.entity'; +export { MessageStatusUpdate } from './message-status-update.entity'; +export { QuickReply } from './quick-reply.entity'; +export { WhatsAppAutomation, AutomationTriggerType, AutomationActionType } from './automation.entity'; +export { Broadcast, BroadcastStatus, AudienceType } from './broadcast.entity'; +export { BroadcastRecipient, RecipientStatus } from './broadcast-recipient.entity'; diff --git a/src/modules/whatsapp/entities/message-status-update.entity.ts b/src/modules/whatsapp/entities/message-status-update.entity.ts new file mode 100644 index 0000000..a729d27 --- /dev/null +++ b/src/modules/whatsapp/entities/message-status-update.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppMessage } from './message.entity'; + +@Entity({ name: 'message_status_updates', schema: 'whatsapp' }) +export class MessageStatusUpdate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'message_id', type: 'uuid' }) + messageId: string; + + @Column({ name: 'status', type: 'varchar', length: 20 }) + status: string; + + @Column({ name: 'previous_status', type: 'varchar', length: 20, nullable: true }) + previousStatus: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_title', type: 'varchar', length: 200, nullable: true }) + errorTitle: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'meta_timestamp', type: 'timestamptz', nullable: true }) + metaTimestamp: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WhatsAppMessage, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/message.entity.ts b/src/modules/whatsapp/entities/message.entity.ts new file mode 100644 index 0000000..d51fc47 --- /dev/null +++ b/src/modules/whatsapp/entities/message.entity.ts @@ -0,0 +1,137 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type MessageType = 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'location' | 'contacts' | 'interactive' | 'template' | 'reaction'; +export type MessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; +export type MessageDirection = 'inbound' | 'outbound'; +export type CostCategory = 'utility' | 'authentication' | 'marketing'; + +@Entity({ name: 'messages', schema: 'whatsapp' }) +export class WhatsAppMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Index() + @Column({ name: 'wa_message_id', type: 'varchar', length: 100, nullable: true }) + waMessageId: string; + + @Column({ name: 'wa_conversation_id', type: 'varchar', length: 100, nullable: true }) + waConversationId: string; + + @Index() + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction: MessageDirection; + + @Column({ name: 'message_type', type: 'varchar', length: 20 }) + messageType: MessageType; + + @Column({ name: 'content', type: 'text', nullable: true }) + content: string; + + @Column({ name: 'caption', type: 'text', nullable: true }) + caption: string; + + @Column({ name: 'media_id', type: 'varchar', length: 100, nullable: true }) + mediaId: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'media_mime_type', type: 'varchar', length: 100, nullable: true }) + mediaMimeType: string; + + @Column({ name: 'media_sha256', type: 'varchar', length: 64, nullable: true }) + mediaSha256: string; + + @Column({ name: 'media_size_bytes', type: 'int', nullable: true }) + mediaSizeBytes: number; + + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'template_name', type: 'varchar', length: 512, nullable: true }) + templateName: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: string[]; + + @Column({ name: 'interactive_type', type: 'varchar', length: 30, nullable: true }) + interactiveType: string; + + @Column({ name: 'interactive_data', type: 'jsonb', default: {} }) + interactiveData: Record; + + @Column({ name: 'context_message_id', type: 'varchar', length: 100, nullable: true }) + contextMessageId: string; + + @Column({ name: 'quoted_message_id', type: 'uuid', nullable: true }) + quotedMessageId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: MessageStatus; + + @Column({ name: 'status_updated_at', type: 'timestamptz', nullable: true }) + statusUpdatedAt: Date; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'is_billable', type: 'boolean', default: false }) + isBillable: boolean; + + @Column({ name: 'cost_category', type: 'varchar', length: 30, nullable: true }) + costCategory: CostCategory; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/quick-reply.entity.ts b/src/modules/whatsapp/entities/quick-reply.entity.ts new file mode 100644 index 0000000..ddb14bf --- /dev/null +++ b/src/modules/whatsapp/entities/quick-reply.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +@Entity({ name: 'quick_replies', schema: 'whatsapp' }) +@Unique(['tenantId', 'shortcut']) +export class QuickReply { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId: string; + + @Index() + @Column({ name: 'shortcut', type: 'varchar', length: 50 }) + shortcut: string; + + @Column({ name: 'title', type: 'varchar', length: 200 }) + title: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'message_type', type: 'varchar', length: 20, default: 'text' }) + messageType: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/template.entity.ts b/src/modules/whatsapp/entities/template.entity.ts new file mode 100644 index 0000000..1100d5d --- /dev/null +++ b/src/modules/whatsapp/entities/template.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type TemplateCategory = 'MARKETING' | 'UTILITY' | 'AUTHENTICATION'; +export type TemplateStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED' | 'DISABLED'; +export type HeaderType = 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'; + +@Entity({ name: 'templates', schema: 'whatsapp' }) +@Unique(['accountId', 'name', 'language']) +export class WhatsAppTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 512 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 200 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30 }) + category: TemplateCategory; + + @Column({ name: 'language', type: 'varchar', length: 10, default: 'es_MX' }) + language: string; + + @Column({ name: 'header_type', type: 'varchar', length: 20, nullable: true }) + headerType: HeaderType; + + @Column({ name: 'header_text', type: 'text', nullable: true }) + headerText: string; + + @Column({ name: 'header_media_url', type: 'text', nullable: true }) + headerMediaUrl: string; + + @Column({ name: 'body_text', type: 'text' }) + bodyText: string; + + @Column({ name: 'body_variables', type: 'text', array: true, default: [] }) + bodyVariables: string[]; + + @Column({ name: 'footer_text', type: 'varchar', length: 60, nullable: true }) + footerText: string; + + @Column({ name: 'buttons', type: 'jsonb', default: [] }) + buttons: Record[]; + + @Column({ name: 'meta_template_id', type: 'varchar', length: 50, nullable: true }) + metaTemplateId: string; + + @Index() + @Column({ name: 'meta_status', type: 'varchar', length: 20, default: 'PENDING' }) + metaStatus: TemplateStatus; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/server.ts b/src/server.ts index 30a6f09..929eebd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -64,7 +64,7 @@ import { createInspectionController, createTicketController } from './modules/qu import { createContractController, createSubcontractorController } from './modules/contracts/controllers'; import { createReportController, createDashboardController, createKpiController } from './modules/reports/controllers'; import { createCostCenterController, createAuditLogController, createSystemSettingController, createBackupController } from './modules/admin/controllers'; -import { createOpportunityController, createBidController, createBidBudgetController, createBidAnalyticsController } from './modules/bidding/controllers'; +import { createOpportunityController, createBidAnalyticsController, createTenderController, createProposalController, createVendorController } from './modules/bidding/controllers'; import { createAccountingController, createAPController, createARController, createCashFlowController, createBankReconciliationController, createReportsController } from './modules/finance/controllers'; // Root API info @@ -316,11 +316,14 @@ async function bootstrap() { const opportunityController = createOpportunityController(AppDataSource); app.use(`/api/${API_VERSION}/opportunities`, opportunityController); - const bidController = createBidController(AppDataSource); - app.use(`/api/${API_VERSION}/bids`, bidController); + const tenderController = createTenderController(AppDataSource); + app.use(`/api/${API_VERSION}/tenders`, tenderController); - const bidBudgetController = createBidBudgetController(AppDataSource); - app.use(`/api/${API_VERSION}/bid-budgets`, bidBudgetController); + const proposalController = createProposalController(AppDataSource); + app.use(`/api/${API_VERSION}/proposals`, proposalController); + + const vendorController = createVendorController(AppDataSource); + app.use(`/api/${API_VERSION}/vendors`, vendorController); const bidAnalyticsController = createBidAnalyticsController(AppDataSource); app.use(`/api/${API_VERSION}/bid-analytics`, bidAnalyticsController); diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 0000000..9dd54e9 --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1,9 @@ +// Re-export all error classes from types +export { + AppError, + ValidationError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, +} from '../types/index'; diff --git a/src/shared/middleware/auth.middleware.ts b/src/shared/middleware/auth.middleware.ts new file mode 100644 index 0000000..04fcaa4 --- /dev/null +++ b/src/shared/middleware/auth.middleware.ts @@ -0,0 +1,120 @@ +/** + * Auth Middleware - JWT Authentication & Authorization + * Compatible con erp-core auth middleware + */ + +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../../config/index'; +import { UnauthorizedError, ForbiddenError } from '../types/index'; +import { logger } from '../utils/logger'; + +export function authenticate( + req: Request, + _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 any; + req.user = payload; + req.tenantId = payload.tenantId; + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new UnauthorizedError('Token expirado'); + } + throw new UnauthorizedError('Token invalido'); + } + } catch (error) { + next(error); + } +} + +export function requireRoles(...roles: string[]) { + return (req: Request, _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.sub, + requiredRoles: roles, + userRoles: req.user.roles, + }); + throw new ForbiddenError('No tiene permisos para esta accion'); + } + + next(); + } catch (error) { + next(error); + } + }; +} + +export function requirePermission(resource: string, action: string) { + return async (req: Request, _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 + logger.debug('Permission check', { + userId: req.user.sub, + resource, + action, + }); + + next(); + } catch (error) { + next(error); + } + }; +} + +export function optionalAuth( + req: Request, + _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 any; + 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 0000000..920bfd5 --- /dev/null +++ b/src/shared/middleware/fieldPermissions.middleware.ts @@ -0,0 +1,286 @@ +/** + * Field Permissions Middleware + * Filtrado de campos a nivel de lectura/escritura por usuario/modelo + * Compatible con erp-core fieldPermissions middleware + */ + +import { Request, Response, NextFunction } from 'express'; +import { AppDataSource } from '../database/typeorm.config'; +import { logger } from '../utils/logger'; + +// ============================================================================ +// 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 +// ============================================================================ + +function getCacheKey(userId: string, tenantId: string, modelName: string): string { + return `${tenantId}:${userId}:${modelName}`; +} + +async function loadFieldPermissions( + userId: string, + tenantId: string, + modelName: string +): Promise { + const cacheKey = getCacheKey(userId, tenantId, modelName); + const cached = permissionsCache.get(cacheKey); + + if (cached && cached.expires > Date.now()) { + return cached.permissions; + } + + try { + const result = await AppDataSource.query( + `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 || result.length === 0) { + 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, + }); + } + + permissionsCache.set(cacheKey, { + permissions, + expires: Date.now() + CACHE_TTL, + }); + + return permissions; + } catch { + // Tables might not exist yet - return null (allow all) + return null; + } +} + +function filterReadFields>( + data: T, + permissions: ModelFieldPermissions | null +): Partial { + 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 (!fieldPerm || fieldPerm.can_read) { + filtered[key] = value; + } + } + return filtered as Partial; +} + +function filterReadFieldsArray>( + data: T[], + permissions: ModelFieldPermissions | null +): Partial[] { + return data.map(item => filterReadFields(item, permissions)); +} + +function validateWriteFields>( + data: T, + permissions: ModelFieldPermissions | null +): { valid: boolean; forbiddenFields: string[] } { + 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 (fieldPerm && !fieldPerm.can_write) { + forbiddenFields.push(key); + } + } + + return { + valid: forbiddenFields.length === 0, + forbiddenFields, + }; +} + +// ============================================================================ +// MIDDLEWARE FACTORIES +// ============================================================================ + +export function filterResponseFields(modelName: string) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const originalJson = res.json.bind(res); + + res.json = function(body: any) { + (async () => { + try { + if (!req.user) { + return originalJson(body); + } + + const permissions = await loadFieldPermissions( + req.user.sub, + req.user.tenantId, + modelName + ); + + if (!permissions || req.user.roles.includes('super_admin')) { + return originalJson(body); + } + + 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(); + }; +} + +export function validateWritePermissions(modelName: string) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + return next(); + } + + if (req.user.roles.includes('super_admin')) { + return next(); + } + + const permissions = await loadFieldPermissions( + req.user.sub, + req.user.tenantId, + modelName + ); + + if (!permissions) { + return next(); + } + + 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.sub, + 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); + } + }; +} + +export function fieldPermissions(modelName: string) { + const readFilter = filterResponseFields(modelName); + const writeValidator = validateWritePermissions(modelName); + + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + await writeValidator(req, res, () => { + readFilter(req, res, next); + }); + } else { + await readFilter(req, res, next); + } + }; +} + +export function clearPermissionsCache(userId?: string, tenantId?: string): void { + if (userId && tenantId) { + const prefix = `${tenantId}:${userId}:`; + for (const key of permissionsCache.keys()) { + if (key.startsWith(prefix)) { + permissionsCache.delete(key); + } + } + } else { + permissionsCache.clear(); + } +} + +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/services/base.service.ts b/src/shared/services/base.service.ts index ce8abc6..905238c 100644 --- a/src/shared/services/base.service.ts +++ b/src/shared/services/base.service.ts @@ -97,7 +97,7 @@ export interface BaseServiceConfig { * } * ``` */ -export abstract class BaseService { +export abstract class BaseService<_T = unknown, _CreateDto = unknown, _UpdateDto = unknown> { protected abstract config: BaseServiceConfig; /** diff --git a/src/shared/services/feature-flags.service.ts b/src/shared/services/feature-flags.service.ts new file mode 100644 index 0000000..9943f0c --- /dev/null +++ b/src/shared/services/feature-flags.service.ts @@ -0,0 +1,183 @@ +/** + * Feature Flags Service + * Permite activar/desactivar funcionalidades por tenant, usuario o porcentaje + * Compatible con erp-core feature-flags service + */ + +export interface FeatureFlag { + enabled: boolean; + enabledTenants?: string[]; + disabledTenants?: string[]; + enabledUsers?: string[]; + disabledUsers?: string[]; + rolloutPercentage?: number; + description?: string; + metadata?: Record; +} + +export interface FeatureFlagContext { + tenantId?: string; + userId?: string; + profileCode?: string; + platform?: string; +} + +export class FeatureFlagService { + private flags: Map = new Map(); + private static instance: FeatureFlagService; + + private constructor() { + this.loadDefaultFlags(); + } + + static getInstance(): FeatureFlagService { + if (!FeatureFlagService.instance) { + FeatureFlagService.instance = new FeatureFlagService(); + } + return FeatureFlagService.instance; + } + + private loadDefaultFlags(): void { + this.flags.set('mobile_app_enabled', { + enabled: true, + description: 'Habilita el acceso a la aplicacion movil', + }); + + this.flags.set('biometric_auth', { + enabled: true, + description: 'Habilita autenticacion biometrica', + }); + + this.flags.set('offline_mode', { + enabled: true, + description: 'Habilita modo offline en la app movil', + }); + + this.flags.set('payment_terminals', { + enabled: true, + description: 'Habilita integracion con terminales de pago', + }); + + this.flags.set('curva_s_dashboard', { + enabled: true, + description: 'Habilita dashboard Curva S (Earned Value)', + }); + + this.flags.set('hse_module', { + enabled: true, + description: 'Habilita modulo HSE completo', + }); + + this.flags.set('infonavit_integration', { + enabled: true, + description: 'Habilita integracion INFONAVIT', + }); + } + + async isEnabled(flagName: string, context?: FeatureFlagContext): Promise { + const flag = this.flags.get(flagName); + + if (!flag) { + return false; + } + + if (!flag.enabled) { + return false; + } + + if (context?.tenantId && flag.disabledTenants?.includes(context.tenantId)) { + return false; + } + + if (context?.tenantId && flag.enabledTenants?.length) { + if (!flag.enabledTenants.includes(context.tenantId)) { + return false; + } + } + + if (context?.userId && flag.disabledUsers?.includes(context.userId)) { + return false; + } + + if (context?.userId && flag.enabledUsers?.length) { + if (!flag.enabledUsers.includes(context.userId)) { + if (flag.rolloutPercentage === undefined) { + return false; + } + } else { + return true; + } + } + + if (flag.rolloutPercentage !== undefined && flag.rolloutPercentage < 100) { + const identifier = context?.userId || context?.tenantId || flagName; + const hash = this.hashString(`${flagName}-${identifier}`); + return (hash % 100) < flag.rolloutPercentage; + } + + return true; + } + + async setFlag(flagName: string, flagConfig: FeatureFlag): Promise { + this.flags.set(flagName, flagConfig); + } + + async getFlag(flagName: string): Promise { + return this.flags.get(flagName); + } + + async getAllFlags(): Promise> { + return new Map(this.flags); + } + + async updateFlag(flagName: string, updates: Partial): Promise { + const existing = this.flags.get(flagName); + if (existing) { + this.flags.set(flagName, { ...existing, ...updates }); + } + } + + async deleteFlag(flagName: string): Promise { + this.flags.delete(flagName); + } + + async enableForTenant(flagName: string, tenantId: string): Promise { + const flag = this.flags.get(flagName); + if (flag) { + const enabledTenants = flag.enabledTenants || []; + if (!enabledTenants.includes(tenantId)) { + enabledTenants.push(tenantId); + } + flag.enabledTenants = enabledTenants; + if (flag.disabledTenants) { + flag.disabledTenants = flag.disabledTenants.filter((t) => t !== tenantId); + } + } + } + + async disableForTenant(flagName: string, tenantId: string): Promise { + const flag = this.flags.get(flagName); + if (flag) { + const disabledTenants = flag.disabledTenants || []; + if (!disabledTenants.includes(tenantId)) { + disabledTenants.push(tenantId); + } + flag.disabledTenants = disabledTenants; + if (flag.enabledTenants) { + flag.enabledTenants = flag.enabledTenants.filter((t) => t !== tenantId); + } + } + } + + private hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); + } +} + +// Export singleton instance +export const featureFlagService = FeatureFlagService.getInstance(); diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts index 74d25cf..096aaed 100644 --- a/src/shared/services/index.ts +++ b/src/shared/services/index.ts @@ -3,3 +3,4 @@ */ export * from './base.service'; +export * from './feature-flags.service'; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..627b611 --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1,161 @@ +/** + * Shared Types - erp-construccion + * Compatible con erp-core shared/types + */ + +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 - compatible con erp-core JwtPayload Y construccion TokenPayload +export interface JwtPayload { + // erp-core format + userId?: string; + // construccion legacy format + sub?: string; + id?: string; + // shared + tenantId: string; + email: string; + roles: string[]; + type?: 'access' | 'refresh'; + sessionId?: string; + jti?: string; + iat?: number; + exp?: number; +} + +// AuthenticatedRequest uses Express global augmentation from modules/auth +// Re-export Request type for convenience +export type AuthenticatedRequest = Request; + +// 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 +export interface Permission { + id: string; + resource: string; + action: string; + description?: string; + module: string; +} + +// Tenant types +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 +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 0000000..fd1365b --- /dev/null +++ b/src/shared/utils/circuit-breaker.ts @@ -0,0 +1,163 @@ +/** + * Circuit Breaker Pattern Implementation + * Previene llamadas a servicios externos cuando estan fallando + * Compatible con erp-core circuit-breaker + */ + +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; + 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; + } + + getName(): string { + return this.name; + } + + 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 for 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 0000000..399ed25 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,3 @@ +export { logger } from './logger'; +export { CircuitBreaker, CircuitBreakerOpenError, circuitBreakerRegistry } from './circuit-breaker'; +export type { CircuitBreakerState, CircuitBreakerOptions } from './circuit-breaker'; diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts new file mode 100644 index 0000000..fc256b3 --- /dev/null +++ b/src/shared/utils/logger.ts @@ -0,0 +1,45 @@ +/** + * Logger utility - Winston-based structured logging + * Compatible con erp-core logger + */ + +import winston from 'winston'; +import { config } from '../../config/index'; + +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' }) + ); +}