Merge branch 'codex/xuxiake-sabc-sync-20260929' into codex/xuxiake-mainline
This commit is contained in:
commit
ee4a1bf489
@ -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)
|
||||
|
||||
@ -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: ['其他品牌-普通大货'],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user