diff --git a/furniture-3d-system/src/lib/finished-product-progress.ts b/furniture-3d-system/src/lib/finished-product-progress.ts index e5636989..39bdcb17 100644 --- a/furniture-3d-system/src/lib/finished-product-progress.ts +++ b/furniture-3d-system/src/lib/finished-product-progress.ts @@ -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 '合同签订' diff --git a/furniture-3d-system/src/server/repositories/__tests__/finished-product-progress-list-order.test.ts b/furniture-3d-system/src/server/repositories/__tests__/finished-product-progress-list-order.test.ts new file mode 100644 index 00000000..4a294703 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/finished-product-progress-list-order.test.ts @@ -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 { + 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']) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts b/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts index 20bfba9c..c09e8518 100644 --- a/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts +++ b/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts @@ -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(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() + 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, } } diff --git a/furniture-3d-system/src/server/services/__tests__/finished-product-factory-intake.service.test.ts b/furniture-3d-system/src/server/services/__tests__/finished-product-factory-intake.service.test.ts new file mode 100644 index 00000000..82f4bdc3 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/finished-product-factory-intake.service.test.ts @@ -0,0 +1,158 @@ +import { + FinishedProductFactoryIntakeService, + FACTORY_INTAKE_LAUNCH_CUTOFF, + linkedProductIdFromFlowUrl, + type FinishedProductFactoryIntakeDeps, +} from '@/server/services/finished-product-factory-intake.service' + +type CreatedRow = Record + +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/', () => { + 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') + }) +}) diff --git a/furniture-3d-system/src/server/services/finished-product-factory-intake.service.ts b/furniture-3d-system/src/server/services/finished-product-factory-intake.service.ts new file mode 100644 index 00000000..be46a8a5 --- /dev/null +++ b/furniture-3d-system/src/server/services/finished-product-factory-intake.service.ts @@ -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> +} + +function readText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +/** 由壹点链接 → 产品开发记录 ID(/non-integrated/) */ +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 { + 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 { + 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 { + 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() diff --git a/furniture-3d-system/src/server/services/sample-progress.service.ts b/furniture-3d-system/src/server/services/sample-progress.service.ts index d119fd74..232825e4 100644 --- a/furniture-3d-system/src/server/services/sample-progress.service.ts +++ b/furniture-3d-system/src/server/services/sample-progress.service.ts @@ -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, '打样记录不存在')