Merge branch 'codex/xuxiake-sabc-sync-20260929' into codex/xuxiake-mainline

This commit is contained in:
居居 2026-10-15 17:08:46 +08:00
commit ee4a1bf489
2 changed files with 96 additions and 18 deletions

View File

@ -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<string, { at: number; value: DepartmentDistributionResult }>()
/** 旧口径快照的后台重建任务(同一月份同时只跑一次) */
const snapshotRebuildInFlight = new Set<string>()
/** 月度原始快照(按负责人/设计师/采购的计数)内存缓存。 */
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<Omit<DepartmentRawSnapshot, 'generatedAt'>> {
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<string, { department: string; tally: OwnerTally }>()
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)

View File

@ -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: ['其他品牌-普通大货'],