feat(c0): catalog 目录模块(属地加载 + 机构项目库)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0be521bd72
commit
945fe5daa3
@ -27,6 +27,7 @@ import { SessionsModule } from './sessions/sessions.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 { AppService } from './app.service.js';
|
||||
|
||||
@ -51,6 +52,7 @@ import { AppService } from './app.service.js';
|
||||
NoticesModule,
|
||||
AgencyModule,
|
||||
StaffModule,
|
||||
CatalogModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user