feat(c0): catalog 目录模块(属地加载 + 机构项目库)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 13:08:47 +08:00
parent 0be521bd72
commit 945fe5daa3
6 changed files with 256 additions and 0 deletions

View File

@ -27,6 +27,7 @@ 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 { AgencyModule } from './agency/agency.module.js';
import { StaffModule } from './staff/staff.module.js'; import { StaffModule } from './staff/staff.module.js';
import { CatalogModule } from './catalog/catalog.module.js';
import { AppController } from './app.controller.js'; import { AppController } from './app.controller.js';
import { AppService } from './app.service.js'; import { AppService } from './app.service.js';
@ -51,6 +52,7 @@ import { AppService } from './app.service.js';
NoticesModule, NoticesModule,
AgencyModule, AgencyModule,
StaffModule, 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;
}