feat(finished-product-progress): auto-import factory-confirmed SKUs on sample completion
This commit is contained in:
parent
667438447a
commit
39d7fc02f6
@ -94,7 +94,12 @@ export function calculateFinishedProductInTransitQuantity(orderedQuantity: numbe
|
||||
return Math.trunc(orderedQuantity) - Math.trunc(deliveryQuantity)
|
||||
}
|
||||
|
||||
export const FINISHED_PRODUCT_CURRENT_NODE_OPTIONS = ['合同签订', '采购在途', '送货中', '已完结'] as const
|
||||
export const FINISHED_PRODUCT_CURRENT_NODE_OPTIONS = ['合同待签', '合同签订', '采购在途', '送货中', '已完结'] as const
|
||||
|
||||
/** 同一 SPU 基号聚组排序用:去掉货号前缀字母(GBSFY1234/GBPM1234/1234 → 1234),前缀不同不影响归组。 */
|
||||
export function finishedProductSpuBase(itemNumber: string) {
|
||||
return String(itemNumber ?? '').trim().replace(/^[A-Za-z]+/, '')
|
||||
}
|
||||
|
||||
export function resolveFinishedProductCurrentNode(contractNumber: string, orderedQuantity: number, deliveryQuantity: number) {
|
||||
if (!String(contractNumber ?? '').trim()) return '合同签订'
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
import { sortFinishedProductRowsForList } from '@/server/repositories/finished-product-progress.repository'
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
orderPlacedDate: Date | null
|
||||
orderSequenceNo: string
|
||||
createdAt: Date
|
||||
itemNumber: string
|
||||
barcode: string | null
|
||||
}
|
||||
|
||||
function row(id: string, overrides: Partial<Row> = {}): Row {
|
||||
return {
|
||||
id,
|
||||
orderPlacedDate: null,
|
||||
orderSequenceNo: '',
|
||||
createdAt: new Date('2026-09-01T00:00:00Z'),
|
||||
itemNumber: id,
|
||||
barcode: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sortFinishedProductRowsForList(成品进度列表聚组排序)', () => {
|
||||
it('有下单日期的订单在前且保持原序,无日期的按 SPU 基号聚组', () => {
|
||||
const rows = [
|
||||
row('undated-gbsfy', { itemNumber: 'GBSFY1234' }),
|
||||
row('dated-a', { orderPlacedDate: new Date('2026-09-01T00:00:00Z'), orderSequenceNo: 'SH003' }),
|
||||
row('undated-plain', { itemNumber: '1234' }),
|
||||
row('undated-gbpm', { itemNumber: 'GBPM1234' }),
|
||||
row('dated-b', { orderPlacedDate: new Date('2026-08-01T00:00:00Z'), orderSequenceNo: 'SH002' }),
|
||||
row('undated-other', { itemNumber: 'GBSFY999' }),
|
||||
]
|
||||
|
||||
const sorted = sortFinishedProductRowsForList(rows)
|
||||
|
||||
expect(sorted.map((r) => r.id)).toEqual([
|
||||
'dated-a', // 有下单日期,最近的在前
|
||||
'dated-b',
|
||||
'undated-other', // 基号 999 < 1234,组在前
|
||||
'undated-plain', // 基号 1234 组:GBPM1234 / 1234 / GBSFY1234 按货号排序聚在一起
|
||||
'undated-gbpm',
|
||||
'undated-gbsfy',
|
||||
])
|
||||
})
|
||||
|
||||
it('同组内条码作为次级排序,创建时间兜底', () => {
|
||||
const rows = [
|
||||
row('x-b', { itemNumber: 'GBSFY1234', barcode: 'BBB' }),
|
||||
row('x-a', { itemNumber: 'GBSFY1234', barcode: 'AAA' }),
|
||||
row('x-c', { itemNumber: 'GBSFY1234', barcode: 'AAA', createdAt: new Date('2026-08-01T00:00:00Z') }),
|
||||
]
|
||||
|
||||
const sorted = sortFinishedProductRowsForList(rows)
|
||||
|
||||
expect(sorted.map((r) => r.id)).toEqual(['x-a', 'x-c', 'x-b'])
|
||||
})
|
||||
|
||||
it('无非日期行时保持原序不变', () => {
|
||||
const rows = [
|
||||
row('d1', { orderPlacedDate: new Date('2026-09-01T00:00:00Z') }),
|
||||
row('d2', { orderPlacedDate: new Date('2026-08-01T00:00:00Z') }),
|
||||
]
|
||||
expect(sortFinishedProductRowsForList(rows).map((r) => r.id)).toEqual(['d1', 'd2'])
|
||||
})
|
||||
})
|
||||
@ -4,6 +4,7 @@ import {
|
||||
FINISHED_PRODUCT_CURRENT_NODE_OPTIONS,
|
||||
buildOrderSequenceNo,
|
||||
buildOrderSequencePrefix,
|
||||
finishedProductSpuBase,
|
||||
resolveFinishedProductCurrentNode,
|
||||
type FinishedProductProgressFilters,
|
||||
type FinishedProductProgressInput,
|
||||
@ -104,10 +105,57 @@ function dataValues(input: FinishedProductProgressInput) {
|
||||
firstDueDate: input.firstDueDate ? new Date(`${input.firstDueDate}T00:00:00Z`) : null,
|
||||
orderDueDate: input.orderDueDate ? new Date(`${input.orderDueDate}T00:00:00Z`) : null,
|
||||
realtimeDate: input.realtimeDate ? new Date(`${input.realtimeDate}T00:00:00Z`) : null,
|
||||
currentNode: resolveFinishedProductCurrentNode(input.contractNumber, input.orderedQuantity, input.deliveryQuantity),
|
||||
// 有合同号的行按数量自动推进节点;空合同行(含定厂自动带入的待签行)保留表单所选节点
|
||||
currentNode: String(input.contractNumber ?? '').trim()
|
||||
? resolveFinishedProductCurrentNode(input.contractNumber, input.orderedQuantity, input.deliveryQuantity)
|
||||
: (String(input.currentNode ?? '').trim() || '合同待签'),
|
||||
}
|
||||
}
|
||||
|
||||
type SortableFinishedProductRow = {
|
||||
orderPlacedDate: Date | null
|
||||
orderSequenceNo: string
|
||||
createdAt: Date
|
||||
itemNumber: string
|
||||
barcode: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表排序:有下单日期的订单保持原序在前;无下单日期的行(合同待签等)按 SPU 基号聚组——
|
||||
* 同基号(GBSFY1234/GBPM1234/1234 → 1234)的行排在一起,组内按货号、条码排序。
|
||||
*/
|
||||
export function sortFinishedProductRowsForList<T extends SortableFinishedProductRow>(rows: T[]): T[] {
|
||||
const dated = rows.filter((row) => row.orderPlacedDate)
|
||||
const undated = rows.filter((row) => !row.orderPlacedDate)
|
||||
if (undated.length === 0) return rows
|
||||
|
||||
const groups = new Map<string, T[]>()
|
||||
for (const row of undated) {
|
||||
const base = finishedProductSpuBase(row.itemNumber) || row.itemNumber || '~'
|
||||
const bucket = groups.get(base)
|
||||
if (bucket) bucket.push(row)
|
||||
else groups.set(base, [row])
|
||||
}
|
||||
const compareBase = (left: string, right: string) => {
|
||||
const leftNumeric = /^\d+$/.test(left)
|
||||
const rightNumeric = /^\d+$/.test(right)
|
||||
if (leftNumeric && rightNumeric) return Number(left) - Number(right)
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
||||
return left.localeCompare(right, 'zh-Hans-CN')
|
||||
}
|
||||
const grouped = [...groups.entries()]
|
||||
.sort(([left], [right]) => compareBase(left, right))
|
||||
.flatMap(([, bucket]) => bucket.sort((left, right) => {
|
||||
const byItemNumber = left.itemNumber.localeCompare(right.itemNumber, 'zh-Hans-CN')
|
||||
if (byItemNumber !== 0) return byItemNumber
|
||||
const leftBarcode = left.barcode ?? ''
|
||||
const rightBarcode = right.barcode ?? ''
|
||||
if (leftBarcode !== rightBarcode) return leftBarcode.localeCompare(rightBarcode)
|
||||
return right.createdAt.getTime() - left.createdAt.getTime()
|
||||
}))
|
||||
return [...dated, ...grouped]
|
||||
}
|
||||
|
||||
export class FinishedProductProgressRepository {
|
||||
private async nextOrderSequenceNoInTransaction(tx: Prisma.TransactionClient, prefix: string) {
|
||||
const normalized = prefix.trim() || 'DD'
|
||||
@ -136,10 +184,11 @@ export class FinishedProductProgressRepository {
|
||||
],
|
||||
})
|
||||
// 行身份重设计后每条记录即一笔独立订单,平铺返回
|
||||
const pagedRows = rows.slice(params.skip, params.skip + params.take)
|
||||
const sortedRows = sortFinishedProductRowsForList(rows)
|
||||
const pagedRows = sortedRows.slice(params.skip, params.skip + params.take)
|
||||
return {
|
||||
groups: pagedRows.map((row) => [row]),
|
||||
total: rows.length,
|
||||
total: sortedRows.length,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
import {
|
||||
FinishedProductFactoryIntakeService,
|
||||
FACTORY_INTAKE_LAUNCH_CUTOFF,
|
||||
linkedProductIdFromFlowUrl,
|
||||
type FinishedProductFactoryIntakeDeps,
|
||||
} from '@/server/services/finished-product-factory-intake.service'
|
||||
|
||||
type CreatedRow = Record<string, unknown>
|
||||
|
||||
const FLOW_URL = 'https://cp.jiansun.vip/product-development/non-integrated/prod-1/detail'
|
||||
|
||||
function makeDeps() {
|
||||
const created: CreatedRow[] = []
|
||||
const deps: FinishedProductFactoryIntakeDeps = {
|
||||
loadProduct: async () => ({
|
||||
id: 'prod-1',
|
||||
brand: '大货',
|
||||
purchaser: '采购小王',
|
||||
payload: {
|
||||
uploadFiles: [
|
||||
{ category: '设计卖点', mimeType: 'image/png', dataUrl: 'https://img.example/design.png' },
|
||||
],
|
||||
factoryRows: [
|
||||
{
|
||||
skuId: 'sku-1',
|
||||
confirmed: true,
|
||||
model: 'GBSFY1234',
|
||||
barcode: 'GBSFY1234N1410V01',
|
||||
specName: '款式五 100/14/2.5 碳纤维黑色 翻板',
|
||||
supplierName: '三环',
|
||||
},
|
||||
{
|
||||
skuId: 'sku-2',
|
||||
confirmed: false,
|
||||
model: 'GBSFY1235',
|
||||
barcode: 'GBSFY1235N1410V01',
|
||||
specName: '款式六',
|
||||
supplierName: '三环',
|
||||
},
|
||||
{
|
||||
skuId: 'sku-3',
|
||||
confirmed: true,
|
||||
model: '',
|
||||
barcode: '',
|
||||
specName: '缺货号条码',
|
||||
supplierName: '三环',
|
||||
},
|
||||
{
|
||||
skuId: 'sku-4',
|
||||
confirmed: true,
|
||||
model: 'GBPM1234',
|
||||
barcode: 'GBPM1234N205V01',
|
||||
specName: '款式七 120/60 白色',
|
||||
supplierName: '瑞敏',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
findExistingAutoRow: async (itemNumber, barcode) => {
|
||||
// 模拟真实持久化:本次测试会话内已创建的行也算已存在
|
||||
if (created.some((c) => c.itemNumber === itemNumber && (c.barcode ?? null) === barcode)) {
|
||||
return { id: 'created-in-session' }
|
||||
}
|
||||
if (itemNumber === 'GBSFY1234') return { id: 'existing-1' }
|
||||
return null
|
||||
},
|
||||
createAutoRow: async (data) => {
|
||||
created.push({ ...data })
|
||||
return { id: `created-${created.length}` }
|
||||
},
|
||||
listCompletedSamplesSince: async () => [
|
||||
{ openingCode: 'XP2026062500000226', productDevelopmentFlowUrl: FLOW_URL },
|
||||
{ openingCode: 'XP2026070100000301', productDevelopmentFlowUrl: null },
|
||||
],
|
||||
}
|
||||
return { deps, created }
|
||||
}
|
||||
|
||||
describe('FinishedProductFactoryIntakeService(打样完结带入成品进度)', () => {
|
||||
it('打样完结时:只带入定厂已确认的 SKU,幂等跳过已存在行,映射字段正确', async () => {
|
||||
const { deps, created } = makeDeps()
|
||||
const service = new FinishedProductFactoryIntakeService(deps)
|
||||
|
||||
const result = await service.syncCompletedSampleToFinishedProgress(
|
||||
{ openingCode: 'XP2026062500000226', productDevelopmentFlowUrl: FLOW_URL },
|
||||
{ id: 'user-1', name: '徐霞客' },
|
||||
)
|
||||
|
||||
// sku-1 已存在跳过;sku-2 未确认跳过;sku-3 缺货号条码跳过;sku-4 创建
|
||||
expect(result).toEqual({ created: 1, skipped: 1 })
|
||||
expect(created).toHaveLength(1)
|
||||
expect(created[0]).toMatchObject({
|
||||
itemNumber: 'GBPM1234',
|
||||
barcode: 'GBPM1234N205V01',
|
||||
productName: '款式七 120/60 白色',
|
||||
brand: '大货',
|
||||
supplier: '瑞敏',
|
||||
imageUrl: 'https://img.example/design.png',
|
||||
followUpOwner: '采购小王',
|
||||
createdById: 'user-1',
|
||||
createdByName: '徐霞客',
|
||||
})
|
||||
expect(String(created[0].remarks)).toContain('定厂完成自动生成')
|
||||
expect(String(created[0].remarks)).toContain('XP2026062500000226')
|
||||
})
|
||||
|
||||
it('由壹点链接解析不出产品 ID 时返回零值', async () => {
|
||||
const { deps, created } = makeDeps()
|
||||
const service = new FinishedProductFactoryIntakeService(deps)
|
||||
await expect(service.syncCompletedSampleToFinishedProgress({
|
||||
openingCode: 'XP1',
|
||||
productDevelopmentFlowUrl: 'https://cp.jiansun.vip/other/path',
|
||||
})).resolves.toEqual({ created: 0, skipped: 0 })
|
||||
expect(created).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('产品不存在时返回零值', async () => {
|
||||
const deps: FinishedProductFactoryIntakeDeps = {
|
||||
loadProduct: async () => null,
|
||||
findExistingAutoRow: async () => null,
|
||||
createAutoRow: async () => ({ id: 'x' }),
|
||||
}
|
||||
const service = new FinishedProductFactoryIntakeService(deps)
|
||||
await expect(service.syncCompletedSampleToFinishedProgress({
|
||||
openingCode: 'XP1',
|
||||
productDevelopmentFlowUrl: FLOW_URL,
|
||||
})).resolves.toEqual({ created: 0, skipped: 0 })
|
||||
})
|
||||
|
||||
it('linkedProductIdFromFlowUrl 解析 /non-integrated/<id>', () => {
|
||||
expect(linkedProductIdFromFlowUrl(FLOW_URL)).toBe('prod-1')
|
||||
expect(linkedProductIdFromFlowUrl('not-a-url')).toBe('')
|
||||
expect(linkedProductIdFromFlowUrl(null)).toBe('')
|
||||
})
|
||||
|
||||
it('每日补抓:扫描上线后完结的打样单并重试,无链接的跳过、同天只跑一次', async () => {
|
||||
const { deps, created } = makeDeps()
|
||||
let now = new Date('2026-09-10T01:30:00Z')
|
||||
const service = new FinishedProductFactoryIntakeService(deps, () => now)
|
||||
|
||||
const first = await service.sweepOncePerDay()
|
||||
// 两条完结单:一条有链接(创建 1 条,1 条已存在跳过),一条无链接(抓不到,明天再试)
|
||||
expect(first).toEqual({ scanned: 2, created: 1, skipped: 1 })
|
||||
expect(created).toHaveLength(1)
|
||||
|
||||
// 同一天再刷列表:守卫生效,不再扫
|
||||
now = new Date('2026-09-10T08:30:00Z')
|
||||
await expect(service.sweepOncePerDay()).resolves.toEqual({ scanned: 0, created: 0, skipped: 0 })
|
||||
expect(created).toHaveLength(1)
|
||||
|
||||
// 第二天:再次扫描,幂等跳过已抓到的
|
||||
now = new Date('2026-09-11T01:30:00Z')
|
||||
const third = await service.sweepOncePerDay()
|
||||
expect(third.scanned).toBe(2)
|
||||
expect(created).toHaveLength(1)
|
||||
expect(FACTORY_INTAKE_LAUNCH_CUTOFF).toBe('2026-09-04')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,207 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const AUTO_CURRENT_NODE = '合同待签'
|
||||
|
||||
/** 功能上线日:打样完结时间早于该日的属于历史数据,每日补抓不扫描。 */
|
||||
export const FACTORY_INTAKE_LAUNCH_CUTOFF = '2026-09-04'
|
||||
|
||||
export type FinishedProductFactoryIntakeActor = {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type FinishedProductFactoryIntakeResult = {
|
||||
created: number
|
||||
skipped: number
|
||||
}
|
||||
|
||||
export type FinishedProductFactoryAutoRowInput = {
|
||||
itemNumber: string
|
||||
barcode: string | null
|
||||
productName: string
|
||||
brand: string
|
||||
supplier: string
|
||||
imageUrl: string | null
|
||||
followUpOwner: string
|
||||
remarks: string
|
||||
createdById: string | null
|
||||
createdByName: string
|
||||
}
|
||||
|
||||
export type FinishedProductFactoryIntakeDeps = {
|
||||
loadProduct(productId: string): Promise<{
|
||||
id: string
|
||||
brand: string
|
||||
purchaser: string
|
||||
payload: unknown
|
||||
} | null>
|
||||
findExistingAutoRow(itemNumber: string, barcode: string | null): Promise<{ id: string } | null>
|
||||
createAutoRow(data: FinishedProductFactoryAutoRowInput): Promise<{ id: string }>
|
||||
/** 上线日后完结的打样单(开款编号+由壹点链接),每日补抓扫描用 */
|
||||
listCompletedSamplesSince(cutoffDate: string): Promise<Array<{ openingCode: string; productDevelopmentFlowUrl: string | null }>>
|
||||
}
|
||||
|
||||
function readText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** 由壹点链接 → 产品开发记录 ID(/non-integrated/<productId>) */
|
||||
export function linkedProductIdFromFlowUrl(flowUrl: string | null | undefined) {
|
||||
try {
|
||||
const segments = new URL(flowUrl ?? '').pathname.split('/').filter(Boolean)
|
||||
const index = segments.lastIndexOf('non-integrated')
|
||||
return index >= 0 ? decodeURIComponent(segments[index + 1] || '') : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 与打样进度 OA 同步同口径:优先「设计卖点」图片,其次款式图
|
||||
function extractProductImage(payload: unknown) {
|
||||
if (!isRecord(payload)) return null
|
||||
const files = Array.isArray(payload.uploadFiles) ? payload.uploadFiles : []
|
||||
const designFile = files.find((value) => {
|
||||
if (!isRecord(value)) return false
|
||||
return value.category === '设计卖点'
|
||||
&& String(value.mimeType || '').startsWith('image/')
|
||||
&& typeof value.dataUrl === 'string'
|
||||
&& Boolean(value.dataUrl.trim())
|
||||
}) as { dataUrl?: string } | undefined
|
||||
if (designFile?.dataUrl?.trim()) return designFile.dataUrl.trim()
|
||||
const styleImages = Array.isArray(payload.styleImages) ? payload.styleImages : []
|
||||
const styleImage = styleImages.find((value) => isRecord(value) && typeof value.url === 'string' && Boolean(value.url.trim())) as { url?: string } | undefined
|
||||
return styleImage?.url?.trim() || null
|
||||
}
|
||||
|
||||
export class FinishedProductFactoryIntakeService {
|
||||
private lastSweepDate: string | null = null
|
||||
|
||||
constructor(
|
||||
private readonly deps: FinishedProductFactoryIntakeDeps = {
|
||||
loadProduct: async (productId) => prisma.intakeNonIntegratedProduct.findFirst({
|
||||
where: { id: productId, deletedAt: null },
|
||||
select: { id: true, brand: true, purchaser: true, payload: true },
|
||||
}),
|
||||
findExistingAutoRow: async (itemNumber, barcode) => prisma.finishedProductProgressItem.findFirst({
|
||||
where: { contractNumber: '', itemNumber, barcode },
|
||||
select: { id: true },
|
||||
}),
|
||||
createAutoRow: async (data) => prisma.finishedProductProgressItem.create({
|
||||
data: {
|
||||
contractDateNumber: '',
|
||||
contractNumber: '',
|
||||
orderSequenceNo: '',
|
||||
orderPlacedDate: null,
|
||||
followUpOwner: data.followUpOwner,
|
||||
imageUrl: data.imageUrl,
|
||||
itemNumber: data.itemNumber,
|
||||
barcode: data.barcode,
|
||||
productName: data.productName,
|
||||
supplier: data.supplier,
|
||||
brand: data.brand,
|
||||
orderedQuantity: 0,
|
||||
inTransitQuantity: 0,
|
||||
firstDueDate: null,
|
||||
orderDueDate: null,
|
||||
realtimeDate: null,
|
||||
deliveryQuantity: 0,
|
||||
currentNode: AUTO_CURRENT_NODE,
|
||||
remarks: data.remarks,
|
||||
createdById: data.createdById,
|
||||
createdByName: data.createdByName,
|
||||
updatedById: data.createdById,
|
||||
updatedByName: data.createdByName,
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
listCompletedSamplesSince: async (cutoffDate) => prisma.sampleProgressItem.findMany({
|
||||
where: { status: 'COMPLETED', actualSampleCompletedAt: { gte: new Date(`${cutoffDate}T00:00:00Z`) } },
|
||||
select: { openingCode: true, productDevelopmentFlowUrl: true },
|
||||
orderBy: { actualSampleCompletedAt: 'asc' },
|
||||
}),
|
||||
},
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
/** 打样完结(实物审样完成,打样状态变为已完结)时调用:按开款编号+由壹点链接
|
||||
* 关联产品开发记录,把已定厂确认的 SKU 幂等带入成品进度明细(合同待签)。
|
||||
* 调用方负责只在「未完结 → 已完结」转换时触发,历史已完结产品不会被翻出。 */
|
||||
async syncCompletedSampleToFinishedProgress(
|
||||
input: { openingCode: string; productDevelopmentFlowUrl: string | null },
|
||||
actor: FinishedProductFactoryIntakeActor = {},
|
||||
): Promise<FinishedProductFactoryIntakeResult> {
|
||||
const productId = linkedProductIdFromFlowUrl(input.productDevelopmentFlowUrl)
|
||||
if (!productId) return { created: 0, skipped: 0 }
|
||||
|
||||
const product = await this.deps.loadProduct(productId)
|
||||
if (!product) return { created: 0, skipped: 0 }
|
||||
|
||||
const payload = product.payload
|
||||
const factoryRows = isRecord(payload) && Array.isArray(payload.factoryRows) ? payload.factoryRows : []
|
||||
if (factoryRows.length === 0) return { created: 0, skipped: 0 }
|
||||
|
||||
const image = extractProductImage(payload)
|
||||
const today = this.now().toISOString().slice(0, 10)
|
||||
const actorId = actor.id ?? null
|
||||
const actorName = actor.name || '系统'
|
||||
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
for (const row of factoryRows) {
|
||||
if (!isRecord(row) || row.confirmed !== true) continue
|
||||
const itemNumber = readText(row.model)
|
||||
const barcode = readText(row.barcode)
|
||||
if (!itemNumber && !barcode) continue
|
||||
const existing = await this.deps.findExistingAutoRow(itemNumber, barcode || null)
|
||||
if (existing) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
await this.deps.createAutoRow({
|
||||
itemNumber,
|
||||
barcode: barcode || null,
|
||||
productName: readText(row.specName),
|
||||
brand: product.brand || '',
|
||||
supplier: readText(row.supplierName),
|
||||
imageUrl: image,
|
||||
followUpOwner: product.purchaser || '',
|
||||
remarks: `定厂完成自动生成 ${today}(打样单 ${input.openingCode})`,
|
||||
createdById: actorId,
|
||||
createdByName: actorName,
|
||||
})
|
||||
created += 1
|
||||
}
|
||||
return { created, skipped }
|
||||
}
|
||||
|
||||
/** 每日补抓:扫描上线后完结的全部打样单并重试带入(幂等)。当时未定厂的会在
|
||||
* 定厂确认后的某天补抓中被抓到,直到抓到为止。 */
|
||||
async runDailyCatchUpSweep(actor: FinishedProductFactoryIntakeActor = {}): Promise<FinishedProductFactoryIntakeResult & { scanned: number }> {
|
||||
const samples = await this.deps.listCompletedSamplesSince(FACTORY_INTAKE_LAUNCH_CUTOFF)
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
let scanned = 0
|
||||
for (const sample of samples) {
|
||||
if (!sample.openingCode) continue
|
||||
scanned += 1
|
||||
const result = await this.syncCompletedSampleToFinishedProgress(sample, actor)
|
||||
created += result.created
|
||||
skipped += result.skipped
|
||||
}
|
||||
this.lastSweepDate = this.now().toISOString().slice(0, 10)
|
||||
return { scanned, created, skipped }
|
||||
}
|
||||
|
||||
/** 每天最多执行一次补抓,其余调用直接跳过(进程内守卫)。 */
|
||||
sweepOncePerDay(actor: FinishedProductFactoryIntakeActor = {}): Promise<FinishedProductFactoryIntakeResult & { scanned: number }> {
|
||||
const today = this.now().toISOString().slice(0, 10)
|
||||
if (this.lastSweepDate === today) return Promise.resolve({ scanned: 0, created: 0, skipped: 0 })
|
||||
return this.runDailyCatchUpSweep(actor)
|
||||
}
|
||||
}
|
||||
|
||||
export const finishedProductFactoryIntakeService = new FinishedProductFactoryIntakeService()
|
||||
@ -17,6 +17,7 @@ import {
|
||||
sampleProgressSupplierSnapshot,
|
||||
} from '@/server/services/sample-progress-change-log'
|
||||
import { serializeSampleProgress } from '@/server/services/sample-progress-serialization'
|
||||
import { finishedProductFactoryIntakeService } from '@/server/services/finished-product-factory-intake.service'
|
||||
import {
|
||||
buildSampleProgressParentSummary,
|
||||
resolveSupplierWrites,
|
||||
@ -242,7 +243,8 @@ async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
|
||||
const endedAt = endedAtFromOa ?? (
|
||||
current?.autoGenerated ? null : current?.actualSampleCompletedAt ?? null
|
||||
)
|
||||
return sampleProgressRepository.updateOaFields({
|
||||
const previousStatus = current?.status ?? null
|
||||
const updatePromise = sampleProgressRepository.updateOaFields({
|
||||
openingCode,
|
||||
brand: item.formSummary.plannedSalesBrands || item.formSummary.salesBrands || item.brand || '',
|
||||
designer: structureDesignerByProductId.get(productId) || item.formSummary.structureDesigner || '',
|
||||
@ -256,6 +258,24 @@ async function syncFromOaItems(items: OaBoardProjectListItemDTO[]) {
|
||||
imageUrl: designImageByProductId.get(linkedProductId(workflowLink)) || item.styleImages[0]?.url || null,
|
||||
productDevelopmentFlowUrl: workflowLink || null,
|
||||
})
|
||||
// 打样状态从未完结变为已完结(实物审样完成)时,把已定厂 SKU 自动带入成品进度明细
|
||||
if (nextStatus === 'COMPLETED' && previousStatus !== 'COMPLETED') {
|
||||
return updatePromise.then(async () => {
|
||||
try {
|
||||
const syncResult = await finishedProductFactoryIntakeService.syncCompletedSampleToFinishedProgress({
|
||||
openingCode,
|
||||
productDevelopmentFlowUrl: workflowLink || null,
|
||||
})
|
||||
if (syncResult.created > 0) {
|
||||
console.log('[sample-progress] 打样完结自动带入成品进度明细:', { openingCode, ...syncResult })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[sample-progress] 打样完结自动带入成品进度失败 (${openingCode}):`, error)
|
||||
}
|
||||
return { count: 1 }
|
||||
})
|
||||
}
|
||||
return updatePromise
|
||||
}))
|
||||
}
|
||||
|
||||
@ -327,6 +347,10 @@ export const sampleProgressService = {
|
||||
async listStored() { return (await sampleProgressRepository.list()).map(serializeSampleProgress) },
|
||||
async list() {
|
||||
await this.syncFromOaDashboard().catch(() => undefined)
|
||||
// 每日一次:上线后完结但当时未定厂的打样单,补抓已定厂 SKU 直到抓到
|
||||
await finishedProductFactoryIntakeService.sweepOncePerDay().catch((error) => {
|
||||
console.error('[sample-progress] 定厂 SKU 每日补抓失败:', error)
|
||||
})
|
||||
return this.listStored()
|
||||
},
|
||||
getOaPrefill: readOaPrefill,
|
||||
@ -393,7 +417,7 @@ export const sampleProgressService = {
|
||||
const changeReason = changedDueSuppliers.map((supplier, index) =>
|
||||
`${supplier.supplier || `供应商 ${index + 1}`}:${supplier.deliveryDateChangeReason?.trim()}`,
|
||||
).join('\n') || deliveryDateChangeReason?.trim() || null
|
||||
return serializeSampleProgress(await sampleProgressRepository.updateWithChangeLog({
|
||||
const record = await sampleProgressRepository.updateWithChangeLog({
|
||||
id,
|
||||
data: { ...merged, openingCode: current.openingCode, autoGenerated: false, updatedBy: actor.id },
|
||||
suppliers: supplierWrites,
|
||||
@ -403,7 +427,22 @@ export const sampleProgressService = {
|
||||
changeReason,
|
||||
changedById: actor.id,
|
||||
changedByName: actor.name || '未知用户',
|
||||
}))
|
||||
})
|
||||
// 人工把打样状态改为已完结(实物审样完成)时,把已定厂 SKU 自动带入成品进度明细
|
||||
if (current.status !== 'COMPLETED' && record.status === 'COMPLETED') {
|
||||
try {
|
||||
const syncResult = await finishedProductFactoryIntakeService.syncCompletedSampleToFinishedProgress({
|
||||
openingCode: current.openingCode,
|
||||
productDevelopmentFlowUrl: current.productDevelopmentFlowUrl ?? null,
|
||||
}, { id: actor.id, name: actor.name })
|
||||
if (syncResult.created > 0) {
|
||||
console.log('[sample-progress] 打样完结(人工)自动带入成品进度明细:', { openingCode: current.openingCode, ...syncResult })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[sample-progress] 打样完结(人工)自动带入成品进度失败 (${current.openingCode}):`, error)
|
||||
}
|
||||
}
|
||||
return serializeSampleProgress(record)
|
||||
},
|
||||
async listChangeLogs(id: string) {
|
||||
if (!(await sampleProgressRepository.findById(id))) throw new AppError(ErrorCode.VALIDATION_FAILED, '打样记录不存在')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user