diff --git a/src/app.module.ts b/src/app.module.ts index 6229c3f..530a190 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/catalog/catalog.controller.ts b/src/catalog/catalog.controller.ts new file mode 100644 index 0000000..63045b5 --- /dev/null +++ b/src/catalog/catalog.controller.ts @@ -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(); + } +} diff --git a/src/catalog/catalog.module.ts b/src/catalog/catalog.module.ts new file mode 100644 index 0000000..0e49910 --- /dev/null +++ b/src/catalog/catalog.module.ts @@ -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 {} diff --git a/src/catalog/catalog.service.spec.ts b/src/catalog/catalog.service.spec.ts new file mode 100644 index 0000000..c80d0a1 --- /dev/null +++ b/src/catalog/catalog.service.spec.ts @@ -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; + svcNationalFindMany?: ReturnType; + svcOrgFindMany?: ReturnType; + svcOrgFindFirst?: ReturnType; + svcOrgUpdate?: ReturnType; +} = {}) { + 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 } }); + }); +}); diff --git a/src/catalog/catalog.service.ts b/src/catalog/catalog.service.ts new file mode 100644 index 0000000..df6b49d --- /dev/null +++ b/src/catalog/catalog.service.ts @@ -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 = {}; + 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); + } + } +} diff --git a/src/catalog/dto.ts b/src/catalog/dto.ts new file mode 100644 index 0000000..4ca1066 --- /dev/null +++ b/src/catalog/dto.ts @@ -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; +}