Compare commits

...

8 Commits

Author SHA1 Message Date
liuyun
aa95586a55 fix(c0): 接线业务 RBAC 与补齐服务机构设置菜单
- APIS 登记 agency/staff/svc-org 业务接口资源(code=权限码)
- syncTenantResources 为业务租户预置 5 个业务角色并授权
- MENUS 补齐 svc-config-loc(属地与目录)与 svc-config-docking(对接档位)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 13:47:55 +08:00
liuyun
945fe5daa3 feat(c0): catalog 目录模块(属地加载 + 机构项目库)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 13:08:47 +08:00
liuyun
0be521bd72 fix(c0): staff 绑定 userId 前校验账号在本租户存在
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 13:01:32 +08:00
liuyun
5a3beb4923 feat(c0): staff 人员模块(user_id 关联账号 + 归属组长)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 12:56:07 +08:00
liuyun
856e906740 feat(c0): agency 机构模块(按租户读写机构信息)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 12:48:27 +08:00
liuyun
198517bde4 feat(c0): 长护服务菜单组 + 目录 seed 骨架
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 12:42:53 +08:00
liuyun
b5eb94d3ae feat(c0): 服务机构端权限码与业务错误码 2026-10-14 12:39:42 +08:00
liuyun
6f32875295 feat(c0): 服务机构端基础数据模型(9 表 + 多租户/软删清单)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 12:36:43 +08:00
24 changed files with 1283 additions and 2 deletions

View 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;

View File

@ -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
View 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 },
});
}

View File

@ -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}。`);
} }

View 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);
}
}

View 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 {}

View 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: '新名称' } });
});
});

View 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
View 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;
}

View File

@ -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: [

View 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();
}
}

View 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 {}

View 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 } });
});
});

View 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
View 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;
}

View File

@ -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];

View File

@ -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 授权与资源生成用)。 */

View File

@ -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 },
});
}
}
}
} }
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */ /** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */

View File

@ -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
View 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;
}

View 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);
}
}

View 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 {}

View 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();
});
});

View 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;
}
}