From fb4fa3efcaccd3b3b244304b769048ac4c9edd98 Mon Sep 17 00:00:00 2001 From: jingchuan Date: Mon, 17 Aug 2026 11:38:37 +0800 Subject: [PATCH 1/7] fix(oa-board): prefer latest restarted child flow --- .../src/lib/oa-board-flow-selection.test.ts | 92 +++++++++++++++++++ .../src/lib/oa-board-flow-selection.ts | 47 ++++++++++ .../server/services/oa-board-sync.service.ts | 6 +- 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 furniture-3d-system/src/lib/oa-board-flow-selection.test.ts create mode 100644 furniture-3d-system/src/lib/oa-board-flow-selection.ts diff --git a/furniture-3d-system/src/lib/oa-board-flow-selection.test.ts b/furniture-3d-system/src/lib/oa-board-flow-selection.test.ts new file mode 100644 index 00000000..920985bf --- /dev/null +++ b/furniture-3d-system/src/lib/oa-board-flow-selection.test.ts @@ -0,0 +1,92 @@ +import { selectLatestRestartableChildFlows } from './oa-board-flow-selection' + +type TestFlowRecord = { + flowType: string + processInstanceId: string + createdAtSource: Date | null + updatedAtSource: Date | null + status: string +} + +function buildFlow( + flowType: string, + processInstanceId: string, + createdAtSource: string | null, + status: string, + updatedAtSource = createdAtSource, +): TestFlowRecord { + return { + flowType, + processInstanceId, + createdAtSource: createdAtSource ? new Date(createdAtSource) : null, + updatedAtSource: updatedAtSource ? new Date(updatedAtSource) : null, + status, + } +} + +describe('OA 看板子流程版本选择', () => { + it('首单、美术和限价流程各自只保留最晚发起的一条', () => { + const records = [ + buildFlow('review', 'review-1', '2026-08-03T02:30:00Z', 'COMPLETED'), + buildFlow('order', 'order-refused', '2026-08-03T08:00:00Z', 'REFUSED'), + buildFlow('order', 'order-running', '2026-08-04T08:00:00Z', 'RUNNING'), + buildFlow('art', 'art-refused', '2026-08-05T08:00:00Z', 'REFUSED'), + buildFlow('art', 'art-completed', '2026-08-06T08:00:00Z', 'COMPLETED'), + buildFlow('pricing', 'pricing-terminated', '2026-08-17T01:49:00Z', 'TERMINATED'), + buildFlow('pricing', 'pricing-completed', '2026-08-17T01:51:00Z', 'COMPLETED'), + buildFlow('main', 'main-1', '2026-08-17T02:00:00Z', 'RUNNING'), + ] + + expect( + selectLatestRestartableChildFlows(records).map((record) => record.processInstanceId), + ).toEqual([ + 'review-1', + 'order-running', + 'art-completed', + 'pricing-completed', + 'main-1', + ]) + }) + + it('以发起时间为主,不让旧流程的较晚更新时间覆盖新流程', () => { + const records = [ + buildFlow( + 'art', + 'old-art-updated-later', + '2026-08-05T08:00:00Z', + 'REFUSED', + '2026-08-08T08:00:00Z', + ), + buildFlow( + 'art', + 'new-art', + '2026-08-06T08:00:00Z', + 'RUNNING', + '2026-08-06T09:00:00Z', + ), + ] + + expect(selectLatestRestartableChildFlows(records)).toEqual([records[1]]) + }) + + it('发起时间相同时用更新时间稳定决定最新记录', () => { + const records = [ + buildFlow( + 'pricing', + 'pricing-older-update', + '2026-08-17T01:51:00Z', + 'TERMINATED', + '2026-08-17T01:52:00Z', + ), + buildFlow( + 'pricing', + 'pricing-newer-update', + '2026-08-17T01:51:00Z', + 'COMPLETED', + '2026-08-17T01:58:00Z', + ), + ] + + expect(selectLatestRestartableChildFlows(records)).toEqual([records[1]]) + }) +}) diff --git a/furniture-3d-system/src/lib/oa-board-flow-selection.ts b/furniture-3d-system/src/lib/oa-board-flow-selection.ts new file mode 100644 index 00000000..b5847080 --- /dev/null +++ b/furniture-3d-system/src/lib/oa-board-flow-selection.ts @@ -0,0 +1,47 @@ +const RESTARTABLE_CHILD_FLOW_TYPES = new Set(['order', 'art', 'pricing']) + +export type OaBoardFlowSelectionRecord = { + flowType: string + processInstanceId: string + createdAtSource: Date | null + updatedAtSource: Date | null +} + +function toTimestamp(value: Date | null) { + const timestamp = value?.getTime() ?? 0 + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function compareFlowRecency( + left: OaBoardFlowSelectionRecord, + right: OaBoardFlowSelectionRecord, +) { + const createdAtDiff = toTimestamp(left.createdAtSource) - toTimestamp(right.createdAtSource) + if (createdAtDiff !== 0) return createdAtDiff + + const updatedAtDiff = toTimestamp(left.updatedAtSource) - toTimestamp(right.updatedAtSource) + if (updatedAtDiff !== 0) return updatedAtDiff + + return left.processInstanceId.localeCompare(right.processInstanceId) +} + +export function selectLatestRestartableChildFlows( + records: T[], +) { + const latestByFlowType = new Map() + + records.forEach((record) => { + if (!RESTARTABLE_CHILD_FLOW_TYPES.has(record.flowType)) return + + const currentLatest = latestByFlowType.get(record.flowType) + if (!currentLatest || compareFlowRecency(record, currentLatest) > 0) { + latestByFlowType.set(record.flowType, record) + } + }) + + return records.filter( + (record) => + !RESTARTABLE_CHILD_FLOW_TYPES.has(record.flowType) || + latestByFlowType.get(record.flowType) === record, + ) +} diff --git a/furniture-3d-system/src/server/services/oa-board-sync.service.ts b/furniture-3d-system/src/server/services/oa-board-sync.service.ts index 056c91bf..ffa4b4ce 100644 --- a/furniture-3d-system/src/server/services/oa-board-sync.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-sync.service.ts @@ -6,6 +6,7 @@ import { import type sql from 'mssql' import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' +import { selectLatestRestartableChildFlows } from '@/lib/oa-board-flow-selection' import { externalEmployeeDirectoryService } from './external-employee-directory.service' import type { OaBoardProjectGraphInput } from './oa-board.service' import { @@ -3106,7 +3107,10 @@ function buildProjectGroups(records: NormalizedFlowRecord[]): PreparedProjectGro }) } - return Array.from(groups.values()) + return Array.from(groups.values()).map((group) => ({ + ...group, + flowRecords: selectLatestRestartableChildFlows(group.flowRecords), + })) } function countNestedRecords(projectGraphs: OaBoardProjectGraphInput[]) { From 5d60abd57bc63ab64bd62b33c00954f65d63c5d8 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 17 Aug 2026 12:07:57 +0800 Subject: [PATCH 2/7] feat(supply-chain): sync product archive sales performance --- furniture-3d-system/package.json | 1 + .../migration.sql | 35 +++++ furniture-3d-system/prisma/schema.prisma | 21 +++ ...ct-archive-sales-performance-sync-timer.sh | 68 +++++++++ ...chain-product-archive-sales-performance.ts | 97 +++++++++++++ .../src/lib/supply-chain-product-archive.ts | 9 ++ ...ive-sales-performance-source.repository.ts | 134 ++++++++++++++++++ ...chive-sales-performance-sync.repository.ts | 86 +++++++++++ ...supply-chain-product-archive.repository.ts | 11 ++ .../product-archive-sales-performance.test.ts | 41 ++++++ ...ive-sales-performance-sync.service.test.ts | 107 ++++++++++++++ .../product-archive-sales-performance.ts | 61 ++++++++ ...-archive-sales-performance-sync.service.ts | 117 +++++++++++++++ .../supply-chain-product-archive.mapper.ts | 15 ++ 14 files changed, 803 insertions(+) create mode 100644 furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql create mode 100644 furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh create mode 100644 furniture-3d-system/scripts/sync-supply-chain-product-archive-sales-performance.ts create mode 100644 furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts create mode 100644 furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts create mode 100644 furniture-3d-system/src/server/services/product-archive-sales-performance.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index 4e95d7e1..a3e55f2b 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -18,6 +18,7 @@ "prisma:studio": "prisma studio", "sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts", "sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts", + "sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts", "sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts", "sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts", "backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts", diff --git a/furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql b/furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql new file mode 100644 index 00000000..a5b8373c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "SupplyChainProductArchiveSalesPerformance" ( + "archiveId" TEXT NOT NULL, + "barcode" TEXT NOT NULL, + "periodStart" DATE NOT NULL, + "periodEnd" DATE NOT NULL, + "shipAmount" DECIMAL(24,4) NOT NULL, + "returnAmount" DECIMAL(24,4) NOT NULL, + "realPerformance" DECIMAL(24,4) NOT NULL, + "shipQty" BIGINT NOT NULL, + "returnQty" BIGINT NOT NULL, + "grossProfit" DECIMAL(24,4) NOT NULL, + "grossMargin" DECIMAL(20,8), + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SupplyChainProductArchiveSalesPerformance_pkey" PRIMARY KEY ("archiveId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SupplyChainProductArchiveSalesPerformance_barcode_key" +ON "SupplyChainProductArchiveSalesPerformance"("barcode"); + +-- CreateIndex +CREATE INDEX "SupplyChainProductArchiveSalesPerformance_periodEnd_idx" +ON "SupplyChainProductArchiveSalesPerformance"("periodEnd"); + +-- CreateIndex +CREATE INDEX "SupplyChainProductArchiveSalesPerformance_syncedAt_idx" +ON "SupplyChainProductArchiveSalesPerformance"("syncedAt"); + +-- AddForeignKey +ALTER TABLE "SupplyChainProductArchiveSalesPerformance" +ADD CONSTRAINT "SupplyChainProductArchiveSalesPerformance_archiveId_fkey" +FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index eeef1727..e7f449f6 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -563,6 +563,7 @@ model SupplyChainProductArchive { sources SupplyChainProductArchiveSource[] imageEmbedding ProductArchiveImageEmbedding? imageCache SupplyChainProductArchiveImageCache? + salesPerformance SupplyChainProductArchiveSalesPerformance? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -576,6 +577,26 @@ model SupplyChainProductArchive { @@index([firstSourceCode, createdAt]) } +// 销售宽表按条码汇总的每日快照;费比和营业利润额待完整费用字段开放后再接入。 +model SupplyChainProductArchiveSalesPerformance { + archiveId String @id + archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade) + barcode String @unique + periodStart DateTime @db.Date + periodEnd DateTime @db.Date + shipAmount Decimal @db.Decimal(24, 4) + returnAmount Decimal @db.Decimal(24, 4) + realPerformance Decimal @db.Decimal(24, 4) + shipQty BigInt + returnQty BigInt + grossProfit Decimal @db.Decimal(24, 4) + grossMargin Decimal? @db.Decimal(20, 8) + syncedAt DateTime @default(now()) + + @@index([periodEnd]) + @@index([syncedAt]) +} + // 图纸与视频导出来源:按条码保存当前有效的图纸/视频状态,支持月度增量覆盖。 model SupplyChainProductArchiveImageCache { id String @id @default(cuid()) diff --git a/furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh b/furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh new file mode 100644 index 00000000..851e5a78 --- /dev/null +++ b/furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${SUPPLY_CHAIN_ARCHIVE_SALES_SYNC_SERVICE_NAME:-furniture-supply-chain-product-archive-sales-performance-sync}" +APP_ROOT="$BASE/current/furniture-3d-system" +SHARED="$BASE/shared" +APP_ENV="$SHARED/.env" +SYNC_ENV="$SHARED/supply-chain-product-archive-sync.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +SYNC_CALENDAR="${SUPPLY_CHAIN_ARCHIVE_SALES_SYNC_CALENDAR:-*-*-* 03:30:00}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +if [ ! -f "$SYNC_ENV" ]; then + echo "Missing sync env: $SYNC_ENV" >&2 + echo "Run install-supply-chain-product-archive-sync-timer.sh and fill the guard identities first." >&2 + exit 1 +fi + +cat >"$SERVICE_FILE" <"$TIMER_FILE" < argument.startsWith(prefix))?.slice(prefix.length) ?? '' +} + +function databaseIdentity(databaseUrl: string) { + const parsed = new URL(databaseUrl) + return [ + parsed.protocol.replace(/:$/, ''), + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + parsed.pathname.replace(/^\//, ''), + parsed.searchParams.get('schema') ?? '', + ].join('|') +} + +function sourceDatabaseIdentity() { + const host = process.env.JST_REPORT_DB_HOST?.trim() ?? '' + const database = process.env.JST_REPORT_DB_NAME?.trim() ?? '' + const user = process.env.JST_REPORT_DB_USER?.trim() ?? '' + if (!host || !database || !user) return '' + return [ + 'sqlserver', + user, + host, + process.env.JST_REPORT_DB_PORT?.trim() || '1433', + database, + process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + ].join('|') +} + +function readAsOf() { + const value = readArgument('as-of') + if (!value) return undefined + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new Error('--as-of must use YYYY-MM-DD.') + } + const date = new Date(`${value}T00:00:00.000Z`) + if (Number.isNaN(date.getTime())) throw new Error('--as-of is not a valid date.') + return date +} + +async function main() { + if (!process.argv.includes('--apply')) { + throw new Error('This sync writes the archive. Pass --apply explicitly.') + } + if (readArgument('confirm') !== CONFIRM_TOKEN) { + throw new Error(`Pass --confirm=${CONFIRM_TOKEN}.`) + } + + const databaseUrl = process.env.DATABASE_URL?.trim() ?? '' + if (!databaseUrl) throw new Error('DATABASE_URL is required.') + const actualDatabaseIdentity = databaseIdentity(databaseUrl) + if (readArgument('expected-database-identity') !== actualDatabaseIdentity) { + throw new Error(`Pass --expected-database-identity=${actualDatabaseIdentity}.`) + } + + const actualSourceIdentity = sourceDatabaseIdentity() + if (!actualSourceIdentity) throw new Error('JST_REPORT_DB_* is required.') + if (readArgument('expected-source-database-identity') !== actualSourceIdentity) { + throw new Error(`Pass --expected-source-database-identity=${actualSourceIdentity}.`) + } + + const batchSize = Number(readArgument('batch-size') || '1000') + if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 2_000) { + throw new Error('--batch-size must be an integer between 1 and 2000.') + } + + const [ + { prisma }, + { supplyChainProductArchiveSalesPerformanceSyncService }, + ] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-archive-sales-performance-sync.service'), + ]) + try { + const result = await supplyChainProductArchiveSalesPerformanceSyncService.syncAll({ + asOf: readAsOf(), + batchSize, + }) + console.log(JSON.stringify(result, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive.ts b/furniture-3d-system/src/lib/supply-chain-product-archive.ts index 0cc53b34..8c9871b4 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts @@ -55,6 +55,15 @@ export type SupplyChainProductArchiveRow = { purchasePrice: string costPrice: string costPriceSecondary: string + shipAmount: string + returnAmount: string + realPerformance: string + shipQty: string + grossProfit: string + grossMargin: string + feeRatio: string + operatingProfit: string + returnQty: string } export const PRODUCT_ARCHIVE_SALES_STATUSES = ['销售', '停产', '退市'] as const diff --git a/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts b/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts new file mode 100644 index 00000000..58690209 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts @@ -0,0 +1,134 @@ +import sql from 'mssql' + +export type ProductArchiveSalesPerformanceAggregate = { + barcode: string + shipAmount: string + returnAmount: string + realPerformance: string + shipQty: string + returnQty: string + clearSalesCost: string + sendFreight: string +} + +export interface ProductArchiveSalesPerformanceSourceRepository { + latestAvailableDate(input: { + periodStart: Date + notAfter: Date + }): Promise + listAggregates(input: { + periodStart: Date + periodEnd: Date + }): Promise + close(): Promise +} + +function requiredEnv(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Missing required environment variable: ${name}`) + return value +} + +function booleanEnv(name: string, fallback: boolean) { + const value = process.env[name]?.trim().toLowerCase() + if (!value) return fallback + return value === 'true' || value === '1' +} + +function safeIdentifier(value: string, label: string) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new Error(`${label} is not a valid SQL Server identifier`) + } + return value +} + +class ProductArchiveSalesPerformanceSourceRepositoryClass + implements ProductArchiveSalesPerformanceSourceRepository +{ + private pool: sql.ConnectionPool | null = null + + private async getPool() { + if (this.pool?.connected) return this.pool + + this.pool = await new sql.ConnectionPool({ + server: requiredEnv('JST_REPORT_DB_HOST'), + port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'), + database: requiredEnv('JST_REPORT_DB_NAME'), + user: requiredEnv('JST_REPORT_DB_USER'), + password: requiredEnv('JST_REPORT_DB_PASSWORD'), + options: { + encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true), + trustServerCertificate: booleanEnv( + 'JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', + true, + ), + }, + pool: { + min: 0, + max: 2, + idleTimeoutMillis: 30_000, + }, + requestTimeout: 180_000, + connectionTimeout: 15_000, + }).connect() + return this.pool + } + + private sourceObject() { + const schema = safeIdentifier( + process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', + 'JST_REPORT_DB_SCHEMA', + ) + return `[${schema}].[dws_sn_sales_full_info_di]` + } + + async latestAvailableDate(input: { periodStart: Date; notAfter: Date }) { + const pool = await this.getPool() + const result = await pool + .request() + .input('periodStart', sql.Date, input.periodStart) + .input('notAfter', sql.Date, input.notAfter) + .query<{ latestDate: Date | null }>(` + SELECT MAX(stat_date) AS latestDate + FROM ${this.sourceObject()} + WHERE stat_date >= @periodStart + AND stat_date <= @notAfter + `) + return result.recordset[0]?.latestDate ?? null + } + + async listAggregates(input: { periodStart: Date; periodEnd: Date }) { + const pool = await this.getPool() + const result = await pool + .request() + .input('periodStart', sql.Date, input.periodStart) + .input('periodEnd', sql.Date, input.periodEnd) + .query(` + SELECT + UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) AS barcode, + CONVERT(varchar(64), SUM(CAST(COALESCE(clear_goods_cost, 0) AS decimal(38, 4)))) AS shipAmount, + CONVERT(varchar(64), SUM(CAST(COALESCE(refund_amount, 0) AS decimal(38, 4)))) AS returnAmount, + CONVERT(varchar(64), SUM(CAST(COALESCE(real_performance, 0) AS decimal(38, 4)))) AS realPerformance, + CONVERT(varchar(64), SUM(CAST(COALESCE(goods_count, 0) AS bigint))) AS shipQty, + CONVERT(varchar(64), SUM(CAST(COALESCE(refund_count, 0) AS bigint))) AS returnQty, + CONVERT(varchar(64), SUM(CAST(COALESCE(clear_sales_cost, 0) AS decimal(38, 4)))) AS clearSalesCost, + CONVERT(varchar(64), SUM(CAST(COALESCE(send_freight, 0) AS decimal(38, 4)))) AS sendFreight + FROM ${this.sourceObject()} + WHERE stat_date >= @periodStart + AND stat_date <= @periodEnd + AND NULLIF(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100)))), '') IS NOT NULL + GROUP BY UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) + ORDER BY barcode + `) + return result.recordset + } + + async close() { + if (!this.pool) return + await this.pool.close() + this.pool = null + } +} + +export const productArchiveSalesPerformanceSourceRepository = + new ProductArchiveSalesPerformanceSourceRepositoryClass() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts new file mode 100644 index 00000000..db5f3b88 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts @@ -0,0 +1,86 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import type { ProductArchiveSalesPerformanceValues } from '@/server/services/product-archive-sales-performance' + +export type ProductArchiveSalesPerformanceTarget = { + id: string + barcode: string +} + +export type ProductArchiveSalesPerformanceSnapshot = + ProductArchiveSalesPerformanceValues & { + archiveId: string + periodStart: Date + periodEnd: Date + syncedAt: Date + } + +export interface SupplyChainProductArchiveSalesPerformanceSyncRepository { + listTargets(): Promise + replaceAll(input: { + snapshots: ProductArchiveSalesPerformanceSnapshot[] + batchSize: number + }): Promise +} + +function chunks(values: readonly T[], size: number) { + const result: T[][] = [] + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)) + } + return result +} + +class SupplyChainProductArchiveSalesPerformanceSyncRepositoryClass + implements SupplyChainProductArchiveSalesPerformanceSyncRepository +{ + async listTargets() { + return prisma.supplyChainProductArchive.findMany({ + select: { id: true, barcode: true }, + orderBy: { id: 'asc' }, + }) + } + + async replaceAll(input: { + snapshots: ProductArchiveSalesPerformanceSnapshot[] + batchSize: number + }) { + if (input.snapshots.length === 0) { + throw new Error('Refusing to replace sales performance with an empty snapshot') + } + const batchSize = Math.min(2_000, Math.max(1, Math.trunc(input.batchSize))) + const batches = chunks(input.snapshots, batchSize) + + await prisma.$transaction( + async (tx) => { + await tx.supplyChainProductArchiveSalesPerformance.deleteMany() + for (const batch of batches) { + await tx.supplyChainProductArchiveSalesPerformance.createMany({ + data: batch.map((snapshot) => ({ + archiveId: snapshot.archiveId, + barcode: snapshot.barcode, + periodStart: snapshot.periodStart, + periodEnd: snapshot.periodEnd, + shipAmount: new Prisma.Decimal(snapshot.shipAmount), + returnAmount: new Prisma.Decimal(snapshot.returnAmount), + realPerformance: new Prisma.Decimal(snapshot.realPerformance), + shipQty: snapshot.shipQty, + returnQty: snapshot.returnQty, + grossProfit: new Prisma.Decimal(snapshot.grossProfit), + grossMargin: + snapshot.grossMargin === null + ? null + : new Prisma.Decimal(snapshot.grossMargin), + syncedAt: snapshot.syncedAt, + })), + }) + } + }, + { maxWait: 10_000, timeout: 180_000 }, + ) + return input.snapshots.length + } +} + +export const supplyChainProductArchiveSalesPerformanceSyncRepository = + new SupplyChainProductArchiveSalesPerformanceSyncRepositoryClass() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts index be572a28..50553596 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts @@ -60,6 +60,17 @@ const productArchiveSelect = { purchasePrice: true, costPrice: true, costPriceSecondary: true, + salesPerformance: { + select: { + shipAmount: true, + returnAmount: true, + realPerformance: true, + shipQty: true, + returnQty: true, + grossProfit: true, + grossMargin: true, + }, + }, createdAt: true, updatedAt: true, } satisfies Prisma.SupplyChainProductArchiveSelect diff --git a/furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts b/furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts new file mode 100644 index 00000000..b751b482 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts @@ -0,0 +1,41 @@ +import { calculateProductArchiveSalesPerformance } from '../product-archive-sales-performance' + +describe('calculateProductArchiveSalesPerformance', () => { + it('calculates gross profit and gross margin from the confirmed components', () => { + expect(calculateProductArchiveSalesPerformance({ + barcode: ' sku-001 ', + shipAmount: '70', + returnAmount: '5.5', + realPerformance: '100', + shipQty: '3', + returnQty: '1', + clearSalesCost: '60', + sendFreight: '15', + })).toEqual({ + barcode: 'SKU-001', + shipAmount: '70.0000', + returnAmount: '5.5000', + realPerformance: '100.0000', + shipQty: 3n, + returnQty: 1n, + grossProfit: '25.0000', + grossMargin: '0.25000000', + }) + }) + + it('leaves gross margin empty when real performance is zero', () => { + const result = calculateProductArchiveSalesPerformance({ + barcode: 'SKU-002', + shipAmount: '0', + returnAmount: '0', + realPerformance: '0', + shipQty: '0', + returnQty: '0', + clearSalesCost: '10', + sendFreight: '2', + }) + + expect(result.grossProfit).toBe('-12.0000') + expect(result.grossMargin).toBeNull() + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts new file mode 100644 index 00000000..9e115b81 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts @@ -0,0 +1,107 @@ +import type { ProductArchiveSalesPerformanceSourceRepository } from '@/server/repositories/product-archive-sales-performance-source.repository' +import type { + ProductArchiveSalesPerformanceSnapshot, + SupplyChainProductArchiveSalesPerformanceSyncRepository, +} from '@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository' +import { SupplyChainProductArchiveSalesPerformanceSyncService } from '../supply-chain-product-archive-sales-performance-sync.service' + +jest.mock('@/server/repositories/product-archive-sales-performance-source.repository', () => ({ + productArchiveSalesPerformanceSourceRepository: {}, +})) + +jest.mock('@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository', () => ({ + supplyChainProductArchiveSalesPerformanceSyncRepository: {}, +})) + +describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => { + it('matches source aggregates to archive barcodes and replaces one complete snapshot', async () => { + const source: ProductArchiveSalesPerformanceSourceRepository = { + latestAvailableDate: jest.fn().mockResolvedValue(new Date('2026-08-15T00:00:00Z')), + listAggregates: jest.fn().mockResolvedValue([ + { + barcode: ' sku-001 ', + shipAmount: '70', + returnAmount: '5', + realPerformance: '100', + shipQty: '3', + returnQty: '1', + clearSalesCost: '60', + sendFreight: '15', + }, + { + barcode: 'NOT-IN-ARCHIVE', + shipAmount: '20', + returnAmount: '0', + realPerformance: '20', + shipQty: '1', + returnQty: '0', + clearSalesCost: '10', + sendFreight: '2', + }, + ]), + close: jest.fn().mockResolvedValue(undefined), + } + let written: ProductArchiveSalesPerformanceSnapshot[] = [] + const repository: SupplyChainProductArchiveSalesPerformanceSyncRepository = { + listTargets: jest.fn().mockResolvedValue([ + { id: 'archive-1', barcode: 'SKU-001' }, + { id: 'archive-2', barcode: 'SKU-002' }, + ]), + replaceAll: jest.fn().mockImplementation(async ({ snapshots }) => { + written = snapshots + return snapshots.length + }), + } + const service = new SupplyChainProductArchiveSalesPerformanceSyncService({ + source, + repository, + now: () => new Date('2026-08-17T02:00:00Z'), + }) + + await expect(service.syncAll({ + asOf: new Date('2026-08-17T00:00:00Z'), + batchSize: 500, + })).resolves.toEqual({ + periodStart: '2026-01-01', + periodEnd: '2026-08-15', + sourceAggregates: 2, + archiveTargets: 2, + matched: 1, + unmatchedSource: 1, + unmatchedArchive: 1, + }) + expect(written).toHaveLength(1) + expect(written[0]).toMatchObject({ + archiveId: 'archive-1', + barcode: 'SKU-001', + grossProfit: '25.0000', + grossMargin: '0.25000000', + periodStart: new Date('2026-01-01T00:00:00Z'), + periodEnd: new Date('2026-08-15T00:00:00Z'), + }) + expect(source.close).toHaveBeenCalledTimes(1) + }) + + it('does not replace local data when the source period is empty', async () => { + const source: ProductArchiveSalesPerformanceSourceRepository = { + latestAvailableDate: jest.fn().mockResolvedValue(null), + listAggregates: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + } + const repository: SupplyChainProductArchiveSalesPerformanceSyncRepository = { + listTargets: jest.fn(), + replaceAll: jest.fn(), + } + const service = new SupplyChainProductArchiveSalesPerformanceSyncService({ + source, + repository, + now: () => new Date('2026-08-17T02:00:00Z'), + }) + + await expect(service.syncAll()).rejects.toThrow( + 'Sales performance source has no data in the requested period', + ) + expect(repository.replaceAll).not.toHaveBeenCalled() + expect(source.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/furniture-3d-system/src/server/services/product-archive-sales-performance.ts b/furniture-3d-system/src/server/services/product-archive-sales-performance.ts new file mode 100644 index 00000000..92e97a01 --- /dev/null +++ b/furniture-3d-system/src/server/services/product-archive-sales-performance.ts @@ -0,0 +1,61 @@ +import { Prisma } from '@prisma/client' +import type { ProductArchiveSalesPerformanceAggregate } from '@/server/repositories/product-archive-sales-performance-source.repository' + +export type ProductArchiveSalesPerformanceValues = { + barcode: string + shipAmount: string + returnAmount: string + realPerformance: string + shipQty: bigint + returnQty: bigint + grossProfit: string + grossMargin: string | null +} + +export function normalizeSalesPerformanceBarcode(value: string) { + return value.trim().toLocaleUpperCase('zh-CN') +} + +function decimal(value: string, field: string) { + try { + return new Prisma.Decimal(value || '0') + } catch { + throw new Error(`Invalid ${field} value: ${value}`) + } +} + +function integer(value: string, field: string) { + try { + return BigInt(value || '0') + } catch { + throw new Error(`Invalid ${field} value: ${value}`) + } +} + +export function calculateProductArchiveSalesPerformance( + aggregate: ProductArchiveSalesPerformanceAggregate, +): ProductArchiveSalesPerformanceValues { + const barcode = normalizeSalesPerformanceBarcode(aggregate.barcode) + if (!barcode) throw new Error('Sales performance barcode is required') + + const shipAmount = decimal(aggregate.shipAmount, 'shipAmount') + const returnAmount = decimal(aggregate.returnAmount, 'returnAmount') + const realPerformance = decimal(aggregate.realPerformance, 'realPerformance') + const clearSalesCost = decimal(aggregate.clearSalesCost, 'clearSalesCost') + const sendFreight = decimal(aggregate.sendFreight, 'sendFreight') + const grossProfit = realPerformance.minus(clearSalesCost).minus(sendFreight) + const grossMargin = realPerformance.isZero() + ? null + : grossProfit.dividedBy(realPerformance).toFixed(8) + + return { + barcode, + shipAmount: shipAmount.toFixed(4), + returnAmount: returnAmount.toFixed(4), + realPerformance: realPerformance.toFixed(4), + shipQty: integer(aggregate.shipQty, 'shipQty'), + returnQty: integer(aggregate.returnQty, 'returnQty'), + grossProfit: grossProfit.toFixed(4), + grossMargin, + } +} diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts new file mode 100644 index 00000000..30f61d27 --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts @@ -0,0 +1,117 @@ +import { + productArchiveSalesPerformanceSourceRepository, + type ProductArchiveSalesPerformanceSourceRepository, +} from '@/server/repositories/product-archive-sales-performance-source.repository' +import { + supplyChainProductArchiveSalesPerformanceSyncRepository, + type SupplyChainProductArchiveSalesPerformanceSyncRepository, +} from '@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository' +import { + calculateProductArchiveSalesPerformance, + normalizeSalesPerformanceBarcode, +} from './product-archive-sales-performance' + +const PERIOD_START = new Date('2026-01-01T00:00:00.000Z') + +function utcDate(value: Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())) +} + +export type SupplyChainProductArchiveSalesPerformanceSyncResult = { + periodStart: string + periodEnd: string + sourceAggregates: number + archiveTargets: number + matched: number + unmatchedSource: number + unmatchedArchive: number +} + +type Dependencies = { + source: ProductArchiveSalesPerformanceSourceRepository + repository: SupplyChainProductArchiveSalesPerformanceSyncRepository + now(): Date +} + +export class SupplyChainProductArchiveSalesPerformanceSyncService { + constructor( + private readonly dependencies: Dependencies = { + source: productArchiveSalesPerformanceSourceRepository, + repository: supplyChainProductArchiveSalesPerformanceSyncRepository, + now: () => new Date(), + }, + ) {} + + async syncAll(input: { asOf?: Date; batchSize?: number } = {}) { + const notAfter = utcDate(input.asOf ?? this.dependencies.now()) + const batchSize = Math.min(2_000, Math.max(1, Math.trunc(input.batchSize ?? 1_000))) + + try { + const periodEnd = await this.dependencies.source.latestAvailableDate({ + periodStart: PERIOD_START, + notAfter, + }) + if (!periodEnd) { + throw new Error('Sales performance source has no data in the requested period') + } + + const normalizedPeriodEnd = utcDate(periodEnd) + const [aggregates, targets] = await Promise.all([ + this.dependencies.source.listAggregates({ + periodStart: PERIOD_START, + periodEnd: normalizedPeriodEnd, + }), + this.dependencies.repository.listTargets(), + ]) + if (aggregates.length === 0) { + throw new Error('Sales performance aggregation returned no rows') + } + + const targetsByBarcode = new Map( + targets.map((target) => [ + normalizeSalesPerformanceBarcode(target.barcode), + target, + ]), + ) + const syncedAt = this.dependencies.now() + let unmatchedSource = 0 + const snapshots = aggregates.flatMap((aggregate) => { + const values = calculateProductArchiveSalesPerformance(aggregate) + const target = targetsByBarcode.get(values.barcode) + if (!target) { + unmatchedSource += 1 + return [] + } + return [{ + ...values, + archiveId: target.id, + periodStart: PERIOD_START, + periodEnd: normalizedPeriodEnd, + syncedAt, + }] + }) + if (snapshots.length === 0) { + throw new Error('Sales performance source did not match any product archive rows') + } + + const matched = await this.dependencies.repository.replaceAll({ + snapshots, + batchSize, + }) + return { + periodStart: PERIOD_START.toISOString().slice(0, 10), + periodEnd: normalizedPeriodEnd.toISOString().slice(0, 10), + sourceAggregates: aggregates.length, + archiveTargets: targets.length, + matched, + unmatchedSource, + unmatchedArchive: Math.max(0, targets.length - matched), + } satisfies SupplyChainProductArchiveSalesPerformanceSyncResult + } finally { + await this.dependencies.source.close() + } + } +} + +export const supplyChainProductArchiveSalesPerformanceSyncService = + new SupplyChainProductArchiveSalesPerformanceSyncService() diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts index 2c4e420d..954f1b47 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts @@ -12,6 +12,12 @@ function decimalText(value: { toString(): string } | null) { return value?.toString() ?? '' } +function percentageText( + value: { mul(multiplier: number): { toFixed(digits: number): string } } | null, +) { + return value ? `${value.mul(100).toFixed(2)}%` : '' +} + export function normalizeSupplierName(value: string) { return value.trim().toLocaleLowerCase('zh-CN') } @@ -88,5 +94,14 @@ export function mapProductArchiveRow({ purchasePrice: record.purchasePrice, costPrice: record.costPrice, costPriceSecondary: record.costPriceSecondary, + shipAmount: decimalText(record.salesPerformance?.shipAmount ?? null), + returnAmount: decimalText(record.salesPerformance?.returnAmount ?? null), + realPerformance: decimalText(record.salesPerformance?.realPerformance ?? null), + shipQty: record.salesPerformance?.shipQty.toString() ?? '', + grossProfit: decimalText(record.salesPerformance?.grossProfit ?? null), + grossMargin: percentageText(record.salesPerformance?.grossMargin ?? null), + feeRatio: '', + operatingProfit: '', + returnQty: record.salesPerformance?.returnQty.toString() ?? '', } satisfies SupplyChainProductArchiveRow } From 5af369ef3d856118f6d35a2d1c5036ecec7f7088 Mon Sep 17 00:00:00 2001 From: jingchuan Date: Mon, 17 Aug 2026 13:36:19 +0800 Subject: [PATCH 3/7] feat: add new product management executive dashboard --- .../NewProductManagementDashboard.tsx | 2393 +++++++++++++++++ .../intake/new-product-management/page.tsx | 39 + 2 files changed, 2432 insertions(+) create mode 100644 furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx create mode 100644 furniture-3d-system/src/app/intake/new-product-management/page.tsx diff --git a/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx new file mode 100644 index 00000000..f67686e1 --- /dev/null +++ b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx @@ -0,0 +1,2393 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + AlertTriangle, + ArrowUpRight, + BarChart3, + CalendarDays, + CheckCircle2, + ChevronLeft, + ChevronRight, + CircleDot, + Clock3, + Filter, + Layers3, + SlidersHorizontal, + TimerReset, + Users, + X, +} from "lucide-react"; +import type { + OaBoardDashboardDTO, + OaBoardProjectListItemDTO, +} from "@/server/services/oa-board.service"; + +type Period = "week" | "month" | "year"; +type AnalysisView = + "overview" | "design" | "sample" | "production" | "art" | "pricing"; +type SampleRow = { + id: string; + taskKey?: string; + openingCode?: string; + spu: string; + name: string; + structure: string; + supplier: string; + buyer: string; + stage: string; + stageStatus: string; + scheduleDate: string; + startDate: string; + finishDate: string; + days: number; + risk: "high" | "medium" | "normal"; +}; + +type StageDatum = { + id: string; + label: string; + actual: number; + target: number; + count: number; + onTime?: number; + tone: "red" | "amber" | "green"; + riskCount?: number; + maxDelay?: number; +}; + +type CurrentBottleneckDatum = { + id: string; + label: string; + count: number; + maxDelay: number; + owner: string; + view: Exclude; +}; + +type CurrentNodeGapDatum = Omit; + +type BaseAnalysisDatum = (typeof ANALYSIS_DATA)[Exclude< + AnalysisView, + "overview" +>]; +type AnalysisDatum = Omit & { + riskTaskKeys?: string[]; + blockers: Array< + BaseAnalysisDatum["blockers"][number] & { taskKeys?: string[] } + >; +}; + +type AnalysisDrilldown = { + label: string; + rows: SampleRow[]; +} | null; + +const VIEW_STAGE_PATTERNS: Record, RegExp> = { + design: /立项|设计|结构|外观|评审/, + sample: /打样|审样|样品/, + production: /生产|首单|产前样|出货|质检|送仓/, + art: /视觉|出图|商品资料|安装图|寄样|上架/, + pricing: /定价|限价|核价|价格/, +}; + +const VIEW_TIMELINE_KEYS: Record< + Exclude, + string[] +> = { + design: ["exterior", "structure", "market_analysis"], + sample: ["sample_prepare", "sample_review"], + production: [ + "production", + "first_order", + "pre_production", + "shipment", + "warehouse_qc", + ], + art: ["material", "install_manual", "sample_send", "art", "listing"], + pricing: ["pricing", "pricing_flow"], +}; + +const ANALYSIS_DIMENSION_KEYS = ["structure", "supplier", "buyer"] as const; + +const PERIOD_LABELS: Record = { + week: "周", + month: "月", + year: "年", +}; + +const PERIOD_DATA: Record< + Period, + { + total: number; + running: number; + completed: number; + rate: number; + newCount: number; + totalDays: number; + targetDays: number; + assessed: number; + timeout: number; + maxOverdueDays: number; + maxOverdueTask: string; + } +> = { + week: { + total: 18, + running: 8, + completed: 10, + rate: 56, + newCount: 5, + totalDays: 31.4, + targetDays: 28, + assessed: 18, + timeout: 4, + maxOverdueDays: 8.6, + maxOverdueTask: "KK-260812-018", + }, + month: { + total: 96, + running: 38, + completed: 58, + rate: 60, + newCount: 24, + totalDays: 34.6, + targetDays: 30, + assessed: 91, + timeout: 17, + maxOverdueDays: 14.2, + maxOverdueTask: "KK-260805-031", + }, + year: { + total: 428, + running: 126, + completed: 302, + rate: 71, + newCount: 428, + totalDays: 32.1, + targetDays: 30, + assessed: 406, + timeout: 64, + maxOverdueDays: 26.8, + maxOverdueTask: "KK-260318-106", + }, +}; + +const STAGE_DATA: StageDatum[] = [ + { + id: "design", + label: "产品设计", + actual: 4.1, + target: 3.2, + count: 17, + onTime: 68, + riskCount: 7, + maxDelay: 6.2, + tone: "red", + }, + { + id: "sample", + label: "打样", + actual: 8.5, + target: 5.5, + count: 12, + onTime: 61, + riskCount: 8, + maxDelay: 9.4, + tone: "red", + }, + { + id: "production", + label: "生产", + actual: 7.2, + target: 7.5, + count: 21, + onTime: 76, + riskCount: 6, + maxDelay: 5.1, + tone: "amber", + }, + { + id: "art", + label: "视觉交付", + actual: 6.4, + target: 5, + count: 6, + onTime: 67, + riskCount: 4, + maxDelay: 7.8, + tone: "red", + }, + { + id: "pricing", + label: "定价", + actual: 2.6, + target: 2, + count: 4, + onTime: 79, + riskCount: 3, + maxDelay: 3.4, + tone: "amber", + }, +]; + +const CURRENT_BOTTLENECK_DATA: CurrentBottleneckDatum[] = [ + { + id: "sample-arrange", + label: "确认打样出货时间", + count: 5, + maxDelay: 6.4, + owner: "采购组", + view: "sample", + }, + { + id: "first-order", + label: "首单下单", + count: 4, + maxDelay: 5.1, + owner: "采购组", + view: "production", + }, + { + id: "material", + label: "商品资料输出", + count: 3, + maxDelay: 3.8, + owner: "品牌策划", + view: "art", + }, +]; + +const PLAN_DATA = [ + { label: "常规开发", count: 42, rate: 74, color: "#245f5a" }, + { label: "快速开发", count: 27, rate: 81, color: "#477a8c" }, + { label: "供应商共创", count: 16, rate: 44, color: "#b66b35" }, + { label: "定制开发", count: 11, rate: 36, color: "#8d5960" }, +]; + +const ANALYSIS_DATA: Record< + Exclude, + { + label: string; + subtitle: string; + active: number; + avgDays: number; + targetDays: number; + onTime: number; + risk: number; + statuses: Array<{ label: string; count: number; color: string }>; + blockers: Array<{ label: string; count: number; meta: string }>; + dimensions: Array<{ label: string; count: number; note: string }>; + } +> = { + design: { + label: "产品设计", + subtitle: "结构、外观和设计评审的交付质量", + active: 26, + avgDays: 8.8, + targetDays: 7, + onTime: 68, + risk: 9, + statuses: [ + { label: "结构设计中", count: 8, color: "#b34e46" }, + { label: "外观设计中", count: 6, color: "#d49344" }, + { label: "待评审", count: 7, color: "#477a8c" }, + { label: "已通过", count: 5, color: "#3d9279" }, + ], + blockers: [ + { label: "结构方案反复修改", count: 6, meta: "平均多 2.1 天" }, + { label: "外观稿待确认", count: 5, meta: "集中在家具类" }, + { label: "评审排期等待", count: 4, meta: "最长 6 天" }, + ], + dimensions: [ + { label: "框架结构 A", count: 9, note: "达标率 67%" }, + { label: "板式结构 B", count: 8, note: "达标率 75%" }, + { label: "岩板结构 C", count: 5, note: "达标率 40%" }, + ], + }, + sample: { + label: "打样", + subtitle: "安排、开样、完成与供应商协同效率", + active: 21, + avgDays: 12.8, + targetDays: 8, + onTime: 54, + risk: 12, + statuses: [ + { label: "待安排", count: 4, color: "#d49344" }, + { label: "已安排未开始", count: 5, color: "#b34e46" }, + { label: "打样中", count: 3, color: "#477a8c" }, + { label: "已完成", count: 9, color: "#3d9279" }, + ], + blockers: [ + { label: "供应商未确认排期", count: 7, meta: "最长 12 天" }, + { label: "采购尚未安排", count: 4, meta: "集中在框架结构" }, + { label: "样品反复返工", count: 3, meta: "平均返工 1.6 次" }, + ], + dimensions: [ + { label: "东莞木作一厂", count: 7, note: "平均 12.0 天" }, + { label: "佛山优木", count: 6, note: "平均 9.0 天" }, + { label: "中山石材协作厂", count: 4, note: "平均 13.0 天" }, + ], + }, + production: { + label: "生产", + subtitle: "首单、产前样与生产交付的节奏", + active: 18, + avgDays: 18.4, + targetDays: 20, + onTime: 81, + risk: 4, + statuses: [ + { label: "等待首单", count: 5, color: "#d49344" }, + { label: "产前样确认", count: 4, color: "#477a8c" }, + { label: "生产中", count: 6, color: "#245f5a" }, + { label: "已完成", count: 3, color: "#3d9279" }, + ], + blockers: [ + { label: "首单合同未回签", count: 4, meta: "最长 8 天" }, + { label: "产前样待确认", count: 3, meta: "影响排产" }, + { label: "物料到位等待", count: 2, meta: "采购跟进中" }, + ], + dimensions: [ + { label: "东莞木作一厂", count: 6, note: "达标率 83%" }, + { label: "佛山优木", count: 5, note: "达标率 80%" }, + { label: "宁波金属件", count: 3, note: "达标率 67%" }, + ], + }, + art: { + label: "视觉交付", + subtitle: "视觉出图、详情制作与商品资料准备", + active: 24, + avgDays: 6.4, + targetDays: 5, + onTime: 73, + risk: 6, + statuses: [ + { label: "待出图", count: 7, color: "#d49344" }, + { label: "出图中", count: 8, color: "#477a8c" }, + { label: "详情制作", count: 5, color: "#245f5a" }, + { label: "已完成", count: 4, color: "#3d9279" }, + ], + blockers: [ + { label: "实拍素材未到", count: 5, meta: "等待生产出样" }, + { label: "品牌规范待确认", count: 3, meta: "平均 2.0 天" }, + { label: "详情文案返修", count: 2, meta: "涉及 2 个品牌" }, + ], + dimensions: [ + { label: "沙发类", count: 9, note: "平均 6.1 天" }, + { label: "柜类", count: 8, note: "平均 5.8 天" }, + { label: "桌几类", count: 5, note: "平均 7.2 天" }, + ], + }, + pricing: { + label: "定价", + subtitle: "成本核算、价格审批与运营上架准备", + active: 16, + avgDays: 2.6, + targetDays: 2, + onTime: 79, + risk: 3, + statuses: [ + { label: "待成本核算", count: 4, color: "#d49344" }, + { label: "限价审批中", count: 5, color: "#477a8c" }, + { label: "待运营确认", count: 4, color: "#245f5a" }, + { label: "已完成", count: 3, color: "#3d9279" }, + ], + blockers: [ + { label: "供应商成本未回传", count: 3, meta: "最长 5 天" }, + { label: "价格带冲突", count: 2, meta: "需要老板拍板" }, + { label: "运营上架排期", count: 2, meta: "等待活动计划" }, + ], + dimensions: [ + { label: "常规开发", count: 7, note: "平均 2.1 天" }, + { label: "快速开发", count: 5, note: "平均 1.8 天" }, + { label: "供应商共创", count: 3, note: "平均 3.6 天" }, + ], + }, +}; + +const SAMPLE_ROWS: SampleRow[] = [ + { + id: "1", + spu: "SPU-260811-018", + name: "云朵模块沙发", + structure: "框架结构 A", + supplier: "东莞木作一厂", + buyer: "陈思远", + stage: "打样", + stageStatus: "已安排未开始", + scheduleDate: "2026-08-04", + startDate: "-", + finishDate: "-", + days: 12, + risk: "high", + }, + { + id: "2", + spu: "SPU-260808-011", + name: "悬浮床头柜", + structure: "板式结构 B", + supplier: "佛山优木", + buyer: "周琳", + stage: "打样", + stageStatus: "打样中", + scheduleDate: "2026-08-01", + startDate: "2026-08-03", + finishDate: "-", + days: 9, + risk: "high", + }, + { + id: "3", + spu: "SPU-260805-007", + name: "岩板餐桌 1.4m", + structure: "岩板结构 C", + supplier: "中山石材协作厂", + buyer: "李浩", + stage: "打样", + stageStatus: "已完成超时", + scheduleDate: "2026-07-26", + startDate: "2026-07-29", + finishDate: "2026-08-08", + days: 13, + risk: "medium", + }, + { + id: "4", + spu: "SPU-260802-003", + name: "窄体收纳高柜", + structure: "板式结构 B", + supplier: "佛山优木", + buyer: "周琳", + stage: "产品结构", + stageStatus: "结构评审", + scheduleDate: "-", + startDate: "-", + finishDate: "-", + days: 7, + risk: "medium", + }, + { + id: "5", + spu: "SPU-260728-029", + name: "奶油风书桌", + structure: "框架结构 A", + supplier: "东莞木作一厂", + buyer: "陈思远", + stage: "采购 / 首单", + stageStatus: "等待供应商确认", + scheduleDate: "-", + startDate: "-", + finishDate: "-", + days: 8, + risk: "high", + }, + { + id: "6", + spu: "SPU-260724-016", + name: "可叠放边几", + structure: "金属结构 D", + supplier: "宁波金属件", + buyer: "王璐", + stage: "审样", + stageStatus: "待审样", + scheduleDate: "2026-07-22", + startDate: "2026-07-25", + finishDate: "-", + days: 6, + risk: "normal", + }, +]; + +function toLiveSampleRow( + item: OaBoardProjectListItemDTO, + index: number, +): SampleRow { + const stage = + item.currentStageName || + item.currentApprovalStageName || + item.formSummary.approvalStageDisplay.split(";")[0] || + "未开始"; + const actualDays = + item.timelineMetrics.find((metric) => metric.key === "total_actual") + ?.actualDays ?? + item.overallActualHours ?? + 0; + const createdDate = item.createdDate?.slice(0, 10) || "-"; + const isCompleted = item.overallStatus === "COMPLETED"; + const currentAssessment = item.nodeAssessments.find( + (assessment) => + assessment.status === "RUNNING" || + assessment.nodeName === item.currentStageName || + assessment.approvalStageName === item.currentApprovalStageName, + ); + return { + id: item.id || String(index), + taskKey: getItemKey(item), + openingCode: + item.formSummary.openingCode || item.openingCode || "字段待补充", + spu: item.spu || item.formSummary.spu || item.openingCode || item.boardKey, + name: item.productName || item.title || "未命名新品", + structure: + item.formSummary.categoryName || item.categoryName || "字段待补充", + supplier: "字段待补充", + buyer: item.formSummary.purchaseOwner || "待分配", + stage, + stageStatus: isCompleted + ? "已完成" + : item.overallTimeoutFlag + ? "超时处理中" + : "进行中", + scheduleDate: createdDate, + startDate: currentAssessment?.startedAtSource?.slice(0, 10) || "-", + finishDate: + currentAssessment?.finishedAtSource?.slice(0, 10) || + item.finishedDate?.slice(0, 10) || + "-", + days: Number(Math.max(0, actualDays).toFixed(1)), + risk: item.overallTimeoutFlag + ? "high" + : item.overallStatus === "RUNNING" + ? "medium" + : "normal", + }; +} + +function getItemKey(item: OaBoardProjectListItemDTO) { + return item.formSummary.openingCode || item.openingCode || item.boardKey; +} + +function getRowTaskKey(row: SampleRow) { + return row.taskKey || row.openingCode || row.spu; +} + +function groupItemsByTask(items: OaBoardProjectListItemDTO[]) { + const groups = new Map(); + items.forEach((item) => { + const key = getItemKey(item); + groups.set(key, [...(groups.get(key) ?? []), item]); + }); + return Array.from(groups.values()); +} + +function getTaskCreatedDate(items: OaBoardProjectListItemDTO[]) { + return items + .map((item) => item.createdDate?.slice(0, 10) || "") + .filter(Boolean) + .sort()[0]; +} + +function getItemStage(item: OaBoardProjectListItemDTO) { + return ( + item.currentStageName || + item.currentApprovalStageName || + item.formSummary.approvalStageDisplay.split(";")[0] || + "未标注阶段" + ); +} + +function classifyStageName(stage: string) { + if (/定价|限价|核价|价格/.test(stage)) return "pricing"; + if (/视觉|出图|商品资料|安装图|寄样|上架/.test(stage)) return "art"; + if (/打样/.test(stage)) return "sample"; + if (/审样/.test(stage)) return "review"; + if (/生产|出货/.test(stage)) return "production"; + if (/首单|产前样/.test(stage)) return "purchase"; + if (/结构/.test(stage)) return "structure"; + if (/外观|设计/.test(stage)) return "appearance"; + if (/立项|需求|评审/.test(stage)) return "requirements"; + return null; +} + +function analysisViewForStage( + stageId: string, +): Exclude { + if (["design", "requirements", "structure", "appearance"].includes(stageId)) { + return "design"; + } + if (["sample", "review"].includes(stageId)) return "sample"; + if (["purchase", "production"].includes(stageId)) return "production"; + if (stageId === "art") return "art"; + return "pricing"; +} + +function dedupeLiveItems(items: OaBoardProjectListItemDTO[]) { + const byTask = new Map(); + items.forEach((item) => { + const key = getItemKey(item); + const current = byTask.get(key); + if ( + !current || + (current.overallStatus === "COMPLETED" && + item.overallStatus !== "COMPLETED") || + (item.overallActualHours ?? 0) > (current.overallActualHours ?? 0) + ) { + byTask.set(key, item); + } + }); + return Array.from(byTask.values()); +} + +function buildLiveRows(items: OaBoardProjectListItemDTO[]) { + return groupItemsByTask(items).map((group, index) => { + const base = dedupeLiveItems(group)[0]; + const row = toLiveSampleRow(base, index); + const stages = Array.from(new Set(group.map(getItemStage))); + const allCompleted = group.every( + (item) => item.overallStatus === "COMPLETED", + ); + const hasTimeout = group.some((item) => item.overallTimeoutFlag); + return { + ...row, + stage: stages.join(" / "), + stageStatus: allCompleted + ? "已完成" + : hasTimeout + ? "超时处理中" + : "进行中", + days: Math.max(...group.map((item) => toLiveSampleRow(item, index).days)), + risk: hasTimeout ? "high" : allCompleted ? "normal" : "medium", + } satisfies SampleRow; + }); +} + +function buildLiveSummary(dashboard: OaBoardDashboardDTO) { + const items = dashboard.items; + const uniqueItems = dedupeLiveItems(items); + const allKeys = new Set(items.map(getItemKey)); + const statusByKey = new Map>(); + items.forEach((item) => { + const statuses = statusByKey.get(getItemKey(item)) ?? new Set(); + statuses.add(item.overallStatus || "UNKNOWN"); + statusByKey.set(getItemKey(item), statuses); + }); + const total = allKeys.size; + const completed = Array.from(statusByKey.values()).filter( + (statuses) => statuses.size === 1 && statuses.has("COMPLETED"), + ).length; + const running = total - completed; + const durationRows = uniqueItems + .map( + (item) => + item.timelineMetrics.find((metric) => metric.key === "total_actual") + ?.actualDays ?? item.overallActualHours, + ) + .filter( + (value): value is number => + value !== null && Number.isFinite(value) && value > 0, + ); + const targetRows = uniqueItems + .map( + (item) => + item.timelineMetrics.find((metric) => metric.key === "total_actual") + ?.targetDays ?? item.overallTargetHours, + ) + .filter( + (value): value is number => + value !== null && Number.isFinite(value) && value > 0, + ); + const timingByKey = new Map< + string, + { delay: number; isAssessed: boolean; task: string } + >(); + items.forEach((item) => { + const totalMetric = item.timelineMetrics.find( + (metric) => metric.key === "total_actual", + ); + const actual = totalMetric?.actualDays ?? item.overallActualHours; + const target = totalMetric?.targetDays ?? item.overallTargetHours; + const isAssessed = + actual !== null && + target !== null && + Number.isFinite(actual) && + Number.isFinite(target) && + target > 0; + const delay = isAssessed ? Math.max(0, actual - target) : 0; + const key = getItemKey(item); + const current = timingByKey.get(key); + if ( + !current || + delay > current.delay || + (isAssessed && !current.isAssessed) + ) { + timingByKey.set(key, { + delay, + isAssessed, + task: key, + }); + } + }); + const assessedRows = Array.from(timingByKey.values()).filter( + (row) => row.isAssessed, + ); + const overdueRows = assessedRows.filter((row) => row.delay > 0); + const maxOverdueRow = overdueRows.reduce<(typeof overdueRows)[number] | null>( + (max, row) => (!max || row.delay > max.delay ? row : max), + null, + ); + return { + total, + running, + completed, + rate: total > 0 ? Math.round((completed / total) * 100) : 0, + newCount: total, + totalDays: durationRows.length + ? Number( + ( + durationRows.reduce((sum, value) => sum + value, 0) / + durationRows.length + ).toFixed(1), + ) + : 0, + targetDays: targetRows.length + ? Number( + ( + targetRows.reduce((sum, value) => sum + value, 0) / + targetRows.length + ).toFixed(1), + ) + : 0, + assessed: assessedRows.length, + timeout: overdueRows.length, + maxOverdueDays: maxOverdueRow ? Number(maxOverdueRow.delay.toFixed(1)) : 0, + maxOverdueTask: maxOverdueRow?.task ?? "暂无超期", + }; +} + +function average(values: number[]) { + return values.length + ? Number( + (values.reduce((sum, value) => sum + value, 0) / values.length).toFixed( + 1, + ), + ) + : 0; +} + +function buildLiveStageData(items: OaBoardProjectListItemDTO[]): StageDatum[] { + const configs: Array<{ + id: Exclude; + label: string; + keys: string[]; + }> = [ + { id: "design", label: "产品设计", keys: VIEW_TIMELINE_KEYS.design }, + { id: "sample", label: "打样", keys: VIEW_TIMELINE_KEYS.sample }, + { + id: "production", + label: "生产", + keys: VIEW_TIMELINE_KEYS.production, + }, + { id: "art", label: "视觉交付", keys: VIEW_TIMELINE_KEYS.art }, + { id: "pricing", label: "定价", keys: VIEW_TIMELINE_KEYS.pricing }, + ]; + const uniqueItems = dedupeLiveItems(items); + return configs.map((config) => { + const metricRows = uniqueItems.flatMap((item) => + item.timelineMetrics + .filter((metric) => config.keys.includes(metric.key)) + .map((metric) => ({ item, metric })), + ); + const actual = average( + metricRows + .map(({ metric }) => metric.actualDays) + .filter( + (value): value is number => value !== null && Number.isFinite(value), + ), + ); + const target = average( + metricRows + .map(({ metric }) => metric.targetDays) + .filter( + (value): value is number => value !== null && Number.isFinite(value), + ), + ); + const count = uniqueItems.filter((item) => { + const stageId = classifyStageName(getItemStage(item)); + return stageId ? analysisViewForStage(stageId) === config.id : false; + }).length; + const assessableRows = metricRows.filter( + ({ metric }) => + metric.actualDays !== null && + metric.targetDays !== null && + Number.isFinite(metric.actualDays) && + Number.isFinite(metric.targetDays) && + metric.targetDays > 0, + ); + const assessedTaskKeys = new Set( + assessableRows.map(({ item }) => getItemKey(item)), + ); + const overdueRows = assessableRows.filter( + ({ metric }) => (metric.actualDays ?? 0) > (metric.targetDays ?? 0), + ); + const overdueTaskKeys = new Set( + overdueRows.map(({ item }) => getItemKey(item)), + ); + const onTime = assessedTaskKeys.size + ? Math.round( + ((assessedTaskKeys.size - overdueTaskKeys.size) / + assessedTaskKeys.size) * + 100, + ) + : 0; + return { + id: config.id, + label: config.label, + actual, + target, + count, + onTime, + riskCount: overdueTaskKeys.size, + maxDelay: overdueRows.length + ? Math.max( + ...overdueRows.map( + ({ metric }) => + (metric.actualDays ?? 0) - (metric.targetDays ?? 0), + ), + ) + : 0, + tone: onTime < 70 ? "red" : onTime < 85 ? "amber" : "green", + }; + }); +} + +function getNodeAnalysisView( + item: OaBoardProjectListItemDTO, + assessment: OaBoardProjectListItemDTO["nodeAssessments"][number], +): Exclude { + const description = [ + assessment.nodeName, + assessment.approvalStageName, + assessment.flowLabel, + assessment.processName, + ] + .filter(Boolean) + .join(" "); + const stageId = classifyStageName(description); + if (stageId) return analysisViewForStage(stageId); + if (/视觉|出图|美工|摄影/.test(description)) return "art"; + if (/定价|限价|核价|价格/.test(description)) return "pricing"; + const currentStageId = classifyStageName(getItemStage(item)); + return currentStageId ? analysisViewForStage(currentStageId) : "design"; +} + +function buildLiveCurrentBottlenecks( + items: OaBoardProjectListItemDTO[], +): CurrentBottleneckDatum[] { + const groups = new Map< + string, + { + taskKeys: Set; + maxDelay: number; + owners: Set; + view: Exclude; + } + >(); + items.forEach((item) => { + item.nodeAssessments + .filter( + (assessment) => + assessment.status === "RUNNING" && assessment.isOvertime, + ) + .forEach((assessment) => { + const label = + assessment.approvalStageName || assessment.nodeName || "未标注节点"; + const current = groups.get(label) ?? { + taskKeys: new Set(), + maxDelay: 0, + owners: new Set(), + view: getNodeAnalysisView(item, assessment), + }; + const calculatedDelay = + assessment.actualHours !== null && assessment.targetHours !== null + ? assessment.actualHours - assessment.targetHours + : 0; + current.taskKeys.add(getItemKey(item)); + current.maxDelay = Math.max( + current.maxDelay, + assessment.overtimeHours ?? calculatedDelay, + 0, + ); + if (assessment.ownerLabel) current.owners.add(assessment.ownerLabel); + groups.set(label, current); + }); + }); + return Array.from(groups.entries()) + .map(([label, value]) => ({ + id: `${value.view}:${label}`, + label, + count: value.taskKeys.size, + maxDelay: Number(value.maxDelay.toFixed(1)), + owner: + value.owners.size === 0 + ? "待分配" + : value.owners.size === 1 + ? Array.from(value.owners)[0] + : `${value.owners.size} 位负责人`, + view: value.view, + })) + .sort((a, b) => b.count - a.count || b.maxDelay - a.maxDelay) + .slice(0, 6); +} + +function buildLiveCurrentNodeCoverage(items: OaBoardProjectListItemDTO[]) { + const activeTaskKeys = new Set(); + const assessedTaskKeys = new Set(); + items.forEach((item) => { + item.nodeAssessments + .filter((assessment) => assessment.status === "RUNNING") + .forEach((assessment) => { + const taskKey = getItemKey(item); + activeTaskKeys.add(taskKey); + if ( + assessment.targetHours !== null && + Number.isFinite(assessment.targetHours) && + assessment.targetHours > 0 + ) { + assessedTaskKeys.add(taskKey); + } + }); + }); + return { total: activeTaskKeys.size, assessed: assessedTaskKeys.size }; +} + +function buildLiveCurrentNodeGaps( + items: OaBoardProjectListItemDTO[], +): CurrentNodeGapDatum[] { + const groups = new Map< + string, + { + taskKeys: Set; + owners: Set; + view: Exclude; + } + >(); + items.forEach((item) => { + item.nodeAssessments + .filter( + (assessment) => + assessment.status === "RUNNING" && + (assessment.targetHours === null || assessment.targetHours <= 0), + ) + .forEach((assessment) => { + const label = + assessment.approvalStageName || assessment.nodeName || "未标注节点"; + const current = groups.get(label) ?? { + taskKeys: new Set(), + owners: new Set(), + view: getNodeAnalysisView(item, assessment), + }; + current.taskKeys.add(getItemKey(item)); + if (assessment.ownerLabel) current.owners.add(assessment.ownerLabel); + groups.set(label, current); + }); + }); + return Array.from(groups.entries()) + .map(([label, value]) => ({ + id: `gap:${value.view}:${label}`, + label, + count: value.taskKeys.size, + owner: + value.owners.size === 0 + ? "待分配" + : value.owners.size === 1 + ? Array.from(value.owners)[0] + : `${value.owners.size} 位负责人`, + view: value.view, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 6); +} + +function buildLivePlanData(items: OaBoardProjectListItemDTO[]) { + const groups = new Map(); + dedupeLiveItems(items).forEach((item) => { + const label = item.formSummary.developmentPlan || "未填写方案"; + const group = groups.get(label) ?? { total: 0, completed: 0 }; + group.total += 1; + if (item.overallStatus === "COMPLETED") group.completed += 1; + groups.set(label, group); + }); + const colors = ["#245f5a", "#477a8c", "#b66b35", "#8d5960"]; + return Array.from(groups.entries()) + .map(([label, group], index) => ({ + label, + count: group.total, + rate: group.total ? Math.round((group.completed / group.total) * 100) : 0, + color: colors[index % colors.length], + })) + .sort((a, b) => b.count - a.count) + .slice(0, 4); +} + +function buildLiveAnalysisData( + view: Exclude, + items: OaBoardProjectListItemDTO[], + rows: SampleRow[], +): AnalysisDatum { + const base = ANALYSIS_DATA[view]; + const pattern = VIEW_STAGE_PATTERNS[view]; + const keys = VIEW_TIMELINE_KEYS[view]; + const uniqueItems = dedupeLiveItems(items); + const domainRows = rows.filter((row) => pattern.test(row.stage)); + const relevantMetrics = uniqueItems.flatMap((item) => + item.timelineMetrics + .filter((metric) => keys.includes(metric.key)) + .map((metric) => ({ item, metric })), + ); + const actualMetrics = relevantMetrics.filter( + ({ metric }) => + metric.actualDays !== null && Number.isFinite(metric.actualDays), + ); + const assessableMetrics = actualMetrics.filter( + ({ metric }) => + metric.targetDays !== null && Number.isFinite(metric.targetDays), + ); + const riskKeys = new Set( + assessableMetrics + .filter( + ({ metric }) => + (metric.actualDays ?? 0) > + (metric.targetDays ?? Number.POSITIVE_INFINITY), + ) + .map(({ item }) => getItemKey(item)), + ); + const statuses = Array.from( + domainRows.reduce((groups, row) => { + const matchingStages = row.stage + .split(" / ") + .filter((stage) => pattern.test(stage)); + const label = matchingStages.join(" / ") || row.stage; + groups.set(label, (groups.get(label) ?? 0) + 1); + return groups; + }, new Map()), + ) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4) + .map(([label, count], index) => ({ + label, + count, + color: ["#b34e46", "#d49344", "#477a8c", "#3d9279"][index], + })); + const blockers = Array.from( + assessableMetrics + .filter( + ({ metric }) => (metric.actualDays ?? 0) > (metric.targetDays ?? 0), + ) + .reduce((groups, { item, metric }) => { + const delay = (metric.actualDays ?? 0) - (metric.targetDays ?? 0); + const current = groups.get(metric.label) ?? { + count: 0, + maxDelay: 0, + taskKeys: new Set(), + }; + current.count += 1; + current.maxDelay = Math.max(current.maxDelay, delay); + current.taskKeys.add(getItemKey(item)); + groups.set(metric.label, current); + return groups; + }, new Map }>()), + ) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 3) + .map(([label, value]) => ({ + label: `${label}超时`, + count: value.taskKeys.size, + meta: `最长超 ${value.maxDelay.toFixed(1)} 天`, + taskKeys: Array.from(value.taskKeys), + })); + return { + ...base, + active: domainRows.length, + avgDays: average(actualMetrics.map(({ metric }) => metric.actualDays ?? 0)), + targetDays: average( + relevantMetrics + .map(({ metric }) => metric.targetDays) + .filter( + (value): value is number => value !== null && Number.isFinite(value), + ), + ), + onTime: assessableMetrics.length + ? Math.round( + (assessableMetrics.filter( + ({ metric }) => + (metric.actualDays ?? 0) <= (metric.targetDays ?? 0), + ).length / + assessableMetrics.length) * + 100, + ) + : 0, + risk: riskKeys.size, + riskTaskKeys: Array.from(riskKeys), + statuses: + statuses.length > 0 + ? statuses + : [{ label: "当前无在途任务", count: 0, color: "#9aa5a8" }], + blockers: + blockers.length > 0 + ? blockers + : [{ label: "当前无超时节点", count: 0, meta: "按阶段目标时效计算" }], + }; +} + +function formatDateInput(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function getPeriodRange(period: Period, anchorDate: string) { + const anchor = new Date(`${anchorDate}T00:00:00`); + const start = new Date(anchor); + const end = new Date(anchor); + if (period === "week") { + const mondayOffset = (anchor.getDay() + 6) % 7; + start.setDate(anchor.getDate() - mondayOffset); + end.setDate(start.getDate() + 6); + } else if (period === "month") { + start.setDate(1); + end.setMonth(anchor.getMonth() + 1, 0); + } else { + start.setMonth(0, 1); + end.setMonth(11, 31); + } + return { start, end }; +} + +function MetricCard({ + label, + value, + suffix, + meta, + trend, + icon, + tone, + onClick, +}: { + label: string; + value: string | number; + suffix?: string; + meta: string; + trend: string; + icon: React.ReactNode; + tone: "teal" | "amber" | "green" | "blue"; + onClick?: () => void; +}) { + const toneClass = { + teal: "text-[#245f5a] bg-[#edf7f5]", + amber: "text-[#a75b29] bg-[#fff5e8]", + green: "text-[#28705a] bg-[#edf8f1]", + blue: "text-[#3e6881] bg-[#edf4f8]", + }[tone]; + const content = ( + <> +
+ {label} + + {icon} + +
+
+
+ + {value} + + {suffix ? ( + + {suffix} + + ) : null} +

{meta}

+
+ + {trend} + +
+ + ); + const className = `group flex min-h-[132px] flex-col justify-between rounded-lg border border-black/[0.08] bg-white p-4 text-left ${onClick ? "transition hover:-translate-y-0.5 hover:shadow-[0_10px_26px_rgba(30,40,46,0.08)]" : ""}`; + return onClick ? ( + + ) : ( +
{content}
+ ); +} + +function EfficiencyCard({ + label, + value, + suffix, + benchmark, + note, + tone, + onClick, +}: { + label: string; + value: string | number; + suffix: string; + benchmark: string; + note: string; + tone: "neutral" | "good" | "warning" | "danger"; + onClick?: () => void; +}) { + const toneClass = { + neutral: "border-l-[#477a8c] text-[#334044]", + good: "border-l-[#3d9279] text-[#28705a]", + warning: "border-l-[#d49344] text-[#a75b29]", + danger: "border-l-[#b34e46] text-[#b34e46]", + }[tone]; + + const content = ( + <> +
+ {label} + {onClick ? ( + + ) : null} +
+
+
+ + {value} + + + {suffix} + +

{benchmark}

+
+ + {note} + +
+ + ); + const className = `group flex min-h-[104px] flex-col justify-between rounded-lg border border-l-[3px] border-black/[0.08] bg-white px-4 py-3 text-left ${onClick ? "transition hover:bg-[#fbfcfc] hover:shadow-[0_8px_20px_rgba(30,40,46,0.06)]" : ""} ${toneClass}`; + return onClick ? ( + + ) : ( +
{content}
+ ); +} + +function SectionHeader({ + eyebrow, + title, + detail, + icon, +}: { + eyebrow?: string; + title: string; + detail?: React.ReactNode; + icon?: React.ReactNode; +}) { + return ( +
+
+
+ {icon} +

{title}

+
+ {eyebrow ? ( +

{eyebrow}

+ ) : null} +
+ {detail ? ( + {detail} + ) : null} +
+ ); +} + +function DetailDrawer({ + row, + onClose, +}: { + row: SampleRow; + onClose: () => void; +}) { + return ( +
+ +
+ ); +} + +function UnifiedWideTable({ + contextLabel, + rows, + onClose, + onSelectRow, +}: { + contextLabel: string; + rows: SampleRow[]; + onClose: () => void; + onSelectRow: (row: SampleRow) => void; +}) { + return ( +
+ } + detail={ + + } + /> +
+ + + + {[ + "开款编号 / 新品", + "当前阶段", + "阶段状态", + "类目", + "合作供应商(待接入)", + "采购负责人", + "立项日期", + "当前节点开始", + "流程完成日期", + "总耗时", + "风险", + ].map((header) => ( + + ))} + + + + {rows.length === 0 ? ( + + + + ) : null} + {rows.map((row) => ( + onSelectRow(row)} + className="cursor-pointer border-t border-black/[0.05] hover:bg-[#f5faf9]" + > + + + + + + + + + + + + + ))} + +
+ {header} +
+ 当前条件下暂无新品明细 +
+

+ {row.openingCode || getRowTaskKey(row)} +

+

+ {row.name} · {row.spu} +

+
+ {row.stage} + + {row.stageStatus} + {row.structure}{row.supplier}{row.buyer} + {row.scheduleDate} + {row.startDate}{row.finishDate} + {row.days} 天 + + {row.risk === "high" + ? "高风险" + : row.risk === "medium" + ? "关注" + : "正常"} +
+
+
+ ); +} + +function AnalysisPage({ + view, + domain, + rows: sourceRows, + isFormal, + onBack, + onSelectRow, +}: { + view: Exclude; + domain: AnalysisDatum; + rows: SampleRow[]; + isFormal: boolean; + onBack: () => void; + onSelectRow: (row: SampleRow) => void; +}) { + const [dimension, setDimension] = useState(0); + const [drilldown, setDrilldown] = useState(null); + const domainRows = useMemo( + () => sourceRows.filter((row) => VIEW_STAGE_PATTERNS[view].test(row.stage)), + [sourceRows, view], + ); + const dimensionItems = useMemo(() => { + const key = ANALYSIS_DIMENSION_KEYS[dimension]; + const groups = new Map(); + domainRows.forEach((row) => { + const label = row[key] || "字段待补充"; + const group = groups.get(label) ?? { count: 0, risk: 0 }; + group.count += 1; + if (row.risk === "high") group.risk += 1; + groups.set(label, group); + }); + return Array.from(groups.entries()) + .map(([label, group]) => ({ + label, + count: group.count, + note: group.risk ? `${group.risk} 个高风险` : "暂无高风险", + })) + .sort((a, b) => b.count - a.count) + .slice(0, 6); + }, [dimension, domainRows]); + const maxStatus = Math.max( + ...domain.statuses.map((status) => status.count), + 1, + ); + const maxDimension = Math.max(...dimensionItems.map((item) => item.count), 1); + const rowsForTaskKeys = (taskKeys?: string[]) => + taskKeys + ? sourceRows.filter((row) => taskKeys.includes(getRowTaskKey(row))) + : isFormal + ? [] + : domainRows.filter((row) => row.risk === "high"); + + return ( +
+
+
+
+ + 专业分析页 +
+

+ {domain.label}分析 +

+

{domain.subtitle}

+ {isFormal ? ( +

+ 正式数据 · 按当前节点与阶段目标时效聚合 +

+ ) : null} +
+ +
+
+ + setDrilldown({ label: "当前阶段任务", rows: domainRows }) + } + /> + domain.targetDays + ? `超 ${(domain.avgDays - domain.targetDays).toFixed(1)} 天` + : "在目标内" + } + tone={domain.avgDays > domain.targetDays ? "warning" : "good"} + onClick={() => + setDrilldown({ label: "阶段时效任务", rows: domainRows }) + } + /> + + setDrilldown({ + label: "未达目标时效", + rows: rowsForTaskKeys(domain.riskTaskKeys), + }) + } + /> + + setDrilldown({ + label: "阶段超期任务", + rows: rowsForTaskKeys(domain.riskTaskKeys), + }) + } + /> +
+
+
+ } + /> +
+ {domain.statuses.map((status) => ( + + ))} +
+
+
+ } + /> +
+ {domain.blockers.map((blocker) => ( + + ))} +
+
+
+
+ } + /> +
+
+ {["类目", "合作供应商", "采购负责人"].map((label, index) => ( + + ))} +
+
+ {dimensionItems.map((item) => ( + + ))} +
+
+
+ {drilldown ? ( + setDrilldown(null)} + onSelectRow={onSelectRow} + /> + ) : null} +
+ ); +} + +export default function NewProductManagementDashboard({ + dashboard, + loadErrorMessage, +}: { + dashboard?: OaBoardDashboardDTO | null; + loadErrorMessage?: string | null; +}) { + const [period, setPeriod] = useState("month"); + const [activeView, setActiveView] = useState("overview"); + const [anchorDate, setAnchorDate] = useState(() => { + const latestDate = groupItemsByTask(dashboard?.items ?? []) + .map(getTaskCreatedDate) + .filter(Boolean) + .sort() + .at(-1); + return latestDate || "2026-08-16"; + }); + const [selectedRow, setSelectedRow] = useState(null); + const periodRange = useMemo( + () => getPeriodRange(period, anchorDate), + [anchorDate, period], + ); + const liveItems = useMemo(() => { + if (!dashboard) return null; + const start = periodRange.start.getTime(); + const end = periodRange.end.getTime(); + return groupItemsByTask(dashboard.items) + .filter((items) => { + const createdDate = getTaskCreatedDate(items); + const timestamp = createdDate + ? new Date(`${createdDate}T00:00:00`).getTime() + : Number.NaN; + return ( + Number.isFinite(timestamp) && timestamp >= start && timestamp <= end + ); + }) + .flat(); + }, [dashboard, periodRange]); + const data = + dashboard && liveItems + ? buildLiveSummary({ ...dashboard, items: liveItems }) + : PERIOD_DATA[period]; + const timingComplianceRate = data.assessed + ? Math.round(((data.assessed - data.timeout) / data.assessed) * 100) + : 0; + const liveTaskItems = useMemo( + () => (liveItems ? dedupeLiveItems(liveItems) : null), + [liveItems], + ); + const rows = useMemo( + () => (dashboard && liveItems ? buildLiveRows(liveItems) : SAMPLE_ROWS), + [dashboard, liveItems], + ); + const stageData: StageDatum[] = useMemo( + () => (dashboard && liveItems ? buildLiveStageData(liveItems) : STAGE_DATA), + [dashboard, liveItems], + ); + const currentBottlenecks = useMemo( + () => + dashboard && liveItems + ? buildLiveCurrentBottlenecks(liveItems) + : CURRENT_BOTTLENECK_DATA, + [dashboard, liveItems], + ); + const currentNodeCoverage = useMemo( + () => + dashboard && liveItems + ? buildLiveCurrentNodeCoverage(liveItems) + : { total: 12, assessed: 12 }, + [dashboard, liveItems], + ); + const currentNodeGaps = useMemo( + () => (dashboard && liveItems ? buildLiveCurrentNodeGaps(liveItems) : []), + [dashboard, liveItems], + ); + const planData = useMemo( + () => (dashboard && liveItems ? buildLivePlanData(liveItems) : PLAN_DATA), + [dashboard, liveItems], + ); + const ownerLoad = useMemo(() => { + if (!dashboard || !liveTaskItems) + return [ + ["陈思远", 12], + ["周琳", 9], + ["李浩", 7], + ["王璐", 5], + ] as Array<[string, number]>; + const groups = new Map(); + liveTaskItems + .filter((item) => item.overallStatus !== "COMPLETED") + .forEach((item) => { + const owner = item.formSummary.currentOwnerDisplay || "待分配"; + groups.set(owner, (groups.get(owner) ?? 0) + 1); + }); + return Array.from(groups.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4); + }, [dashboard, liveTaskItems]); + const sampleRows = rows.filter((row) => + VIEW_STAGE_PATTERNS.sample.test(row.stage), + ); + const sampleProgress = { + pending: sampleRows.filter((row) => row.startDate === "-").length, + running: sampleRows.filter( + (row) => row.startDate !== "-" && row.finishDate === "-", + ).length, + completed: sampleRows.filter((row) => row.finishDate !== "-").length, + timeout: sampleRows.filter((row) => row.risk === "high").length, + }; + const activeAnalysis = + activeView === "overview" + ? null + : dashboard && liveItems + ? buildLiveAnalysisData(activeView, liveItems, rows) + : ANALYSIS_DATA[activeView]; + const periodLabel = + period === "week" + ? `${formatDateInput(periodRange.start)} - ${formatDateInput(periodRange.end)}` + : period === "month" + ? `${periodRange.start.getFullYear()} 年 ${periodRange.start.getMonth() + 1} 月` + : `${periodRange.start.getFullYear()} 年度`; + + return ( +
+
+
+
+
+ + 供应链中心 / 新品管理 +
+

+ 新品经营总览 +

+
+
+
+ {(["week", "month", "year"] as Period[]).map((item) => ( + + ))} +
+ + +
+
+ {loadErrorMessage ? ( +
+ {loadErrorMessage} +
+ ) : null} +
+ + 统计区间: + + {periodLabel} + {" "} + · 按立项日期筛选 · 当前状态 · 开款编号口径 + + + + {dashboard + ? `正式系统数据 · ${data.total} 个开款任务 / ${liveItems?.length ?? 0} 条流程` + : "本地演示数据"} + +
+ + {activeView === "overview" ? ( + <> +
+ } + tone="teal" + /> + } + tone="amber" + /> + } + tone="green" + /> + } + tone="blue" + /> +
+
+
+
+

+ + 总体时效 +

+

+ 先看整款总周期,再通过下方定位阶段节点 +

+
+
+

按整款总目标统计

+

+ 可评估 {data.assessed} / {data.total} 个任务 +

+
+
+
+ data.targetDays + ? `超 ${(data.totalDays - data.targetDays).toFixed(1)} 天` + : "目标内" + } + tone={data.totalDays > data.targetDays ? "warning" : "good"} + /> + = 85 + ? "达到 ≥85% 目标" + : "未达 ≥85% 目标" + } + tone={ + data.assessed > 0 && timingComplianceRate >= 85 + ? "good" + : "warning" + } + /> + + +
+
+
+
+
+

+ + 阶段时效 +

+

+ 按节点目标识别超期环节,再进入专项分析 +

+
+ + 按开款编号去重 + +
+
+
+ } + detail="点击进入专项分析" + /> +
+ {stageData.map((stage) => ( + + ))} +
+
+
+ } + detail={`${currentBottlenecks.length} 类超期 · ${currentNodeGaps.length} 类待配置`} + /> +
+ {currentBottlenecks.map((bottleneck) => ( + + ))} + {currentNodeGaps + .slice(0, Math.max(0, 6 - currentBottlenecks.length)) + .map((gap) => ( + + ))} + {currentBottlenecks.length === 0 && + currentNodeGaps.length === 0 ? ( +
+ 当前没有节点预警 +
+ ) : null} +
+
+
+
+
+
+ + } + /> +
+ {planData.map((plan) => ( +
+
+ + {plan.label} + + + {plan.count} 个 · {plan.rate}% + +
+
+
+
+
+ ))} +
+
+
+ } + detail={ + + } + /> +
+
+

待处理

+

+ {sampleProgress.pending} +

+
+
+

超时

+

+ {sampleProgress.timeout} +

+
+
+

进行中

+

+ {sampleProgress.running} +

+
+
+

已完成

+

+ {sampleProgress.completed} +

+
+
+
+
+ } + /> +
+ {ownerLoad.map(([name, count]) => ( +
+
+ + {name} + + {count} 个 +
+
+
entry[1]), 1)) * 100}%`, + }} + /> +
+
+ ))} +
+
+
+ + ) : ( + setActiveView("overview")} + onSelectRow={setSelectedRow} + /> + )} +
+ {selectedRow ? ( + setSelectedRow(null)} /> + ) : null} +
+ ); +} diff --git a/furniture-3d-system/src/app/intake/new-product-management/page.tsx b/furniture-3d-system/src/app/intake/new-product-management/page.tsx new file mode 100644 index 00000000..e431bb65 --- /dev/null +++ b/furniture-3d-system/src/app/intake/new-product-management/page.tsx @@ -0,0 +1,39 @@ +import { requireSession } from "@/lib/server-session"; +import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from "@/server/services/oa-board.constants"; +import { + createEmptyOaBoardDashboard, + oaBoardService, +} from "@/server/services/oa-board.service"; +import NewProductManagementDashboard from "./NewProductManagementDashboard"; + +export const dynamic = "force-dynamic"; + +export default async function NewProductManagementPage() { + await requireSession("/login"); + + const result = await oaBoardService + .getDashboard({ + overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES], + }) + .then((dashboard) => ({ + dashboard, + loadErrorMessage: null as string | null, + })) + .catch((error) => { + console.error("Failed to load new product management dashboard", error); + return { + dashboard: createEmptyOaBoardDashboard(), + loadErrorMessage: + error instanceof Error + ? `新品管理数据读取失败:${error.message}` + : "新品管理数据读取失败,请稍后重试。", + }; + }); + + return ( + + ); +} From 330cf30ac999e29623135e59a5ddf41489a7e195 Mon Sep 17 00:00:00 2001 From: jingchuan Date: Mon, 17 Aug 2026 13:44:30 +0800 Subject: [PATCH 4/7] feat: expose new product management dashboard --- furniture-3d-system/src/components/layout/AppShell.tsx | 6 ++++-- furniture-3d-system/src/components/layout/AppTopNav.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx index fabfee6d..78b03022 100644 --- a/furniture-3d-system/src/components/layout/AppShell.tsx +++ b/furniture-3d-system/src/components/layout/AppShell.tsx @@ -96,6 +96,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe const isBomCostingRoute = pathname === '/bom-costing' || pathname.startsWith('/bom-costing/') const isOaBoardRoute = pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/') + const isNewProductManagementRoute = pathname === '/intake/new-product-management' || pathname.startsWith('/intake/new-product-management/') const isSampleProgressRoute = pathname === '/intake/sample-progress' || pathname.startsWith('/intake/sample-progress/') const isLocalOaBoardPreview = localOaBoardPreview && isOaBoardRoute const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/') @@ -132,6 +133,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe const shouldLockMainViewport = isWorkbenchShellRoute && !isOaBoardRoute && + !isNewProductManagementRoute && !isSampleProgressRoute && !isMobileProductDetailLinkView @@ -290,10 +292,10 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
Date: Mon, 17 Aug 2026 14:55:22 +0800 Subject: [PATCH 5/7] feat(installation-demo): refine printable drawing workflow --- .../drawing-text-annotations/route.ts | 50 +++ .../InstallationDemoShareDrawingPage.tsx | 3 + ...stallationDemoShareDrawingDocument.test.ts | 1 + .../InstallationDemoWorkbenchClient.tsx | 15 + .../InstallationDrawingActionsPage.tsx | 43 ++- ...allationDrawingAutoCaptureControl.test.tsx | 56 ++++ .../InstallationDrawingAutoCaptureControl.tsx | 180 +++++++---- .../components/InstallationDrawingSheet.tsx | 67 ++++ .../InstallationDrawingSheetScroll.test.tsx | 2 + ...nstallationDrawingTextAnnotationDialog.tsx | 199 ++++++++++++ ...nstallationDrawingTextAnnotations.test.tsx | 76 +++++ .../InstallationDrawingTextAnnotations.tsx | 133 ++++++++ .../useInstallationDrawingAutoCapture.ts | 23 +- .../hooks/useInstallationDrawingCapture.ts | 19 +- .../useInstallationDrawingTextAnnotations.ts | 117 +++++++ ...tallationDrawingActionCaptureFrame.test.ts | 82 +++++ .../installationDrawingActionCaptureFrame.ts | 42 ++- ...installationDrawingAutoCapturePlan.test.ts | 35 ++- .../lib/installationDrawingAutoCapturePlan.ts | 17 +- .../lib/installationDrawingTextAnnotations.ts | 74 +++++ furniture-3d-system/src/lib/3d/engine.ts | 35 +++ .../lib/3d/technical-line-annotations.test.ts | 293 +++++++++++++++++ .../src/lib/3d/technical-line-annotations.ts | 297 ++++++++++++++++++ .../src/lib/3d/technical-line-capture.test.ts | 58 ++++ .../src/lib/3d/technical-line-capture.ts | 88 ++++++ .../lib/3d/technical-line-feature-loops.ts | 102 ++++++ .../src/lib/installation-demo-share-types.ts | 2 + ...mo-drawing-text-annotation.service.test.ts | 87 +++++ ...on-demo-drawing-text-annotation.service.ts | 143 +++++++++ .../installation-demo-share.service.test.ts | 16 + .../installation-demo-share.service.ts | 5 + 31 files changed, 2289 insertions(+), 71 deletions(-) create mode 100644 furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTextAnnotations.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTextAnnotations.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-annotations.test.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-annotations.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.test.ts create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.ts diff --git a/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts b/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts new file mode 100644 index 00000000..dad767f4 --- /dev/null +++ b/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from "next/server"; +import { AppError } from "@/server/core/errors"; +import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response"; +import { installationDemoDrawingTextAnnotationService } from "@/server/services/installation-demo/installation-demo-drawing-text-annotation.service"; + +function respondWithAppError(error: unknown, requestId: string) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, requestId); + } + throw error; +} + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const snapshot = await installationDemoDrawingTextAnnotationService.get( + ctx.user, + request.nextUrl.searchParams.get("drawingId"), + ); + return jsonSuccess({ snapshot }, 200, ctx.requestId); + } catch (error) { + return respondWithAppError(error, ctx.requestId); + } + }, + { requireAuth: true }, + ); +} + +export async function PATCH(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const snapshot = + await installationDemoDrawingTextAnnotationService.updateTarget( + ctx.user, + await request.json(), + ); + return jsonSuccess({ snapshot }, 200, ctx.requestId); + } catch (error) { + return respondWithAppError(error, ctx.requestId); + } + }, + { requireAuth: true }, + ); +} diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx index efab831c..7366f261 100644 --- a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx @@ -65,6 +65,9 @@ function InstallationDemoShareDrawingPanel({ imageMode={imageMode} snapshotUrls={snapshot.numberingSnapshotUrls} snapshotLineArtUrls={snapshotLineArtUrls} + textAnnotationsByTargetKey={ + snapshot.drawingDocument?.textAnnotationsByTargetKey ?? {} + } allowManualCapture={false} readOnly /> diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts index b6c7fda1..779e36ef 100644 --- a/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts @@ -41,6 +41,7 @@ function createSnapshot(): InstallationDemoShareSnapshot { actions: [], inventoryTransforms: {}, }, + textAnnotationsByTargetKey: {}, snapshotLineArtUrls: {}, toolSnapshotLineArtUrls: {}, }, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx index f188ff3b..6e37fa52 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx @@ -107,6 +107,7 @@ import { InstallationDrawingToolLineArtGenerator } from "./components/Installati import { useInstallationDrawingCapture } from "./hooks/useInstallationDrawingCapture"; import { useInstallationDrawingAutoCapture } from "./hooks/useInstallationDrawingAutoCapture"; import { useInstallationDrawingConfirmation } from "./hooks/useInstallationDrawingConfirmation"; +import { useInstallationDrawingTextAnnotations } from "./hooks/useInstallationDrawingTextAnnotations"; import { buildInstallationDrawingCaptureFingerprint } from "./lib/installationDrawingCaptureFingerprint"; import { isInstallationDrawingActionCaptureTarget, @@ -401,6 +402,10 @@ export default function InstallationDemoWorkbenchClient({ setMode, setActiveStepId: stepEditor.setActiveStepId, }); + const drawingTextAnnotations = useInstallationDrawingTextAnnotations( + selectedDrawingId, + catalog.access.canEdit, + ); const drawingOverviewCaptureActive = isInstallationDrawingOverviewCaptureTarget(drawingCapture.captureTarget); const drawingActionCaptureTarget = isInstallationDrawingActionCaptureTarget( @@ -1732,6 +1737,8 @@ export default function InstallationDemoWorkbenchClient({ templateId: "set-1", imageMode: drawingImageMode ?? "line-art", captures: drawingCapture.captures, + textAnnotationsByTargetKey: + drawingTextAnnotations.annotationsByTargetKey, snapshotLineArtUrls: shareNumberingSnapshotBatch.snapshotLineArtUrls ?? {}, toolSnapshotLineArtUrls: @@ -1763,6 +1770,7 @@ export default function InstallationDemoWorkbenchClient({ numberingModelParts.length, numberingPlaying, drawingCapture.captures, + drawingTextAnnotations.annotationsByTargetKey, drawingTools.selectedTools, numberingVisibleModelParts, playing, @@ -2614,6 +2622,10 @@ export default function InstallationDemoWorkbenchClient({ numberingLineArtGenerating || Boolean(missingDrawingToolLineArt) } captures={drawingCapture.captures} + textAnnotationsByTargetKey={ + drawingTextAnnotations.annotationsByTargetKey + } + textAnnotationMessage={drawingTextAnnotations.message} initialScrollTop={ drawingSheetScrollPosition.drawingId === selectedDrawingId ? drawingSheetScrollPosition.scrollTop @@ -2638,6 +2650,9 @@ export default function InstallationDemoWorkbenchClient({ numberingMarkerCountOverrides, ); }} + onUpdateTextAnnotations={ + drawingTextAnnotations.saveTargetAnnotations + } autoCapture={{ phase: drawingAutoCapture.phase, error: drawingAutoCapture.error, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx index 9db6a7cb..36b032b8 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx @@ -1,14 +1,22 @@ -import { ListChecks, Maximize2 } from 'lucide-react' +import { ListChecks, Maximize2, Type } from 'lucide-react' import type { InstallationDrawingActionCapture, InstallationDrawingCaptureTarget, InstallationDrawingImageMode, InstallationDrawingNumberMarker, } from '../lib/installationDrawingCaptureTypes' -import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes' +import { + getInstallationDrawingCaptureTargetKey, + isInstallationDrawingImageCaptured, +} from '../lib/installationDrawingCaptureTypes' import type { InstallationDrawingActionGroup } from '../lib/installationDrawingLayout' +import type { + InstallationDrawingTextAnnotation, + InstallationDrawingTextAnnotationsByTarget, +} from '../lib/installationDrawingTextAnnotations' import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame' import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers' +import { InstallationDrawingTextAnnotations } from './InstallationDrawingTextAnnotations' interface Props { groups: InstallationDrawingActionGroup[] @@ -23,6 +31,12 @@ interface Props { target: InstallationDrawingCaptureTarget, markers: InstallationDrawingNumberMarker[], ) => void + textAnnotationsByTargetKey?: InstallationDrawingTextAnnotationsByTarget + onManageTextAnnotations?: (target: InstallationDrawingCaptureTarget) => void + onCommitTextAnnotations?: ( + target: InstallationDrawingCaptureTarget, + annotations: InstallationDrawingTextAnnotation[], + ) => void allowManualCapture: boolean readOnly?: boolean } @@ -77,6 +91,9 @@ export function InstallationDrawingActionsPage({ onAdjustImage, onManageNumberingMarkers, onCommitNumberingMarkers, + textAnnotationsByTargetKey = {}, + onManageTextAnnotations, + onCommitTextAnnotations, allowManualCapture, readOnly = false, }: Props) { @@ -100,6 +117,8 @@ export function InstallationDrawingActionsPage({ actionId: action.actionId, sequence: action.sequence, } + const targetKey = getInstallationDrawingCaptureTargetKey(target) + const textAnnotations = textAnnotationsByTargetKey[targetKey] ?? [] const captured = capture ? isInstallationDrawingImageCaptured(capture.image) : false return (
) : null} + {captured && textAnnotations.length > 0 ? ( + onCommitTextAnnotations(target, annotations) + : undefined} + /> + ) : null} {capture && capture.tools.length > 0 ? ( @@ -155,6 +183,17 @@ export function InstallationDrawingActionsPage({ ) : null} + {capture && captured ? ( + + ) : null} {capture && captured ? ( - ) : ( - - )} - {hasDraftBatch && !running ? ( - - 已完成 {completedCount}/{totalCount} - + <> +
+ {settled ? ( + + ) : running ? ( + + ) : ( + + )} + {hasDraftBatch && !running ? ( + + 已完成 {completedCount}/{totalCount} + + ) : null} + {configurationChanged ? ( + + 配置已变更 + + ) : null} + {error && !running ? ( + + {error} + + ) : null} +
+ + {restartDialogOpen ? ( +
+
+
+
+ +

+ 重新批量带入全部画面? +

+
+ +
+
+

+ 将重新抓取 {totalCount} 张原图和打印线稿。当前版本会保留到新批次全部完成,已有画面位置和编号设置继续沿用。 +

+
+ + +
+
+
+
) : null} - {configurationChanged ? ( - - 配置已变更 - - ) : null} - {error && !running ? ( - - {error} - - ) : null} - + ); } diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx index 527d0237..66e7ae03 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx @@ -12,6 +12,7 @@ import type { } from "../types"; import type { InstallationDrawingCaptures, + InstallationDrawingActionCaptureTarget, InstallationDrawingCaptureTarget, InstallationDrawingCapturedImage, InstallationDrawingImageMode, @@ -20,11 +21,16 @@ import type { } from "../lib/installationDrawingCaptureTypes"; import { DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM, + getInstallationDrawingCaptureTargetKey, isInstallationDrawingImageAvailable, isInstallationDrawingImageCaptured, resolveInstallationDrawingConfirmedAtForMode, resolveInstallationDrawingImageMode, } from "../lib/installationDrawingCaptureTypes"; +import type { + InstallationDrawingTextAnnotation, + InstallationDrawingTextAnnotationsByTarget, +} from "../lib/installationDrawingTextAnnotations"; import { buildInstallationDrawingLayout } from "../lib/installationDrawingLayout"; import { useInstallationDrawingMarkerInventory } from "../hooks/useInstallationDrawingMarkerInventory"; import { @@ -39,6 +45,7 @@ import { InstallationDrawingInventoryPage } from "./InstallationDrawingInventory import { InstallationDrawingOverviewPage } from "./InstallationDrawingOverviewPage"; import { InstallationDrawingImageAdjustDialog } from "./InstallationDrawingImageAdjustDialog"; import { InstallationDrawingMarkerManagerDialog } from "./InstallationDrawingMarkerManagerDialog"; +import { InstallationDrawingTextAnnotationDialog } from "./InstallationDrawingTextAnnotationDialog"; import { buildInstallationDrawingInventoryItems, type InstallationDrawingOverviewTool, @@ -57,6 +64,8 @@ interface InstallationDrawingSheetProps { toolSnapshotLineArtUrls: Record; lineArtGenerating?: boolean; captures: InstallationDrawingCaptures; + textAnnotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget; + textAnnotationMessage?: string | null; initialScrollTop: number; onCaptureTarget: ( target: InstallationDrawingCaptureTarget, @@ -68,6 +77,10 @@ interface InstallationDrawingSheetProps { numberingMarkers: InstallationDrawingNumberMarker[], numberingMarkerCountOverrides: Record, ) => void; + onUpdateTextAnnotations: ( + targetKey: string, + annotations: InstallationDrawingTextAnnotation[], + ) => void; autoCapture: { phase: | "loading" @@ -162,9 +175,12 @@ export function InstallationDrawingSheet({ toolSnapshotLineArtUrls, lineArtGenerating = false, captures, + textAnnotationsByTargetKey, + textAnnotationMessage = null, initialScrollTop, onCaptureTarget, onUpdateImageLayout, + onUpdateTextAnnotations, autoCapture, confirmation, onClose, @@ -180,6 +196,8 @@ export function InstallationDrawingSheet({ ); const [adjustTarget, setAdjustTarget] = useState(null); + const [textAnnotationTarget, setTextAnnotationTarget] = + useState(null); const scrollContainerRef = useRef(null); const { markerManagerTarget, @@ -297,6 +315,23 @@ export function InstallationDrawingSheet({ image.numberingMarkerCountOverrides, ); }; + const commitTextAnnotations = ( + target: InstallationDrawingActionCaptureTarget, + annotations: InstallationDrawingTextAnnotation[], + ) => { + const image = resolveCapturedImage(reconciledCaptures, target); + if (!image) return; + onUpdateTextAnnotations( + getInstallationDrawingCaptureTargetKey(target), + annotations, + ); + onUpdateImageLayout( + target, + image.transform, + image.numberingMarkers, + image.numberingMarkerCountOverrides, + ); + }; const captureFromCurrentPosition = ( target: InstallationDrawingCaptureTarget, ) => { @@ -429,6 +464,14 @@ export function InstallationDrawingSheet({ {confirmation.message}

) : null} + {textAnnotationMessage ? ( +

+ {textAnnotationMessage} +

+ ) : null}
{ + if (target.kind === "action") setTextAnnotationTarget(target); + }} + onCommitTextAnnotations={(target, annotations) => { + if (target.kind === "action") { + commitTextAnnotations(target, annotations); + } + }} allowManualCapture={ !autoCapture.running && !autoCapture.hasDraftBatch } @@ -647,6 +699,21 @@ export function InstallationDrawingSheet({ onClose={() => setMarkerManagerTarget(null)} /> ) : null} + {textAnnotationTarget ? ( + { + commitTextAnnotations(textAnnotationTarget, annotations); + setTextAnnotationTarget(null); + }} + onClose={() => setTextAnnotationTarget(null)} + /> + ) : null}
, document.body, ); diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx index 1fbf77f8..0703228c 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx @@ -80,9 +80,11 @@ function buildProps(initialScrollTop: number, onCaptureTarget: jest.Mock) { actions: [], inventoryTransforms: {}, }, + textAnnotationsByTargetKey: {}, initialScrollTop, onCaptureTarget, onUpdateImageLayout: jest.fn(), + onUpdateTextAnnotations: jest.fn(), autoCapture: { phase: "idle" as const, error: null, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx new file mode 100644 index 00000000..002fe343 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState } from "react"; +import { Check, Minus, Plus, Trash2, Type, X } from "lucide-react"; +import { + MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET, + MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE, + MAX_INSTALLATION_DRAWING_TEXT_LENGTH, + MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE, + clampInstallationDrawingTextFontSize, + cloneInstallationDrawingTextAnnotations, + createInstallationDrawingTextAnnotation, + type InstallationDrawingTextAnnotation, +} from "../lib/installationDrawingTextAnnotations"; + +interface Props { + title: string; + annotations: InstallationDrawingTextAnnotation[]; + onSave: (annotations: InstallationDrawingTextAnnotation[]) => void; + onClose: () => void; +} + +export function InstallationDrawingTextAnnotationDialog({ + title, + annotations, + onSave, + onClose, +}: Props) { + const [items, setItems] = useState(() => + cloneInstallationDrawingTextAnnotations(annotations), + ); + + const updateItem = ( + id: string, + patch: Partial, + ) => { + setItems((current) => + current.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + }; + + return ( +
+
+
+
+

文字标注 · {title}

+

+ 保存后直接在动作画面中拖动文字到目标位置。 +

+
+ +
+ +
+
+ 共 {items.length} 个文本框 + +
+ + {items.length === 0 ? ( +
+ 点击“添加文本”创建第一个文字标注 +
+ ) : ( +
+ {items.map((item, index) => ( +
+ + {index + 1} + +