import { Prisma, PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; import { TENANT_ADMIN_ROLE_CODE } from '../src/common/constants.js'; import { initTenant } from '../src/common/tenant-template.js'; /** * 测试机构(TY20261015 投云养老服务机构)演示数据 seed。 * 幂等:可重复执行(tenant/user/userRole/staff/elder/svc_org 按业务键 upsert,plan/order/record/abnormal/qc 用固定 id)。 * * 运行:pnpm exec tsx prisma/seed-demo.ts */ const prisma = new PrismaClient(); const TENANT_CODE = 'TY20261015'; const TENANT_NAME = '投云养老服务机构'; const ADMIN_USERNAME = 'admin01'; const PASSWORD = 'Touyun@123'; const BIZ_LINE = 'ltci'; const LOC_CODE = '530100'; // 固定业务 id(幂等锚点) const IDS = { staffLeader: 'demo-staff-leader-0001', staffNurseZhang: 'demo-staff-nurse-0001', staffNurseLi: 'demo-staff-nurse-0002', staffNurseWang: 'demo-staff-nurse-0003', planZhang: 'demo-plan-zhang-0001', orderZhang: 'demo-order-zhang-0001', recordZhang: 'demo-record-zhang-0001', abnormalZhang: 'demo-abnormal-zhang-0001', qcZhang: 'demo-qc-zhang-0001', } as const; // L1 目录样例(10 项)+ L4 拍照分档/配价(photoTier:沐浴/压疮/鼻饲/导管=must,擦浴=optional,其余 free) const SERVICES: { natCode: string; natName: string; natCategory: string; isMedical: boolean; photoTier: string; price: string }[] = [ { natCode: 'L0000106010020001', natName: '协助翻身', natCategory: 'life', isMedical: false, photoTier: 'free', price: '25.00' }, { natCode: 'L0000106010020002', natName: '协助进食', natCategory: 'life', isMedical: false, photoTier: 'free', price: '25.00' }, { natCode: 'L0000106010020003', natName: '口腔清洁', natCategory: 'life', isMedical: false, photoTier: 'free', price: '20.00' }, { natCode: 'L0000106010020004', natName: '沐浴', natCategory: 'life', isMedical: false, photoTier: 'must', price: '50.00' }, { natCode: 'L0000106010020005', natName: '压疮护理', natCategory: 'medical', isMedical: true, photoTier: 'must', price: '60.00' }, { natCode: 'L0000106010020006', natName: '擦浴', natCategory: 'life', isMedical: false, photoTier: 'optional', price: '40.00' }, { natCode: 'L0000106010020007', natName: '鼻饲', natCategory: 'medical', isMedical: true, photoTier: 'must', price: '45.00' }, { natCode: 'L0000106010020008', natName: '导管护理', natCategory: 'medical', isMedical: true, photoTier: 'must', price: '55.00' }, { natCode: 'L0000106010020009', natName: '血糖监测', natCategory: 'medical', isMedical: true, photoTier: 'free', price: '15.00' }, { natCode: 'L0000106010020010', natName: '关节活动', natCategory: 'life', isMedical: false, photoTier: 'free', price: '30.00' }, ]; // 业务角色账号(admin01 由 initTenant 创建并绑 tenant_super_admin) const ACCOUNTS = [ { username: 'director01', roleCode: 'director', nickname: '院长' }, { username: 'qc01', roleCode: 'qc', nickname: '质控员' }, { username: 'leader01', roleCode: 'leader', nickname: '组长' }, { username: 'nurse01', roleCode: 'nurse', nickname: '护理员' }, ]; // ---- 日期工具(@db.Date 用 UTC 零点,datetime 用本地墙上时间) ---- function pad(n: number): string { return String(n).padStart(2, '0'); } /** YYYY-MM-DD → @db.Date(UTC 零点)。 */ function dateOnly(s: string): Date { return new Date(`${s}T00:00:00Z`); } /** 今天(本地)YYYY-MM-DD。 */ function todayStr(): string { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } /** 距今天 N 天的 YYYY-MM-DD(本地)。 */ function daysFromToday(days: number): string { const d = new Date(); d.setDate(d.getDate() + days); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } /** 本月最后一天 YYYY-MM-DD。 */ function lastDayOfMonth(): string { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate())}`; } /** 今天本地时间 HH:mm 的 Date。 */ function todayAt(hour: number, minute: number): Date { const d = new Date(); d.setHours(hour, minute, 0, 0); return d; } // ---- GB11643 校验位(生成合法 18 位身份证) ---- 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']; function idCard(prefix17: string): string { const sum = ID_WEIGHTS.reduce((acc, w, i) => acc + w * Number(prefix17[i]), 0); return prefix17 + ID_CHECK_CODES[sum % 11]; } // 关键老人身份证(张三用任务给定值,李四与 staff 用校验位生成) const ID_ZHANG = '530102199001011231'; const ID_LI = idCard('53010219900303342'); // ---- 机构项目快照结构 ---- interface SvcRecord { svcId: string; svcName: string; svcCode: string; photoTier: string; isMedical: boolean; needCert: boolean; } function itemSnapshot(s: SvcRecord): Prisma.InputJsonObject { return { svcId: s.svcId, svcName: s.svcName, svcCode: s.svcCode, freqType: null, freqValue: null, durationMin: null, photoTier: s.photoTier, needCert: s.needCert, isMedical: s.isMedical, }; } async function main(): Promise { // 1. 机构(租户)+ 平台化初始化(admin01 + 资源 + 内置字典 + business roles 授权) await prisma.tenant.upsert({ where: { code: TENANT_CODE }, update: { name: TENANT_NAME, status: 'enabled' }, create: { code: TENANT_CODE, name: TENANT_NAME, status: 'enabled' }, }); await initTenant({ prisma, tenantCode: TENANT_CODE, tenantName: TENANT_NAME, adminRoleCode: TENANT_ADMIN_ROLE_CODE, adminRoleName: '机构管理员', adminUsername: ADMIN_USERNAME, adminPassword: PASSWORD, includePlatformPerms: false, forcePasswordChange: false, }); // 2. 角色账号 + 绑角色 const passwordHash = await bcrypt.hash(PASSWORD, 10); const userIds: Record = {}; for (const acc of ACCOUNTS) { const user = await prisma.user.upsert({ where: { tenantCode_username: { tenantCode: TENANT_CODE, username: acc.username } }, update: { nickname: acc.nickname, status: 'enabled' }, create: { tenantCode: TENANT_CODE, username: acc.username, passwordHash, nickname: acc.nickname, status: 'enabled', orgCode: TENANT_CODE, }, }); userIds[acc.username] = user.id; await prisma.userRole.upsert({ where: { tenantCode_userId_roleCode: { tenantCode: TENANT_CODE, userId: user.id, roleCode: acc.roleCode } }, update: {}, create: { tenantCode: TENANT_CODE, userId: user.id, roleCode: acc.roleCode }, }); } // 3. staff:1 组长(刘组长,绑 leader01)+ 3 护理员(张/李/王,张护理绑 nurse01) const leaderId = IDS.staffLeader; await prisma.staff.upsert({ where: { id: leaderId }, update: { userId: userIds.leader01, name: '刘组长', idCardNo: idCard('53010219850101004'), jobType: 'leader', workType: 'fulltime', employStatus: 'on_job', groupLeaderId: null, }, create: { id: leaderId, tenantCode: TENANT_CODE, userId: userIds.leader01, name: '刘组长', idCardNo: idCard('53010219850101004'), phone: '13800000004', jobType: 'leader', workType: 'fulltime', entryDate: dateOnly('2024-01-01'), employStatus: 'on_job', groupLeaderId: null, }, }); const nurseDefs = [ { id: IDS.staffNurseZhang, userId: userIds.nurse01, name: '张护理', idPrefix: '53010219880515001', phone: '13800000001' }, { id: IDS.staffNurseLi, userId: null, name: '李护理', idPrefix: '53010219920310002', phone: '13800000002' }, { id: IDS.staffNurseWang, userId: null, name: '王护理', idPrefix: '53010219941025003', phone: '13800000003' }, ]; const nurseStaffIds: Record = {}; for (const nd of nurseDefs) { await prisma.staff.upsert({ where: { id: nd.id }, update: { userId: nd.userId, name: nd.name, idCardNo: idCard(nd.idPrefix), jobType: 'nurse', workType: 'fulltime', employStatus: 'on_job', groupLeaderId: leaderId, }, create: { id: nd.id, tenantCode: TENANT_CODE, userId: nd.userId, name: nd.name, idCardNo: idCard(nd.idPrefix), phone: nd.phone, jobType: 'nurse', workType: 'fulltime', entryDate: dateOnly('2024-06-01'), employStatus: 'on_job', groupLeaderId: leaderId, }, }); nurseStaffIds[nd.name] = nd.id; } const zhangStaffId = nurseStaffIds['张护理']; // 4. L1 国家目录 + L4 机构项目库(srcId = svc_national.id,与 plan/record 解析逻辑一致) const svcs: SvcRecord[] = []; for (const svc of SERVICES) { const national = await prisma.svcNational.upsert({ where: { natCode: svc.natCode }, update: { natName: svc.natName, natCategory: svc.natCategory, isMedical: svc.isMedical }, create: { natCode: svc.natCode, natName: svc.natName, natCategory: svc.natCategory, isMedical: svc.isMedical }, }); const existing = await prisma.svcOrg.findFirst({ where: { tenantCode: TENANT_CODE, srcId: national.id } }); let svcOrgId: string; if (existing) { await prisma.svcOrg.update({ where: { id: existing.id }, data: { srcType: 'national', isEnabled: true, price: svc.price, photoTier: svc.photoTier }, }); svcOrgId = existing.id; } else { const created = await prisma.svcOrg.create({ data: { tenantCode: TENANT_CODE, srcType: 'national', srcId: national.id, isEnabled: true, price: svc.price, photoTier: svc.photoTier, }, }); svcOrgId = created.id; } svcs.push({ svcId: svcOrgId, svcName: svc.natName, svcCode: svc.natCode, photoTier: svc.photoTier, isMedical: svc.isMedical, needCert: svc.isMedical, }); } // 5. 老人:张三(active)、李四(waiting_period,垫付演示) const zhang = await prisma.elder.upsert({ where: { tenantCode_idCardNo: { tenantCode: TENANT_CODE, idCardNo: ID_ZHANG } }, update: {}, create: { tenantCode: TENANT_CODE, locCode: LOC_CODE, name: '张三', gender: 'M', birthDate: dateOnly('1990-01-01'), idCardNo: ID_ZHANG, phone: '13900000001', address: '昆明市五华区春城路 1 号', guardianName: '张小明', guardianPhone: '13900000002', insurNo: 'YN5301000001', insurType: 'employee', careLevel: 'care_1', treatStatus: 'active', benefitStart: dateOnly(daysFromToday(-365)), benefitEnd: dateOnly(daysFromToday(365)), bizLine: BIZ_LINE, }, }); await prisma.elder.upsert({ where: { tenantCode_idCardNo: { tenantCode: TENANT_CODE, idCardNo: ID_LI } }, update: {}, create: { tenantCode: TENANT_CODE, locCode: LOC_CODE, name: '李四', gender: 'F', birthDate: dateOnly('1990-03-03'), idCardNo: ID_LI, phone: '13900000003', address: '昆明市盘龙区东风路 2 号', insurNo: 'YN5301000002', insurType: 'resident', careLevel: 'care_2', treatStatus: 'waiting_period', benefitStart: dateOnly(daysFromToday(30)), benefitEnd: dateOnly(daysFromToday(400)), bizLine: BIZ_LINE, }, }); // 6. 计划(张三,active,覆盖本月)+ 计划项(勾选全部 10 项,photoTier 快照自 svc_org) const planId = IDS.planZhang; const planPeriodStart = `${new Date().getFullYear()}-${pad(new Date().getMonth() + 1)}-01`; await prisma.plan.upsert({ where: { id: planId }, update: { elderId: zhang.id, planPeriodStart: dateOnly(planPeriodStart), planPeriodEnd: dateOnly(lastDayOfMonth()), careLevel: 'care_1', careMode: 'home_individual', itemCount: svcs.length, planStatus: 'active', }, create: { id: planId, tenantCode: TENANT_CODE, elderId: zhang.id, planPeriodStart: dateOnly(planPeriodStart), planPeriodEnd: dateOnly(lastDayOfMonth()), careLevel: 'care_1', careMode: 'home_individual', itemCount: svcs.length, planStatus: 'active', bizLine: BIZ_LINE, }, }); await prisma.planItem.deleteMany({ where: { planId } }); for (const s of svcs) { await prisma.planItem.create({ data: { planId, tenantCode: TENANT_CODE, svcId: s.svcId, svcNameSnapshot: s.svcName, svcCodeSnapshot: s.svcCode, photoTier: s.photoTier, isMedical: s.isMedical, needCert: s.needCert, }, }); } // 7. 工单(张三,今天 09:00-10:00,派给张护理,itemList 快照自 plan_item) const orderId = IDS.orderZhang; const itemList = svcs.map(itemSnapshot); const needPhoto = svcs.some((s) => s.photoTier === 'must'); await prisma.order.upsert({ where: { id: orderId }, update: { elderId: zhang.id, planId, serviceDate: dateOnly(todayStr()), timeSlotStart: todayAt(9, 0), timeSlotEnd: todayAt(10, 0), assigneeId: zhangStaffId, orderStatus: 'assigned', itemList, needPhoto, }, create: { id: orderId, tenantCode: TENANT_CODE, elderId: zhang.id, planId, serviceDate: dateOnly(todayStr()), timeSlotStart: todayAt(9, 0), timeSlotEnd: todayAt(10, 0), assigneeId: zhangStaffId, assignTime: new Date(), assignMode: 'manual', orderStatus: 'assigned', itemList, needPhoto, billable: true, bizLine: BIZ_LINE, }, }); // 8. 留痕(张三,submitted)+ 明细 + 照片 const recordId = IDS.recordZhang; await prisma.record.upsert({ where: { id: recordId }, update: { orderId, elderId: zhang.id, assigneeId: zhangStaffId, checkinTime: todayAt(9, 0), checkoutTime: todayAt(10, 0), durationMin: 60, recordStatus: 'submitted', }, create: { id: recordId, tenantCode: TENANT_CODE, orderId, elderId: zhang.id, assigneeId: zhangStaffId, checkinTime: todayAt(9, 0), checkoutTime: todayAt(10, 0), durationMin: 60, recordStatus: 'submitted', submitTime: todayAt(10, 0), isPrepaid: false, billable: true, bizLine: BIZ_LINE, }, }); await prisma.recordItem.deleteMany({ where: { recordId } }); for (const s of svcs) { await prisma.recordItem.create({ data: { tenantCode: TENANT_CODE, recordId, svcId: s.svcId, svcNameSnapshot: s.svcName, execResult: 'done', photoRequired: s.photoTier === 'must', }, }); } await prisma.recordPhoto.deleteMany({ where: { recordId } }); const mustSvc = svcs.find((s) => s.photoTier === 'must')!; await prisma.recordPhoto.create({ data: { tenantCode: TENANT_CODE, recordId, svcId: mustSvc.svcId, filePath: '/demo/TY20261015/record/zhang/photo1.jpg', fileSize: 128000, visibleToFamily: false, }, }); // 9. 异常上报(压疮) await prisma.recordAbnormal.upsert({ where: { id: IDS.abnormalZhang }, update: { recordId, elderId: zhang.id, reporterId: zhangStaffId, abnormalType: 'pressure_sore', notifyStatus: 'pending', handleStatus: 'open', }, create: { id: IDS.abnormalZhang, tenantCode: TENANT_CODE, recordId, orderId, elderId: zhang.id, reporterId: zhangStaffId, abnormalType: 'pressure_sore', abnormalDesc: '发现背部皮肤压红,需关注', notifyStatus: 'pending', handleStatus: 'open', }, }); // 10. 质控(pending) await prisma.qc.upsert({ where: { id: IDS.qcZhang }, update: { recordId, qcType: 'normal', qcResult: 'pending' }, create: { id: IDS.qcZhang, tenantCode: TENANT_CODE, recordId, qcType: 'normal', qcResult: 'pending', }, }); console.log(`seed-demo 完成:机构 ${TENANT_CODE} ${TENANT_NAME}`); } async function verify(): Promise { const tenant = await prisma.tenant.findUnique({ where: { code: TENANT_CODE } }); const users = await prisma.user.findMany({ where: { tenantCode: TENANT_CODE }, orderBy: { username: 'asc' } }); const roles = await prisma.role.findMany({ where: { tenantCode: TENANT_CODE }, orderBy: { code: 'asc' } }); const userRoles = await prisma.userRole.findMany({ where: { tenantCode: TENANT_CODE } }); const staff = await prisma.staff.findMany({ where: { tenantCode: TENANT_CODE }, orderBy: { name: 'asc' } }); const elders = await prisma.elder.findMany({ where: { tenantCode: TENANT_CODE } }); const plans = await prisma.plan.findMany({ where: { tenantCode: TENANT_CODE } }); const planItems = await prisma.planItem.findMany({ where: { tenantCode: TENANT_CODE } }); const orders = await prisma.order.findMany({ where: { tenantCode: TENANT_CODE } }); const records = await prisma.record.findMany({ where: { tenantCode: TENANT_CODE } }); const recordItems = await prisma.recordItem.findMany({ where: { tenantCode: TENANT_CODE } }); const recordPhotos = await prisma.recordPhoto.findMany({ where: { tenantCode: TENANT_CODE } }); const abnormals = await prisma.recordAbnormal.findMany({ where: { tenantCode: TENANT_CODE } }); const qcs = await prisma.qc.findMany({ where: { tenantCode: TENANT_CODE } }); const svcOrgs = await prisma.svcOrg.findMany({ where: { tenantCode: TENANT_CODE } }); const roleByUser: Record = {}; for (const ur of userRoles) { (roleByUser[ur.userId] ??= []).push(ur.roleCode); } const nameByUser = new Map(users.map((u) => [u.id, u.username])); console.log('\n===== 验证结果 ====='); console.log(`tenant: ${tenant ? '存在 ' + tenant.name : '缺失'} (code=${TENANT_CODE})`); console.log(`账号数: ${users.length}(期望 5)`); for (const u of users) { console.log(` - ${u.username} → [${(roleByUser[u.id] ?? []).join(',')}]`); } console.log(`角色数: ${roles.length}(business roles: ${roles.map((r) => r.code).join('/')})`); console.log(`staff: ${staff.length}(期望 4)→ ${staff.map((s) => `${s.name}(${s.jobType},${s.employStatus})`).join(' / ')}`); console.log(`elder: ${elders.length}(期望 2)→ ${elders.map((e) => `${e.name}(${e.treatStatus},${e.careLevel})`).join(' / ')}`); console.log(`svc_org(L4): ${svcOrgs.length}(期望 10)`); console.log(`plan: ${plans.length}(期望 1); plan_item: ${planItems.length}(期望 10)`); console.log(`order: ${orders.length}(期望 1); record: ${records.length}(期望 1); record_item: ${recordItems.length}(期望 10); record_photo: ${recordPhotos.length}(期望 1)`); console.log(`record_abnormal: ${abnormals.length}(期望 1); qc: ${qcs.length}(期望 1)`); console.log('\n===== 测试账号 ====='); const expected = ['admin01', 'director01', 'qc01', 'leader01', 'nurse01']; for (const uname of expected) { const u = users.find((x) => x.username === uname); console.log(` ${uname} / ${PASSWORD} → [${(u ? roleByUser[u.id] ?? [] : []).join(',')}]`); } // 未绑任何角色的多余账号告警 const orphans = users.filter((u) => !expected.includes(u.username)); if (orphans.length > 0) { console.log(`\n⚠️ 存在非预期账号: ${orphans.map((u) => `${u.username}[${nameByUser.get(u.id)}]`).join(', ')}`); } } main() .then(() => verify()) .catch((e) => { console.error(e); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); });