Merge origin/codex/xuxiake-mainline into codex/local-3001-integration(字段权限提交前同步)

This commit is contained in:
WuYongJie 2026-10-16 11:26:27 +08:00
commit 6daf4c38a7
7 changed files with 310 additions and 26 deletions

View File

@ -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>

View File

@ -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([
'品类二部', '品类九部', '品类十一部', '品类二十八部',
])
})
})

View 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')
}

View File

@ -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)

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

View File

@ -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)
}

View File

@ -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: '电脑椅',