diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index 80170db8..648dde29 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -44,6 +44,11 @@ "materials:sync-standard": "tsx scripts/sync-standard-finishes.ts", "materials:sync-standard:apply": "tsx scripts/sync-standard-finishes.ts --apply", "sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts", + "sync:oa-board": "tsx scripts/sync-oa-board.ts", + "sync:oa-board:apply": "tsx scripts/sync-oa-board.ts --apply", + "import:oa-board-activity-mappings": "tsx scripts/import-oa-board-activity-mappings.ts", + "import:oa-board-node-rules": "tsx scripts/import-oa-board-node-rules.ts", + "report:oa-board-activities": "tsx scripts/report-oa-board-activities.ts", "sync:employee-directory": "tsx scripts/sync-employee-directory.ts", "sync:employee-directory:apply": "tsx scripts/sync-employee-directory.ts --apply", "db:verify-master-data:truth": "node scripts/verify-master-data-truth-sources.js", diff --git a/furniture-3d-system/scripts/sync-oa-board.ts b/furniture-3d-system/scripts/sync-oa-board.ts new file mode 100644 index 00000000..05145e65 --- /dev/null +++ b/furniture-3d-system/scripts/sync-oa-board.ts @@ -0,0 +1,104 @@ +import 'dotenv/config' + +type CliOptions = { + apply: boolean + limit?: number + help: boolean +} + +function readNumberArg(value: string | undefined, name: string) { + if (!value) return undefined + + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive number`) + } + + return Math.floor(parsed) +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + apply: false, + help: false, + } + + argv.forEach((arg) => { + if (arg === '--apply') { + options.apply = true + return + } + + if (arg === '--dry-run') { + options.apply = false + return + } + + if (arg === '--help' || arg === '-h') { + options.help = true + return + } + + if (arg.startsWith('--limit=')) { + options.limit = readNumberArg(arg.split('=')[1], '--limit') + return + } + + throw new Error(`Unknown argument: ${arg}`) + }) + + return options +} + +function printHelp() { + console.log(` +Usage: + npm run sync:oa-board -- [--dry-run] [--apply] [--limit=100] + +Default mode is --dry-run. +当前 OA 新品流程看板采用企划库直读模式,--apply 仅保留为兼容参数,不再写本地数据库。 + +Preferred source environment variable: + OA_BOARD_SOURCE_DATABASE_URL=mysql://user:password@host:port/database + +Alternative source environment variables: + OA_BOARD_SOURCE_DB_HOST # prefer 127.0.0.1 / private IP / internal service name + OA_BOARD_SOURCE_DB_PORT + OA_BOARD_SOURCE_DB_NAME + OA_BOARD_SOURCE_DB_USER + OA_BOARD_SOURCE_DB_PASSWORD + +Compatible fallback source environment variables: + PLANNING_DATABASE_URL + PLANNING_DB_HOST + PLANNING_DB_PORT + PLANNING_DB_NAME + PLANNING_DB_USER + PLANNING_DB_PASSWORD +`.trim()) +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (options.help) { + printHelp() + return + } + + if (options.apply) { + console.warn('当前为企划库直读模式,--apply 不会写本地数据库,将按 dry-run 执行。') + } + + const { syncOaBoard } = await import('../src/server/services/oa-board-sync.service') + const result = await syncOaBoard({ + apply: false, + limit: options.limit, + }) + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/app/api/intake/oa-board/route.ts b/furniture-3d-system/src/app/api/intake/oa-board/route.ts new file mode 100644 index 00000000..7de872d0 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/oa-board/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { createEmptyOaBoardDashboard, oaBoardService } from '@/server/services/oa-board.service' + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const searchParams = request.nextUrl.searchParams + const keyword = searchParams.get('keyword') ?? '' + const overallStatus = searchParams.get('overallStatus') ?? '' + const productType = searchParams.get('productType') ?? '' + const timeoutOnly = searchParams.get('timeoutOnly') === '1' + + try { + const dashboard = await oaBoardService.getDashboard({ + keyword: keyword || undefined, + overallStatus: overallStatus || undefined, + productType: productType || undefined, + timeoutOnly, + }) + + return jsonSuccess(dashboard, 200, ctx.requestId) + } catch (error) { + console.error('Failed to load OA board dashboard', error) + return jsonSuccess( + { + ...createEmptyOaBoardDashboard(), + notice: '当前看板表尚未完成迁移或同步,先返回空列表占位。', + }, + 200, + ctx.requestId, + ) + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx new file mode 100644 index 00000000..07e2cb73 --- /dev/null +++ b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx @@ -0,0 +1,387 @@ +'use client' + +import { useState } from 'react' +import { AlertTriangle, CheckCircle2, Clock3, Layers3, LoaderCircle } from 'lucide-react' +import type { + OaBoardDashboardDTO, + OaBoardNodeAssessmentDTO, + OaBoardProjectListItemDTO, +} from '@/server/services/oa-board.service' + +function StatCard({ + label, + value, + description, + tone = 'default', +}: { + label: string + value: string | number + description: string + tone?: 'default' | 'warning' | 'danger' | 'success' +}) { + const toneClass = + tone === 'danger' + ? 'border-rose-200 bg-rose-50 text-rose-700' + : tone === 'warning' + ? 'border-amber-200 bg-amber-50 text-amber-700' + : tone === 'success' + ? 'border-emerald-200 bg-emerald-50 text-emerald-700' + : 'border-apple-separator bg-apple-bg-primary text-apple-label-primary' + + return ( +
+
+ {label} + {value} +
+

{description}

+
+ ) +} + +function FlowChip({ + flow, +}: { + flow: OaBoardProjectListItemDTO['flowChips'][number] +}) { + const statusClass = + flow.status === 'RUNNING' + ? 'border-amber-200 bg-amber-50 text-amber-700' + : flow.status === 'COMPLETED' + ? 'border-emerald-200 bg-emerald-50 text-emerald-700' + : flow.status === 'TERMINATED' + ? 'border-rose-200 bg-rose-50 text-rose-700' + : 'border-apple-separator bg-apple-bg-secondary text-apple-label-secondary' + + return ( +
+
+ {flow.flowLabel} + {flow.usedWorkflowFallback ? ( + 兜底 + ) : null} +
+
+ + {flow.actualHours !== null ? `${flow.actualHours}h` : '-'} +
+
+ ) +} + +function ProjectRow({ + item, + index, + selected, + onSelect, +}: { + item: OaBoardProjectListItemDTO + index: number + selected: boolean + onSelect: (boardKey: string) => void +}) { + return ( + onSelect(item.boardKey)} + > + {index + 1} + + {item.formSummary.styleImageLabel || '-'} + + +
+ {item.formSummary.openingCode || item.openingCode || item.boardKey} +
+
{item.title}
+ + + {item.formSummary.totalFlowStatusLabel || '-'} + + + {item.formSummary.approvalStageDisplay || item.currentApprovalStageName || '-'} + + + {item.formSummary.currentOwnerDisplay || '-'} + + + {item.formSummary.currentNodeDisplay || '-'} + + + {item.formSummary.demandType || '-'} + + + {item.formSummary.developmentPlan || '-'} + + + {item.formSummary.productType || item.productType || '-'} + + + {item.formSummary.plannedSalesBrands || '-'} + + + {item.formSummary.productPlanner || '-'} + + + {item.formSummary.spuOwner || '-'} + + + {item.formSummary.purchaseOwner || '-'} + + +
目标:{item.overallTargetHours ?? '-'}h
+
实际:{item.overallActualHours ?? '-'}h
+
+ {item.overallTimeoutFlag ? `超时 ${item.overallTimeoutHours ?? '-'}h` : '未超时'} +
+ + +
+ {item.flowChips.map((flow) => ( + + ))} +
+ + + ) +} + +function NodeAssessmentRow({ + item, + index, +}: { + item: OaBoardNodeAssessmentDTO + index: number +}) { + return ( + + {index + 1} + {item.flowLabel} + {item.nodeName} + {item.approvalStageName || '-'} + {item.departmentLabel || '-'} + {item.ownerLabel || '-'} + {item.targetHours ?? '-'}h + {item.actualHours ?? '-'}h + + {item.isOvertime ? '超时' : '未超时'} + + {item.overtimeHours ?? '-'}h + {item.status} + {item.startedAtSource || '-'} + {item.finishedAtSource || '-'} + + ) +} + +function DetailField({ + label, + value, +}: { + label: string + value: string | null | undefined +}) { + return ( +
+
{label}
+
{value || '-'}
+
+ ) +} + +export default function OaBoardClient({ + dashboard, + loadErrorMessage, +}: { + dashboard: OaBoardDashboardDTO + loadErrorMessage?: string | null +}) { + const { summary, items } = dashboard + const [selectedBoardKey, setSelectedBoardKey] = useState(items[0]?.boardKey ?? '') + const selectedItem = items.find((item) => item.boardKey === selectedBoardKey) ?? items[0] ?? null + + return ( +
+
+ {loadErrorMessage ? ( +
+
+ +
+
当前页面已挂载,但企划库流程数据还没准备好。
+
{loadErrorMessage}
+
+
+
+ ) : null} + +
+
+
+

OA 新品流程看板

+

+ 直接读取企划库流程数据,集中查看新品开款、审批进度和节点考核时效。今天这版先完成列表和节点详情联看雏形。 +

+
+
+ + 当前页面直读企划库数据 +
+
+
+ +
+ + + + +
+ +
+
+
+

项目列表

+

+ 列表字段对齐附件三口径,选中项目后,下方会联动展示该项目的节点考核详情。 +

+
+
+ + {items.length} 个项目 +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item, index) => ( + + )) + )} + +
序号款式图片开款编号总流程状态流程审批阶段当前负责人当前审批节点需求类型开发方案产品类型计划售卖品牌商品企划SPU负责人采购负责人限价负责人SPU总时效流程片段
+
+ +

当前还没有拿到可展示的企划库流程数据。先检查企划库连接与节点/时效规则,再刷新页面。

+
+
+
+
+ +
+
+
+

节点考核详情

+

+ 选中一个项目后,按节点查看目标时效、实际用时和超时情况。今天先做成单页联看的监控雏形。 +

+
+ {selectedItem ? ( +
+ + {selectedItem.formSummary.openingCode || selectedItem.boardKey} +
+ ) : null} +
+ + {!selectedItem ? ( +
+ 暂无可查看的项目节点明细。 +
+ ) : ( + <> +
+ + + + + + +
+ +
+ + node.isOvertime).length} description="目标时效已被超过的节点数量。" tone="danger" /> + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + {selectedItem.nodeAssessments.length === 0 ? ( + + + + ) : ( + selectedItem.nodeAssessments.map((node, index) => ( + + )) + )} + +
序号流程节点名称审批阶段责任部门负责人目标时效实际用时是否超时超时小时状态开始时间完成时间
+ 当前项目还没有可展示的节点考核数据。 +
+
+ + )} +
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/intake/oa-board/page.tsx b/furniture-3d-system/src/app/intake/oa-board/page.tsx new file mode 100644 index 00000000..4b852f62 --- /dev/null +++ b/furniture-3d-system/src/app/intake/oa-board/page.tsx @@ -0,0 +1,37 @@ +import { requireSession } from '@/lib/server-session' +import { createEmptyOaBoardDashboard, oaBoardService } from '@/server/services/oa-board.service' +import OaBoardClient from './OaBoardClient' + +export const dynamic = 'force-dynamic' + +export default async function OaBoardPage({ + searchParams, +}: { + searchParams?: Promise> +}) { + await requireSession('/login') + const params = searchParams ? await searchParams : {} + + const keyword = typeof params.keyword === 'string' ? params.keyword : '' + const overallStatus = typeof params.overallStatus === 'string' ? params.overallStatus : '' + const productType = typeof params.productType === 'string' ? params.productType : '' + const timeoutOnly = params.timeoutOnly === '1' + let loadErrorMessage: string | null = null + + const dashboard = await oaBoardService + .getDashboard({ + keyword: keyword || undefined, + overallStatus: overallStatus || undefined, + productType: productType || undefined, + timeoutOnly, + }) + .catch((error) => { + loadErrorMessage = + error instanceof Error + ? `页面骨架已生成,但读取企划库流程数据失败:${error.message}` + : '页面骨架已生成,但读取企划库流程数据失败。' + return createEmptyOaBoardDashboard() + }) + + return +} diff --git a/furniture-3d-system/src/components/layout/AppTopNav.tsx b/furniture-3d-system/src/components/layout/AppTopNav.tsx index 4b217cca..270f8cc9 100644 --- a/furniture-3d-system/src/components/layout/AppTopNav.tsx +++ b/furniture-3d-system/src/components/layout/AppTopNav.tsx @@ -8,6 +8,7 @@ import { Bell, Boxes, ClipboardCheck, + ClipboardList, Home, Layers, Menu, @@ -31,6 +32,7 @@ const WORKBENCH_NAV_ITEMS = [ { href: '/assisted-assembly', icon: Layers, label: '辅助装配' }, { href: '/draft-groups', icon: PackageOpen, label: '草稿组' }, { href: '/intake', icon: ArrowDownToLine, label: '产品资料' }, + { href: '/intake/oa-board', icon: ClipboardList, label: 'OA 新品流程看板' }, { href: '/warehouse', icon: Warehouse, label: '仓库管理' }, { href: '/warehouse-quality', icon: ClipboardCheck, label: '仓库质检' }, ] 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 new file mode 100644 index 00000000..e1af1bdd --- /dev/null +++ b/furniture-3d-system/src/server/services/oa-board-sync.service.ts @@ -0,0 +1,1239 @@ +import * as mysql from 'mysql2/promise' +import { + type ConnectionOptions, + type RowDataPacket, +} from 'mysql2/promise' +import { Prisma } from '@prisma/client' +import type { OaBoardProjectGraphInput } from './oa-board.service' +import { + OA_BOARD_COMPLETED_STATUSES, + OA_BOARD_DEFAULT_ACTIVITY_MAPPINGS, + OA_BOARD_DEFAULT_STAGE_RULES, + OA_BOARD_FLOW_LABELS, + OA_BOARD_FLOW_PRIORITY, + OA_BOARD_JOIN_FIELD_NAMES, + OA_BOARD_PARALLEL_FLOW_TYPES, + OA_BOARD_PROCESS_CODES, + OA_BOARD_RUNNING_STATUSES, + OA_BOARD_WORKBOOK_FLOW_NAME_MAP, + getOaBoardProductTargetHours, + resolveOaBoardTimelineStage, + type OaBoardFlowType, + type OaBoardActivityMappingSeed, + type OaBoardStageRuleSeed, +} from './oa-board.constants' + +type SourceWorkflowRow = RowDataPacket & { + finishTime: string | null + businessId: string | null + title: string + result: string | null + originatorUserId: string | null + originatorDeptId: string | null + originatorDeptName: string | null + status: string + bizAction: string | null + processInstanceId: string + joinAt: string | null + updateAt: string | null + createAt: string | null + processCode: string + flowType: string | null +} + +type SourceSchemeRow = RowDataPacket & { + processInstanceId: string + componentType: string | null + name: string | null + value: string | null + extValue: string | null +} + +type SourceTaskRow = RowDataPacket & { + processInstanceId: string + processCode: string + result: string | null + activityId: string + finishTime: string | null + createTime: string | null + userId: string | null + taskId: string + status: string +} + +type NormalizedFieldSnapshot = { + fieldName: string + fieldValue: string | null + fieldExtValue: string | null + normalizedValue: string | null + metadata: Prisma.InputJsonValue | null +} + +type NormalizedTaskRecord = { + taskId: string + activityId: string + userId: string | null + status: string + result: string | null + nodeKey: string | null + nodeName: string | null + stageKey: string | null + stageName: string | null + timelineStageKey: string | null + timelineStageName: string | null + departmentLabel: string | null + ownerLabel: string | null + targetHours: number | null + overtimeHours: number | null + isOvertime: boolean + startedAtSource: Date | null + finishedAtSource: Date | null + actualHours: number | null + isFallback: boolean + metadata: Prisma.InputJsonValue | null +} + +type NormalizedFlowRecord = { + flowType: OaBoardFlowType + processCode: string + processName: string + processInstanceId: string + title: string + status: string + result: string | null + businessId: string | null + originatorUserId: string | null + originatorDeptId: string | null + originatorDeptName: string | null + createdAtSource: Date | null + updatedAtSource: Date | null + finishedAtSource: Date | null + actualHours: number | null + usedWorkflowFallback: boolean + metadata: Prisma.InputJsonValue | null + rawSnapshot: Prisma.InputJsonValue + fieldSnapshots: NormalizedFieldSnapshot[] + taskRecords: NormalizedTaskRecord[] + openingCode: string | null + orderCode: string | null + pricingCode: string | null + artCode: string | null + spu: string | null + productType: string | null + brand: string | null + productCategory: string | null + categoryName: string | null + productName: string | null +} + +type PreparedProjectGroup = { + boardKey: string + boardKeyType: string + flowRecords: NormalizedFlowRecord[] +} + +type ActivityMappingLookup = Map< + string, + { + nodeKey: string + nodeName: string + stageKey: string + stageName: string + timelineStageKey: string + timelineStageName: string + sequence: number + } +> + +function buildActivityMappingLookup(records: OaBoardActivityMappingSeed[]) { + return new Map( + records.map((mapping) => [ + `${mapping.processCode}::${mapping.activityId}`, + { + nodeKey: mapping.nodeKey, + nodeName: mapping.nodeName, + stageKey: mapping.stageKey, + stageName: mapping.stageName, + timelineStageKey: mapping.timelineStageKey ?? mapping.stageKey, + timelineStageName: mapping.timelineStageName ?? mapping.stageName, + sequence: mapping.sequence, + }, + ]), + ) as ActivityMappingLookup +} + +function buildNodeRuleLookup(records: OaBoardStageRuleSeed[]) { + return new Map( + records.map((record) => [ + `${record.productType}::${record.ruleKey}`, + { + targetHours: record.targetHours, + departmentLabel: record.departmentLabel, + ownerLabel: record.ownerLabel, + sequence: record.sequence, + }, + ]), + ) as NodeRuleLookup +} + +type NodeRuleLookup = Map< + string, + { + targetHours: number | null + departmentLabel: string | null + ownerLabel: string | null + sequence: number + } +> + +function buildOaBoardWorkbookFlowMap() { + return new Map( + Object.entries(OA_BOARD_WORKBOOK_FLOW_NAME_MAP).map(([flowName, flowType]) => [ + flowName, + flowType, + ]), + ) +} + +function loadActivityMappingLookup(): ActivityMappingLookup { + return buildActivityMappingLookup(OA_BOARD_DEFAULT_ACTIVITY_MAPPINGS) +} + +function loadNodeRuleLookup(): NodeRuleLookup { + return new Map() +} + +export type OaBoardSyncOptions = { + apply?: boolean + limit?: number +} + +export type OaBoardSyncResult = { + apply: boolean + startedAt: string + finishedAt: string + scannedCount: number + projectCount: number + flowCount: number + fieldCount: number + taskCount: number + previewBoardKeys: string[] +} + +export type OaBoardPreviewResult = { + projectGraphs: OaBoardProjectGraphInput[] + scannedCount: number + flowCount: number + fieldCount: number + taskCount: number +} + +function readEnv(name: string) { + return process.env[name]?.trim() ?? '' +} + +function readFirstEnv(names: string[]) { + return names.map(readEnv).find(Boolean) ?? '' +} + +function createPlanningConnectionConfig(): string | ConnectionOptions { + const databaseUrl = readFirstEnv([ + 'OA_BOARD_SOURCE_DATABASE_URL', + 'PLANNING_DATABASE_URL', + ]) + + if (databaseUrl) { + return databaseUrl + } + + const host = readFirstEnv([ + 'OA_BOARD_SOURCE_DB_HOST', + 'PLANNING_DB_HOST', + 'JST_PLANNING_DB_HOST', + ]) + const database = readFirstEnv([ + 'OA_BOARD_SOURCE_DB_NAME', + 'PLANNING_DB_NAME', + 'JST_PLANNING_DB_NAME', + ]) + const user = readFirstEnv([ + 'OA_BOARD_SOURCE_DB_USER', + 'PLANNING_DB_USER', + 'JST_PLANNING_DB_USER', + ]) + const password = readFirstEnv([ + 'OA_BOARD_SOURCE_DB_PASSWORD', + 'PLANNING_DB_PASSWORD', + 'JST_PLANNING_DB_PASSWORD', + ]) + + if (!host || !database || !user || !password) { + throw new Error('OA 看板来源数据库未配置') + } + + const port = Number( + readFirstEnv([ + 'OA_BOARD_SOURCE_DB_PORT', + 'PLANNING_DB_PORT', + 'JST_PLANNING_DB_PORT', + ]) || '3306', + ) + const charset = + readFirstEnv([ + 'OA_BOARD_SOURCE_DB_CHARSET', + 'PLANNING_DB_CHARSET', + 'JST_PLANNING_DB_CHARSET', + ]) || 'utf8mb4' + const sslEnabled = + readFirstEnv([ + 'OA_BOARD_SOURCE_DB_SSL', + 'PLANNING_DB_SSL', + 'JST_PLANNING_DB_SSL', + ]) === 'true' + + return { + host, + port, + database, + user, + password, + charset, + ssl: sslEnabled ? {} : undefined, + supportBigNumbers: true, + dateStrings: true, + } +} + +function normalizeText(value: unknown): string | null { + const text = String(value ?? '').trim() + return text || null +} + +function normalizeUpperText(value: unknown): string | null { + const text = normalizeText(value) + return text ? text.toUpperCase() : null +} + +function parseChinaDateTime(value: string | null | undefined): Date | null { + const text = normalizeText(value) + if (!text) return null + + if (/[zZ]|[+-]\d{2}:\d{2}$/.test(text)) { + const parsed = new Date(text) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) { + const parsed = new Date(text.replace(' ', 'T') + '+08:00') + return Number.isNaN(parsed.getTime()) ? null : parsed + } + + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) { + const parsed = new Date(text + ':00+08:00') + return Number.isNaN(parsed.getTime()) ? null : parsed + } + + const parsed = new Date(text) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function calculateHours(start: Date | null, end: Date | null, now = new Date()) { + if (!start) return null + const effectiveEnd = end ?? now + const diff = effectiveEnd.getTime() - start.getTime() + if (!Number.isFinite(diff) || diff < 0) return null + return Number((diff / 3_600_000).toFixed(2)) +} + +function toJsonValue(value: Record): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue +} + +function inferFlowType(processCode: string): OaBoardFlowType | null { + const entry = Object.entries(OA_BOARD_PROCESS_CODES).find(([, code]) => code === processCode) + return (entry?.[0] as OaBoardFlowType | undefined) ?? null +} + +function readFieldValue(fieldSnapshots: NormalizedFieldSnapshot[], names: readonly string[]) { + for (const name of names) { + const matched = fieldSnapshots.find((fieldSnapshot) => fieldSnapshot.fieldName === name) + if (matched?.fieldValue) { + return matched.fieldValue + } + } + + return null +} + +function readFieldSnapshot(fieldSnapshots: NormalizedFieldSnapshot[], names: readonly string[]) { + for (const name of names) { + const matched = fieldSnapshots.find((fieldSnapshot) => fieldSnapshot.fieldName === name) + if (matched) return matched + } + return null +} + +function parseJsonArrayOfStrings(value: string | null | undefined): string[] { + const text = normalizeText(value) + if (!text) return [] + if (!text.startsWith('[')) return [text] + + try { + const parsed = JSON.parse(text) + if (Array.isArray(parsed)) { + return parsed + .map((item) => normalizeText(item)) + .filter((item): item is string => Boolean(item)) + } + } catch { + return [text] + } + + return [] +} + +function toDisplayText(fieldSnapshot: NormalizedFieldSnapshot | null) { + if (!fieldSnapshot) return '' + + const direct = normalizeText(fieldSnapshot.fieldValue) + if (direct && !direct.startsWith('[')) { + return direct + } + + const extValue = normalizeText(fieldSnapshot.fieldExtValue) + if (extValue && extValue.startsWith('[')) { + try { + const parsed = JSON.parse(extValue) + if (Array.isArray(parsed)) { + const names = parsed + .map((item) => { + if (item && typeof item === 'object') { + return normalizeText((item as Record).name) + } + return normalizeText(item) + }) + .filter((item): item is string => Boolean(item)) + + if (names.length > 0) { + return names.join(';') + } + } + } catch { + // Keep fallback below. + } + } + + const values = parseJsonArrayOfStrings(fieldSnapshot.fieldValue) + if (values.length > 0) { + return values.join(';') + } + + return direct || extValue || '' +} + +function toImageCountLabel(fieldSnapshot: NormalizedFieldSnapshot | null) { + if (!fieldSnapshot) return '0个图片' + const values = parseJsonArrayOfStrings(fieldSnapshot.fieldValue) + if (values.length > 0) { + return `${values.length}个图片` + } + return normalizeText(fieldSnapshot.fieldValue) ? '1个图片' : '0个图片' +} + +function toFlowStatusLabel(status: string | null | undefined, result: string | null | undefined) { + const normalizedStatus = normalizeText(status)?.toUpperCase() ?? '' + const normalizedResult = normalizeText(result)?.toUpperCase() ?? '' + + if (normalizedStatus === 'RUNNING' || normalizedStatus === 'PROCESSING' || normalizedStatus === 'PENDING') { + return '进行中' + } + if (normalizedStatus === 'TERMINATED') { + return '已终止' + } + if (normalizedStatus === 'COMPLETED' && normalizedResult === 'REFUSE') { + return '已驳回' + } + if (normalizedStatus === 'COMPLETED') { + return '已完成' + } + return normalizedStatus || '-' +} + +function pickActiveTasks(records: NormalizedFlowRecord[]) { + const reviewRecord = records.find((record) => record.flowType === 'review') ?? null + const orderRecord = records.find((record) => record.flowType === 'order') ?? null + const artRecord = records.find((record) => record.flowType === 'art') ?? null + const pricingRecord = records.find((record) => record.flowType === 'pricing') ?? null + + const runningTasks = (record: NormalizedFlowRecord | null) => + record?.taskRecords.filter((taskRecord) => taskRecord.status === 'RUNNING') ?? [] + + if (reviewRecord && OA_BOARD_RUNNING_STATUSES.has(reviewRecord.status)) { + return runningTasks(reviewRecord) + } + + if ( + reviewRecord && + reviewRecord.status === 'COMPLETED' && + (reviewRecord.result?.toUpperCase() ?? '') === 'AGREE' + ) { + const parallelTasks = [...runningTasks(orderRecord), ...runningTasks(artRecord)] + if (parallelTasks.length > 0) return parallelTasks + + const pricingTasks = runningTasks(pricingRecord) + if (pricingTasks.length > 0) return pricingTasks + } + + return [] +} + +function joinUnique(values: Array) { + const items = Array.from( + new Set(values.map((value) => normalizeText(value)).filter((value): value is string => Boolean(value))), + ) + return items.join(';') +} + +function buildFieldSnapshots(rows: SourceSchemeRow[]): NormalizedFieldSnapshot[] { + const fieldMap = new Map() + + rows.forEach((row) => { + const fieldName = normalizeText(row.name) + if (!fieldName) return + + fieldMap.set(fieldName, { + fieldName, + fieldValue: normalizeText(row.value), + fieldExtValue: normalizeText(row.extValue), + normalizedValue: normalizeUpperText(row.value), + metadata: toJsonValue({ + componentType: normalizeText(row.componentType), + }), + }) + }) + + return Array.from(fieldMap.values()) +} + +function buildTaskRecords(rows: SourceTaskRow[], processName: string): NormalizedTaskRecord[] { + return buildTaskRecordsWithMappings(rows, processName, new Map()) +} + +function buildTaskRecordsWithMappings( + rows: SourceTaskRow[], + processName: string, + mappingLookup: ActivityMappingLookup, +): NormalizedTaskRecord[] { + const taskMap = new Map() + + rows.forEach((row) => { + const startedAtSource = parseChinaDateTime(row.createTime) + const finishedAtSource = parseChinaDateTime(row.finishTime) + const processCode = normalizeText((row as RowDataPacket).processCode) ?? '' + const activityId = normalizeText(row.activityId) ?? '' + const mapped = + mappingLookup.get(`${processCode}::${activityId}`) ?? + mappingLookup.get(`::${activityId}`) ?? + null + const taskKey = [ + normalizeText(row.taskId) ?? '', + activityId, + normalizeText(row.userId) ?? '', + ].join('::') + + taskMap.set(taskKey, { + taskId: normalizeText(row.taskId) ?? taskKey, + activityId, + userId: normalizeText(row.userId), + status: normalizeText(row.status) ?? 'UNKNOWN', + result: normalizeText(row.result), + nodeKey: mapped?.nodeKey ?? activityId, + nodeName: mapped?.nodeName ?? activityId, + stageKey: mapped?.stageKey ?? processName, + stageName: mapped?.stageName ?? processName, + timelineStageKey: mapped?.timelineStageKey ?? processName, + timelineStageName: mapped?.timelineStageName ?? processName, + departmentLabel: null, + ownerLabel: null, + targetHours: null, + overtimeHours: null, + isOvertime: false, + startedAtSource, + finishedAtSource, + actualHours: calculateHours(startedAtSource, finishedAtSource), + isFallback: false, + metadata: mapped + ? toJsonValue({ + sequence: mapped.sequence, + mappedBy: 'default-activity-mapping', + }) + : null, + }) + }) + + return Array.from(taskMap.values()) +} + +function applyNodeRuleTargets( + taskRecords: NormalizedTaskRecord[], + productType: string | null, + nodeRuleLookup: NodeRuleLookup, +): NormalizedTaskRecord[] { + if (!productType) return taskRecords + + return taskRecords.map((taskRecord) => { + const rule = nodeRuleLookup.get(`${productType}::${taskRecord.nodeName ?? ''}`) + const targetHours = rule?.targetHours ?? null + const actualHours = taskRecord.actualHours + const overtimeHours = + targetHours !== null && actualHours !== null && actualHours > targetHours + ? Number((actualHours - targetHours).toFixed(2)) + : 0 + + return { + ...taskRecord, + departmentLabel: rule?.departmentLabel ?? null, + ownerLabel: rule?.ownerLabel ?? null, + targetHours, + overtimeHours, + isOvertime: overtimeHours > 0, + } + }) +} + +function buildNormalizedFlowRecord( + workflow: SourceWorkflowRow, + fieldSnapshots: NormalizedFieldSnapshot[], + taskRecords: NormalizedTaskRecord[], +): NormalizedFlowRecord | null { + const flowType = inferFlowType(workflow.processCode) + if (!flowType) return null + + const processName = OA_BOARD_FLOW_LABELS[flowType] + const createdAtSource = parseChinaDateTime(workflow.createAt) + const updatedAtSource = parseChinaDateTime(workflow.updateAt) + const finishedAtSource = parseChinaDateTime(workflow.finishTime) + + return { + flowType, + processCode: workflow.processCode, + processName, + processInstanceId: workflow.processInstanceId, + title: normalizeText(workflow.title) ?? processName, + status: normalizeText(workflow.status) ?? 'UNKNOWN', + result: normalizeText(workflow.result), + businessId: normalizeText(workflow.businessId), + originatorUserId: normalizeText(workflow.originatorUserId), + originatorDeptId: normalizeText(workflow.originatorDeptId), + originatorDeptName: normalizeText(workflow.originatorDeptName), + createdAtSource, + updatedAtSource, + finishedAtSource, + actualHours: calculateHours(createdAtSource, finishedAtSource), + usedWorkflowFallback: flowType === 'main', + metadata: toJsonValue({ + bizAction: normalizeText(workflow.bizAction), + flowType: normalizeText(workflow.flowType), + joinAt: normalizeText(workflow.joinAt), + }), + rawSnapshot: toJsonValue({ + finishTime: workflow.finishTime, + businessId: workflow.businessId, + title: workflow.title, + result: workflow.result, + originatorUserId: workflow.originatorUserId, + originatorDeptId: workflow.originatorDeptId, + originatorDeptName: workflow.originatorDeptName, + status: workflow.status, + bizAction: workflow.bizAction, + processInstanceId: workflow.processInstanceId, + joinAt: workflow.joinAt, + updateAt: workflow.updateAt, + createAt: workflow.createAt, + processCode: workflow.processCode, + flowType: workflow.flowType, + }), + fieldSnapshots, + taskRecords, + openingCode: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.openingCode), + orderCode: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.orderCode), + pricingCode: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.pricingCode), + artCode: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.artCode), + spu: normalizeUpperText(readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.spu)), + productType: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.productType), + brand: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.brand), + productCategory: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.productCategory), + categoryName: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.categoryName), + productName: readFieldValue(fieldSnapshots, OA_BOARD_JOIN_FIELD_NAMES.productName), + } +} + +function buildBoardKeyParts(record: NormalizedFlowRecord) { + return { + opening: normalizeUpperText(record.openingCode), + order: normalizeUpperText(record.orderCode), + pricing: normalizeUpperText(record.pricingCode), + art: normalizeUpperText(record.artCode), + spu: normalizeUpperText(record.spu), + } +} + +function buildAliasKeys(record: NormalizedFlowRecord) { + const keys = buildBoardKeyParts(record) + + return [ + keys.opening ? `OPENING:${keys.opening}` : null, + keys.order ? `ORDER:${keys.order}` : null, + keys.pricing ? `PRICING:${keys.pricing}` : null, + keys.art ? `ART:${keys.art}` : null, + keys.spu ? `SPU:${keys.spu}` : null, + keys.opening ? `VALUE:${keys.opening}` : null, + keys.order ? `VALUE:${keys.order}` : null, + keys.pricing ? `VALUE:${keys.pricing}` : null, + keys.art ? `VALUE:${keys.art}` : null, + keys.spu ? `VALUE:${keys.spu}` : null, + ].filter((value): value is string => Boolean(value)) +} + +function chooseBoardKey( + record: NormalizedFlowRecord, + candidateBoardKey: string | null, +): { boardKey: string; boardKeyType: string } { + if (candidateBoardKey) { + const [boardKeyType] = candidateBoardKey.split(':', 1) + return { boardKey: candidateBoardKey, boardKeyType } + } + + const keys = buildBoardKeyParts(record) + + if (keys.opening) return { boardKey: `OPENING:${keys.opening}`, boardKeyType: 'OPENING' } + if (keys.order) return { boardKey: `ORDER:${keys.order}`, boardKeyType: 'ORDER' } + if (keys.pricing) return { boardKey: `PRICING:${keys.pricing}`, boardKeyType: 'PRICING' } + if (keys.spu) return { boardKey: `SPU:${keys.spu}`, boardKeyType: 'SPU' } + if (keys.art) return { boardKey: `ART:${keys.art}`, boardKeyType: 'ART' } + return { + boardKey: `INSTANCE:${record.processInstanceId}`, + boardKeyType: 'INSTANCE', + } +} + +function pickCurrentFlow(records: NormalizedFlowRecord[]): NormalizedFlowRecord | null { + const activeRecord = OA_BOARD_FLOW_PRIORITY.flatMap((flowType) => + records.filter( + (record) => + record.flowType === flowType && OA_BOARD_RUNNING_STATUSES.has(record.status), + ), + )[0] + + if (activeRecord) return activeRecord + + return [...records].sort((left, right) => { + const leftTime = left.updatedAtSource?.getTime() ?? 0 + const rightTime = right.updatedAtSource?.getTime() ?? 0 + return rightTime - leftTime + })[0] ?? null +} + +function computeOverallActualHours(records: NormalizedFlowRecord[]) { + const byType = new Map() + + records.forEach((record) => { + if (record.actualHours === null) return + const current = byType.get(record.flowType) ?? 0 + byType.set(record.flowType, Math.max(current, record.actualHours)) + }) + + const reviewHours = byType.get('review') ?? 0 + const orderHours = byType.get('order') ?? 0 + const artHours = byType.get('art') ?? 0 + const pricingHours = byType.get('pricing') ?? 0 + const mainHours = byType.get('main') ?? 0 + + const total = + reviewHours + Math.max(orderHours, artHours) + pricingHours + + if (total > 0) { + return Number(total.toFixed(2)) + } + + return mainHours > 0 ? Number(mainHours.toFixed(2)) : null +} + +function computeOverallStatus(records: NormalizedFlowRecord[]) { + if (records.some((record) => OA_BOARD_RUNNING_STATUSES.has(record.status))) { + return 'RUNNING' + } + if (records.some((record) => record.status === 'TERMINATED' || record.result === 'refuse')) { + return 'TERMINATED' + } + if (records.every((record) => OA_BOARD_COMPLETED_STATUSES.has(record.status))) { + return 'COMPLETED' + } + return records[0]?.status ?? null +} + +function buildProjectGraph(group: PreparedProjectGroup): OaBoardProjectGraphInput { + const records = [...group.flowRecords].sort((left, right) => { + const leftPriority = OA_BOARD_FLOW_PRIORITY.indexOf(left.flowType) + const rightPriority = OA_BOARD_FLOW_PRIORITY.indexOf(right.flowType) + return leftPriority - rightPriority + }) + + const reviewRecord = records.find((record) => record.flowType === 'review') ?? null + const orderRecord = records.find((record) => record.flowType === 'order') ?? null + const artRecord = records.find((record) => record.flowType === 'art') ?? null + const pricingRecord = records.find((record) => record.flowType === 'pricing') ?? null + const mainRecord = records.find((record) => record.flowType === 'main') ?? null + const currentFlow = pickCurrentFlow(records) + const activeTasks = pickActiveTasks(records) + + const productType = + reviewRecord?.productType ?? + orderRecord?.productType ?? + pricingRecord?.productType ?? + artRecord?.productType ?? + null + + const overallActualHours = computeOverallActualHours(records) + const overallTargetHours = getOaBoardProductTargetHours(productType) + const overallTimeoutHours = + overallTargetHours !== null && overallActualHours !== null + ? Number((overallActualHours - overallTargetHours).toFixed(2)) + : null + + const lastSourceUpdatedAt = records.reduce((latest, record) => { + if (!record.updatedAtSource) return latest + if (!latest || record.updatedAtSource.getTime() > latest.getTime()) { + return record.updatedAtSource + } + return latest + }, null) + + const runningTask = + currentFlow?.taskRecords.find((taskRecord) => taskRecord.status === 'RUNNING') ?? null + + const reviewFieldSnapshots = reviewRecord?.fieldSnapshots ?? [] + const formSummary = { + styleImageLabel: toImageCountLabel(readFieldSnapshot(reviewFieldSnapshots, ['款式图片'])), + openingCode: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['开款编号'])), + totalFlowStatusLabel: toFlowStatusLabel(mainRecord?.status ?? reviewRecord?.status, mainRecord?.result ?? reviewRecord?.result), + approvalStageDisplay: joinUnique(activeTasks.map((taskRecord) => taskRecord.stageName)), + currentOwnerDisplay: joinUnique( + activeTasks.map((taskRecord) => taskRecord.ownerLabel || taskRecord.userId || null), + ) || '-', + currentNodeDisplay: joinUnique(activeTasks.map((taskRecord) => taskRecord.nodeName)) || '-', + demandType: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['需求类型'])), + developmentPlan: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['开发方案'])), + productType: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['产品类型'])), + shippingType: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['发货类型'])), + needVisualDesign: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['是否需要视觉出图'])), + plannedSalesBrands: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['计划售卖品牌'])), + category: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['品类'])), + categoryName: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['类目'])), + salesPriceBand: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['售卖价格带'])), + platformPriceBand: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['平台价格带'])), + competitorLink: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['竞品链接'])), + openingReason: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['开款原因'])), + isTestStyle: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['是否测款'])), + testStyleCode: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['测款编号'])), + productPlanner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['商品企划'])), + operationBrandOwner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['运营品牌负责人'])), + spuOwner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['SPU负责人'])), + appearanceDesigner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['外观设计师'])), + structureDesigner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['结构设计师'])), + purchaseOwner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['采购负责人'])), + merchandiserOwner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['跟单负责人'])), + pricingOwner: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['限价负责人'])), + workflowLink: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['由壹点产品开发流程链接地址'])), + needPatent: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['是否申请专利'])), + needTrademark: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['是否注册商标'])), + reviewResult: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['产品开款评审结果'])), + sampleReviewResult: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['审样结果'])), + salesBrands: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['售卖品牌'])), + spu: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['SPU'])), + openingBrand: toDisplayText(readFieldSnapshot(reviewFieldSnapshots, ['开款品牌'])), + } + + return { + boardKey: group.boardKey, + boardKeyType: group.boardKeyType, + primaryFlowType: reviewRecord?.flowType ?? records[0]?.flowType ?? 'review', + title: + reviewRecord?.title ?? + orderRecord?.title ?? + artRecord?.title ?? + pricingRecord?.title ?? + mainRecord?.title ?? + group.boardKey, + productName: + reviewRecord?.productName ?? + orderRecord?.productName ?? + artRecord?.productName ?? + pricingRecord?.productName ?? + null, + productType, + brand: + reviewRecord?.brand ?? + orderRecord?.brand ?? + artRecord?.brand ?? + pricingRecord?.brand ?? + null, + productCategory: + reviewRecord?.productCategory ?? + orderRecord?.productCategory ?? + pricingRecord?.productCategory ?? + null, + categoryName: + reviewRecord?.categoryName ?? + orderRecord?.categoryName ?? + pricingRecord?.categoryName ?? + artRecord?.categoryName ?? + null, + spu: + reviewRecord?.spu ?? + orderRecord?.spu ?? + artRecord?.spu ?? + pricingRecord?.spu ?? + null, + openingCode: reviewRecord?.openingCode ?? null, + orderCode: orderRecord?.orderCode ?? null, + pricingCode: pricingRecord?.pricingCode ?? null, + sourceBusinessId: + reviewRecord?.businessId ?? + orderRecord?.businessId ?? + pricingRecord?.businessId ?? + artRecord?.businessId ?? + mainRecord?.businessId ?? + null, + originatorUserId: + reviewRecord?.originatorUserId ?? + orderRecord?.originatorUserId ?? + artRecord?.originatorUserId ?? + pricingRecord?.originatorUserId ?? + mainRecord?.originatorUserId ?? + null, + originatorDeptId: + reviewRecord?.originatorDeptId ?? + orderRecord?.originatorDeptId ?? + artRecord?.originatorDeptId ?? + pricingRecord?.originatorDeptId ?? + mainRecord?.originatorDeptId ?? + null, + originatorDeptName: + reviewRecord?.originatorDeptName ?? + orderRecord?.originatorDeptName ?? + artRecord?.originatorDeptName ?? + pricingRecord?.originatorDeptName ?? + mainRecord?.originatorDeptName ?? + null, + currentStageKey: runningTask?.timelineStageKey ?? currentFlow?.flowType ?? null, + currentStageName: runningTask?.timelineStageName ?? currentFlow?.processName ?? null, + currentApprovalStageKey: runningTask?.stageKey ?? null, + currentApprovalStageName: joinUnique(activeTasks.map((taskRecord) => taskRecord.stageName)) || null, + overallStatus: computeOverallStatus(records), + overallResult: + pricingRecord?.result ?? + orderRecord?.result ?? + artRecord?.result ?? + reviewRecord?.result ?? + mainRecord?.result ?? + null, + overallTargetHours, + overallActualHours, + overallTimeoutHours, + overallTimeoutFlag: Boolean( + overallTargetHours !== null && + overallActualHours !== null && + overallActualHours > overallTargetHours, + ), + lastSourceUpdatedAt, + metadata: toJsonValue({ + flowTypes: records.map((record) => record.flowType), + formSummary, + parallelFlowHours: records + .filter((record) => OA_BOARD_PARALLEL_FLOW_TYPES.has(record.flowType)) + .map((record) => ({ + flowType: record.flowType, + actualHours: record.actualHours, + })), + }), + flowInstances: records.map((record) => ({ + flowType: record.flowType, + processCode: record.processCode, + processName: record.processName, + processInstanceId: record.processInstanceId, + title: record.title, + status: record.status, + result: record.result, + businessId: record.businessId, + originatorUserId: record.originatorUserId, + originatorDeptId: record.originatorDeptId, + originatorDeptName: record.originatorDeptName, + joinKeyType: + record.openingCode + ? 'OPENING' + : record.orderCode + ? 'ORDER' + : record.pricingCode + ? 'PRICING' + : record.spu + ? 'SPU' + : record.artCode + ? 'ART' + : null, + joinKey: + record.openingCode ?? + record.orderCode ?? + record.pricingCode ?? + record.spu ?? + record.artCode ?? + null, + createdAtSource: record.createdAtSource, + updatedAtSource: record.updatedAtSource, + finishedAtSource: record.finishedAtSource, + actualHours: record.actualHours, + usedWorkflowFallback: record.usedWorkflowFallback, + metadata: record.metadata, + rawSnapshot: record.rawSnapshot, + fieldSnapshots: record.fieldSnapshots, + taskRecords: record.taskRecords, + })), + } +} + +async function fetchSourceData(limit?: number): Promise { + const connectionConfig = createPlanningConnectionConfig() + const connection = + typeof connectionConfig === 'string' + ? await mysql.createConnection(connectionConfig) + : await mysql.createConnection(connectionConfig) + + try { + const processCodes = Object.values(OA_BOARD_PROCESS_CODES) + const workflowLimitSql = limit ? `LIMIT ${Math.max(1, Math.floor(limit))}` : '' + + const [workflowRows] = await connection.query( + ` + SELECT finishTime, businessId, title, result, originatorUserId, originatorDeptId, + originatorDeptName, status, bizAction, processInstanceId, joinAt, + updateAt, createAt, processCode, flowType + FROM ding_Workflow + WHERE processCode IN (${processCodes.map(() => '?').join(', ')}) + ORDER BY updateAt DESC + ${workflowLimitSql} + `, + processCodes, + ) + + const processInstanceIds = Array.from( + new Set( + workflowRows + .map((workflowRow) => normalizeText(workflowRow.processInstanceId)) + .filter((value): value is string => Boolean(value)), + ), + ) + + if (processInstanceIds.length === 0) { + return [] + } + + const [schemeRows] = await connection.query( + ` + SELECT processInstanceId, componentType, name, CAST(value AS CHAR) AS value, CAST(extValue AS CHAR) AS extValue + FROM ding_WorkflowScheme + WHERE processInstanceId IN (${processInstanceIds.map(() => '?').join(', ')}) + `, + processInstanceIds, + ) + + const [taskRows] = await connection.query( + ` + SELECT t.processInstanceId, t.result, t.activityId, t.finishTime, t.createTime, t.userId, t.taskId, t.status, + w.processCode AS processCode + FROM ding_WorkflowTask t + JOIN ding_Workflow w + ON w.processInstanceId = t.processInstanceId + WHERE t.processInstanceId IN (${processInstanceIds.map(() => '?').join(', ')}) + `, + processInstanceIds, + ) + + const schemeMap = new Map() + schemeRows.forEach((schemeRow) => { + const current = schemeMap.get(schemeRow.processInstanceId) ?? [] + current.push(schemeRow) + schemeMap.set(schemeRow.processInstanceId, current) + }) + + const taskMap = new Map() + taskRows.forEach((taskRow) => { + const current = taskMap.get(taskRow.processInstanceId) ?? [] + current.push(taskRow) + taskMap.set(taskRow.processInstanceId, current) + }) + + const [mappingLookup, nodeRuleLookup] = await Promise.all([ + loadActivityMappingLookup(), + loadNodeRuleLookup(), + ]) + + return workflowRows + .map((workflowRow) => { + const flowRecord = buildNormalizedFlowRecord( + workflowRow, + buildFieldSnapshots(schemeMap.get(workflowRow.processInstanceId) ?? []), + buildTaskRecordsWithMappings( + taskMap.get(workflowRow.processInstanceId) ?? [], + OA_BOARD_FLOW_LABELS[inferFlowType(workflowRow.processCode) as OaBoardFlowType] ?? + workflowRow.processCode, + mappingLookup, + ), + ) + + if (!flowRecord) return null + + return { + ...flowRecord, + taskRecords: applyNodeRuleTargets( + flowRecord.taskRecords, + flowRecord.productType, + nodeRuleLookup, + ), + } + }) + .filter((record): record is NormalizedFlowRecord => Boolean(record)) + } finally { + await connection.end() + } +} + +function buildProjectGroups(records: NormalizedFlowRecord[]): PreparedProjectGroup[] { + const groups = new Map() + const aliasToBoardKey = new Map() + + const sortedRecords = [...records].sort((left, right) => { + const leftPriority = OA_BOARD_FLOW_PRIORITY.indexOf(left.flowType) + const rightPriority = OA_BOARD_FLOW_PRIORITY.indexOf(right.flowType) + if (leftPriority !== rightPriority) return leftPriority - rightPriority + const leftTime = left.createdAtSource?.getTime() ?? 0 + const rightTime = right.createdAtSource?.getTime() ?? 0 + return leftTime - rightTime + }) + + for (const record of sortedRecords) { + const aliases = buildAliasKeys(record) + + let candidateBoardKey = + aliases.map((alias) => aliasToBoardKey.get(alias)).find(Boolean) ?? null + + if (!candidateBoardKey && record.flowType === 'main') { + const fuzzyMatchedGroup = Array.from(groups.values()).find((group) => + group.flowRecords.some((groupRecord) => { + if (!record.originatorUserId || !groupRecord.originatorUserId) return false + if (record.originatorUserId !== groupRecord.originatorUserId) return false + if (!record.createdAtSource || !groupRecord.createdAtSource) return false + const diff = Math.abs( + record.createdAtSource.getTime() - groupRecord.createdAtSource.getTime(), + ) + return diff <= 30 * 60 * 1000 + }), + ) + candidateBoardKey = fuzzyMatchedGroup?.boardKey ?? null + } + + const { boardKey, boardKeyType } = chooseBoardKey(record, candidateBoardKey) + const group = + groups.get(boardKey) ?? + { + boardKey, + boardKeyType, + flowRecords: [], + } + + group.flowRecords.push(record) + groups.set(boardKey, group) + + aliases.forEach((alias) => { + aliasToBoardKey.set(alias, boardKey) + }) + } + + return Array.from(groups.values()) +} + +function countNestedRecords(projectGraphs: OaBoardProjectGraphInput[]) { + let flowCount = 0 + let fieldCount = 0 + let taskCount = 0 + + projectGraphs.forEach((projectGraph) => { + flowCount += projectGraph.flowInstances.length + projectGraph.flowInstances.forEach((flowInstance) => { + fieldCount += flowInstance.fieldSnapshots.length + taskCount += flowInstance.taskRecords.length + }) + }) + + return { flowCount, fieldCount, taskCount } +} + +export async function queryOaBoardPreview(limit?: number): Promise { + const normalizedLimit = + typeof limit === 'number' && Number.isFinite(limit) && limit > 0 + ? Math.floor(limit) + : undefined + const sourceRecords = await fetchSourceData(normalizedLimit) + const projectGraphs = buildProjectGroups(sourceRecords).map(buildProjectGraph) + const counts = countNestedRecords(projectGraphs) + + return { + projectGraphs, + scannedCount: sourceRecords.length, + flowCount: counts.flowCount, + fieldCount: counts.fieldCount, + taskCount: counts.taskCount, + } +} + +export async function syncOaBoard(options: OaBoardSyncOptions = {}): Promise { + const startedAt = new Date() + const apply = options.apply === true + const normalizedLimit = + typeof options.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0 + ? Math.floor(options.limit) + : undefined + + const preview = await queryOaBoardPreview(normalizedLimit) + const { projectGraphs, scannedCount } = preview + const counts = { + flowCount: preview.flowCount, + fieldCount: preview.fieldCount, + taskCount: preview.taskCount, + } + + if (!apply) { + return { + apply, + startedAt: startedAt.toISOString(), + finishedAt: new Date().toISOString(), + scannedCount, + projectCount: projectGraphs.length, + flowCount: counts.flowCount, + fieldCount: counts.fieldCount, + taskCount: counts.taskCount, + previewBoardKeys: projectGraphs.slice(0, 20).map((projectGraph) => projectGraph.boardKey), + } + } + + return { + apply, + startedAt: startedAt.toISOString(), + finishedAt: new Date().toISOString(), + scannedCount, + projectCount: projectGraphs.length, + flowCount: counts.flowCount, + fieldCount: counts.fieldCount, + taskCount: counts.taskCount, + previewBoardKeys: projectGraphs.slice(0, 20).map((projectGraph) => projectGraph.boardKey), + } +} diff --git a/furniture-3d-system/src/server/services/oa-board.constants.ts b/furniture-3d-system/src/server/services/oa-board.constants.ts new file mode 100644 index 00000000..7cb41499 --- /dev/null +++ b/furniture-3d-system/src/server/services/oa-board.constants.ts @@ -0,0 +1,377 @@ +export const OA_BOARD_PROCESS_CODES = { + main: 'PROC-BD1F3F5C-8663-49DA-9B8E-43447298275B', + review: 'PROC-3A0218B4-03F7-42E6-B354-F56BD2FDDDDB', + order: 'PROC-34AC5E78-83DD-469C-8D5F-479CEC076FB7', + art: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838', + pricing: 'PROC-897997DD-180B-4086-B2A1-4C65C998FC5C', +} as const + +export type OaBoardFlowType = keyof typeof OA_BOARD_PROCESS_CODES + +export const OA_BOARD_FLOW_LABELS: Record = { + main: '新品开发流程', + review: '新品开发立项评审流程', + order: '新品首单下单出货流程', + art: '美术设计申请', + pricing: '新品限价流程', +} + +export const OA_BOARD_WORKBOOK_FLOW_NAME_MAP = { + 新品开发立项评审流程: 'review', + 新品首单下单流程: 'order', + 美术设计申请流程: 'art', + 新品限价流程: 'pricing', +} as const satisfies Record> + +export const OA_BOARD_FLOW_PRIORITY: OaBoardFlowType[] = [ + 'review', + 'order', + 'pricing', + 'art', + 'main', +] + +export const OA_BOARD_PRODUCT_TARGET_HOURS: Record = { + '成品开发-实木': 45, + '成品开发-板式/钢木/铝制(无需磨具)': 21, + '成品开发-钢木/铝制(需磨具)': 46, + '成品开发-塑料/软体': 8, + '部件开发-实木部件': 15, + '部件开发-板式/钢/铝/塑料/五金部件(无需磨具)': 9, + '部件开发-板式/钢/铝/塑料/五金部件(需磨具)': 46, + '部件开发-纸箱/泡沫': 17, +} + +export type OaBoardStageDefinition = { + key: string + name: string + flowType: OaBoardFlowType | 'main' | 'shared' + summary: string + sequence: number +} + +export const OA_BOARD_STAGE_DEFINITIONS: OaBoardStageDefinition[] = [ + { + key: 'review_stage', + name: '立项评审', + flowType: 'review', + summary: '从产品经理分配任务开始,到结构审核为止。', + sequence: 1, + }, + { + key: 'sample_prepare_stage', + name: '打样安排', + flowType: 'order', + summary: '承接确认打样出货时间、填写样品快递单号。', + sequence: 2, + }, + { + key: 'sample_review_stage', + name: '样品评审', + flowType: 'order', + summary: '承接样品评审、售卖品牌确认与空间布局。', + sequence: 3, + }, + { + key: 'order_delivery_stage', + name: '首单下单出货', + flowType: 'order', + summary: '商品资料输出到送仓入库前的供应链推进阶段。', + sequence: 4, + }, + { + key: 'art_stage', + name: '美术设计', + flowType: 'art', + summary: '品牌策划主管分配任务、出策划案、出图。', + sequence: 5, + }, + { + key: 'pricing_stage', + name: '限价审批', + flowType: 'pricing', + summary: '发起产品限价到部门负责人审核。', + sequence: 6, + }, + { + key: 'listing_stage', + name: '运营上架', + flowType: 'main', + summary: '当前以总流程承接,后续再细化审批阶段数据。', + sequence: 7, + }, +] + +export type OaBoardStageTargetMatrix = Record + +export const OA_BOARD_STAGE_TARGET_MATRIX: Record = { + '成品开发-实木': { + review_stage: 5, + sample_prepare_stage: 8, + sample_review_stage: 2, + order_delivery_stage: 30, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, + '成品开发-板式/钢木/铝制(无需磨具)': { + review_stage: 2, + sample_prepare_stage: 3, + sample_review_stage: 2, + order_delivery_stage: 13, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, + '成品开发-钢木/铝制(需磨具)': { + review_stage: 2, + sample_prepare_stage: 7, + sample_review_stage: 2, + order_delivery_stage: 34, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, + '成品开发-塑料/软体': { + review_stage: 2, + sample_prepare_stage: 0, + sample_review_stage: 2, + order_delivery_stage: 3, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, + '部件开发-实木部件': { + review_stage: 2, + sample_prepare_stage: 3, + sample_review_stage: 2, + order_delivery_stage: 7, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, + '部件开发-板式/钢/铝/塑料/五金部件(无需磨具)': { + review_stage: 2, + sample_prepare_stage: 2, + sample_review_stage: 2, + order_delivery_stage: 2, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, + '部件开发-板式/钢/铝/塑料/五金部件(需磨具)': { + review_stage: 2, + sample_prepare_stage: 7, + sample_review_stage: 2, + order_delivery_stage: 34, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, + '部件开发-纸箱/泡沫': { + review_stage: 2, + sample_prepare_stage: 5, + sample_review_stage: 2, + order_delivery_stage: 7, + art_stage: 4, + pricing_stage: 1, + listing_stage: 1, + }, +} + +export type OaBoardDefaultStageRule = { + productType: string + ruleType: 'STAGE' + ruleKey: string + displayName: string + flowType: string + stageKey: string + targetHours: number + sequence: number + metadata: { + source: '看板需求.xlsx' + summary: string + } +} + +export const OA_BOARD_DEFAULT_STAGE_RULES: OaBoardDefaultStageRule[] = Object.entries( + OA_BOARD_STAGE_TARGET_MATRIX, +).flatMap(([productType, stageTargets]) => + OA_BOARD_STAGE_DEFINITIONS.map((stage) => ({ + productType, + ruleType: 'STAGE' as const, + ruleKey: stage.key, + displayName: stage.name, + flowType: stage.flowType, + stageKey: stage.key, + targetHours: stageTargets[stage.key] ?? 0, + sequence: stage.sequence, + metadata: { + source: '看板需求.xlsx' as const, + summary: stage.summary, + }, + })), +) + +export type OaBoardDefaultActivityMapping = { + flowType: OaBoardFlowType + processCode: string + activityId: string + nodeKey: string + nodeName: string + stageKey: string + stageName: string + timelineStageKey: string + timelineStageName: string + sequence: number + metadata: { + source: '流程节点代码汇总表_审批阶段.xlsx' + } +} + +type MappingSeed = Array<{ + activityId: string + nodeName: string + stageName: string | null +}> + +const OA_BOARD_ACTIVITY_MAPPING_SEED: Record = { + review: [ + { activityId: 'sid-startevent', nodeName: '发起人', stageName: '发起申请' }, + { activityId: '6053_7843', nodeName: '申请人填写信息', stageName: '发起申请' }, + { activityId: 'de44_dd3d', nodeName: '申请人填写信息', stageName: '发起申请' }, + { activityId: '2579_b24b', nodeName: '企划市场分析', stageName: '企划市场分析' }, + { activityId: '32c3_8a31', nodeName: '申请人填写信息', stageName: '发起申请' }, + { activityId: 'a2e4_8dad', nodeName: '申请人填写信息', stageName: '发起申请' }, + { activityId: '02a7_67ab', nodeName: '抄送部门负责人', stageName: null }, + { activityId: 'e720_4896', nodeName: '企划市场分析', stageName: '企划市场分析' }, + { activityId: '9bbc_05e2', nodeName: '企划市场分析会', stageName: '企划市场分析' }, + { activityId: '312e_88c8', nodeName: '确认开发方案', stageName: '企划市场分析' }, + { activityId: '3acd_4e51', nodeName: '商品企划负责人审核', stageName: '企划市场分析' }, + { activityId: '4363_12fe', nodeName: '产品经理分配任务', stageName: '产品设计-外观' }, + { activityId: 'f097_3875', nodeName: '设计外观', stageName: '产品设计-外观' }, + ], + order: [ + { activityId: 'sid-startevent', nodeName: '发起人', stageName: '产品生产-首单下单' }, + { activityId: '800d_01f4', nodeName: '首单下单(线下签订合同)', stageName: '产品生产-首单下单' }, + { activityId: '1a3f_a9be', nodeName: '供应商核对信息', stageName: '产品生产-首单下单' }, + { activityId: 'e191_b262', nodeName: '核对工厂反馈信息', stageName: '产品生产-首单下单' }, + { activityId: 'c77b_ba99', nodeName: '产前样确认-采购反馈', stageName: '产品生产-产前样' }, + { activityId: '6d5d_7e35', nodeName: '产前样确认-结构设计师确认', stageName: '产品生产-产前样' }, + { activityId: 'b715_3d1f', nodeName: '生产/巡检/出货', stageName: '产品生产-生产出货' }, + ], + art: [ + { activityId: 'sid-startevent', nodeName: '发起人', stageName: '出图-需求发起' }, + { activityId: '1de3_de55', nodeName: '部门负责人审核', stageName: '出图-需求审核' }, + { activityId: '94c1_1f0d', nodeName: '部门总监审核', stageName: '出图-需求审核' }, + { activityId: '3cb8_8096', nodeName: '品牌策划总监审批', stageName: '出图-需求审核' }, + { activityId: '38da_544a', nodeName: '品牌策划主管分配任务', stageName: '出图-制作中' }, + { activityId: '31cf_5b23', nodeName: '出策划案', stageName: '出图-制作中' }, + { activityId: 'a9e1_5be0', nodeName: '出图', stageName: '出图-制作中' }, + { activityId: 'de46_708d', nodeName: '平面', stageName: '出图-制作中' }, + ], + pricing: [ + { activityId: 'sid-startevent', nodeName: '发起人', stageName: '限价-发起' }, + { activityId: 'c4f8_b69c', nodeName: '部门负责人审批', stageName: '限价-审核' }, + { activityId: '3c61_a425', nodeName: '商品企划负责人审核', stageName: '限价-审核' }, + ], + main: [ + { activityId: 'sid-startevent', nodeName: '发起人', stageName: '总流程-发起' }, + ], +} + +export function resolveOaBoardTimelineStage( + flowType: OaBoardFlowType, + nodeName: string, + approvalStageName: string | null, +) { + if (flowType === 'review') { + return { key: 'review_stage', name: '立项评审' } + } + + if (flowType === 'art') { + return { key: 'art_stage', name: '美术设计' } + } + + if (flowType === 'pricing') { + return { key: 'pricing_stage', name: '限价审批' } + } + + if (flowType === 'main') { + return { key: 'listing_stage', name: '运营上架' } + } + + const combinedText = `${nodeName} ${approvalStageName ?? ''}` + + if ( + combinedText.includes('确认打样出货时间') || + combinedText.includes('填写样品快递单号') + ) { + return { key: 'sample_prepare_stage', name: '打样安排' } + } + + if ( + combinedText.includes('样品评审') || + combinedText.includes('售卖品牌') || + combinedText.includes('空间布局') + ) { + return { key: 'sample_review_stage', name: '样品评审' } + } + + return { key: 'order_delivery_stage', name: '首单下单出货' } +} + +export const OA_BOARD_DEFAULT_ACTIVITY_MAPPINGS: OaBoardDefaultActivityMapping[] = ( + Object.entries(OA_BOARD_ACTIVITY_MAPPING_SEED) as Array<[OaBoardFlowType, MappingSeed]> +).flatMap(([flowType, rows]) => + rows.map((row, index) => { + const stageKey = + row.stageName?.trim() + .replace(/[^\p{L}\p{N}]+/gu, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() ?? `flow_${flowType}` + + const nodeKey = `${flowType}:${row.activityId}` + const timelineStage = resolveOaBoardTimelineStage(flowType, row.nodeName, row.stageName) + + return { + flowType, + processCode: OA_BOARD_PROCESS_CODES[flowType], + activityId: row.activityId, + nodeKey, + nodeName: row.nodeName, + stageKey, + stageName: row.stageName ?? '未归类阶段', + timelineStageKey: timelineStage.key, + timelineStageName: timelineStage.name, + sequence: index + 1, + metadata: { + source: '流程节点代码汇总表_审批阶段.xlsx' as const, + }, + } + }), +) + +export function getOaBoardProductTargetHours(productType: string | null | undefined) { + if (!productType) return null + return OA_BOARD_PRODUCT_TARGET_HOURS[productType] ?? null +} + +export const OA_BOARD_JOIN_FIELD_NAMES = { + openingCode: ['开款编号'], + orderCode: ['新品首单下单号'], + pricingCode: ['品牌限价单号'], + artCode: ['视觉出图单号'], + spu: ['SPU'], + productType: ['产品类型'], + brand: ['品牌', '开款品牌'], + productCategory: ['品类'], + categoryName: ['类目'], + productName: ['产品名称'], +} as const + +export const OA_BOARD_RUNNING_STATUSES = new Set(['RUNNING', 'PROCESSING', 'PENDING']) + +export const OA_BOARD_COMPLETED_STATUSES = new Set(['COMPLETED', 'TERMINATED']) + +export const OA_BOARD_PARALLEL_FLOW_TYPES = new Set(['order', 'art']) diff --git a/furniture-3d-system/src/server/services/oa-board.service.ts b/furniture-3d-system/src/server/services/oa-board.service.ts new file mode 100644 index 00000000..ab5d38c4 --- /dev/null +++ b/furniture-3d-system/src/server/services/oa-board.service.ts @@ -0,0 +1,418 @@ +import { + getOaBoardProductTargetHours, + OA_BOARD_FLOW_LABELS, + OA_BOARD_FLOW_PRIORITY, + OA_BOARD_STAGE_DEFINITIONS, + OA_BOARD_STAGE_TARGET_MATRIX, +} from './oa-board.constants' +import { queryOaBoardPreview } from './oa-board-sync.service' + +export interface OaBoardProjectListFilter { + keyword?: string + overallStatus?: string + productType?: string + timeoutOnly?: boolean +} + +export interface OaBoardFlowChipDTO { + flowType: string + flowLabel: string + processName: string + processInstanceId: string + status: string + result: string | null + actualHours: number | null + createdAtSource: string | null + finishedAtSource: string | null + usedWorkflowFallback: boolean +} + +export interface OaBoardNodeAssessmentDTO { + flowType: string + flowLabel: string + processName: string + taskId: string + nodeName: string + approvalStageName: string | null + ownerLabel: string | null + departmentLabel: string | null + targetHours: number | null + actualHours: number | null + overtimeHours: number | null + isOvertime: boolean + status: string + startedAtSource: string | null + finishedAtSource: string | null +} + +export interface OaBoardProjectListItemDTO { + id: string + boardKey: string + boardKeyType: string + title: string + productName: string | null + productType: string | null + brand: string | null + productCategory: string | null + categoryName: string | null + spu: string | null + openingCode: string | null + orderCode: string | null + pricingCode: string | null + currentStageKey: string | null + currentStageName: string | null + currentApprovalStageKey: string | null + currentApprovalStageName: string | null + overallStatus: string | null + overallResult: string | null + overallTargetHours: number | null + overallActualHours: number | null + overallTimeoutHours: number | null + overallTimeoutFlag: boolean + originatorDeptName: string | null + lastSourceUpdatedAt: string | null + formSummary: { + styleImageLabel: string + openingCode: string + totalFlowStatusLabel: string + approvalStageDisplay: string + currentOwnerDisplay: string + currentNodeDisplay: string + demandType: string + developmentPlan: string + productType: string + shippingType: string + needVisualDesign: string + plannedSalesBrands: string + category: string + categoryName: string + salesPriceBand: string + platformPriceBand: string + competitorLink: string + openingReason: string + isTestStyle: string + testStyleCode: string + productPlanner: string + operationBrandOwner: string + spuOwner: string + appearanceDesigner: string + structureDesigner: string + purchaseOwner: string + merchandiserOwner: string + pricingOwner: string + workflowLink: string + needPatent: string + needTrademark: string + reviewResult: string + sampleReviewResult: string + salesBrands: string + spu: string + openingBrand: string + } + flowChips: OaBoardFlowChipDTO[] + nodeAssessments: OaBoardNodeAssessmentDTO[] +} + +export interface OaBoardSummaryDTO { + totalProjects: number + timeoutProjects: number + runningProjects: number + completedProjects: number + productTypes: string[] +} + +export interface OaBoardStageMatrixRowDTO { + productType: string + totalTargetHours: number + stageTargets: Array<{ + stageKey: string + stageName: string + targetHours: number | null + }> +} + +export interface OaBoardDashboardDTO { + items: OaBoardProjectListItemDTO[] + summary: OaBoardSummaryDTO + stageMatrix: OaBoardStageMatrixRowDTO[] +} + +export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoardDashboardDTO { + return { + items: [], + summary: { + totalProjects: 0, + timeoutProjects: 0, + runningProjects: 0, + completedProjects: 0, + productTypes, + }, + stageMatrix: buildStageMatrixRows(), + } +} + +function toIsoString(value: Date | null | undefined) { + return value ? value.toISOString() : null +} + +function toHours(value: number | null | undefined) { + if (value === null || value === undefined || Number.isNaN(value)) return null + return Number(value.toFixed(2)) +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function asText(value: unknown) { + return typeof value === 'string' ? value : '' +} + +function readFormSummary(value: unknown): OaBoardProjectListItemDTO['formSummary'] { + const empty = { + styleImageLabel: '', + openingCode: '', + totalFlowStatusLabel: '', + approvalStageDisplay: '', + currentOwnerDisplay: '', + currentNodeDisplay: '', + demandType: '', + developmentPlan: '', + productType: '', + shippingType: '', + needVisualDesign: '', + plannedSalesBrands: '', + category: '', + categoryName: '', + salesPriceBand: '', + platformPriceBand: '', + competitorLink: '', + openingReason: '', + isTestStyle: '', + testStyleCode: '', + productPlanner: '', + operationBrandOwner: '', + spuOwner: '', + appearanceDesigner: '', + structureDesigner: '', + purchaseOwner: '', + merchandiserOwner: '', + pricingOwner: '', + workflowLink: '', + needPatent: '', + needTrademark: '', + reviewResult: '', + sampleReviewResult: '', + salesBrands: '', + spu: '', + openingBrand: '', + } + + if (!isRecord(value)) return empty + + return { + styleImageLabel: asText(value.styleImageLabel), + openingCode: asText(value.openingCode), + totalFlowStatusLabel: asText(value.totalFlowStatusLabel), + approvalStageDisplay: asText(value.approvalStageDisplay), + currentOwnerDisplay: asText(value.currentOwnerDisplay), + currentNodeDisplay: asText(value.currentNodeDisplay), + demandType: asText(value.demandType), + developmentPlan: asText(value.developmentPlan), + productType: asText(value.productType), + shippingType: asText(value.shippingType), + needVisualDesign: asText(value.needVisualDesign), + plannedSalesBrands: asText(value.plannedSalesBrands), + category: asText(value.category), + categoryName: asText(value.categoryName), + salesPriceBand: asText(value.salesPriceBand), + platformPriceBand: asText(value.platformPriceBand), + competitorLink: asText(value.competitorLink), + openingReason: asText(value.openingReason), + isTestStyle: asText(value.isTestStyle), + testStyleCode: asText(value.testStyleCode), + productPlanner: asText(value.productPlanner), + operationBrandOwner: asText(value.operationBrandOwner), + spuOwner: asText(value.spuOwner), + appearanceDesigner: asText(value.appearanceDesigner), + structureDesigner: asText(value.structureDesigner), + purchaseOwner: asText(value.purchaseOwner), + merchandiserOwner: asText(value.merchandiserOwner), + pricingOwner: asText(value.pricingOwner), + workflowLink: asText(value.workflowLink), + needPatent: asText(value.needPatent), + needTrademark: asText(value.needTrademark), + reviewResult: asText(value.reviewResult), + sampleReviewResult: asText(value.sampleReviewResult), + salesBrands: asText(value.salesBrands), + spu: asText(value.spu), + openingBrand: asText(value.openingBrand), + } +} + +type PreviewProjectRecord = Awaited>['projectGraphs'][number] + +function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItemDTO { + const metadata = isRecord(record.metadata) ? record.metadata : null + const formSummary = readFormSummary(metadata?.formSummary) + const nodeAssessments = record.flowInstances + .flatMap((flowInstance) => + flowInstance.taskRecords + .filter((taskRecord) => taskRecord.status !== 'CANCELED') + .map((taskRecord) => ({ + flowType: flowInstance.flowType, + flowLabel: + OA_BOARD_FLOW_LABELS[flowInstance.flowType as keyof typeof OA_BOARD_FLOW_LABELS] ?? + flowInstance.processName, + processName: flowInstance.processName, + taskId: taskRecord.taskId, + nodeName: taskRecord.nodeName ?? taskRecord.activityId, + approvalStageName: taskRecord.stageName ?? null, + ownerLabel: taskRecord.ownerLabel ?? null, + departmentLabel: taskRecord.departmentLabel ?? null, + targetHours: toHours(taskRecord.targetHours), + actualHours: toHours(taskRecord.actualHours), + overtimeHours: toHours(taskRecord.overtimeHours), + isOvertime: taskRecord.isOvertime, + status: taskRecord.status, + startedAtSource: toIsoString(taskRecord.startedAtSource), + finishedAtSource: toIsoString(taskRecord.finishedAtSource), + })), + ) + .sort((left, right) => { + const leftPriority = OA_BOARD_FLOW_PRIORITY.indexOf(left.flowType as (typeof OA_BOARD_FLOW_PRIORITY)[number]) + const rightPriority = OA_BOARD_FLOW_PRIORITY.indexOf(right.flowType as (typeof OA_BOARD_FLOW_PRIORITY)[number]) + if (leftPriority !== rightPriority) return leftPriority - rightPriority + + const leftTime = left.startedAtSource ? new Date(left.startedAtSource).getTime() : Number.MAX_SAFE_INTEGER + const rightTime = right.startedAtSource ? new Date(right.startedAtSource).getTime() : Number.MAX_SAFE_INTEGER + if (leftTime !== rightTime) return leftTime - rightTime + + return left.nodeName.localeCompare(right.nodeName, 'zh-CN') + }) + + return { + id: record.id, + boardKey: record.boardKey, + boardKeyType: record.boardKeyType, + title: record.title, + productName: record.productName, + productType: record.productType, + brand: record.brand, + productCategory: record.productCategory, + categoryName: record.categoryName, + spu: record.spu, + openingCode: record.openingCode, + orderCode: record.orderCode, + pricingCode: record.pricingCode, + currentStageKey: record.currentStageKey, + currentStageName: record.currentStageName, + currentApprovalStageKey: record.currentApprovalStageKey, + currentApprovalStageName: record.currentApprovalStageName, + overallStatus: record.overallStatus, + overallResult: record.overallResult, + overallTargetHours: toHours(record.overallTargetHours), + overallActualHours: toHours(record.overallActualHours), + overallTimeoutHours: toHours(record.overallTimeoutHours), + overallTimeoutFlag: record.overallTimeoutFlag, + originatorDeptName: record.originatorDeptName, + lastSourceUpdatedAt: toIsoString(record.lastSourceUpdatedAt), + formSummary, + flowChips: record.flowInstances.map((flowInstance) => ({ + flowType: flowInstance.flowType, + flowLabel: + OA_BOARD_FLOW_LABELS[flowInstance.flowType as keyof typeof OA_BOARD_FLOW_LABELS] ?? + flowInstance.processName, + processName: flowInstance.processName, + processInstanceId: flowInstance.processInstanceId, + status: flowInstance.status, + result: flowInstance.result, + actualHours: toHours(flowInstance.actualHours), + createdAtSource: toIsoString(flowInstance.createdAtSource), + finishedAtSource: toIsoString(flowInstance.finishedAtSource), + usedWorkflowFallback: flowInstance.usedWorkflowFallback, + })), + nodeAssessments, + } +} + +function buildStageMatrixRows(): OaBoardStageMatrixRowDTO[] { + return Object.entries(OA_BOARD_STAGE_TARGET_MATRIX).map(([productType, stageTargets]) => ({ + productType, + totalTargetHours: getOaBoardProductTargetHours(productType) ?? 0, + stageTargets: OA_BOARD_STAGE_DEFINITIONS.map((stage) => ({ + stageKey: stage.key, + stageName: stage.name, + targetHours: stageTargets[stage.key] ?? null, + })), + })) +} + +export class OaBoardService { + async getDashboard(filter: OaBoardProjectListFilter = {}): Promise { + const pageLimit = Number(process.env.OA_BOARD_PAGE_LIMIT || '120') + const preview = await queryOaBoardPreview(Number.isFinite(pageLimit) && pageLimit > 0 ? pageLimit : 120) + const allItems = preview.projectGraphs.map(toProjectListItem) + + const items = allItems.filter((item) => { + if (filter.keyword?.trim()) { + const keyword = filter.keyword.trim().toLowerCase() + const haystack = [ + item.boardKey, + item.title, + item.productName, + item.productType, + item.brand, + item.spu, + item.openingCode, + item.orderCode, + item.pricingCode, + item.formSummary.currentNodeDisplay, + item.formSummary.currentOwnerDisplay, + ] + .map((value) => String(value ?? '').toLowerCase()) + .join('\n') + + if (!haystack.includes(keyword)) { + return false + } + } + + if (filter.overallStatus?.trim() && item.overallStatus !== filter.overallStatus.trim()) { + return false + } + + if (filter.productType?.trim() && item.productType !== filter.productType.trim()) { + return false + } + + if (filter.timeoutOnly && !item.overallTimeoutFlag) { + return false + } + + return true + }) + + const productTypes = Array.from( + new Set( + allItems + .map((item) => item.productType) + .filter((value): value is string => Boolean(value)), + ), + ).sort((left, right) => left.localeCompare(right, 'zh-CN')) + + return { + items, + summary: { + totalProjects: items.length, + timeoutProjects: items.filter((item) => item.overallTimeoutFlag).length, + runningProjects: items.filter((item) => item.overallStatus === 'RUNNING').length, + completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length, + productTypes, + }, + stageMatrix: buildStageMatrixRows(), + } + } +} + +export const oaBoardService = new OaBoardService()