diff --git a/furniture-3d-system/src/server/services/supply-chain-department-distribution.service.ts b/furniture-3d-system/src/server/services/supply-chain-department-distribution.service.ts index 074fd80a..a9ea16f4 100644 --- a/furniture-3d-system/src/server/services/supply-chain-department-distribution.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-department-distribution.service.ts @@ -88,9 +88,18 @@ export const PERSON_BLOCK_EXCLUDED_DEPARTMENTS = new Set(['山东项目组']) export const DEPARTMENT_SETTING_KEY = 'departmentDistribution' +/** + * 快照口径版本: + * v2(2026-10-15)产品中心/采购中心改为「只要有设计师/采购就统计」,不再依赖运营负责人。 + * 读到的快照版本低于当前版本时按实时口径重算,并在后台重建快照。 + */ +export const DEPARTMENT_SNAPSHOT_LOGIC_VERSION = 2 + /** 结果缓存:同一个人看同一个月,2 分钟内直接用上次算好的结果。 */ const DEPARTMENT_RESULT_CACHE_TTL_MS = 2 * 60 * 1000 const departmentResultCache = new Map() +/** 旧口径快照的后台重建任务(同一月份同时只跑一次) */ +const snapshotRebuildInFlight = new Set() /** 月度原始快照(按负责人/设计师/采购的计数)内存缓存。 */ const DEPARTMENT_RAW_CACHE_TTL_MS = 30 * 60 * 1000 @@ -423,6 +432,8 @@ type DepartmentRawPerson = { type DepartmentRawSnapshot = { generatedAt: string + /** 生成该快照时的口径版本,见 DEPARTMENT_SNAPSHOT_LOGIC_VERSION */ + logicVersion?: number targets: DepartmentTargets profitCenterTargets: DepartmentProfitCenterTargets[] brandScope: string[] @@ -463,8 +474,19 @@ export class SupplyChainDepartmentDistributionService { const brand = String(row.brand ?? '').trim() if (!brandScope.has(brand)) continue const owner = String(row.opsHead ?? '').trim() || settings.brandOwnerAlias[brand] || '' - if (!owner) continue const cleanedOwner = cleanPersonName(owner) + // 没有运营负责人的行:不进运营中心的按人汇总, + // 但产品中心/采购中心只认设计师/采购,仍要统计这些行。 + if (!cleanedOwner) { + rows.push({ + ...row, + effectiveOwner: '', + profitCenter: '', + department: '', + secondaryDepartment: '', + }) + continue + } const profile = profileMap.get(owner) const resolvedProfile = profile ?? profileMap.get(cleanedOwner) const profitCenter = resolveProfitCenter(cleanedOwner, resolvedProfile, settings) @@ -487,7 +509,11 @@ export class SupplyChainDepartmentDistributionService { async generateSnapshot(input: { year: number; month: number; generatedAt?: Date }) { const raw = await this.computeRaw(input.year, input.month) const { start, end } = createPeriodDates(input.year, input.month) - const payload = { ...raw, generatedAt: (input.generatedAt ?? new Date()).toISOString() } + const payload = { + ...raw, + generatedAt: (input.generatedAt ?? new Date()).toISOString(), + logicVersion: DEPARTMENT_SNAPSHOT_LOGIC_VERSION, + } await prisma.supplyChainProductArchiveDepartmentSnapshot.upsert({ where: { periodStart_periodEnd: { periodStart: start, periodEnd: end } }, create: { @@ -531,6 +557,11 @@ export class SupplyChainDepartmentDistributionService { }) if (!row?.payload) return null const payload = row.payload as unknown as DepartmentRawSnapshot + if (payload.logicVersion !== DEPARTMENT_SNAPSHOT_LOGIC_VERSION) { + // 旧口径快照(如产品/采购中心以前依赖运营负责人):本次按实时口径出数,并在后台重建快照 + this.scheduleSnapshotRebuild(year, month) + return null + } departmentRawCache.set(cacheKey, { at: Date.now(), value: payload }) return payload } catch { @@ -539,6 +570,19 @@ export class SupplyChainDepartmentDistributionService { } } + private scheduleSnapshotRebuild(year: number, month: number) { + const key = `${year}:${month}` + if (snapshotRebuildInFlight.has(key)) return + snapshotRebuildInFlight.add(key) + void this.generateSnapshot({ year, month }) + .catch((error) => { + console.error('[department-distribution] 旧口径快照重建失败', error) + }) + .finally(() => { + snapshotRebuildInFlight.delete(key) + }) + } + private async computeRaw(year: number, month: number): Promise> { const settings = await loadDepartmentSettings() const { rows, configs } = await this.buildRows(year, month, settings) @@ -561,6 +605,23 @@ export class SupplyChainDepartmentDistributionService { for (const row of rows) { const owner = row.effectiveOwner + // 产品中心/采购中心:只要有设计师/采购就统计,不依赖运营负责人是否存在。 + const designer = String(row.designer ?? '').trim() + if (designer && !PERSON_BLOCK_EXCLUDED_DEPARTMENTS.has(personDepartment(designer))) { + const designerKey = `${designer}\u0000${owner}` + const entryPerson = designerMap.get(designerKey) ?? { name: designer, department: personDepartment(designer), ownerKey: owner, tally: emptyTally() } + addRowToTally(entryPerson.tally, row, { movingExcludesNewProduct: true }) + designerMap.set(designerKey, entryPerson) + } + const purchaser = String(row.purchaser ?? '').trim() + if (purchaser && !PERSON_BLOCK_EXCLUDED_DEPARTMENTS.has(personDepartment(purchaser))) { + const purchaserKey = `${purchaser}\u0000${owner}` + const entryPerson = purchaserMap.get(purchaserKey) ?? { name: purchaser, department: personDepartment(purchaser), ownerKey: owner, tally: emptyTally() } + addRowToTally(entryPerson.tally, row, { movingExcludesNewProduct: true }) + purchaserMap.set(purchaserKey, entryPerson) + } + // 运营中心只统计有运营负责人的行 + if (!owner) continue const profitCenter = row.profitCenter // 人员信息里没有事业部/品类部的(分销部、外贸部、合并后的运营中心G 等)统一用利润中心兜底展示; // 「其他(未分配)」留空,界面上显示「—」。 @@ -580,21 +641,6 @@ export class SupplyChainDepartmentDistributionService { } addRowToTally(entry.tally, row) ownerMap.set(key, entry) - - const designer = String(row.designer ?? '').trim() - if (designer && !PERSON_BLOCK_EXCLUDED_DEPARTMENTS.has(personDepartment(designer))) { - const designerKey = `${designer}\u0000${owner}` - const entryPerson = designerMap.get(designerKey) ?? { name: designer, department: personDepartment(designer), ownerKey: owner, tally: emptyTally() } - addRowToTally(entryPerson.tally, row, { movingExcludesNewProduct: true }) - designerMap.set(designerKey, entryPerson) - } - const purchaser = String(row.purchaser ?? '').trim() - if (purchaser && !PERSON_BLOCK_EXCLUDED_DEPARTMENTS.has(personDepartment(purchaser))) { - const purchaserKey = `${purchaser}\u0000${owner}` - const entryPerson = purchaserMap.get(purchaserKey) ?? { name: purchaser, department: personDepartment(purchaser), ownerKey: owner, tally: emptyTally() } - addRowToTally(entryPerson.tally, row, { movingExcludesNewProduct: true }) - purchaserMap.set(purchaserKey, entryPerson) - } } return { @@ -692,7 +738,8 @@ export class SupplyChainDepartmentDistributionService { const personBlocks = (entries: DepartmentRawPerson[]) => { const merged = new Map() for (const entry of entries) { - if (!visibleOwnerKeys.has(entry.ownerKey)) continue + // 没有运营负责人的行(ownerKey 为空)同样要进产品/采购中心 + if (entry.ownerKey && !visibleOwnerKeys.has(entry.ownerKey)) continue const current = merged.get(entry.name) ?? { department: entry.department, tally: emptyTally() } mergeTally(current.tally, entry.tally) merged.set(entry.name, current) diff --git a/furniture-3d-system/src/server/services/supply-chain-department-distribution.test.ts b/furniture-3d-system/src/server/services/supply-chain-department-distribution.test.ts index cf5a4b27..57a46dbd 100644 --- a/furniture-3d-system/src/server/services/supply-chain-department-distribution.test.ts +++ b/furniture-3d-system/src/server/services/supply-chain-department-distribution.test.ts @@ -25,6 +25,7 @@ import { supplyChainProductSabcService } from './supply-chain-product-sabc.servi import { clearDepartmentDistributionCache, DEFAULT_BRAND_SCOPE, + DEPARTMENT_SNAPSHOT_LOGIC_VERSION, normalizeDepartmentSettings, resolveDepartmentViewerScope, supplyChainDepartmentDistributionService, @@ -118,6 +119,35 @@ describe('supply-chain department distribution', () => { expect(ordinary.allowedOwners).toBeNull() }) + it('counts 设计师/采购 even when the row has no 运营负责人', async () => { + ownerFindMany.mockResolvedValue([ + config({ operationOwner: '比干', profitCenter: '运营中心A', department: '事业一部' }), + ]) + listExportRows.mockResolvedValue([ + // 有运营负责人 + 设计师 + 采购 + baseRow({ productCode: 'A1', opsHead: '比干', designer: '财神', purchaser: '宁珂', realPerformance: '100' }), + // 没有运营负责人,但有设计师/采购:产品中心/采购中心仍要统计 + baseRow({ productCode: 'A2', opsHead: '', designer: '财神', purchaser: '宁珂', realPerformance: '50' }), + // 既没有设计师也没有采购:两块都不统计 + baseRow({ productCode: 'A3', opsHead: '', designer: '', purchaser: '', realPerformance: '10' }), + ]) + + const result = await supplyChainDepartmentDistributionService.build({ + year: 2026, + month: 8, + user: { id: 'u', email: '', name: '管理员', role: 'ADMIN', status: 'ACTIVE' }, + canExport: true, + }) + + // 运营中心只统计有运营负责人的行 + expect(result.summary.productTotal).toBe(1) + // 产品中心/采购中心:只要有设计师/采购就统计,与运营负责人无关 + expect(result.designerTotal.productTotal).toBe(2) + expect(result.purchaserTotal.productTotal).toBe(2) + expect(result.designers.map((designer) => designer.name)).toEqual(['财神']) + expect(result.purchasers.map((purchaser) => purchaser.name)).toEqual(['宁珂']) + }) + it('orders 事业部 by 事业一部、二部、三部、四部 instead of performance', async () => { ownerFindMany.mockResolvedValue([ config({ operationOwner: '甲', profitCenter: '运营中心A', department: '事业四部' }), @@ -276,6 +306,7 @@ describe('supply-chain department distribution', () => { snapshotFindUnique.mockResolvedValue({ payload: { generatedAt: '2026-09-03T01:00:00.000Z', + logicVersion: DEPARTMENT_SNAPSHOT_LOGIC_VERSION, targets: { newProductSuccess: 0.3, newProductMoving: 0.45, saRatio: 0.4, saTargetRatio: 0.32314862765406527 }, profitCenterTargets: [], brandScope: ['其他品牌-普通大货'],