Compare commits

..

7 Commits

Author SHA1 Message Date
liuyun
64c3efd931 fix(c1): elder 日期用 UTC 零点,修复 @db.Date 提前一天的 off-by-one
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 21:27:26 +08:00
liuyun
a949acbc3b fix(c1): elder 建档日期字段转 Date 落库(修复 50000)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 21:23:21 +08:00
liuyun
0cbdb7f825 fix(c1): elder 十态校验/不可逆态守卫/待遇止>起 + statusLog 单测
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 17:29:41 +08:00
liuyun
1308675f80 feat(c1): elder 待遇档案模块(建档/状态机/掩码/上传)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 17:19:07 +08:00
liuyun
465057cce0 feat(c1): MinIO 存储模块
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 17:03:12 +08:00
liuyun
f3c775cea9 feat(c1): elder 权限码/错误码/接口资源
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 16:57:55 +08:00
liuyun
b83c59611c feat(c1): elder + elder_status_log 数据模型
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 16:54:45 +08:00
17 changed files with 1226 additions and 1 deletions

View File

@ -29,3 +29,10 @@ REDIS_PORT="6379"
REDIS_DB="0" REDIS_DB="0"
REDIS_PASSWORD="" REDIS_PASSWORD=""
REDIS_KEY_PREFIX="sysmgr:" REDIS_KEY_PREFIX="sysmgr:"
# MinIO(对象存储,供 elder 模块上传评估结论副本)
MINIO_ENDPOINT="localhost"
MINIO_PORT=9000
MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="minioadmin"
MINIO_BUCKET="carelink"

View File

@ -43,6 +43,7 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"minio": "^8.0.7",
"nestjs-pino": "^5.3.1", "nestjs-pino": "^5.3.1",
"pino": "^10.4.0", "pino": "^10.4.0",
"pino-pretty": "^13.2.0", "pino-pretty": "^13.2.0",

214
pnpm-lock.yaml generated
View File

@ -205,6 +205,9 @@ importers:
ioredis: ioredis:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0(supports-color@7.2.0) version: 6.0.0(supports-color@7.2.0)
minio:
specifier: ^8.0.7
version: 8.0.7
nestjs-pino: nestjs-pino:
specifier: ^5.3.1 specifier: ^5.3.1
version: 5.3.1(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(pino-http@11.0.0)(pino@10.4.0)(rxjs@7.8.2) version: 5.3.1(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(pino-http@11.0.0)(pino@10.4.0)(rxjs@7.8.2)
@ -790,6 +793,9 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16} engines: {node: ^14.21.3 || >=16}
'@nodable/entities@3.1.0':
resolution: {integrity: sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==}
'@oxc-project/types@0.153.0': '@oxc-project/types@0.153.0':
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
@ -1224,6 +1230,9 @@ packages:
resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==}
engines: {node: '>=14'} engines: {node: '>=14'}
anynum@1.0.1:
resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
append-field@1.0.0: append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
@ -1243,6 +1252,9 @@ packages:
ast-v8-to-istanbul@1.0.7: ast-v8-to-istanbul@1.0.7:
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0: asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@ -1258,6 +1270,9 @@ packages:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true hasBin: true
block-stream2@2.1.0:
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
body-parser@2.3.0: body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1266,6 +1281,13 @@ packages:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
browser-or-node@2.1.1:
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1: buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@ -1428,6 +1450,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
deepmerge-ts@7.1.5: deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@ -1538,6 +1564,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.4.0: expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -1574,6 +1603,13 @@ packages:
fast-wrap-ansi@0.2.2: fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
fast-xml-builder@1.3.1:
resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==}
fast-xml-parser@5.11.2:
resolution: {integrity: sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==}
hasBin: true
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -1587,6 +1623,10 @@ packages:
resolution: {integrity: sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==} resolution: {integrity: sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==}
engines: {node: '>=22'} engines: {node: '>=22'}
filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
finalhandler@2.1.1: finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'} engines: {node: '>= 18.0.0'}
@ -1690,6 +1730,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
ipaddr.js@2.5.0:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'}
is-fullwidth-code-point@3.0.0: is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1705,6 +1749,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
is-unsafe@2.0.2:
resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==}
istanbul-lib-coverage@3.2.2: istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1859,6 +1906,9 @@ packages:
lodash.once@4.1.1: lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@7.0.1: log-symbols@7.0.1:
resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1932,6 +1982,10 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minio@8.0.7:
resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
engines: {node: ^16 || ^18 || >=20}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -2029,6 +2083,10 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
path-expression-matcher@1.6.2:
resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
engines: {node: '>=14.0.0'}
path-to-regexp@8.4.2: path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
@ -2108,6 +2166,10 @@ packages:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
quick-format-unescaped@4.0.4: quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@ -2122,6 +2184,10 @@ packages:
rc9@2.1.2: rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@ -2170,6 +2236,10 @@ packages:
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.6.1:
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
engines: {node: '>=11.0.0'}
secure-json-parse@4.1.0: secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
@ -2237,6 +2307,10 @@ packages:
resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
split2@4.2.0: split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
@ -2258,10 +2332,20 @@ packages:
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
engines: {node: '>=18'} engines: {node: '>=18'}
stream-chain@2.2.5:
resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
stream-json@1.9.1:
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
streamsearch@1.1.0: streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2270,6 +2354,9 @@ packages:
resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1: strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2286,6 +2373,9 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
strnum@2.5.1:
resolution: {integrity: sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==}
strtok3@10.3.5: strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2309,6 +2399,9 @@ packages:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -2386,6 +2479,9 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
validator@13.15.35: validator@13.15.35:
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -2494,6 +2590,18 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xml-naming@0.3.0:
resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
engines: {node: '>=16.0.0'}
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
yaml@2.9.1: yaml@2.9.1:
resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
@ -2895,6 +3003,8 @@ snapshots:
'@noble/hashes@1.8.0': {} '@noble/hashes@1.8.0': {}
'@nodable/entities@3.1.0': {}
'@oxc-project/types@0.153.0': {} '@oxc-project/types@0.153.0': {}
'@oxlint-tsgolint/darwin-arm64@7.0.2003': '@oxlint-tsgolint/darwin-arm64@7.0.2003':
@ -3233,6 +3343,8 @@ snapshots:
ansis@4.4.0: {} ansis@4.4.0: {}
anynum@1.0.1: {}
append-field@1.0.0: {} append-field@1.0.0: {}
argparse@2.0.1: {} argparse@2.0.1: {}
@ -3249,6 +3361,8 @@ snapshots:
estree-walker: 3.0.3 estree-walker: 3.0.3
js-tokens: 10.0.0 js-tokens: 10.0.0
async@3.2.6: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
@ -3257,6 +3371,10 @@ snapshots:
bcryptjs@3.0.3: {} bcryptjs@3.0.3: {}
block-stream2@2.1.0:
dependencies:
readable-stream: 3.6.2
body-parser@2.3.0(supports-color@7.2.0): body-parser@2.3.0(supports-color@7.2.0):
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@ -3275,6 +3393,10 @@ snapshots:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
browser-or-node@2.1.1: {}
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {} buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
@ -3411,6 +3533,8 @@ snapshots:
optionalDependencies: optionalDependencies:
supports-color: 7.2.0 supports-color: 7.2.0
decode-uri-component@0.2.2: {}
deepmerge-ts@7.1.5: {} deepmerge-ts@7.1.5: {}
defu@6.1.7: {} defu@6.1.7: {}
@ -3521,6 +3645,8 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
eventemitter3@5.0.4: {}
expect-type@1.4.0: {} expect-type@1.4.0: {}
express@5.2.1(supports-color@7.2.0): express@5.2.1(supports-color@7.2.0):
@ -3580,6 +3706,20 @@ snapshots:
dependencies: dependencies:
fast-string-width: 3.0.2 fast-string-width: 3.0.2
fast-xml-builder@1.3.1:
dependencies:
path-expression-matcher: 1.6.2
xml-naming: 0.3.0
fast-xml-parser@5.11.2:
dependencies:
'@nodable/entities': 3.1.0
fast-xml-builder: 1.3.1
is-unsafe: 2.0.2
path-expression-matcher: 1.6.2
strnum: 2.5.1
xml-naming: 0.3.0
fdir@6.5.0(picomatch@4.0.7): fdir@6.5.0(picomatch@4.0.7):
optionalDependencies: optionalDependencies:
picomatch: 4.0.7 picomatch: 4.0.7
@ -3593,6 +3733,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
filter-obj@1.1.0: {}
finalhandler@2.1.1(supports-color@7.2.0): finalhandler@2.1.1(supports-color@7.2.0):
dependencies: dependencies:
debug: 4.4.3(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0)
@ -3709,6 +3851,8 @@ snapshots:
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
ipaddr.js@2.5.0: {}
is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@3.0.0: {}
is-interactive@2.0.0: {} is-interactive@2.0.0: {}
@ -3717,6 +3861,8 @@ snapshots:
is-unicode-supported@2.1.0: {} is-unicode-supported@2.1.0: {}
is-unsafe@2.0.2: {}
istanbul-lib-coverage@3.2.2: {} istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1: istanbul-lib-report@3.0.1:
@ -3839,6 +3985,8 @@ snapshots:
lodash.once@4.1.1: {} lodash.once@4.1.1: {}
lodash@4.18.1: {}
log-symbols@7.0.1: log-symbols@7.0.1:
dependencies: dependencies:
is-unicode-supported: 2.1.0 is-unicode-supported: 2.1.0
@ -3896,6 +4044,22 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
minio@8.0.7:
dependencies:
async: 3.2.6
block-stream2: 2.1.0
browser-or-node: 2.1.1
buffer-crc32: 1.0.0
eventemitter3: 5.0.4
fast-xml-parser: 5.11.2
ipaddr.js: 2.5.0
lodash: 4.18.1
mime-types: 2.1.35
query-string: 7.1.3
stream-json: 1.9.1
through2: 4.0.2
xml2js: 0.6.2
ms@2.1.3: {} ms@2.1.3: {}
multer@2.4.0: multer@2.4.0:
@ -4002,6 +4166,8 @@ snapshots:
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-expression-matcher@1.6.2: {}
path-to-regexp@8.4.2: {} path-to-regexp@8.4.2: {}
pathe@2.0.3: {} pathe@2.0.3: {}
@ -4104,6 +4270,13 @@ snapshots:
es-define-property: 1.0.1 es-define-property: 1.0.1
side-channel: 1.1.1 side-channel: 1.1.1
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
filter-obj: 1.1.0
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
quick-format-unescaped@4.0.4: {} quick-format-unescaped@4.0.4: {}
range-parser@1.3.0: {} range-parser@1.3.0: {}
@ -4120,6 +4293,12 @@ snapshots:
defu: 6.1.7 defu: 6.1.7
destr: 2.0.5 destr: 2.0.5
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@4.1.2: {} readdirp@4.1.2: {}
readdirp@5.1.1: {} readdirp@5.1.1: {}
@ -4178,6 +4357,8 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
sax@1.6.1: {}
secure-json-parse@4.1.0: {} secure-json-parse@4.1.0: {}
semver@7.8.5: {} semver@7.8.5: {}
@ -4264,6 +4445,8 @@ snapshots:
source-map@0.8.0: {} source-map@0.8.0: {}
split-on-first@1.1.0: {}
split2@4.2.0: {} split2@4.2.0: {}
stackback@0.0.2: {} stackback@0.0.2: {}
@ -4276,8 +4459,16 @@ snapshots:
stdin-discarder@0.3.2: {} stdin-discarder@0.3.2: {}
stream-chain@2.2.5: {}
stream-json@1.9.1:
dependencies:
stream-chain: 2.2.5
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
strict-uri-encode@2.0.0: {}
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@ -4289,6 +4480,10 @@ snapshots:
get-east-asian-width: 1.7.0 get-east-asian-width: 1.7.0
strip-ansi: 7.2.0 strip-ansi: 7.2.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1: strip-ansi@6.0.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@ -4301,6 +4496,10 @@ snapshots:
strip-json-comments@5.0.3: {} strip-json-comments@5.0.3: {}
strnum@2.5.1:
dependencies:
anynum: 1.0.1
strtok3@10.3.5: strtok3@10.3.5:
dependencies: dependencies:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
@ -4339,6 +4538,10 @@ snapshots:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
through2@4.0.2:
dependencies:
readable-stream: 3.6.2
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@1.3.1: {} tinyexec@1.3.1: {}
@ -4401,6 +4604,8 @@ snapshots:
unpipe@1.0.0: {} unpipe@1.0.0: {}
util-deprecate@1.0.2: {}
validator@13.15.35: {} validator@13.15.35: {}
vary@1.1.2: {} vary@1.1.2: {}
@ -4466,6 +4671,15 @@ snapshots:
wrappy@1.0.2: {} wrappy@1.0.2: {}
xml-naming@0.3.0: {}
xml2js@0.6.2:
dependencies:
sax: 1.6.1
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
yaml@2.9.1: yaml@2.9.1:
optional: true optional: true

View File

@ -0,0 +1,80 @@
-- CreateTable
CREATE TABLE `ltc_elder` (
`id` VARCHAR(36) NOT NULL,
`tenant_code` VARCHAR(64) NOT NULL,
`loc_code` VARCHAR(12) NOT NULL,
`prev_loc_code` VARCHAR(12) NULL,
`transfer_confirm_at` DATETIME(3) NULL,
`name` VARCHAR(30) NOT NULL,
`gender` CHAR(1) NOT NULL,
`birth_date` DATE NOT NULL,
`id_card_no` VARCHAR(255) NOT NULL,
`phone` VARCHAR(15) NULL,
`address` VARCHAR(200) NOT NULL,
`guardian_name` VARCHAR(30) NULL,
`guardian_phone` VARCHAR(15) NULL,
`insur_no` VARCHAR(30) NOT NULL,
`insur_type` VARCHAR(10) NOT NULL,
`insur_years` INTEGER NULL,
`insur_verify_status` VARCHAR(20) NULL,
`insur_verify_date` DATE NULL,
`assess_level` VARCHAR(20) NULL,
`care_level` VARCHAR(20) NULL,
`assess_date` DATE NULL,
`assess_org` VARCHAR(100) NULL,
`assess_evidence_file` VARCHAR(255) NULL,
`benefit_start` DATE NULL,
`benefit_end` DATE NULL,
`treat_status` VARCHAR(20) NOT NULL DEFAULT 'pending_file',
`status_source` VARCHAR(10) NOT NULL DEFAULT 'manual',
`status_change_date` DATE NULL,
`hosp_start` DATE NULL,
`hosp_end` DATE NULL,
`photo_tier_override` JSON NULL,
`need_assess` JSON NULL,
`note` TEXT NULL,
`biz_line` VARCHAR(20) NOT NULL DEFAULT 'ltci',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by` VARCHAR(36) NULL,
`updated_at` DATETIME(3) NOT NULL,
`deleted_at` DATETIME(3) NULL,
INDEX `ltc_elder_tenant_code_name_idx`(`tenant_code`, `name`),
UNIQUE INDEX `ltc_elder_tenant_code_id_card_no_key`(`tenant_code`, `id_card_no`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- CreateTable
CREATE TABLE `ltc_elder_status_log` (
`id` VARCHAR(36) NOT NULL,
`elder_id` VARCHAR(36) NOT NULL,
`tenant_code` VARCHAR(64) NOT NULL,
`from_status` VARCHAR(20) NOT NULL,
`to_status` VARCHAR(20) NOT NULL,
`change_reason` VARCHAR(200) NOT NULL,
`source` VARCHAR(10) NOT NULL DEFAULT 'manual',
`operator_id` VARCHAR(36) NOT NULL,
`evidence_file` VARCHAR(255) NULL,
`changed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `ltc_elder_status_log_elder_id_idx`(`elder_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- RenameIndex
ALTER TABLE `ltc_staff` RENAME INDEX `sys_staff_tenant_code_name_idx` TO `ltc_staff_tenant_code_name_idx`;
-- RenameIndex
ALTER TABLE `ltc_staff` RENAME INDEX `sys_staff_user_id_key` TO `ltc_staff_user_id_key`;
-- RenameIndex
ALTER TABLE `svc_local` RENAME INDEX `sys_svc_local_loc_code_idx` TO `svc_local_loc_code_idx`;
-- RenameIndex
ALTER TABLE `svc_mapping` RENAME INDEX `sys_svc_mapping_loc_id_idx` TO `svc_mapping_loc_id_idx`;
-- RenameIndex
ALTER TABLE `svc_national` RENAME INDEX `sys_svc_national_nat_code_key` TO `svc_national_nat_code_key`;
-- RenameIndex
ALTER TABLE `svc_org` RENAME INDEX `sys_svc_org_tenant_code_src_type_idx` TO `svc_org_tenant_code_src_type_idx`;

View File

@ -425,3 +425,66 @@ model LocConfig {
@@map("loc_config") @@map("loc_config")
} }
/// 老人档案
model Elder {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
locCode String @map("loc_code") @db.VarChar(12)
prevLocCode String? @map("prev_loc_code") @db.VarChar(12)
transferConfirmAt DateTime? @map("transfer_confirm_at")
name String @db.VarChar(30)
gender String @db.Char(1)
birthDate DateTime @map("birth_date") @db.Date
idCardNo String @map("id_card_no") @db.VarChar(255)
phone String? @db.VarChar(15)
address String @db.VarChar(200)
guardianName String? @map("guardian_name") @db.VarChar(30)
guardianPhone String? @map("guardian_phone") @db.VarChar(15)
insurNo String @map("insur_no") @db.VarChar(30)
insurType String @map("insur_type") @db.VarChar(10)
insurYears Int? @map("insur_years")
insurVerifyStatus String? @map("insur_verify_status") @db.VarChar(20)
insurVerifyDate DateTime? @map("insur_verify_date") @db.Date
assessLevel String? @map("assess_level") @db.VarChar(20)
careLevel String? @map("care_level") @db.VarChar(20)
assessDate DateTime? @map("assess_date") @db.Date
assessOrg String? @map("assess_org") @db.VarChar(100)
assessEvidenceFile String? @map("assess_evidence_file") @db.VarChar(255)
benefitStart DateTime? @map("benefit_start") @db.Date
benefitEnd DateTime? @map("benefit_end") @db.Date
treatStatus String @default("pending_file") @map("treat_status") @db.VarChar(20)
statusSource String @default("manual") @map("status_source") @db.VarChar(10)
statusChangeDate DateTime? @map("status_change_date") @db.Date
hospStart DateTime? @map("hosp_start") @db.Date
hospEnd DateTime? @map("hosp_end") @db.Date
photoTierOverride Json? @map("photo_tier_override")
needAssess Json? @map("need_assess")
note String? @db.Text
bizLine String @default("ltci") @map("biz_line") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@unique([tenantCode, idCardNo])
@@index([tenantCode, name])
@@map("ltc_elder")
}
/// 待遇状态变更日志
model ElderStatusLog {
id String @id @default(uuid(7)) @db.VarChar(36)
elderId String @map("elder_id") @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
fromStatus String @map("from_status") @db.VarChar(20)
toStatus String @map("to_status") @db.VarChar(20)
changeReason String @map("change_reason") @db.VarChar(200)
source String @default("manual") @db.VarChar(10)
operatorId String @map("operator_id") @db.VarChar(36)
evidenceFile String? @map("evidence_file") @db.VarChar(255)
changedAt DateTime @default(now()) @map("changed_at")
@@index([elderId])
@@map("ltc_elder_status_log")
}

View File

@ -28,6 +28,8 @@ import { NoticesModule } from './notices/notices.module.js';
import { AgencyModule } from './agency/agency.module.js'; import { AgencyModule } from './agency/agency.module.js';
import { StaffModule } from './staff/staff.module.js'; import { StaffModule } from './staff/staff.module.js';
import { CatalogModule } from './catalog/catalog.module.js'; import { CatalogModule } from './catalog/catalog.module.js';
import { StorageModule } from './storage/storage.module.js';
import { ElderModule } from './elder/elder.module.js';
import { AppController } from './app.controller.js'; import { AppController } from './app.controller.js';
import { AppService } from './app.service.js'; import { AppService } from './app.service.js';
@ -53,6 +55,8 @@ import { AppService } from './app.service.js';
AgencyModule, AgencyModule,
StaffModule, StaffModule,
CatalogModule, CatalogModule,
StorageModule,
ElderModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@ -39,6 +39,11 @@ export const ErrorCodes = {
SVC_LOC_NOT_FOUND: 42001, SVC_LOC_NOT_FOUND: 42001,
SVC_ORG_NOT_FOUND: 42002, SVC_ORG_NOT_FOUND: 42002,
SVC_LOC_IMMUTABLE: 42003, SVC_LOC_IMMUTABLE: 42003,
// 服务机构端·老人档案(43)
ELDER_NOT_FOUND: 43001,
ELDER_ID_CARD_EXISTS: 43002,
ELDER_STATUS_REASON_REQUIRED: 43003,
ELDER_LEVEL_REQUIRES_EVIDENCE: 43004,
} as const; } as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];

View File

@ -91,6 +91,13 @@ export const APIS: { code: string; name: string; method: string; path: string; p
{ code: Permissions.svcOrg.list, name: '机构项目列表', method: 'GET', path: '/api/v1/svc/org/list', parent: 'svc-config-catalog' }, { code: Permissions.svcOrg.list, name: '机构项目列表', method: 'GET', path: '/api/v1/svc/org/list', parent: 'svc-config-catalog' },
{ code: Permissions.svcOrg.config, name: '机构项目配置', method: 'POST', path: '/api/v1/svc/org/config', parent: 'svc-config-catalog' }, { code: Permissions.svcOrg.config, name: '机构项目配置', method: 'POST', path: '/api/v1/svc/org/config', parent: 'svc-config-catalog' },
{ code: Permissions.svcOrg.newNational, name: '国家新增项', method: 'GET', path: '/api/v1/svc/org/new-national', parent: 'svc-config-loc' }, { code: Permissions.svcOrg.newNational, name: '国家新增项', method: 'GET', path: '/api/v1/svc/org/new-national', parent: 'svc-config-loc' },
// 服务机构端·老人档案(C1)
{ code: Permissions.elder.list, name: '老人列表', method: 'GET', path: '/api/v1/elder', parent: 'elder' },
{ code: Permissions.elder.create, name: '创建老人', method: 'POST', path: '/api/v1/elder', parent: 'elder' },
{ code: Permissions.elder.detail, name: '老人详情', method: 'GET', path: '/api/v1/elder/:id', parent: 'elder' },
{ code: Permissions.elder.statusChange, name: '变更待遇状态', method: 'POST', path: '/api/v1/elder/:id/status', parent: 'elder' },
{ code: Permissions.elder.detail, name: '状态日志', method: 'GET', path: '/api/v1/elder/:id/status/log', parent: 'elder' },
{ code: Permissions.elder.create, name: '上传评估结论', method: 'POST', path: '/api/v1/elder/assess-evidence', parent: 'elder' },
]; ];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */ /** 按钮资源(纯前端按钮,无独立受控接口)。 */

182
src/elder/dto.ts Normal file
View File

@ -0,0 +1,182 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
/** 十态待遇状态机(唯一来源,见 docs/superpowers/specs C1 设计 §4)。 */
export const TREAT_STATUSES = [
'pending_file',
'waiting_period',
'active',
'hosp_pause',
'review_waiting',
'transferring',
'dead',
'long_away',
'benefit_expired',
'terminated',
] as const;
/** 不可逆态:处于这些状态时禁止再变更。 */
export const IRREVERSIBLE_TREAT_STATUSES: readonly string[] = ['dead', 'terminated'];
/**
* 老人档案(elder)DTO。字段字典以 shared/data-model 为准(ltc_elder)。
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
* - birth_date 由身份证 7-14 位带出,不接受前端传入;
* - treat_status/status_source 由服务端定,不接受前端传入;
* - id_card_no 本期存明文(加密机制由后续安全 change 统一落地,见 elder.service.ts TODO)。
*/
export class CreateElderDto {
@ApiProperty({ description: '参保地/统筹区 code(loc_code)' })
@IsString()
@IsNotEmpty()
locCode!: string;
@ApiProperty({ description: '姓名' })
@IsString()
@IsNotEmpty()
@MaxLength(30)
name!: string;
@ApiProperty({ description: '性别:M/F' })
@IsString()
@IsNotEmpty()
@IsIn(['M', 'F'])
gender!: string;
@ApiProperty({ description: '身份证号(18 位,服务端做 GB11643 校验位校验)' })
@IsString()
@IsNotEmpty()
idCardNo!: string;
@ApiPropertyOptional({ description: '联系电话' })
@IsOptional()
@IsString()
phone?: string;
@ApiProperty({ description: '居住地址' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
address!: string;
@ApiPropertyOptional({ description: '紧急联系人' })
@IsOptional()
@IsString()
guardianName?: string;
@ApiPropertyOptional({ description: '紧急联系人电话' })
@IsOptional()
@IsString()
guardianPhone?: string;
@ApiProperty({ description: '医保参保号' })
@IsString()
@IsNotEmpty()
insurNo!: string;
@ApiProperty({ description: '参保类型:employee/resident' })
@IsString()
@IsNotEmpty()
insurType!: string;
@ApiPropertyOptional({ description: '缴费年限' })
@IsOptional()
@IsInt()
insurYears?: number;
@ApiPropertyOptional({ description: '参保核验状态:verified/unverified' })
@IsOptional()
@IsString()
insurVerifyStatus?: string;
@ApiPropertyOptional({ description: '参保核验日期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
insurVerifyDate?: string;
@ApiPropertyOptional({ description: '失能等级(sev_1/sev_2/sev_3),有值则须上传评估结论副本' })
@IsOptional()
@IsString()
assessLevel?: string;
@ApiPropertyOptional({ description: '护理等级(care_1/care_2/care_3),有值则须上传评估结论副本' })
@IsOptional()
@IsString()
careLevel?: string;
@ApiPropertyOptional({ description: '评估结论日期(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
assessDate?: string;
@ApiPropertyOptional({ description: '评估机构名称' })
@IsOptional()
@IsString()
assessOrg?: string;
@ApiPropertyOptional({ description: '评估结论副本 MinIO key(有等级则必填)' })
@IsOptional()
@IsString()
assessEvidenceFile?: string;
@ApiPropertyOptional({ description: '待遇有效期起(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
benefitStart?: string;
@ApiPropertyOptional({ description: '待遇有效期止(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
benefitEnd?: string;
@ApiPropertyOptional({ description: '住院起(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
hospStart?: string;
@ApiPropertyOptional({ description: '住院止(YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
hospEnd?: string;
@ApiPropertyOptional({ description: '备注' })
@IsOptional()
@IsString()
note?: string;
}
export class ListElderQuery extends PageQuery {
@ApiPropertyOptional({ description: '姓名(模糊)' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: '待遇状态' })
@IsOptional()
@IsString()
treatStatus?: string;
@ApiPropertyOptional({ description: '失能/护理等级(匹配 assess_level 或 care_level)' })
@IsOptional()
@IsString()
level?: string;
}
export class ChangeStatusDto {
@ApiProperty({ description: '目标待遇状态(十态之一)', enum: TREAT_STATUSES })
@IsString()
@IsNotEmpty()
@IsIn(TREAT_STATUSES)
toStatus!: string;
@ApiProperty({ description: '变更原因(必填)' })
@IsString()
@IsNotEmpty()
changeReason!: string;
@ApiPropertyOptional({ description: '佐证材料 MinIO key' })
@IsOptional()
@IsString()
evidenceFile?: string;
}

View File

@ -0,0 +1,51 @@
import { Body, Controller, Get, Param, Post, Query, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { ChangeStatusDto, CreateElderDto, ListElderQuery } from './dto.js';
import { ElderService } from './elder.service.js';
@Controller('elder')
export class ElderController {
constructor(private readonly service: ElderService) {}
@Get()
@RequirePermissions(Permissions.elder.list)
list(@Query() q: ListElderQuery) {
return this.service.listElder(q);
}
@Post()
@RequirePermissions(Permissions.elder.create)
@AuditLog('elder', 'create')
create(@Body() dto: CreateElderDto) {
return this.service.createElder(dto);
}
@Get(':id')
@RequirePermissions(Permissions.elder.detail)
detail(@Param('id') id: string) {
return this.service.detail(id);
}
@Post(':id/status')
@RequirePermissions(Permissions.elder.statusChange)
@AuditLog('elder', 'status-change')
changeStatus(@Param('id') id: string, @Body() dto: ChangeStatusDto) {
return this.service.changeStatus(id, dto);
}
@Get(':id/status/log')
@RequirePermissions(Permissions.elder.detail)
statusLog(@Param('id') id: string) {
return this.service.statusLog(id);
}
@Post('assess-evidence')
@RequirePermissions(Permissions.elder.create)
@AuditLog('elder', 'upload-evidence')
@UseInterceptors(FileInterceptor('file'))
uploadEvidence(@UploadedFile() file: { buffer: Buffer; originalname: string }) {
return this.service.uploadEvidence(file);
}
}

12
src/elder/elder.module.ts Normal file
View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ElderController } from './elder.controller.js';
import { ElderService } from './elder.service.js';
import { StorageModule } from '../storage/storage.module.js';
import { AuditLogsModule } from '../audit-logs/audit-logs.module.js';
@Module({
imports: [StorageModule, AuditLogsModule],
controllers: [ElderController],
providers: [ElderService],
})
export class ElderModule {}

View File

@ -0,0 +1,315 @@
import { describe, expect, it, vi } from 'vitest';
import { ElderService, maskIdCard, toDateOnly } from './elder.service.js';
import type { PrismaService } from '../prisma/prisma.service.js';
import type { StorageService } from '../storage/storage.service.js';
import type { AuditLogsService } from '../audit-logs/audit-logs.service.js';
import { ErrorCodes } from '../common/error-codes.js';
import { runWithRequestContext } from '../common/context/request-context.js';
import type { RequestContext } from '../common/context/request-context.js';
const ctx: RequestContext = {
tenantCode: 'T001',
userId: 'u1',
username: 'admin',
super: false,
dataScope: 'all',
orgCode: null,
orgCodes: [],
};
/** 通过 GB11643 校验位校验的合法身份证(出生日期 1990-01-01)。 */
const VALID_ID_CARD = '530102199001011231';
const baseDto = {
locCode: '530100',
name: '张三',
gender: 'M',
idCardNo: VALID_ID_CARD,
phone: '13800000000',
address: '昆明市五华区',
insurNo: 'INS001',
insurType: 'employee',
};
function makeService(
overrides: {
elderFindFirst?: ReturnType<typeof vi.fn>;
elderFindUnique?: ReturnType<typeof vi.fn>;
elderFindMany?: ReturnType<typeof vi.fn>;
elderCount?: ReturnType<typeof vi.fn>;
elderCreate?: ReturnType<typeof vi.fn>;
elderUpdate?: ReturnType<typeof vi.fn>;
userRoleFindMany?: ReturnType<typeof vi.fn>;
statusLogCreate?: ReturnType<typeof vi.fn>;
statusLogFindMany?: ReturnType<typeof vi.fn>;
auditRecord?: ReturnType<typeof vi.fn>;
} = {},
) {
const elderFindFirst = overrides.elderFindFirst ?? vi.fn().mockResolvedValue(null);
const elderFindUnique = overrides.elderFindUnique ?? vi.fn().mockResolvedValue(null);
const elderFindMany = overrides.elderFindMany ?? vi.fn().mockResolvedValue([]);
const elderCount = overrides.elderCount ?? vi.fn().mockResolvedValue(0);
const elderCreate = overrides.elderCreate ?? vi.fn().mockResolvedValue({ id: 'e1', ...baseDto });
const elderUpdate = overrides.elderUpdate ?? vi.fn().mockResolvedValue({ id: 'e1' });
const userRoleFindMany = overrides.userRoleFindMany ?? vi.fn().mockResolvedValue([]);
const statusLogCreate = overrides.statusLogCreate ?? vi.fn().mockResolvedValue({ id: 'sl1' });
const statusLogFindMany = overrides.statusLogFindMany ?? vi.fn().mockResolvedValue([]);
const auditRecord = overrides.auditRecord ?? vi.fn().mockResolvedValue(undefined);
const prisma = {
client: {
elder: {
findFirst: elderFindFirst,
findUnique: elderFindUnique,
findMany: elderFindMany,
count: elderCount,
create: elderCreate,
update: elderUpdate,
},
elderStatusLog: { create: statusLogCreate, findMany: statusLogFindMany },
userRole: { findMany: userRoleFindMany },
},
};
const storage = {
buildKey: vi.fn().mockReturnValue('elder/T001/1-a.jpg'),
upload: vi.fn().mockResolvedValue('elder/T001/1-a.jpg'),
};
const auditLogs = { record: auditRecord };
const service = new ElderService(
prisma as unknown as PrismaService,
storage as unknown as StorageService,
auditLogs as unknown as AuditLogsService,
);
return {
service,
elderFindFirst,
elderFindUnique,
elderFindMany,
elderCount,
elderCreate,
elderUpdate,
userRoleFindMany,
statusLogCreate,
statusLogFindMany,
auditRecord,
storage,
};
}
describe('maskIdCard', () => {
it('18 位 → 前4 + 10个* + 后4', () => {
expect(maskIdCard('530102199001011231')).toBe('5301**********1231');
});
it('15 位 → 前3 + 8个* + 后4', () => {
expect(maskIdCard('530102900101123')).toBe('530********1123');
});
it('空串与非常见长度原样返回', () => {
expect(maskIdCard('')).toBe('');
expect(maskIdCard('123')).toBe('123');
});
});
describe('toDateOnly', () => {
it('date-only 字符串 → UTC 零点 Date(不提前一天)', () => {
expect(toDateOnly('1990-01-01')?.toISOString()).toBe('1990-01-01T00:00:00.000Z');
expect(toDateOnly('2025-01-01')?.toISOString()).toBe('2025-01-01T00:00:00.000Z');
});
it('空值返回 undefined', () => {
expect(toDateOnly(undefined)).toBeUndefined();
expect(toDateOnly('')).toBeUndefined();
});
});
describe('ElderService', () => {
it('create 有等级无评估结论副本 → 抛 ELDER_LEVEL_REQUIRES_EVIDENCE', async () => {
const { service, elderCreate } = makeService();
await expect(
runWithRequestContext(ctx, () => service.createElder({ ...baseDto, assessLevel: 'sev_1' })),
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_LEVEL_REQUIRES_EVIDENCE } });
expect(elderCreate).not.toHaveBeenCalled();
});
it('create benefit_start 未来 → treatStatus = waiting_period', async () => {
const { service, elderCreate } = makeService();
await runWithRequestContext(ctx, () =>
service.createElder({ ...baseDto, benefitStart: '2099-01-01', benefitEnd: '2099-12-31' }),
);
expect(elderCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ treatStatus: 'waiting_period', statusSource: 'manual' }),
});
});
it('create 待遇有效期覆盖今天 → treatStatus = active,且出生日期由身份证带出为 Date', async () => {
const { service, elderCreate } = makeService();
await runWithRequestContext(ctx, () =>
service.createElder({ ...baseDto, benefitStart: '2000-01-01', benefitEnd: '2099-12-31' }),
);
expect(elderCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ treatStatus: 'active', birthDate: expect.any(Date) }),
});
});
it('合法建档(含日期字段)成功创建,日期字段转为 UTC 零点 Date 且值正确', async () => {
const { service, elderCreate } = makeService();
await runWithRequestContext(ctx, () =>
service.createElder({
...baseDto,
benefitStart: '2025-01-01',
benefitEnd: '2025-12-31',
assessDate: '2025-01-15',
insurVerifyDate: '2025-01-10',
hospStart: '2025-02-01',
hospEnd: '2025-02-05',
}),
);
const data = elderCreate.mock.calls[0][0].data as Record<string, unknown>;
expect(data.birthDate).toBeInstanceOf(Date);
expect((data.birthDate as Date).toISOString()).toBe('1990-01-01T00:00:00.000Z');
expect((data.benefitStart as Date).toISOString()).toBe('2025-01-01T00:00:00.000Z');
expect((data.benefitEnd as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z');
expect((data.assessDate as Date).toISOString()).toBe('2025-01-15T00:00:00.000Z');
expect((data.insurVerifyDate as Date).toISOString()).toBe('2025-01-10T00:00:00.000Z');
expect((data.hospStart as Date).toISOString()).toBe('2025-02-01T00:00:00.000Z');
expect((data.hospEnd as Date).toISOString()).toBe('2025-02-05T00:00:00.000Z');
});
it('同机构重复 id_card_no → 抛 ELDER_ID_CARD_EXISTS', async () => {
const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e-other', idCardNo: VALID_ID_CARD });
const elderCreate = vi.fn();
const { service } = makeService({ elderFindFirst, elderCreate });
await expect(
runWithRequestContext(ctx, () => service.createElder(baseDto)),
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_ID_CARD_EXISTS } });
expect(elderFindFirst).toHaveBeenCalledWith({ where: { idCardNo: VALID_ID_CARD } });
expect(elderCreate).not.toHaveBeenCalled();
});
it('changeStatus 缺 changeReason → 抛 ELDER_STATUS_REASON_REQUIRED', async () => {
const { service, elderUpdate } = makeService();
await expect(
runWithRequestContext(ctx, () => service.changeStatus('e1', { toStatus: 'active', changeReason: '' })),
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_STATUS_REASON_REQUIRED } });
expect(elderUpdate).not.toHaveBeenCalled();
});
it('changeStatus 写状态日志(from/to/reason/operator)', async () => {
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period', name: '张三' });
const { service, statusLogCreate } = makeService({ elderFindUnique });
const result = await runWithRequestContext(ctx, () =>
service.changeStatus('e1', { toStatus: 'active', changeReason: '待遇生效' }),
);
expect(result).toEqual({ id: 'e1', fromStatus: 'waiting_period', toStatus: 'active' });
expect(statusLogCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
elderId: 'e1',
tenantCode: 'T001',
fromStatus: 'waiting_period',
toStatus: 'active',
changeReason: '待遇生效',
operatorId: 'u1',
}),
});
});
it('changeStatus 对不可逆态(dead/terminated)拒绝变更', async () => {
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'dead', name: '张三' });
const { service, statusLogCreate } = makeService({ elderFindUnique });
await expect(
runWithRequestContext(ctx, () => service.changeStatus('e1', { toStatus: 'active', changeReason: '恢复' })),
).rejects.toThrowError('当前待遇状态不可再变更');
expect(statusLogCreate).not.toHaveBeenCalled();
});
it('changeStatus 后 statusLog 返回写入的日志', async () => {
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period', name: '张三' });
const statusLogFindMany = vi.fn().mockResolvedValue([
{ id: 'sl1', elderId: 'e1', fromStatus: 'waiting_period', toStatus: 'active', changeReason: '待遇生效', operatorId: 'u1' },
]);
const { service, statusLogCreate } = makeService({ elderFindUnique, statusLogFindMany });
await runWithRequestContext(ctx, () => service.changeStatus('e1', { toStatus: 'active', changeReason: '待遇生效' }));
const logs = await runWithRequestContext(ctx, () => service.statusLog('e1'));
expect(statusLogCreate).toHaveBeenCalled();
expect(logs).toHaveLength(1);
expect(logs[0]).toMatchObject({ fromStatus: 'waiting_period', toStatus: 'active', operatorId: 'u1' });
});
it('create 待遇止早于起 → 抛 400', async () => {
const { service, elderCreate } = makeService();
await expect(
runWithRequestContext(ctx, () =>
service.createElder({ ...baseDto, benefitStart: '2025-01-01', benefitEnd: '2024-01-01' }),
),
).rejects.toThrowError('待遇有效期止须晚于待遇有效期起');
expect(elderCreate).not.toHaveBeenCalled();
});
it('list 返回掩码 id_card_no', async () => {
const elderFindMany = vi.fn().mockResolvedValue([
{ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三', treatStatus: 'active' },
]);
const elderCount = vi.fn().mockResolvedValue(1);
const { service } = makeService({ elderFindMany, elderCount });
const res = await runWithRequestContext(ctx, () => service.listElder({ page: 1, pageSize: 20 }));
expect(res.list[0].idCardNo).toBe('5301**********1231');
expect(res.total).toBe(1);
});
it('detail 非 admin/qc 不返回明文', async () => {
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三' });
const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'nurse' }]);
const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany });
const res = await runWithRequestContext(ctx, () => service.detail('e1'));
expect(res.idCardNo).toBe('5301**********1231');
expect(auditRecord).not.toHaveBeenCalled();
});
it('detail admin/qc 返回明文并写审计', async () => {
const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三' });
const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'admin' }]);
const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany });
const res = await runWithRequestContext(ctx, () => service.detail('e1'));
expect(res.idCardNo).toBe(VALID_ID_CARD);
expect(auditRecord).toHaveBeenCalledWith(
expect.objectContaining({ module: 'elder', action: 'view-id-card', userId: 'u1' }),
);
});
it('uploadEvidence 读 buffer → buildKey + upload → 返回 { key }', async () => {
const { service, storage } = makeService();
const res = await runWithRequestContext(ctx, () =>
service.uploadEvidence({ buffer: Buffer.from('x'), originalname: 'a.jpg' }),
);
expect(storage.buildKey).toHaveBeenCalledWith('T001', 'a.jpg');
expect(storage.upload).toHaveBeenCalledWith(expect.any(Buffer), 'elder/T001/1-a.jpg');
expect(res).toEqual({ key: 'elder/T001/1-a.jpg' });
});
});

226
src/elder/elder.service.ts Normal file
View File

@ -0,0 +1,226 @@
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
import { StorageService } from '../storage/storage.service.js';
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
import { ChangeStatusDto, CreateElderDto, IRREVERSIBLE_TREAT_STATUSES, ListElderQuery } from './dto.js';
/**
* 身份证掩码:18 位 → 前4 + 10个* + 后4;15 位 → 前3 + 8个* + 后4;其余原样。
* 列表/非授权详情返回掩码;明文仅 admin/qc(或平台超管)可见且落审计。
*/
export function maskIdCard(id: string): string {
if (!id) return '';
if (id.length === 18) return `${id.slice(0, 4)}**********${id.slice(-4)}`;
if (id.length === 15) return `${id.slice(0, 3)}********${id.slice(-4)}`;
return id;
}
/** GB11643-1999 加权因子与校验码映射。 */
const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const ID_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
/** 身份证 18 位 + 校验位校验,通过返回出生日期(YYYY-MM-DD)。 */
function deriveBirthDate(idCardNo: string): string {
if (!/^\d{17}[\dXx]$/.test(idCardNo)) {
throw new BadRequestException('身份证号格式不正确(须 18 位,末位可为 X)');
}
const sum = ID_WEIGHTS.reduce((acc, w, i) => acc + w * Number(idCardNo[i]), 0);
if (ID_CHECK_CODES[sum % 11] !== idCardNo[17].toUpperCase()) {
throw new BadRequestException('身份证号校验位错误');
}
return `${idCardNo.slice(6, 10)}-${idCardNo.slice(10, 12)}-${idCardNo.slice(12, 14)}`;
}
/** 本地今天(YYYY-MM-DD,字符串比较即日期比较)。 */
function todayStr(): string {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${day}`;
}
/** 建档初始状态:待遇有效期覆盖今天 → active;否则 waiting_period。 */
function resolveInitialStatus(benefitStart?: string, benefitEnd?: string): string {
if (benefitStart && benefitEnd) {
const today = todayStr();
if (benefitStart <= today && today <= benefitEnd) return 'active';
}
return 'waiting_period';
}
/** YYYY-MM-DD → UTC 零点 Date(Prisma @db.Date 按 UTC instant 取日期,须用 UTC 零点避免时区回退一天);空值返回 undefined。 */
export function toDateOnly(v?: string): Date | undefined {
if (!v) return undefined;
return new Date(`${v}T00:00:00Z`);
}
/**
* 老人档案(elder)业务服务。elder 是租户级软删模型,读写经 Prisma 扩展自动注入
* tenantCode / deletedAt 过滤;创建时 tenantCode/createdBy 由 BaseCrudService 注入。
* elder_status_log 为 append-only 租户级模型(无软删),tenantCode 由本 service 显式写入。
*/
@Injectable()
export class ElderService extends BaseCrudService {
constructor(
prisma: PrismaService,
private readonly storage: StorageService,
private readonly auditLogs: AuditLogsService,
) {
super(prisma, 'elder');
}
/** 分页列表:状态/等级/姓名筛选;身份证服务端掩码。 */
async listElder(q: ListElderQuery) {
const where: Record<string, unknown> = {};
if (q.name) where.name = { contains: q.name };
if (q.treatStatus) where.treatStatus = q.treatStatus;
if (q.level) where.OR = [{ assessLevel: q.level }, { careLevel: q.level }];
const page = await this.paginate(q.page, q.pageSize, where);
return {
...page,
list: page.list.map((e: Record<string, unknown>) => ({ ...e, idCardNo: maskIdCard(String(e.idCardNo ?? '')) })),
};
}
/**
* 建档:校验身份证(18 位+校验位)→ 同机构防重 → 等级与副本绑定 →
* 出生日期由身份证带出 → 初始状态由待遇有效期决定。
*/
async createElder(dto: CreateElderDto) {
const idCardNo = dto.idCardNo.trim();
const birthDate = deriveBirthDate(idCardNo);
this.assertBenefitRange(dto.benefitStart, dto.benefitEnd);
await this.assertIdCardUnique(idCardNo);
if ((dto.assessLevel || dto.careLevel) && !dto.assessEvidenceFile) {
throw new BusinessException(
ErrorCodes.ELDER_LEVEL_REQUIRES_EVIDENCE,
'填写等级须上传评估结论副本',
HttpStatus.BAD_REQUEST,
);
}
const treatStatus = resolveInitialStatus(dto.benefitStart, dto.benefitEnd);
// TODO(id_card_no 加密): 本期直接存明文,加密机制由后续 security change 统一落地。
return super.create({
...dto,
idCardNo,
birthDate: toDateOnly(birthDate),
treatStatus,
statusSource: 'manual',
benefitStart: toDateOnly(dto.benefitStart),
benefitEnd: toDateOnly(dto.benefitEnd),
assessDate: toDateOnly(dto.assessDate),
insurVerifyDate: toDateOnly(dto.insurVerifyDate),
hospStart: toDateOnly(dto.hospStart),
hospEnd: toDateOnly(dto.hospEnd),
});
}
/** 详情:admin/qc(或平台超管)返回身份证明文并显式写审计;否则掩码。 */
async detail(id: string) {
const elder = await this.requireExists(id);
if (await this.canViewPlainIdCard()) {
const ctx = getRequestContext();
await this.auditLogs.record({
tenantCode: ctx?.tenantCode ?? '',
userId: ctx?.userId ?? null,
username: ctx?.username ?? null,
module: 'elder',
action: 'view-id-card',
status: 'success',
params: JSON.stringify({ elderId: id, elderName: elder.name }),
});
return elder;
}
return { ...elder, idCardNo: maskIdCard(String(elder.idCardNo ?? '')) };
}
/** 变更待遇状态:changeReason 必填;写 elder_status_log(from/to/reason/operator/changedAt)。 */
async changeStatus(id: string, dto: ChangeStatusDto) {
if (!dto.changeReason) {
throw new BusinessException(
ErrorCodes.ELDER_STATUS_REASON_REQUIRED,
'变更状态须填写原因',
HttpStatus.BAD_REQUEST,
);
}
const elder = await this.requireExists(id);
if (IRREVERSIBLE_TREAT_STATUSES.includes(elder.treatStatus)) {
throw new BadRequestException('当前待遇状态不可再变更');
}
const ctx = getRequestContext();
const fromStatus = elder.treatStatus;
await this.update(id, {
treatStatus: dto.toStatus,
statusSource: 'manual',
statusChangeDate: new Date(),
});
await this.prisma.client.elderStatusLog.create({
data: {
elderId: id,
tenantCode: ctx?.tenantCode ?? '',
fromStatus,
toStatus: dto.toStatus,
changeReason: dto.changeReason,
source: 'manual',
operatorId: ctx?.userId ?? '',
evidenceFile: dto.evidenceFile ?? null,
},
});
return { id, fromStatus, toStatus: dto.toStatus };
}
/** 状态变更历史(按时间倒序)。 */
statusLog(id: string) {
const ctx = getRequestContext();
return this.prisma.client.elderStatusLog.findMany({
where: { elderId: id, tenantCode: ctx?.tenantCode },
orderBy: { changedAt: 'desc' },
});
}
/** 上传评估结论副本:读 buffer → StorageService.buildKey + upload → 返回 { key }。 */
async uploadEvidence(file: { buffer: Buffer; originalname: string }) {
const ctx = getRequestContext();
const key = this.storage.buildKey(ctx?.tenantCode ?? '', file.originalname);
await this.storage.upload(file.buffer, key);
return { key };
}
/** admin/qc 角色(或平台超管)可查看身份证明文。 */
private async canViewPlainIdCard(): Promise<boolean> {
const ctx = getRequestContext();
if (!ctx) return false;
if (ctx.super) return true;
const rows = await this.prisma.client.userRole.findMany({
where: { tenantCode: ctx.tenantCode, userId: ctx.userId },
select: { roleCode: true },
});
return rows.some((r) => r.roleCode === 'admin' || r.roleCode === 'qc');
}
private async assertIdCardUnique(idCardNo: string) {
const existing = await this.db().findFirst({ where: { idCardNo } });
if (existing) {
throw new BusinessException(ErrorCodes.ELDER_ID_CARD_EXISTS, '已有档案', HttpStatus.CONFLICT);
}
}
/** 待遇有效期须「止 > 起」。 */
private assertBenefitRange(benefitStart?: string, benefitEnd?: string) {
if (benefitStart && benefitEnd && benefitEnd <= benefitStart) {
throw new BadRequestException('待遇有效期止须晚于待遇有效期起');
}
}
private async requireExists(id: string): Promise<Record<string, any>> {
const existing = await this.findById(id);
if (!existing) {
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
}
return existing;
}
}

View File

@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
/** 不带 tenantCode 的模型(租户表自身)。 */ /** 不带 tenantCode 的模型(租户表自身)。 */
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']); const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']);
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */ /** 软删模型:读操作统一注入 deletedAt IS NULL。 */
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg']); const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg', 'Elder']);
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']); const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */ /** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
const NO_WHERE_OPS = new Set(['create', 'createMany']); const NO_WHERE_OPS = new Set(['create', 'createMany']);

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service.js';
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@ -0,0 +1,20 @@
import { describe, it, expect, vi } from 'vitest';
import { StorageService } from './storage.service.js';
describe('StorageService', () => {
it('upload 写桶并返回 key', async () => {
const putObject = vi.fn().mockResolvedValue({ etag: 'x' });
const svc = new StorageService();
(svc as any).client = { putObject };
(svc as any).bucket = 'carelink';
const key = await svc.upload(Buffer.from('x'), 'elder/evidence/a.jpg');
expect(putObject).toHaveBeenCalledWith('carelink', 'elder/evidence/a.jpg', expect.any(Buffer), expect.any(Number));
expect(key).toBe('elder/evidence/a.jpg');
});
it('buildKey 带租户前缀', () => {
const svc = new StorageService();
const key = svc.buildKey('T001', 'a.jpg');
expect(key).toMatch(/^elder\/T001\/\d+-a\.jpg$/);
});
});

View File

@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Client } from 'minio';
@Injectable()
export class StorageService {
private readonly client: Client;
private readonly bucket: string;
constructor() {
// new Client() 只是构造客户端对象、不建立连接;putObject 时才连。
// 测试里直接替换 (svc as any).client / .bucket 为 mock。
this.client = new Client({
endPoint: process.env.MINIO_ENDPOINT ?? 'localhost',
port: Number(process.env.MINIO_PORT ?? 9000),
useSSL: false,
accessKey: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin',
});
this.bucket = process.env.MINIO_BUCKET ?? 'carelink';
}
buildKey(tenantCode: string, filename: string): string {
return `elder/${tenantCode}/${Date.now()}-${filename}`;
}
async upload(buffer: Buffer, key: string): Promise<string> {
await this.client.putObject(this.bucket, key, buffer, buffer.length);
return key;
}
}