ec-nest/prisma/seed-demo.ts
liuyun c825ff8a5f fix(seed-demo): itemSnapshot 返回类型 InputJsonObject,修 tsc 类型错误
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 10:18:10 +08:00

558 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
// 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<string, string> = {};
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<string, string> = {};
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<void> {
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<string, string[]> = {};
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();
});