Compare commits
8 Commits
8fca4a1b1e
...
aa95586a55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa95586a55 | ||
|
|
945fe5daa3 | ||
|
|
0be521bd72 | ||
|
|
5a3beb4923 | ||
|
|
856e906740 | ||
|
|
198517bde4 | ||
|
|
b5eb94d3ae | ||
|
|
6f32875295 |
150
prisma/migrations/20261014043411_c0_base/migration.sql
Normal file
150
prisma/migrations/20261014043411_c0_base/migration.sql
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_agency` (
|
||||||
|
`code` VARCHAR(64) NOT NULL,
|
||||||
|
`org_name` VARCHAR(100) NOT NULL,
|
||||||
|
`org_type` VARCHAR(20) NOT NULL,
|
||||||
|
`credit_code` VARCHAR(18) NOT NULL,
|
||||||
|
`loc_code` VARCHAR(12) NOT NULL,
|
||||||
|
`loc_name` VARCHAR(50) NOT NULL,
|
||||||
|
`svc_source` VARCHAR(10) NOT NULL,
|
||||||
|
`license_no` VARCHAR(50) NULL,
|
||||||
|
`address` VARCHAR(200) NOT NULL,
|
||||||
|
`contact_name` VARCHAR(30) NOT NULL,
|
||||||
|
`contact_phone` VARCHAR(15) NOT NULL,
|
||||||
|
`bed_count` INTEGER NULL,
|
||||||
|
`sign_date` DATE NULL,
|
||||||
|
`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,
|
||||||
|
|
||||||
|
PRIMARY KEY (`code`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_staff` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`user_id` VARCHAR(36) NULL,
|
||||||
|
`name` VARCHAR(30) NOT NULL,
|
||||||
|
`id_card_no` VARCHAR(255) NOT NULL,
|
||||||
|
`person_code` VARCHAR(20) NULL,
|
||||||
|
`phone` VARCHAR(15) NOT NULL,
|
||||||
|
`job_type` VARCHAR(20) NOT NULL,
|
||||||
|
`work_type` VARCHAR(10) NOT NULL,
|
||||||
|
`entry_date` DATE NULL,
|
||||||
|
`leave_date` DATE NULL,
|
||||||
|
`employ_status` VARCHAR(10) NOT NULL,
|
||||||
|
`cert_type` VARCHAR(20) NULL,
|
||||||
|
`cert_no` VARCHAR(50) NULL,
|
||||||
|
`cert_level` VARCHAR(20) NULL,
|
||||||
|
`cert_expire` DATE NULL,
|
||||||
|
`cert_status` VARCHAR(20) NULL,
|
||||||
|
`cert_verify_url` VARCHAR(200) NULL,
|
||||||
|
`group_leader_id` VARCHAR(36) NULL,
|
||||||
|
`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,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sys_staff_user_id_key`(`user_id`),
|
||||||
|
INDEX `sys_staff_tenant_code_name_idx`(`tenant_code`, `name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_svc_national` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`nat_code` VARCHAR(20) NOT NULL,
|
||||||
|
`nat_name` VARCHAR(100) NOT NULL,
|
||||||
|
`nat_category` VARCHAR(20) NOT NULL,
|
||||||
|
`nat_sub_category` VARCHAR(50) NULL,
|
||||||
|
`recommend_staff` VARCHAR(50) NULL,
|
||||||
|
`recommend_duration` VARCHAR(50) NULL,
|
||||||
|
`is_medical` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`version` INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sys_svc_national_nat_code_key`(`nat_code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_svc_local` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`loc_code` VARCHAR(12) NOT NULL,
|
||||||
|
`local_item_name` VARCHAR(100) NOT NULL,
|
||||||
|
`local_category` VARCHAR(20) NULL,
|
||||||
|
`svc_time` VARCHAR(50) NULL,
|
||||||
|
`min_freq` VARCHAR(50) NULL,
|
||||||
|
`staff_qual` VARCHAR(50) NULL,
|
||||||
|
`is_medical` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`source_doc` VARCHAR(200) NULL,
|
||||||
|
`map_status` VARCHAR(20) NOT NULL DEFAULT 'mapped',
|
||||||
|
|
||||||
|
INDEX `sys_svc_local_loc_code_idx`(`loc_code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_svc_mapping` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`loc_id` VARCHAR(36) NOT NULL,
|
||||||
|
`nat_id` VARCHAR(36) NULL,
|
||||||
|
`map_type` VARCHAR(20) NOT NULL,
|
||||||
|
`map_note` VARCHAR(200) NULL,
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'confirmed',
|
||||||
|
|
||||||
|
INDEX `sys_svc_mapping_loc_id_idx`(`loc_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_svc_org` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`src_type` VARCHAR(10) NOT NULL,
|
||||||
|
`src_id` VARCHAR(36) NOT NULL,
|
||||||
|
`is_enabled` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`price` VARCHAR(50) NULL,
|
||||||
|
`price_effective` DATETIME(3) NULL,
|
||||||
|
`is_new_national` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`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 `sys_svc_org_tenant_code_src_type_idx`(`tenant_code`, `src_type`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_dict_abnormal_type` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`code` VARCHAR(50) NOT NULL,
|
||||||
|
`name` VARCHAR(50) NOT NULL,
|
||||||
|
`sort` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sys_dict_abnormal_type_code_key`(`code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_dict_photo_tier` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`code` VARCHAR(20) NOT NULL,
|
||||||
|
`name` VARCHAR(50) NOT NULL,
|
||||||
|
`tier` VARCHAR(10) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sys_dict_photo_tier_code_key`(`code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sys_loc_config` (
|
||||||
|
`loc_code` VARCHAR(12) NOT NULL,
|
||||||
|
`loc_name` VARCHAR(50) NOT NULL,
|
||||||
|
`svc_source` VARCHAR(10) NOT NULL,
|
||||||
|
`item_count_rule` VARCHAR(100) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`loc_code`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
@ -298,3 +298,150 @@ model RoleOrg {
|
|||||||
@@unique([tenantCode, roleCode, orgCode])
|
@@unique([tenantCode, roleCode, orgCode])
|
||||||
@@map("sys_role_org")
|
@@map("sys_role_org")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 机构(=租户的业务信息,1:1 于 Tenant,code 与 tenant.code 同值)
|
||||||
|
model Agency {
|
||||||
|
code String @id @db.VarChar(64)
|
||||||
|
orgName String @map("org_name") @db.VarChar(100)
|
||||||
|
orgType String @map("org_type") @db.VarChar(20)
|
||||||
|
creditCode String @map("credit_code") @db.VarChar(18)
|
||||||
|
locCode String @map("loc_code") @db.VarChar(12)
|
||||||
|
locName String @map("loc_name") @db.VarChar(50)
|
||||||
|
svcSource String @map("svc_source") @db.VarChar(10)
|
||||||
|
licenseNo String? @map("license_no") @db.VarChar(50)
|
||||||
|
address String @map("address") @db.VarChar(200)
|
||||||
|
contactName String @map("contact_name") @db.VarChar(30)
|
||||||
|
contactPhone String @map("contact_phone") @db.VarChar(15)
|
||||||
|
bedCount Int? @map("bed_count")
|
||||||
|
signDate DateTime? @map("sign_date") @db.Date
|
||||||
|
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")
|
||||||
|
|
||||||
|
@@map("sys_agency")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 人员(护理员/医护/组长,账号在 User,staff.user_id 关联 user.id)
|
||||||
|
model Staff {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
userId String? @unique @map("user_id") @db.VarChar(36)
|
||||||
|
name String @db.VarChar(30)
|
||||||
|
idCardNo String @map("id_card_no") @db.VarChar(255)
|
||||||
|
personCode String? @map("person_code") @db.VarChar(20)
|
||||||
|
phone String @db.VarChar(15)
|
||||||
|
jobType String @map("job_type") @db.VarChar(20)
|
||||||
|
workType String @map("work_type") @db.VarChar(10)
|
||||||
|
entryDate DateTime? @map("entry_date") @db.Date
|
||||||
|
leaveDate DateTime? @map("leave_date") @db.Date
|
||||||
|
employStatus String @map("employ_status") @db.VarChar(10)
|
||||||
|
certType String? @map("cert_type") @db.VarChar(20)
|
||||||
|
certNo String? @map("cert_no") @db.VarChar(50)
|
||||||
|
certLevel String? @map("cert_level") @db.VarChar(20)
|
||||||
|
certExpire DateTime? @map("cert_expire") @db.Date
|
||||||
|
certStatus String? @map("cert_status") @db.VarChar(20)
|
||||||
|
certVerifyUrl String? @map("cert_verify_url") @db.VarChar(200)
|
||||||
|
groupLeaderId String? @map("group_leader_id") @db.VarChar(36)
|
||||||
|
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")
|
||||||
|
|
||||||
|
@@index([tenantCode, name])
|
||||||
|
@@map("sys_staff")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 国家基准库 L1(全局,36 项)
|
||||||
|
model SvcNational {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
natCode String @unique @map("nat_code") @db.VarChar(20)
|
||||||
|
natName String @map("nat_name") @db.VarChar(100)
|
||||||
|
natCategory String @map("nat_category") @db.VarChar(20)
|
||||||
|
natSubCategory String? @map("nat_sub_category") @db.VarChar(50)
|
||||||
|
recommendStaff String? @map("recommend_staff") @db.VarChar(50)
|
||||||
|
recommendDuration String? @map("recommend_duration") @db.VarChar(50)
|
||||||
|
isMedical Boolean @default(false) @map("is_medical")
|
||||||
|
version Int @default(1)
|
||||||
|
|
||||||
|
@@map("sys_svc_national")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 属地目录 L2(全局,昆明 45 项 / 州市引用 L1)
|
||||||
|
model SvcLocal {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
locCode String @map("loc_code") @db.VarChar(12)
|
||||||
|
localItemName String @map("local_item_name") @db.VarChar(100)
|
||||||
|
localCategory String? @map("local_category") @db.VarChar(20)
|
||||||
|
svcTime String? @map("svc_time") @db.VarChar(50)
|
||||||
|
minFreq String? @map("min_freq") @db.VarChar(50)
|
||||||
|
staffQual String? @map("staff_qual") @db.VarChar(50)
|
||||||
|
isMedical Boolean @default(false) @map("is_medical")
|
||||||
|
sourceDoc String? @map("source_doc") @db.VarChar(200)
|
||||||
|
mapStatus String @default("mapped") @map("map_status") @db.VarChar(20)
|
||||||
|
|
||||||
|
@@index([locCode])
|
||||||
|
@@map("sys_svc_local")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 属地↔国家映射 L3(全局,机构不可见)
|
||||||
|
model SvcMapping {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
locId String @map("loc_id") @db.VarChar(36)
|
||||||
|
natId String? @map("nat_id") @db.VarChar(36)
|
||||||
|
mapType String @map("map_type") @db.VarChar(20)
|
||||||
|
mapNote String? @map("map_note") @db.VarChar(200)
|
||||||
|
status String @default("confirmed") @db.VarChar(20)
|
||||||
|
|
||||||
|
@@index([locId])
|
||||||
|
@@map("sys_svc_mapping")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 机构项目库 L4(租户级:机构勾选+配价)
|
||||||
|
model SvcOrg {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
srcType String @map("src_type") @db.VarChar(10)
|
||||||
|
srcId String @map("src_id") @db.VarChar(36)
|
||||||
|
isEnabled Boolean @default(true) @map("is_enabled")
|
||||||
|
price String? @map("price") @db.VarChar(50)
|
||||||
|
priceEffective DateTime? @map("price_effective")
|
||||||
|
isNewNational Boolean @default(false) @map("is_new_national")
|
||||||
|
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")
|
||||||
|
|
||||||
|
@@index([tenantCode, srcType])
|
||||||
|
@@map("sys_svc_org")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异常分类字典(全局)
|
||||||
|
model DictAbnormalType {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
code String @unique @db.VarChar(50)
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
sort Int @default(0)
|
||||||
|
|
||||||
|
@@map("sys_dict_abnormal_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拍照分档字典(全局)
|
||||||
|
model DictPhotoTier {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
code String @unique @db.VarChar(20)
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
tier String @db.VarChar(10)
|
||||||
|
|
||||||
|
@@map("sys_dict_photo_tier")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统筹区配置(全局)
|
||||||
|
model LocConfig {
|
||||||
|
locCode String @id @map("loc_code") @db.VarChar(12)
|
||||||
|
locName String @map("loc_name") @db.VarChar(50)
|
||||||
|
svcSource String @map("svc_source") @db.VarChar(10)
|
||||||
|
itemCountRule String? @map("item_count_rule") @db.VarChar(100)
|
||||||
|
|
||||||
|
@@map("sys_loc_config")
|
||||||
|
}
|
||||||
|
|||||||
17
prisma/seed-catalog.ts
Normal file
17
prisma/seed-catalog.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function seedCatalog(prisma: PrismaClient): Promise<void> {
|
||||||
|
// loc_config 骨架(昆明 + 占位州市)
|
||||||
|
await prisma.locConfig.upsert({
|
||||||
|
where: { locCode: '530100' },
|
||||||
|
update: { locName: '昆明市', svcSource: 'local' },
|
||||||
|
create: { locCode: '530100', locName: '昆明市', svcSource: 'local' },
|
||||||
|
});
|
||||||
|
// L1/L2/L3 目录项:C0 先落结构 + 少量样例(如「协助翻身叩背排痰」),
|
||||||
|
// 全量 36/45 项数据在 C1 上线前由运营导入(字段结构已就绪)。
|
||||||
|
await prisma.svcNational.upsert({
|
||||||
|
where: { natCode: 'L0000106010010000' },
|
||||||
|
update: {},
|
||||||
|
create: { natCode: 'L0000106010010000', natName: '协助翻身叩背排痰', natCategory: 'life', isMedical: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE } from '../src/common/constants.js';
|
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE } from '../src/common/constants.js';
|
||||||
import { initTenant } from '../src/common/tenant-template.js';
|
import { initTenant } from '../src/common/tenant-template.js';
|
||||||
|
import { seedCatalog } from './seed-catalog.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -29,6 +30,9 @@ async function main(): Promise<void> {
|
|||||||
includePlatformPerms: true,
|
includePlatformPerms: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 3. 全局目录 seed(loc_config 骨架 + 国家基准库样例)
|
||||||
|
await seedCatalog(prisma);
|
||||||
|
|
||||||
console.log(`seed 完成:平台租户 ${TENANT}、用户 ${ADMIN_USERNAME}、角色 ${SUPER_ROLE}。`);
|
console.log(`seed 完成:平台租户 ${TENANT}、用户 ${ADMIN_USERNAME}、角色 ${SUPER_ROLE}。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
src/agency/agency.controller.ts
Normal file
23
src/agency/agency.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import { UpdateAgencyDto } from './dto.js';
|
||||||
|
import { AgencyService } from './agency.service.js';
|
||||||
|
|
||||||
|
@Controller('agency')
|
||||||
|
export class AgencyController {
|
||||||
|
constructor(private readonly service: AgencyService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.agency.view)
|
||||||
|
get() {
|
||||||
|
return this.service.getCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@RequirePermissions(Permissions.agency.update)
|
||||||
|
@AuditLog('agency', 'update')
|
||||||
|
update(@Body() dto: UpdateAgencyDto) {
|
||||||
|
return this.service.update(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/agency/agency.module.ts
Normal file
9
src/agency/agency.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AgencyController } from './agency.controller.js';
|
||||||
|
import { AgencyService } from './agency.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AgencyController],
|
||||||
|
providers: [AgencyService],
|
||||||
|
})
|
||||||
|
export class AgencyModule {}
|
||||||
39
src/agency/agency.service.spec.ts
Normal file
39
src/agency/agency.service.spec.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AgencyService } from './agency.service.js';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service.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: 'self',
|
||||||
|
orgCode: null,
|
||||||
|
orgCodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AgencyService', () => {
|
||||||
|
it('getCurrent 按当前租户 code 读机构', async () => {
|
||||||
|
const findUnique = vi.fn().mockResolvedValue({ code: 'T001', orgName: '测试机构' });
|
||||||
|
const prisma = { client: { agency: { findUnique, update: vi.fn() } } };
|
||||||
|
const service = new AgencyService(prisma as unknown as PrismaService);
|
||||||
|
|
||||||
|
const result = await runWithRequestContext(ctx, () => service.getCurrent());
|
||||||
|
|
||||||
|
expect(findUnique).toHaveBeenCalledWith({ where: { code: 'T001' } });
|
||||||
|
expect(result).toEqual({ code: 'T001', orgName: '测试机构' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update 按当前租户 code 更新机构', async () => {
|
||||||
|
const findUnique = vi.fn().mockResolvedValue({ code: 'T001' });
|
||||||
|
const update = vi.fn().mockResolvedValue({ code: 'T001', orgName: '新名称' });
|
||||||
|
const prisma = { client: { agency: { findUnique, update } } };
|
||||||
|
const service = new AgencyService(prisma as unknown as PrismaService);
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.update({ orgName: '新名称' }));
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith({ where: { code: 'T001' }, data: { orgName: '新名称' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/agency/agency.service.ts
Normal file
31
src/agency/agency.service.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
import { BusinessException } from '../common/business-exception.js';
|
||||||
|
import { UpdateAgencyDto } from './dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机构(=租户)信息读写。Agency 是 TENANT_FREE 模型,主键 code = 当前租户 code,
|
||||||
|
* 因此读写一律按 `ctx.tenantCode` 定位,而非 BaseCrudService 的 `findById(id)`。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AgencyService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** 当前租户的机构信息(code = ctx.tenantCode)。 */
|
||||||
|
async getCurrent() {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
return this.prisma.client.agency.findUnique({ where: { code: ctx.tenantCode } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新当前机构信息;写操作同样按 code = ctx.tenantCode 定位,禁止按 id。 */
|
||||||
|
async update(dto: UpdateAgencyDto) {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
const existing = await this.getCurrent();
|
||||||
|
if (!existing) {
|
||||||
|
throw new BusinessException(ErrorCodes.ORG_NOT_FOUND, '机构不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return this.prisma.client.agency.update({ where: { code: ctx.tenantCode }, data: dto });
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/agency/dto.ts
Normal file
61
src/agency/dto.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsDateString, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新机构信息。仅暴露业务可编辑字段:
|
||||||
|
* - 不含主键 `code`(= 租户 code,服务端按 ctx.tenantCode 注入);
|
||||||
|
* - 不含 `locCode`/`locName`(参保地,PRD 定为只读);
|
||||||
|
* - 不含 `createdAt/createdBy/updatedAt/deletedAt`(系统字段)。
|
||||||
|
*/
|
||||||
|
export class UpdateAgencyDto {
|
||||||
|
@ApiPropertyOptional({ description: '机构名称' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
orgName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '机构类型' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
orgType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '统一社会信用代码' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
creditCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '服务来源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
svcSource?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '许可证号' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
licenseNo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '地址' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '法人/联系人' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contactName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '联系方式' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '床位数' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
bedCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '签约日期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
signDate?: string;
|
||||||
|
}
|
||||||
@ -25,6 +25,9 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
|||||||
import { LoginLogsModule } from './login-logs/login-logs.module.js';
|
import { LoginLogsModule } from './login-logs/login-logs.module.js';
|
||||||
import { SessionsModule } from './sessions/sessions.module.js';
|
import { SessionsModule } from './sessions/sessions.module.js';
|
||||||
import { NoticesModule } from './notices/notices.module.js';
|
import { NoticesModule } from './notices/notices.module.js';
|
||||||
|
import { AgencyModule } from './agency/agency.module.js';
|
||||||
|
import { StaffModule } from './staff/staff.module.js';
|
||||||
|
import { CatalogModule } from './catalog/catalog.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';
|
||||||
|
|
||||||
@ -47,6 +50,9 @@ import { AppService } from './app.service.js';
|
|||||||
LoginLogsModule,
|
LoginLogsModule,
|
||||||
SessionsModule,
|
SessionsModule,
|
||||||
NoticesModule,
|
NoticesModule,
|
||||||
|
AgencyModule,
|
||||||
|
StaffModule,
|
||||||
|
CatalogModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
35
src/catalog/catalog.controller.ts
Normal file
35
src/catalog/catalog.controller.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import { ConfigSvcOrgDto } from './dto.js';
|
||||||
|
import { CatalogService } from './catalog.service.js';
|
||||||
|
|
||||||
|
@Controller('svc')
|
||||||
|
export class CatalogController {
|
||||||
|
constructor(private readonly service: CatalogService) {}
|
||||||
|
|
||||||
|
@Get('local/list')
|
||||||
|
@RequirePermissions(Permissions.svcOrg.list)
|
||||||
|
listLocal(@Query('loc_code') locCode: string) {
|
||||||
|
return this.service.listLocal(locCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('org/list')
|
||||||
|
@RequirePermissions(Permissions.svcOrg.list)
|
||||||
|
listOrg() {
|
||||||
|
return this.service.listOrg();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('org/config')
|
||||||
|
@RequirePermissions(Permissions.svcOrg.config)
|
||||||
|
@AuditLog('svc-org', 'config')
|
||||||
|
config(@Body() dto: ConfigSvcOrgDto) {
|
||||||
|
return this.service.config(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('org/new-national')
|
||||||
|
@RequirePermissions(Permissions.svcOrg.newNational)
|
||||||
|
newNational() {
|
||||||
|
return this.service.newNational();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/catalog/catalog.module.ts
Normal file
9
src/catalog/catalog.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CatalogController } from './catalog.controller.js';
|
||||||
|
import { CatalogService } from './catalog.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CatalogController],
|
||||||
|
providers: [CatalogService],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
112
src/catalog/catalog.service.spec.ts
Normal file
112
src/catalog/catalog.service.spec.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CatalogService } from './catalog.service.js';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.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';
|
||||||
|
import type { ConfigSvcOrgDto } from './dto.js';
|
||||||
|
|
||||||
|
const ctx: RequestContext = {
|
||||||
|
tenantCode: 'T001',
|
||||||
|
userId: 'u1',
|
||||||
|
username: 'admin',
|
||||||
|
super: false,
|
||||||
|
dataScope: 'self',
|
||||||
|
orgCode: null,
|
||||||
|
orgCodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(overrides: {
|
||||||
|
svcLocalFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
svcNationalFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
svcOrgFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
svcOrgFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
svcOrgUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {}) {
|
||||||
|
const svcLocalFindMany = overrides.svcLocalFindMany ?? vi.fn().mockResolvedValue([{ id: 'l1', locCode: '530100', localItemName: '头面清洁' }]);
|
||||||
|
const svcNationalFindMany = overrides.svcNationalFindMany ?? vi.fn().mockResolvedValue([{ id: 'n1', natName: '擦浴' }]);
|
||||||
|
const svcOrgFindMany = overrides.svcOrgFindMany ?? vi.fn().mockResolvedValue([{ id: 'o1', tenantCode: 'T001', isEnabled: true }]);
|
||||||
|
const svcOrgFindFirst = overrides.svcOrgFindFirst ?? vi.fn().mockResolvedValue({ id: 'o1', tenantCode: 'T001' });
|
||||||
|
const svcOrgUpdate = overrides.svcOrgUpdate ?? vi.fn().mockResolvedValue({ id: 'o1', isEnabled: false });
|
||||||
|
const prisma = {
|
||||||
|
client: {
|
||||||
|
svcLocal: { findMany: svcLocalFindMany },
|
||||||
|
svcNational: { findMany: svcNationalFindMany },
|
||||||
|
svcOrg: { findMany: svcOrgFindMany, findFirst: svcOrgFindFirst, update: svcOrgUpdate },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new CatalogService(prisma as unknown as PrismaService),
|
||||||
|
svcLocalFindMany,
|
||||||
|
svcNationalFindMany,
|
||||||
|
svcOrgFindMany,
|
||||||
|
svcOrgFindFirst,
|
||||||
|
svcOrgUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CatalogService', () => {
|
||||||
|
it('listLocal 按 locCode 读全局 SvcLocal(不注入 tenantCode)', async () => {
|
||||||
|
const { service, svcLocalFindMany, svcNationalFindMany } = makeService();
|
||||||
|
|
||||||
|
const result = await service.listLocal('530100');
|
||||||
|
|
||||||
|
expect(svcLocalFindMany).toHaveBeenCalledWith({ where: { locCode: '530100' } });
|
||||||
|
expect(svcNationalFindMany).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual([{ id: 'l1', locCode: '530100', localItemName: '头面清洁' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listLocal 无本地目录时回退 SvcNational', async () => {
|
||||||
|
const svcLocalFindMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const svcNationalFindMany = vi.fn().mockResolvedValue([{ id: 'n1', natName: '擦浴' }]);
|
||||||
|
const { service } = makeService({ svcLocalFindMany, svcNationalFindMany });
|
||||||
|
|
||||||
|
const result = await service.listLocal('530200');
|
||||||
|
|
||||||
|
expect(svcNationalFindMany).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual([{ id: 'n1', natName: '擦浴' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listOrg 读当前租户 SvcOrg', async () => {
|
||||||
|
const { service, svcOrgFindMany } = makeService();
|
||||||
|
|
||||||
|
const result = await runWithRequestContext(ctx, () => service.listOrg());
|
||||||
|
|
||||||
|
expect(svcOrgFindMany).toHaveBeenCalledWith({ where: { tenantCode: 'T001' } });
|
||||||
|
expect(result).toEqual([{ id: 'o1', tenantCode: 'T001', isEnabled: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('config 只允许改 isEnabled/price,写操作按当前租户定位', async () => {
|
||||||
|
const { service, svcOrgFindFirst, svcOrgUpdate } = makeService();
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () =>
|
||||||
|
service.config({ id: 'o1', isEnabled: false, price: '50' } as ConfigSvcOrgDto),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(svcOrgFindFirst).toHaveBeenCalledWith({ where: { id: 'o1', tenantCode: 'T001' } });
|
||||||
|
expect(svcOrgUpdate).toHaveBeenCalledWith({ where: { id: 'o1' }, data: { isEnabled: false, price: '50' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['srcId', 'n1'],
|
||||||
|
['srcType', 'national'],
|
||||||
|
])('config 夹带 %s 抛 SVC_LOC_IMMUTABLE', async (key, value) => {
|
||||||
|
const { service, svcOrgFindFirst, svcOrgUpdate } = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.config({ id: 'o1', [key]: value } as ConfigSvcOrgDto)),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_LOC_IMMUTABLE } });
|
||||||
|
|
||||||
|
expect(svcOrgFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(svcOrgUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('config 目标不存在抛 SVC_ORG_NOT_FOUND', async () => {
|
||||||
|
const svcOrgFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const { service } = makeService({ svcOrgFindFirst });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.config({ id: 'missing', isEnabled: true } as ConfigSvcOrgDto)),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } });
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/catalog/catalog.service.ts
Normal file
63
src/catalog/catalog.service.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
|
import { BusinessException } from '../common/business-exception.js';
|
||||||
|
import { ConfigSvcOrgDto } from './dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务目录四层(L1 国家基准库 / L2 属地目录 / L3 映射(机构不可见) / L4 机构项目库)。
|
||||||
|
*
|
||||||
|
* - SvcNational/SvcLocal/SvcMapping 是全局表(TENANT_FREE),读取不会注入 tenantCode;
|
||||||
|
* - SvcOrg 是租户级表(读自动注入 tenantCode + 软删),tenantCode 由本 service 显式写入
|
||||||
|
* (读操作再次被 PrismaService 扩展注入同值 tenantCode,幂等)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** 按属地加载目录:优先 SvcLocal;无本地目录(新建制州市)回退 SvcNational 全量。 */
|
||||||
|
async listLocal(locCode: string) {
|
||||||
|
const locals = await this.prisma.client.svcLocal.findMany({ where: { locCode } });
|
||||||
|
if (locals.length > 0) return locals;
|
||||||
|
return this.prisma.client.svcNational.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前租户的机构项目库(SvcOrg)。 */
|
||||||
|
listOrg() {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
return this.prisma.client.svcOrg.findMany({ where: { tenantCode: ctx.tenantCode } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 勾选+配价:只允许改 isEnabled/price,夹带 srcType/srcId 等业务字段一律拒绝。 */
|
||||||
|
async config(dto: ConfigSvcOrgDto) {
|
||||||
|
this.assertOnlyMutableFields(dto);
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
const existing = await this.prisma.client.svcOrg.findFirst({
|
||||||
|
where: { id: dto.id, tenantCode: ctx.tenantCode },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (dto.isEnabled !== undefined) data.isEnabled = dto.isEnabled;
|
||||||
|
if (dto.price !== undefined) data.price = dto.price;
|
||||||
|
return this.prisma.client.svcOrg.update({ where: { id: dto.id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 国家新增项(昆明目录缺、国家目录有的 5 项,机构勾选界面需显式提示)。 */
|
||||||
|
newNational() {
|
||||||
|
// TODO(C1): SvcNational 无 is_new_national 列,「国家新增项」= 昆明 L2 缺而国家 L1 有的
|
||||||
|
// 差集(L3 映射 national_only)。C1 需按 L3 映射补齐并标记 is_new_national,此处暂返回全量。
|
||||||
|
return this.prisma.client.svcNational.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 白名单校验:config 只允许 id/isEnabled/price,夹带其他业务字段抛 SVC_LOC_IMMUTABLE。 */
|
||||||
|
private assertOnlyMutableFields(dto: ConfigSvcOrgDto) {
|
||||||
|
const allowed = new Set(['id', 'isEnabled', 'price']);
|
||||||
|
const extra = Object.keys(dto).filter((key) => !allowed.has(key));
|
||||||
|
if (extra.length > 0) {
|
||||||
|
throw new BusinessException(ErrorCodes.SVC_LOC_IMMUTABLE, '仅可调整启用状态与价格', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/catalog/dto.ts
Normal file
35
src/catalog/dto.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机构项目库「勾选+配价」入参。机构只能改 `is_enabled`/`price`(PRD 五条纪律之
|
||||||
|
* 「机构不可改目录」),`id` 仅用于定位目标 svc_org 行(来自 `GET /svc/org/list`)。
|
||||||
|
*
|
||||||
|
* `srcType`/`srcId` 刻意声明在 DTO 中:全局 ValidationPipe(whitelist) 不会剥离它们,
|
||||||
|
* 从而由 service 的白名单校验捕获并抛 SVC_LOC_IMMUTABLE(只读业务字段夹带即拒绝)。
|
||||||
|
*/
|
||||||
|
export class ConfigSvcOrgDto {
|
||||||
|
@ApiProperty({ description: '机构项目库记录 ID(由 /svc/org/list 返回)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '是否开展(机构只能开关,不能删)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '服务价格(预留 R1,本期可填可看、不参与结算)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
price?: string;
|
||||||
|
|
||||||
|
// —— 以下为只读业务字段,仅用于白名单校验捕获「夹带」,service 一律拒绝 ——
|
||||||
|
@ApiPropertyOptional({ description: '来源层级(只读,机构不可改)' })
|
||||||
|
@IsOptional()
|
||||||
|
srcType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '来源项目 ID(只读,机构不可改)' })
|
||||||
|
@IsOptional()
|
||||||
|
srcId?: string;
|
||||||
|
}
|
||||||
@ -28,6 +28,17 @@ export const ErrorCodes = {
|
|||||||
|
|
||||||
// 会话(20)
|
// 会话(20)
|
||||||
SESSION_NOT_FOUND: 20001, // 会话不存在或已下线(404)
|
SESSION_NOT_FOUND: 20001, // 会话不存在或已下线(404)
|
||||||
|
|
||||||
|
// 服务机构端·机构(40)
|
||||||
|
ORG_NOT_FOUND: 40001,
|
||||||
|
ORG_CODE_MISMATCH: 40002,
|
||||||
|
// 服务机构端·人员(41)
|
||||||
|
STAFF_NOT_FOUND: 41001,
|
||||||
|
STAFF_USER_LINKED: 41002,
|
||||||
|
// 服务机构端·目录(42)
|
||||||
|
SVC_LOC_NOT_FOUND: 42001,
|
||||||
|
SVC_ORG_NOT_FOUND: 42002,
|
||||||
|
SVC_LOC_IMMUTABLE: 42003,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||||
|
|||||||
@ -61,6 +61,17 @@ export const Permissions = {
|
|||||||
},
|
},
|
||||||
audit: { list: 'audit:list' },
|
audit: { list: 'audit:list' },
|
||||||
loginLog: { list: 'login-log:list' },
|
loginLog: { list: 'login-log:list' },
|
||||||
|
// 服务机构端业务(C0 落地 agency/svcOrg/staff,其余为 C1–C4 预留)
|
||||||
|
agency: { view: 'agency:view', update: 'agency:update' },
|
||||||
|
svcOrg: { list: 'svc-org:list', config: 'svc-org:config', newNational: 'svc-org:new-national' },
|
||||||
|
staff: { list: 'staff:list', create: 'staff:create', update: 'staff:update', delete: 'staff:delete', groupAssign: 'staff:group-assign' },
|
||||||
|
elder: { list: 'elder:list', detail: 'elder:detail', create: 'elder:create', update: 'elder:update', statusChange: 'elder:status-change', import: 'elder:import', svcSave: 'elder:svc-save' },
|
||||||
|
plan: { create: 'plan:create', change: 'plan:change', detail: 'plan:detail', confirm: 'plan:confirm' },
|
||||||
|
order: { generate: 'order:generate', assign: 'order:assign', reassign: 'order:reassign', cancel: 'order:cancel', detail: 'order:detail', today: 'order:today' },
|
||||||
|
record: { checkin: 'record:checkin', checkout: 'record:checkout', itemSave: 'record:item-save', photoUpload: 'record:photo-upload', detail: 'record:detail' },
|
||||||
|
abnormal: { report: 'abnormal:report', list: 'abnormal:list', handle: 'abnormal:handle' },
|
||||||
|
qc: { pendingList: 'qc:pending-list', detail: 'qc:detail', pass: 'qc:pass', return: 'qc:return', exceptionLink: 'qc:exception-link' },
|
||||||
|
export: { export: 'export:export' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 全部权限码扁平列表(seed 授权与资源生成用)。 */
|
/** 全部权限码扁平列表(seed 授权与资源生成用)。 */
|
||||||
|
|||||||
@ -19,6 +19,19 @@ export const MENUS = [
|
|||||||
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
|
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
|
||||||
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
|
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
|
||||||
{ code: 'session', name: '在线会话', parent: 'monitor', path: '/monitor/session', component: 'monitor/session/index', icon: 'mdi:account-multiple-check', sort: 3 },
|
{ code: 'session', name: '在线会话', parent: 'monitor', path: '/monitor/session', component: 'monitor/session/index', icon: 'mdi:account-multiple-check', sort: 3 },
|
||||||
|
{ code: 'ltc', name: '长护服务', parent: null, path: '/ltc', component: 'Layout', icon: 'mdi:home-heart', sort: 5 },
|
||||||
|
{ code: 'elder', name: '服务对象', parent: 'ltc', path: '/ltc/elder', component: 'ltc/elder/index', icon: 'mdi:account-heart', sort: 1 },
|
||||||
|
{ code: 'schedule', name: '排班派单', parent: 'ltc', path: '/ltc/schedule', component: 'ltc/schedule/index', icon: 'mdi:calendar-clock', sort: 2 },
|
||||||
|
{ code: 'record', name: '工单留痕', parent: 'ltc', path: '/ltc/record', component: 'ltc/record/index', icon: 'mdi:clipboard-check', sort: 3 },
|
||||||
|
{ code: 'qc', name: '质控中心', parent: 'ltc', path: '/ltc/qc', component: 'ltc/qc/index', icon: 'mdi:shield-check', sort: 4 },
|
||||||
|
{ code: 'document', name: '文书档案', parent: 'ltc', path: '/ltc/document', component: 'ltc/document/index', icon: 'mdi:file-document', sort: 5 },
|
||||||
|
{ code: 'svc-config', name: '服务机构设置', parent: 'ltc', path: '/ltc/config', component: 'Layout', icon: 'mdi:cog', sort: 6 },
|
||||||
|
{ code: 'svc-config-info', name: '机构信息', parent: 'svc-config', path: '/ltc/config/info', component: 'ltc/config/info/index', icon: 'mdi:office-building', sort: 1 },
|
||||||
|
{ code: 'svc-config-loc', name: '属地与目录', parent: 'svc-config', path: '/ltc/config/loc', component: 'ltc/config/loc/index', icon: 'mdi:map-marker', sort: 2 },
|
||||||
|
{ code: 'svc-config-catalog', name: '服务项目与配价', parent: 'svc-config', path: '/ltc/config/catalog', component: 'ltc/config/catalog/index', icon: 'mdi:format-list-bulleted', sort: 3 },
|
||||||
|
{ code: 'svc-config-photo', name: '拍照分档配置', parent: 'svc-config', path: '/ltc/config/photo', component: 'ltc/config/photo/index', icon: 'mdi:camera', sort: 4 },
|
||||||
|
{ code: 'svc-config-qc', name: '质控提醒配置', parent: 'svc-config', path: '/ltc/config/qc', component: 'ltc/config/qc/index', icon: 'mdi:bell-alert', sort: 5 },
|
||||||
|
{ code: 'svc-config-docking', name: '对接档位', parent: 'svc-config', path: '/ltc/config/docking', component: 'ltc/config/docking/index', icon: 'mdi:swap-horizontal', sort: 6 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 接口资源(每个受控接口一个,code = 权限码)。 */
|
/** 接口资源(每个受控接口一个,code = 权限码)。 */
|
||||||
@ -66,6 +79,18 @@ export const APIS: { code: string; name: string; method: string; path: string; p
|
|||||||
{ code: Permissions.notice.create, name: '创建通知', method: 'POST', path: '/api/v1/notices', parent: 'notice' },
|
{ code: Permissions.notice.create, name: '创建通知', method: 'POST', path: '/api/v1/notices', parent: 'notice' },
|
||||||
{ code: Permissions.notice.update, name: '更新通知', method: 'PATCH', path: '/api/v1/notices/:id', parent: 'notice' },
|
{ code: Permissions.notice.update, name: '更新通知', method: 'PATCH', path: '/api/v1/notices/:id', parent: 'notice' },
|
||||||
{ code: Permissions.notice.delete, name: '删除通知', method: 'DELETE', path: '/api/v1/notices/:id', parent: 'notice' },
|
{ code: Permissions.notice.delete, name: '删除通知', method: 'DELETE', path: '/api/v1/notices/:id', parent: 'notice' },
|
||||||
|
// 服务机构端业务(C0)
|
||||||
|
{ code: Permissions.agency.view, name: '查看机构信息', method: 'GET', path: '/api/v1/agency', parent: 'svc-config-info' },
|
||||||
|
{ code: Permissions.agency.update, name: '更新机构信息', method: 'PATCH', path: '/api/v1/agency', parent: 'svc-config-info' },
|
||||||
|
{ code: Permissions.staff.list, name: '人员列表', method: 'GET', path: '/api/v1/staff', parent: 'svc-config' },
|
||||||
|
{ code: Permissions.staff.create, name: '创建人员', method: 'POST', path: '/api/v1/staff', parent: 'svc-config' },
|
||||||
|
{ code: Permissions.staff.update, name: '更新人员', method: 'PATCH', path: '/api/v1/staff/:id', parent: 'svc-config' },
|
||||||
|
{ code: Permissions.staff.delete, name: '删除人员', method: 'DELETE', path: '/api/v1/staff/:id', parent: 'svc-config' },
|
||||||
|
{ code: Permissions.staff.groupAssign, name: '人员分组', method: 'POST', path: '/api/v1/staff/:id/group', parent: 'svc-config' },
|
||||||
|
{ code: Permissions.svcOrg.list, name: '属地目录列表', method: 'GET', path: '/api/v1/svc/local/list', parent: 'svc-config-loc' },
|
||||||
|
{ 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.newNational, name: '国家新增项', method: 'GET', path: '/api/v1/svc/org/new-national', parent: 'svc-config-loc' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
||||||
@ -76,6 +101,40 @@ export const BUTTONS = [
|
|||||||
{ code: 'login-log:export', name: '登录日志导出', parent: 'login-log' },
|
{ code: 'login-log:export', name: '登录日志导出', parent: 'login-log' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 业务租户预置角色(C0:nurse/leader/qc/admin/director)及初始授权。 */
|
||||||
|
export const BUSINESS_ROLES: { code: string; name: string; permissions: string[] }[] = [
|
||||||
|
{ code: 'director', name: '院长', permissions: [Permissions.agency.view] },
|
||||||
|
{
|
||||||
|
code: 'admin',
|
||||||
|
name: '机构管理员',
|
||||||
|
permissions: [
|
||||||
|
Permissions.agency.view,
|
||||||
|
Permissions.agency.update,
|
||||||
|
Permissions.staff.list,
|
||||||
|
Permissions.staff.create,
|
||||||
|
Permissions.staff.update,
|
||||||
|
Permissions.staff.delete,
|
||||||
|
Permissions.staff.groupAssign,
|
||||||
|
Permissions.svcOrg.list,
|
||||||
|
Permissions.svcOrg.config,
|
||||||
|
Permissions.svcOrg.newNational,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'qc',
|
||||||
|
name: '质控员',
|
||||||
|
permissions: [
|
||||||
|
Permissions.agency.view,
|
||||||
|
Permissions.staff.list,
|
||||||
|
Permissions.svcOrg.list,
|
||||||
|
Permissions.svcOrg.config,
|
||||||
|
Permissions.svcOrg.newNational,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ code: 'leader', name: '组长', permissions: [Permissions.staff.list, Permissions.svcOrg.list] },
|
||||||
|
{ code: 'nurse', name: '护理员', permissions: [] },
|
||||||
|
];
|
||||||
|
|
||||||
/** 内置枚举字典(值对齐 enums.ts,仅展示层 label/颜色/排序可改)。 */
|
/** 内置枚举字典(值对齐 enums.ts,仅展示层 label/颜色/排序可改)。 */
|
||||||
export const DICTS: { code: string; name: string; items: { label: string; value: string; colorType: string; sort: number }[] }[] = [
|
export const DICTS: { code: string; name: string; items: { label: string; value: string; colorType: string; sort: number }[] }[] = [
|
||||||
{ code: 'common_status', name: '通用状态', items: [
|
{ code: 'common_status', name: '通用状态', items: [
|
||||||
@ -183,6 +242,24 @@ export async function syncTenantResources(opts: SyncTenantResourcesOptions): Pro
|
|||||||
create: { tenantCode, roleCode: adminRoleCode, resourceCode: code },
|
create: { tenantCode, roleCode: adminRoleCode, resourceCode: code },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 业务租户预置业务角色(nurse/leader/qc/admin/director)并授权(幂等)
|
||||||
|
if (!includePlatformPerms) {
|
||||||
|
for (const r of BUSINESS_ROLES) {
|
||||||
|
await prisma.role.upsert({
|
||||||
|
where: { tenantCode_code: { tenantCode, code: r.code } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantCode, code: r.code, name: r.name, dataScope: 'all', status: 'enabled' },
|
||||||
|
});
|
||||||
|
for (const code of r.permissions) {
|
||||||
|
await prisma.roleResource.upsert({
|
||||||
|
where: { tenantCode_roleCode_resourceCode: { tenantCode, roleCode: r.code, resourceCode: code } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantCode, roleCode: r.code, resourceCode: code },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
|
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { getRequestContext } from '../common/context/request-context.js';
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
|
||||||
/** 不带 tenantCode 的模型(租户表自身)。 */
|
/** 不带 tenantCode 的模型(租户表自身)。 */
|
||||||
const TENANT_FREE = new Set(['Tenant']);
|
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'DictAbnormalType', 'DictPhotoTier', 'LocConfig']);
|
||||||
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
||||||
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice']);
|
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg']);
|
||||||
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']);
|
||||||
|
|||||||
207
src/staff/dto.ts
Normal file
207
src/staff/dto.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { PageQuery } from '../common/page.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人员(staff)DTO。字段字典以 shared/data-model 为准,本期只落地 C0 骨架字段。
|
||||||
|
* - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入);
|
||||||
|
* - id_card_no 本期存明文(加密机制由后续安全 change 统一落地,见 staff.service.ts TODO)。
|
||||||
|
*/
|
||||||
|
export class CreateStaffDto {
|
||||||
|
@ApiPropertyOptional({ description: '关联账号 user.id(可空,1:1;院长/质控/管理员无 staff 记录)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '姓名' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '身份证号(本期明文)' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
idCardNo!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '工号(预留)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
personCode?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '手机号' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '岗位类型:nurse / nurse_medical / manager' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
jobType!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '用工类型' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
workType!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '入职日期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
entryDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '离职日期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
leaveDate?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '在职状态' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
employStatus!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书类型' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书编号' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certNo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书等级' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certLevel?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书有效期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
certExpire?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书状态' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certStatus?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书核验链接(预留)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certVerifyUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '归属组长 staff.id' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
groupLeaderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新人员信息:全部 optional;`userId` 允许改绑账号(service 层校验唯一)。 */
|
||||||
|
export class UpdateStaffDto {
|
||||||
|
@ApiPropertyOptional({ description: '关联账号 user.id(可空,改绑需唯一)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '姓名' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '身份证号(本期明文)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
idCardNo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '工号(预留)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
personCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '手机号' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '岗位类型' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
jobType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '用工类型' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
workType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '入职日期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
entryDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '离职日期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
leaveDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '在职状态' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
employStatus?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书类型' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书编号' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certNo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书等级' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certLevel?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书有效期(YYYY-MM-DD)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
certExpire?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书状态' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certStatus?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '证书核验链接(预留)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
certVerifyUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '归属组长 staff.id' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
groupLeaderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListStaffQuery extends PageQuery {
|
||||||
|
@ApiPropertyOptional({ description: '姓名(模糊)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '岗位类型' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
jobType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '在职状态' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
employStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定归属组长;传 null 解除归属。 */
|
||||||
|
export class AssignGroupDto {
|
||||||
|
@ApiPropertyOptional({ description: '归属组长 staff.id(传 null 解除归属)', nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
groupLeaderId?: string | null;
|
||||||
|
}
|
||||||
44
src/staff/staff.controller.ts
Normal file
44
src/staff/staff.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.js';
|
||||||
|
import { StaffService } from './staff.service.js';
|
||||||
|
|
||||||
|
@Controller('staff')
|
||||||
|
export class StaffController {
|
||||||
|
constructor(private readonly service: StaffService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.staff.list)
|
||||||
|
list(@Query() q: ListStaffQuery) {
|
||||||
|
return this.service.listStaff(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.staff.create)
|
||||||
|
@AuditLog('staff', 'create')
|
||||||
|
create(@Body() dto: CreateStaffDto) {
|
||||||
|
return this.service.createStaff(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.staff.update)
|
||||||
|
@AuditLog('staff', 'update')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateStaffDto) {
|
||||||
|
return this.service.updateStaff(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.staff.delete)
|
||||||
|
@AuditLog('staff', 'delete')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.removeStaff(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/group')
|
||||||
|
@RequirePermissions(Permissions.staff.groupAssign)
|
||||||
|
@AuditLog('staff', 'group-assign')
|
||||||
|
assignGroup(@Param('id') id: string, @Body() dto: AssignGroupDto) {
|
||||||
|
return this.service.assignGroup(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/staff/staff.module.ts
Normal file
9
src/staff/staff.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StaffController } from './staff.controller.js';
|
||||||
|
import { StaffService } from './staff.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [StaffController],
|
||||||
|
providers: [StaffService],
|
||||||
|
})
|
||||||
|
export class StaffModule {}
|
||||||
93
src/staff/staff.service.spec.ts
Normal file
93
src/staff/staff.service.spec.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { StaffService } from './staff.service.js';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.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: 'self',
|
||||||
|
orgCode: null,
|
||||||
|
orgCodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseDto = {
|
||||||
|
name: '张三',
|
||||||
|
idCardNo: '530102199001011234',
|
||||||
|
phone: '13800000000',
|
||||||
|
jobType: 'nurse',
|
||||||
|
workType: 'full',
|
||||||
|
employStatus: 'on',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
overrides: {
|
||||||
|
staffFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
userFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
create?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const staffFindFirst = overrides.staffFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
|
const userFindFirst = overrides.userFindFirst ?? vi.fn().mockResolvedValue({ id: 'u9', tenantCode: 'T001' });
|
||||||
|
const create = overrides.create ?? vi.fn().mockResolvedValue({ id: 's1', ...baseDto, tenantCode: 'T001' });
|
||||||
|
const prisma = {
|
||||||
|
client: { staff: { findFirst: staffFindFirst, create }, user: { findFirst: userFindFirst } },
|
||||||
|
};
|
||||||
|
return { service: new StaffService(prisma as unknown as PrismaService), staffFindFirst, userFindFirst, create };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StaffService', () => {
|
||||||
|
it('create 自动注入 tenantCode/createdBy', async () => {
|
||||||
|
const { service, create } = makeService();
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
|
||||||
|
|
||||||
|
expect(create).toHaveBeenCalledWith({
|
||||||
|
data: { ...baseDto, tenantCode: 'T001', createdBy: 'u1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('userId 指向的账号不存在时 create 抛 USER_NOT_FOUND', async () => {
|
||||||
|
const userFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
|
const staffFindFirst = vi.fn();
|
||||||
|
const create = vi.fn();
|
||||||
|
const { service } = makeService({ userFindFirst, staffFindFirst, create });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.createStaff({ ...baseDto, userId: 'u9' })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.USER_NOT_FOUND } });
|
||||||
|
|
||||||
|
expect(userFindFirst).toHaveBeenCalledWith({ where: { id: 'u9', tenantCode: 'T001' } });
|
||||||
|
expect(staffFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('userId 已被占用时 create 抛 STAFF_USER_LINKED', async () => {
|
||||||
|
const staffFindFirst = vi.fn().mockResolvedValue({ id: 's-other', userId: 'u9' });
|
||||||
|
const create = vi.fn();
|
||||||
|
const { service } = makeService({ staffFindFirst, create });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.createStaff({ ...baseDto, userId: 'u9' })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.STAFF_USER_LINKED } });
|
||||||
|
|
||||||
|
expect(staffFindFirst).toHaveBeenCalledWith({ where: { userId: 'u9' } });
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('userId 为空时不校验、直接创建', async () => {
|
||||||
|
const userFindFirst = vi.fn();
|
||||||
|
const staffFindFirst = vi.fn();
|
||||||
|
const { service, create } = makeService({ userFindFirst, staffFindFirst });
|
||||||
|
|
||||||
|
await runWithRequestContext(ctx, () => service.createStaff(baseDto));
|
||||||
|
|
||||||
|
expect(userFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(staffFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
88
src/staff/staff.service.ts
Normal file
88
src/staff/staff.service.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { 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 { AssignGroupDto, CreateStaffDto, ListStaffQuery, UpdateStaffDto } from './dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人员(staff)业务服务。staff 是租户级软删模型,读写经 Prisma 扩展自动注入
|
||||||
|
* tenantCode / deletedAt 过滤;创建时 tenantCode/createdBy 由 BaseCrudService 注入。
|
||||||
|
* 沿用 tenants 范式:BaseCrudService 的 create/update/softRemove 原样可用,业务校验
|
||||||
|
* 收敛在 createStaff/updateStaff 等包装方法内。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class StaffService extends BaseCrudService {
|
||||||
|
constructor(prisma: PrismaService) {
|
||||||
|
super(prisma, 'staff');
|
||||||
|
}
|
||||||
|
|
||||||
|
listStaff(q: ListStaffQuery) {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (q.name) where.name = { contains: q.name };
|
||||||
|
if (q.jobType) where.jobType = q.jobType;
|
||||||
|
if (q.employStatus) where.employStatus = q.employStatus;
|
||||||
|
return this.paginate(q.page, q.pageSize, where);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建档:先校验 userId 指向的账号存在且未被占用,再走 BaseCrudService 注入 tenantCode/createdBy。 */
|
||||||
|
async createStaff(dto: CreateStaffDto) {
|
||||||
|
await this.assertUserIdValid(dto.userId);
|
||||||
|
// TODO(id_card_no 加密): 本期直接存明文,加密机制由后续安全 change 统一落地后再在此处加解密。
|
||||||
|
return super.create({ ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改人员:改绑 userId 时同样校验账号存在与唯一;人员不存在抛 STAFF_NOT_FOUND。 */
|
||||||
|
async updateStaff(id: string, dto: UpdateStaffDto) {
|
||||||
|
await this.requireExists(id);
|
||||||
|
await this.assertUserIdValid(dto.userId, id);
|
||||||
|
return super.update(id, { ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除(软删)。 */
|
||||||
|
async removeStaff(id: string) {
|
||||||
|
await this.requireExists(id);
|
||||||
|
return this.softRemove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定归属组长(groupLeaderId 传 null 解除归属)。 */
|
||||||
|
async assignGroup(id: string, dto: AssignGroupDto) {
|
||||||
|
await this.requireExists(id);
|
||||||
|
return super.update(id, { groupLeaderId: dto.groupLeaderId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** userId 为 1:1 关联(可空):非空时校验账号存在 + 未被其他 staff 占用。 */
|
||||||
|
private async assertUserIdValid(userId: string | undefined, excludeId?: string) {
|
||||||
|
if (!userId) return;
|
||||||
|
await this.assertUserExists(userId);
|
||||||
|
await this.assertUserIdFree(userId, excludeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 userId 指向的账号在本租户内存在(staff.user_id 无 DB 外键,须业务层兜底)。 */
|
||||||
|
private async assertUserExists(userId: string) {
|
||||||
|
const ctx = getRequestContext();
|
||||||
|
const user = await this.prisma.client.user.findFirst({
|
||||||
|
where: { id: userId, tenantCode: ctx?.tenantCode },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new BusinessException(ErrorCodes.USER_NOT_FOUND, '账号不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 userId 是否已被其他 staff 占用(唯一关联)。 */
|
||||||
|
private async assertUserIdFree(userId: string, excludeId?: string) {
|
||||||
|
const existing = await this.db().findFirst({ where: { userId } });
|
||||||
|
if (existing && existing.id !== excludeId) {
|
||||||
|
throw new BusinessException(ErrorCodes.STAFF_USER_LINKED, '该账号已关联其他人员', HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireExists(id: string) {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new BusinessException(ErrorCodes.STAFF_NOT_FOUND, '人员不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user