Merge origin/codex/xuxiake-mainline into codex/local-3001-integration(字段权限提交前同步)
This commit is contained in:
commit
6daf4c38a7
@ -9,6 +9,7 @@ import {
|
||||
type DepartmentDistributionResult,
|
||||
type DepartmentProfitCenterBlock,
|
||||
} from '@/lib/supply-chain-department-distribution'
|
||||
import { compareDepartmentName } from '@/lib/supply-chain-department-order'
|
||||
import type { ManagementBlankPageDefinition } from '@/lib/supply-chain-management-blank-pages'
|
||||
import { SabcTabNav } from './SabcTabNav'
|
||||
import modalStyles from './department-distribution.module.css'
|
||||
@ -122,6 +123,9 @@ export function DepartmentDistributionView({
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [draft, setDraft] = useState<SettingsDraft>(EMPTY_DRAFT)
|
||||
const [configMessage, setConfigMessage] = useState('')
|
||||
// 运营中心卡片「导出」:先选要导出的事业部(默认全选;页面已筛事业部时默认只选它)
|
||||
const [exportScope, setExportScope] = useState<{ profitCenter: string; departments: string[] } | null>(null)
|
||||
const [exportSelectedDepartments, setExportSelectedDepartments] = useState<string[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@ -167,7 +171,7 @@ export function DepartmentDistributionView({
|
||||
values.add(department.department)
|
||||
}
|
||||
}
|
||||
return [...values].sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
return [...values].sort(compareDepartmentName)
|
||||
}, [data, profitCenterFilter])
|
||||
|
||||
const secondaryOptions = useMemo(() => {
|
||||
@ -183,10 +187,15 @@ export function DepartmentDistributionView({
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...values].sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
return [...values].sort(compareDepartmentName)
|
||||
}, [data, departmentFilter, profitCenterFilter])
|
||||
|
||||
const exportRows = async (options?: { profitCenter?: string; block?: 'operations' | 'designers' | 'purchasers' }) => {
|
||||
const exportRows = async (options?: {
|
||||
profitCenter?: string
|
||||
block?: 'operations' | 'designers' | 'purchasers'
|
||||
/** 指定要导出的事业部(不传则按页面筛选条件) */
|
||||
departments?: string[]
|
||||
}) => {
|
||||
if (exporting) return
|
||||
setExporting(true)
|
||||
setError('')
|
||||
@ -195,7 +204,10 @@ export function DepartmentDistributionView({
|
||||
const profitCenterScope = options?.profitCenter ?? profitCenterFilter
|
||||
if (profitCenterScope) params.set('profitCenter', profitCenterScope)
|
||||
if (options?.block) params.set('block', options.block)
|
||||
if (departmentFilter) params.set('department', departmentFilter)
|
||||
const departmentScope = options?.departments && options.departments.length > 0
|
||||
? options.departments.join(',')
|
||||
: departmentFilter
|
||||
if (departmentScope) params.set('department', departmentScope)
|
||||
if (secondaryFilter) params.set('secondaryDepartment', secondaryFilter)
|
||||
const response = await fetch(`/api/supply-chain/product-sabc/departments/export?${params}`, { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
@ -221,6 +233,20 @@ export function DepartmentDistributionView({
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片「导出」:先让用户勾选要导出的事业部,再按勾选范围导出该利润中心
|
||||
const openExportPicker = (profitCenter: DepartmentProfitCenterBlock) => {
|
||||
const names = profitCenter.departments
|
||||
.map((department) => department.department)
|
||||
.filter((name) => Boolean(name) && name !== profitCenter.profitCenter)
|
||||
if (names.length === 0) {
|
||||
void exportRows({ profitCenter: profitCenter.profitCenter })
|
||||
return
|
||||
}
|
||||
const preselected = departmentFilter && names.includes(departmentFilter) ? [departmentFilter] : names
|
||||
setExportScope({ profitCenter: profitCenter.profitCenter, departments: names })
|
||||
setExportSelectedDepartments(preselected)
|
||||
}
|
||||
|
||||
const openConfig = async () => {
|
||||
setConfigMessage('')
|
||||
setShowConfig(true)
|
||||
@ -466,6 +492,67 @@ export function DepartmentDistributionView({
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
{exportScope ? (
|
||||
<div
|
||||
className={modalStyles.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(event) => { if (event.target === event.currentTarget) setExportScope(null) }}
|
||||
>
|
||||
<section className={modalStyles.dialog}>
|
||||
<div className={modalStyles.dialogHead}>
|
||||
<h3 className={modalStyles.dialogTitle}>导出 {exportScope.profitCenter}</h3>
|
||||
<button type="button" className={modalStyles.dialogClose} aria-label="关闭" onClick={() => setExportScope(null)}>×</button>
|
||||
</div>
|
||||
<div className={modalStyles.dialogBody}>
|
||||
<div className={styles.deptConfigField}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<span>选择要导出的事业部(默认全部)</span>
|
||||
<button type="button" className={styles.secondaryButton} onClick={() => setExportSelectedDepartments(exportScope.departments)}>全选</button>
|
||||
<button type="button" className={styles.secondaryButton} onClick={() => setExportSelectedDepartments([])}>清空</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 6, maxHeight: 260, overflowY: 'auto' }}>
|
||||
{exportScope.departments.map((name) => (
|
||||
<label key={name} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportSelectedDepartments.includes(name)}
|
||||
onChange={(event) => setExportSelectedDepartments((current) => (
|
||||
event.target.checked
|
||||
? [...new Set([...current, name])]
|
||||
: current.filter((item) => item !== name)
|
||||
))}
|
||||
/>
|
||||
{name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={modalStyles.dialogActions}>
|
||||
<p className={modalStyles.message}>
|
||||
{exportSelectedDepartments.length === 0
|
||||
? '请至少选择一个事业部'
|
||||
: `将导出 ${exportSelectedDepartments.length} 个事业部`}
|
||||
</p>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className={styles.secondaryButton} onClick={() => setExportScope(null)}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
disabled={exporting || exportSelectedDepartments.length === 0}
|
||||
onClick={() => {
|
||||
const scope = exportScope
|
||||
setExportScope(null)
|
||||
void exportRows({ profitCenter: scope.profitCenter, departments: exportSelectedDepartments })
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
<section className={styles.deptBlock}>
|
||||
<h2>运营中心</h2>
|
||||
{loading ? <p className={styles.deptEmpty}>加载中…</p> : null}
|
||||
@ -482,7 +569,7 @@ export function DepartmentDistributionView({
|
||||
className={styles.secondaryButton}
|
||||
style={{ marginLeft: 10, height: 28, padding: '0 10px' }}
|
||||
disabled={exporting}
|
||||
onClick={() => void exportRows({ profitCenter: profitCenter.profitCenter })}
|
||||
onClick={() => openExportPicker(profitCenter)}
|
||||
>
|
||||
<Download size={13} />导出
|
||||
</button>
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { compareDepartmentName, parseDepartmentOrder } from './supply-chain-department-order'
|
||||
|
||||
describe('部门名称排序', () => {
|
||||
it('解析中文与阿拉伯数字序号', () => {
|
||||
expect(parseDepartmentOrder('事业一部')).toBe(1)
|
||||
expect(parseDepartmentOrder('事业四部')).toBe(4)
|
||||
expect(parseDepartmentOrder('品类十二部')).toBe(12)
|
||||
expect(parseDepartmentOrder('品类二十八部')).toBe(28)
|
||||
expect(parseDepartmentOrder('品类十部')).toBe(10)
|
||||
expect(parseDepartmentOrder('11部')).toBe(11)
|
||||
expect(parseDepartmentOrder('外贸部')).toBeNull()
|
||||
expect(parseDepartmentOrder('运营中心A')).toBeNull()
|
||||
})
|
||||
|
||||
it('按序号从小到大排列,没有序号的排在后面', () => {
|
||||
const names = ['事业三部', '外贸部', '事业一部', '事业四部', '事业二部', '分销部']
|
||||
expect([...names].sort(compareDepartmentName)).toEqual([
|
||||
'事业一部', '事业二部', '事业三部', '事业四部', '分销部', '外贸部',
|
||||
])
|
||||
})
|
||||
|
||||
it('品类部同样按序号排列,不再按拼音', () => {
|
||||
const names = ['品类十一部', '品类九部', '品类二十八部', '品类二部']
|
||||
expect([...names].sort(compareDepartmentName)).toEqual([
|
||||
'品类二部', '品类九部', '品类十一部', '品类二十八部',
|
||||
])
|
||||
})
|
||||
})
|
||||
34
furniture-3d-system/src/lib/supply-chain-department-order.ts
Normal file
34
furniture-3d-system/src/lib/supply-chain-department-order.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 部门名称排序:把「事业一部 / 品类十二部 / 11部」里的序号解析成数字,
|
||||
* 让「事业一部、事业二部、事业三部、事业四部」按自然顺序排列,
|
||||
* 而不是按拼音(一/二/三/四 的中文拼音顺序会把顺序打乱)。
|
||||
*/
|
||||
const CHINESE_DIGITS: Record<string, number> = {
|
||||
一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9,
|
||||
}
|
||||
|
||||
export function parseDepartmentOrder(name: string): number | null {
|
||||
const match = String(name ?? '').match(/(\d+|[一二三四五六七八九十]+)部/)
|
||||
if (!match) return null
|
||||
const raw = match[1]
|
||||
if (/^\d+$/.test(raw)) return Number(raw)
|
||||
if (raw === '十') return 10
|
||||
if (raw.includes('十')) {
|
||||
const [head, tail] = raw.split('十')
|
||||
const tens = head ? CHINESE_DIGITS[head] ?? 0 : 1
|
||||
const ones = tail ? CHINESE_DIGITS[tail] ?? 0 : 0
|
||||
const value = tens * 10 + ones
|
||||
return value > 0 ? value : null
|
||||
}
|
||||
return raw.length === 1 ? CHINESE_DIGITS[raw] ?? null : null
|
||||
}
|
||||
|
||||
/** 带序号的部门按序号升序排在前面;没有序号的按名称排在后面。 */
|
||||
export function compareDepartmentName(left: string, right: string) {
|
||||
const leftOrder = parseDepartmentOrder(left)
|
||||
const rightOrder = parseDepartmentOrder(right)
|
||||
if (leftOrder !== null && rightOrder !== null && leftOrder !== rightOrder) return leftOrder - rightOrder
|
||||
if (leftOrder !== null && rightOrder === null) return -1
|
||||
if (leftOrder === null && rightOrder !== null) return 1
|
||||
return String(left ?? '').localeCompare(String(right ?? ''), 'zh-CN')
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isAdmin } from '@/lib/role-scope'
|
||||
import { compareDepartmentName } from '@/lib/supply-chain-department-order'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { listJstFlowerNameStatuses } from '@/server/repositories/jst-hr-flower-name.repository'
|
||||
@ -87,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
|
||||
@ -422,6 +432,8 @@ type DepartmentRawPerson = {
|
||||
|
||||
type DepartmentRawSnapshot = {
|
||||
generatedAt: string
|
||||
/** 生成该快照时的口径版本,见 DEPARTMENT_SNAPSHOT_LOGIC_VERSION */
|
||||
logicVersion?: number
|
||||
targets: DepartmentTargets
|
||||
profitCenterTargets: DepartmentProfitCenterTargets[]
|
||||
brandScope: string[]
|
||||
@ -462,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)
|
||||
@ -486,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: {
|
||||
@ -530,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 {
|
||||
@ -538,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)
|
||||
@ -560,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 等)统一用利润中心兜底展示;
|
||||
// 「其他(未分配)」留空,界面上显示「—」。
|
||||
@ -579,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 {
|
||||
@ -665,7 +712,12 @@ export class SupplyChainDepartmentDistributionService {
|
||||
}))
|
||||
return { department, metrics: metricsFromTally(departmentMetrics, centerTargets), owners: sortMetricsRows(owners) }
|
||||
})
|
||||
sortMetricsRows(departments)
|
||||
// 事业部按「事业一部、二部、三部、四部…」自然顺序排列(页面表格与导出共用这份顺序)
|
||||
departments.sort((left, right) => {
|
||||
const byName = compareDepartmentName(left.department, right.department)
|
||||
if (byName !== 0) return byName
|
||||
return (right.metrics.totalPerformance ?? 0) - (left.metrics.totalPerformance ?? 0)
|
||||
})
|
||||
return { profitCenter, metrics: metricsFromTally(tally, centerTargets), departments }
|
||||
})
|
||||
// 未分配分组固定放在最后
|
||||
@ -686,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)
|
||||
@ -763,7 +816,6 @@ export class SupplyChainDepartmentDistributionService {
|
||||
const cached = departmentResultCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.at < DEPARTMENT_RESULT_CACHE_TTL_MS) return cached.value
|
||||
|
||||
const settings = await loadDepartmentSettings()
|
||||
const configs = await loadOwnerConfigs()
|
||||
const viewer = await resolveDepartmentViewerScope(input.user, configs)
|
||||
const snapshot = await this.loadRawSnapshot(input.year, input.month)
|
||||
|
||||
@ -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,62 @@ 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: '事业四部' }),
|
||||
config({ operationOwner: '乙', profitCenter: '运营中心A', department: '事业二部' }),
|
||||
config({ operationOwner: '丙', profitCenter: '运营中心A', department: '事业一部' }),
|
||||
config({ operationOwner: '丁', profitCenter: '运营中心A', department: '外贸部' }),
|
||||
])
|
||||
listExportRows.mockResolvedValue([
|
||||
baseRow({ productCode: 'A1', opsHead: '甲', realPerformance: '100' }),
|
||||
baseRow({ productCode: 'B1', opsHead: '乙', realPerformance: '400' }),
|
||||
baseRow({ productCode: 'C1', opsHead: '丙', realPerformance: '200' }),
|
||||
baseRow({ productCode: 'D1', opsHead: '丁', realPerformance: '300' }),
|
||||
])
|
||||
|
||||
const result = await supplyChainDepartmentDistributionService.build({
|
||||
year: 2026,
|
||||
month: 8,
|
||||
user: { id: 'u', email: '', name: '管理员', role: 'ADMIN', status: 'ACTIVE' },
|
||||
canExport: true,
|
||||
})
|
||||
|
||||
const centerA = result.profitCenters.find((center) => center.profitCenter === '运营中心A')
|
||||
expect(centerA?.departments.map((department) => department.department)).toEqual([
|
||||
'事业一部', '事业二部', '事业四部', '外贸部',
|
||||
])
|
||||
})
|
||||
|
||||
it('aggregates rows by profit center, department and owner with the manual-table metrics', async () => {
|
||||
ownerFindMany.mockResolvedValue([
|
||||
config({ operationOwner: '比干', profitCenter: '运营中心A', department: '事业一部' }),
|
||||
@ -249,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: ['其他品牌-普通大货'],
|
||||
|
||||
@ -320,6 +320,18 @@ function splitSearchKeywords(value: string | undefined, splitWhitespace = false)
|
||||
.slice(0, SABC_MAX_BATCH_TERMS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 新品区间分布:30-60 天 / 60-90 天 / 90 天以上。
|
||||
* 边界口径:90 天整算「创建60-90天」,91 天起才是「创建90天以上」。
|
||||
*/
|
||||
export function sabcNewProductRangeLabel(createDays: number) {
|
||||
if (!Number.isFinite(createDays)) return ''
|
||||
if (createDays > 90) return '创建90天以上'
|
||||
if (createDays >= 60) return '创建60-90天'
|
||||
if (createDays >= 30) return '创建30-60天'
|
||||
return '创建不足30天'
|
||||
}
|
||||
|
||||
function currentYearMonth(now: Date) {
|
||||
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
return {
|
||||
@ -503,7 +515,7 @@ function createBlankRow(
|
||||
createDays: String(createDays),
|
||||
categoryClass: categoryClassValue,
|
||||
newOldLabel: createDays > 30 ? '否' : '是',
|
||||
newProductRange: createDays >= 90 ? '创建90天以上' : createDays >= 60 ? '创建60-90天' : createDays >= 30 ? '创建30-60天' : '创建不足30天',
|
||||
newProductRange: sabcNewProductRangeLabel(createDays),
|
||||
newProductPeriod: String(newPeriodDays),
|
||||
newProductStatus: createDays > newPeriodDays ? '老品' : '纯新品',
|
||||
movingStatus: '',
|
||||
@ -624,6 +636,9 @@ export function parseSabcSnapshotRow(value: unknown): SabcPageRow | null {
|
||||
const row = { eligible: Boolean(raw.eligible) } as SabcPageRow
|
||||
for (const field of SNAPSHOT_STRING_FIELDS) row[field] = String(raw[field] ?? '')
|
||||
row.productCode = productCode
|
||||
// 新品区间分布由创建天数推导,口径修正后按快照里的创建天数重算,旧快照也能立即按新口径显示
|
||||
const storedCreateDays = String(row.createDays ?? '').trim()
|
||||
if (storedCreateDays) row.newProductRange = sabcNewProductRangeLabel(Number(storedCreateDays))
|
||||
return applyBrandOwnerFallback(row)
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
clearSabcOverviewCache,
|
||||
createSabcArchiveScope,
|
||||
newProductPeriodDays,
|
||||
sabcNewProductRangeLabel,
|
||||
SupplyChainProductSabcService,
|
||||
} from './supply-chain-product-sabc.service'
|
||||
|
||||
@ -60,6 +61,15 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('supply-chain-product-sabc rules', () => {
|
||||
it('marks exactly 90 create days as 创建60-90天', () => {
|
||||
expect(sabcNewProductRangeLabel(29)).toBe('创建不足30天')
|
||||
expect(sabcNewProductRangeLabel(30)).toBe('创建30-60天')
|
||||
expect(sabcNewProductRangeLabel(60)).toBe('创建60-90天')
|
||||
expect(sabcNewProductRangeLabel(61)).toBe('创建60-90天')
|
||||
expect(sabcNewProductRangeLabel(90)).toBe('创建60-90天')
|
||||
expect(sabcNewProductRangeLabel(91)).toBe('创建90天以上')
|
||||
})
|
||||
|
||||
it('matches the August spreadsheet computer-chair example', () => {
|
||||
const result = classifySabc({
|
||||
category: '电脑椅',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user