diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts index 61e696aa..0efe6ffe 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts @@ -2,6 +2,10 @@ import html2canvas from "html2canvas"; import QRCode from "qrcode"; +import { + GUJIA_CERTIFICATE_CANVAS, + getGujiaCertificateFieldStyle, +} from "./barcodePrintGujiaCertificateLayout"; import type { BarcodePrintRow } from "./barcodePrintRows"; type CertificateTemplate = { @@ -428,7 +432,12 @@ function renderGujiaCertificate( _options: CertificatePrintOptions, backgroundDataUrl: string, ) { - const container = createCertificateMain("certificate_main_gj", backgroundDataUrl, 1323, 1559); + const container = createCertificateMain( + "certificate_main_gj", + backgroundDataUrl, + GUJIA_CERTIFICATE_CANVAS.width, + GUJIA_CERTIFICATE_CANVAS.height, + ); [ getProductLabel(record), record.brandProcode || record.procode, @@ -440,14 +449,12 @@ function renderGujiaCertificate( getLast10DayText(), "", ].forEach((text, index) => { - appendItem(container, `item_${index + 1} item`, text, { - position: "absolute", - fontSize: "42px", - color: "#000000", - lineHeight: "48px", - left: "455px", - top: `${530 + index * 101}px`, - }); + appendItem( + container, + `item_${index + 1} item`, + text, + getGujiaCertificateFieldStyle(index), + ); }); return container; diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts new file mode 100644 index 00000000..2b53ba82 --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts @@ -0,0 +1,33 @@ +import { + GUJIA_CERTIFICATE_CANVAS, + getGujiaCertificateFieldStyle, +} from "./barcodePrintGujiaCertificateLayout"; + +describe("Gujia certificate layout", () => { + it("centers every value inside the preprinted field area", () => { + for (let index = 0; index < 8; index += 1) { + expect(getGujiaCertificateFieldStyle(index)).toEqual( + expect.objectContaining({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "700px", + height: "48px", + lineHeight: "1", + textAlign: "center", + left: "455px", + }), + ); + } + }); + + it("raises only the product name while preserving the remaining row spacing", () => { + expect(getGujiaCertificateFieldStyle(0).top).toBe("520px"); + expect(getGujiaCertificateFieldStyle(1).top).toBe("631px"); + expect(getGujiaCertificateFieldStyle(2).top).toBe("732px"); + }); + + it("keeps the original Gujia canvas dimensions", () => { + expect(GUJIA_CERTIFICATE_CANVAS).toEqual({ width: 1323, height: 1559 }); + }); +}); diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts new file mode 100644 index 00000000..21e9a0b3 --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts @@ -0,0 +1,35 @@ +export const GUJIA_CERTIFICATE_CANVAS = { + width: 1323, + height: 1559, +} as const; + +const GUJIA_FIELD_LEFT = 455; +const GUJIA_FIELD_WIDTH = 700; +const GUJIA_FIELD_TOP = 530; +const GUJIA_FIELD_HEIGHT = 48; +const GUJIA_FIELD_ROW_GAP = 101; +const GUJIA_PRODUCT_NAME_TOP_OFFSET = -10; + +export function getGujiaCertificateFieldStyle( + index: number, +): Partial { + const top = + GUJIA_FIELD_TOP + + index * GUJIA_FIELD_ROW_GAP + + (index === 0 ? GUJIA_PRODUCT_NAME_TOP_OFFSET : 0); + + return { + position: "absolute", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: `${GUJIA_FIELD_WIDTH}px`, + height: `${GUJIA_FIELD_HEIGHT}px`, + fontSize: "42px", + color: "#000000", + lineHeight: "1", + textAlign: "center", + left: `${GUJIA_FIELD_LEFT}px`, + top: `${top}px`, + }; +} diff --git a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx index 54e55f81..cec92a42 100644 --- a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx +++ b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx @@ -20,6 +20,7 @@ import { import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants' import type { OaBoardDashboardDTO, + OaBoardNodeAssessmentDTO, OaBoardProjectListItemDTO, OaBoardStageGraphDTO, OaBoardStageGraphNodeDTO, @@ -127,6 +128,9 @@ type TimeoutFollowupDraft = { type OvertimeStage = { stageKey: string stageLabel: string + stageContextLabel: string + nodeName: string + ownerLabel: string targetDays: number actualDays: number overtimeDays: number @@ -679,46 +683,56 @@ function buildTimingDetailFields(item: OaBoardProjectListItemDTO): TimingDetailF }) } +function buildCurrentNodeOvertimeStageKey(node: OaBoardNodeAssessmentDTO) { + return `${node.flowType}:${node.nodeName}`.trim() +} + function getOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeStage[] { - const metrics = createTimelineMetricMap(item.timelineMetrics) + const stageNames = splitStageText( + item.formSummary.approvalStageDisplay || item.currentApprovalStageName, + ) + const currentNodeNames = splitStageText(item.formSummary.currentNodeDisplay) + const resolvedCurrentOwner = resolveCurrentOwnerDisplay(item) - return TIMING_DETAIL_DEFINITIONS - .filter((field) => field.valueType === 'timeout') - .flatMap((field) => { - const metric = metrics[field.metricKey] - if (!metric || metric.targetDays === null || metric.actualDays === null) return [] - - const overtimeDays = Number((metric.actualDays - metric.targetDays).toFixed(2)) - if (overtimeDays <= 0) return [] - - return [{ - stageKey: field.metricKey, - stageLabel: field.label.replace(/超时$/, ''), - targetDays: metric.targetDays, - actualDays: metric.actualDays, - overtimeDays, - }] - }) + return item.nodeAssessments + .filter((assessment) => assessment.status === 'RUNNING') + .filter((assessment) => assessment.isOvertime) + .filter( + (assessment) => + assessment.targetHours !== null && + assessment.actualHours !== null && + assessment.overtimeHours !== null && + assessment.overtimeHours > 0, + ) + .filter((assessment) => + currentNodeNames.length > 0 ? currentNodeNames.includes(assessment.nodeName) : true, + ) + .filter((assessment) => + assessment.approvalStageName && stageNames.length > 0 + ? stageNames.includes(assessment.approvalStageName) + : true, + ) + .map((assessment) => ({ + stageKey: buildCurrentNodeOvertimeStageKey(assessment), + stageLabel: assessment.nodeName, + stageContextLabel: assessment.approvalStageName || assessment.flowLabel, + nodeName: assessment.nodeName, + ownerLabel: + resolvedCurrentOwner && resolvedCurrentOwner !== '-' + ? resolvedCurrentOwner + : assessment.ownerLabel || '待分配', + targetDays: assessment.targetHours as number, + actualDays: assessment.actualHours as number, + overtimeDays: assessment.overtimeHours as number, + })) + .sort((left, right) => right.overtimeDays - left.overtimeDays) } function resolveTimeoutFollowupOwner(item: OaBoardProjectListItemDTO, stageKey: string) { - const formSummary = item.formSummary - const ownerByStage: Record = { - exterior: formSummary.appearanceDesigner, - structure: formSummary.structureDesigner, - sample_prepare: formSummary.purchaseOwner, - sample_review: formSummary.structureDesigner, - production: formSummary.merchandiserOwner, - material: formSummary.structureDesigner, - first_order: formSummary.merchandiserOwner, - pre_production: formSummary.merchandiserOwner, - shipment: formSummary.merchandiserOwner, - pricing: formSummary.merchandiserOwner, - art: formSummary.brandPlanningOwner, - pricing_flow: formSummary.pricingOwner, - } - - return ownerByStage[stageKey]?.trim() || '待分配' + const stage = getOvertimeStages(item).find((entry) => entry.stageKey === stageKey) + if (stage?.ownerLabel?.trim()) return stage.ownerLabel.trim() + const resolvedCurrentOwner = resolveCurrentOwnerDisplay(item) + return resolvedCurrentOwner && resolvedCurrentOwner !== '-' ? resolvedCurrentOwner : '待分配' } function getTimeoutFollowupStatusLabel(status: TimeoutFollowupStatus) { diff --git a/furniture-3d-system/src/server/services/dingtalk-todo.service.ts b/furniture-3d-system/src/server/services/dingtalk-todo.service.ts index f9aa518a..9b12f251 100644 --- a/furniture-3d-system/src/server/services/dingtalk-todo.service.ts +++ b/furniture-3d-system/src/server/services/dingtalk-todo.service.ts @@ -175,7 +175,7 @@ class DingtalkTodoService { creatorId: input.creatorUnionId, executorIds: input.executorUnionIds, participantIds: participantUnionIds, - title: input.title, + subject: input.title, description: input.description, detailUrl: input.detailUrl, dueTime: input.dueTime ?? undefined, @@ -202,7 +202,7 @@ class DingtalkTodoService { creatorId: input.creatorUnionId, executorIds: input.executorUnionIds, participantIds: participantUnionIds, - title: input.title, + subject: input.title, description: input.description, detailUrl: input.detailUrl, dueTime: input.dueTime ?? undefined, diff --git a/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts b/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts index d0d31d83..301e9178 100644 --- a/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-dingtalk-todo-dispatch.service.ts @@ -9,6 +9,7 @@ import { } from './dingtalk-todo.service' import { oaBoardService, + type OaBoardNodeAssessmentDTO, type OaBoardProjectListFilter, type OaBoardProjectListItemDTO, } from './oa-board.service' @@ -57,13 +58,6 @@ CREATE TABLE IF NOT EXISTS ${OA_BOARD_DINGTALK_TODO_DISPATCH_TABLE} ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ` -const OA_BOARD_DEFAULT_TODO_STAGE_NAMES = [ - '产品设计-外观', - '产品设计-结构', - '产品生产-首单下单', - '限价-发起', -] as const - export const OA_BOARD_DINGTALK_TODO_STATUSES = [ 'PENDING_CREATE', 'OPEN', @@ -151,11 +145,6 @@ function toIsoText(value: string | Date | null | undefined) { return text || null } -function readNumberEnv(value: string | undefined, fallback: number) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback -} - function toStageKey(stageName: string) { return stageName .trim() @@ -164,12 +153,46 @@ function toStageKey(stageName: string) { .toLowerCase() } -function getSupportedStageNames() { - const configured = (process.env.DINGTALK_TODO_STAGE_NAMES ?? '') - .split(/[;,,;]/) - .map((value) => value.trim()) +function splitDisplayValues(value: string | null | undefined) { + return String(value ?? '') + .split(/[;;]/) + .map((entry) => entry.trim()) .filter(Boolean) - return new Set(configured.length > 0 ? configured : OA_BOARD_DEFAULT_TODO_STAGE_NAMES) +} + +function pickCurrentOverdueAssessment( + item: OaBoardProjectListItemDTO, +): OaBoardNodeAssessmentDTO | null { + const stageNames = splitDisplayValues( + item.formSummary.approvalStageDisplay || item.currentApprovalStageName, + ) + const currentNodeNames = splitDisplayValues(item.formSummary.currentNodeDisplay) + + const candidates = item.nodeAssessments.filter((assessment) => { + if (assessment.status !== 'RUNNING') return false + if (!assessment.isOvertime) return false + if (assessment.targetHours === null || assessment.actualHours === null) return false + if (assessment.overtimeHours === null || assessment.overtimeHours <= 0) return false + if (currentNodeNames.length > 0 && !currentNodeNames.includes(assessment.nodeName)) return false + if ( + stageNames.length > 0 && + assessment.approvalStageName && + !stageNames.includes(assessment.approvalStageName) + ) { + return false + } + return true + }) + + return candidates.sort((left, right) => { + const rightOvertime = right.overtimeHours ?? 0 + const leftOvertime = left.overtimeHours ?? 0 + if (rightOvertime !== leftOvertime) return rightOvertime - leftOvertime + + const leftStarted = left.startedAtSource ? new Date(left.startedAtSource).getTime() : Number.MAX_SAFE_INTEGER + const rightStarted = right.startedAtSource ? new Date(right.startedAtSource).getTime() : Number.MAX_SAFE_INTEGER + return leftStarted - rightStarted + })[0] ?? null } function isDispatchStatus(value: string): value is OaBoardDingtalkTodoDispatchStatus { @@ -250,14 +273,20 @@ function buildTodoTitle(item: OaBoardProjectListItemDTO, stageName: string) { return `【OA 新品流程】${primaryId} ${stageName}` } -function buildTodoDescription(item: OaBoardProjectListItemDTO, stageName: string) { +function buildTimeoutTodoDescription( + item: OaBoardProjectListItemDTO, + stageName: string, + assessment: OaBoardNodeAssessmentDTO, +) { const lines = [ `开款编号:${item.formSummary.openingCode || item.openingCode || '-'}`, `当前阶段:${stageName}`, - `当前节点:${item.formSummary.currentNodeDisplay || '-'}`, + `当前节点:${assessment.nodeName || item.formSummary.currentNodeDisplay || '-'}`, + `当前负责人:${item.formSummary.currentOwnerDisplay || assessment.ownerLabel || '-'}`, + `标准时效:${assessment.targetHours ?? '-'} 天`, + `实际用时:${assessment.actualHours ?? '-'} 天`, + `超时用时:${assessment.overtimeHours ?? '-'} 天`, `产品名称:${item.productName || item.title || '-'}`, - `产品类型:${item.productType || item.formSummary.productType || '-'}`, - `计划售卖品牌:${item.formSummary.plannedSalesBrands || '-'}`, ] return lines.join('\n') } @@ -271,28 +300,35 @@ function buildPayload( stageName: string, stageKey: string, owner: OaBoardResolvedTodoOwner, + assessment: OaBoardNodeAssessmentDTO, ): DingtalkTodoCreateInput | null { if (!owner.dingtalkUserId || !owner.dingtalkUnionId) return null const detailUrl = buildDetailUrl(item) if (!detailUrl) return null - const dueTime = item.lastSourceUpdatedAt - ? new Date(item.lastSourceUpdatedAt).getTime() + readNumberEnv(process.env.DINGTALK_TODO_ESCALATION_DAYS, 3) * 86400000 - : undefined + const startedAt = assessment.startedAtSource + ? new Date(assessment.startedAtSource).getTime() + : item.lastSourceUpdatedAt + ? new Date(item.lastSourceUpdatedAt).getTime() + : undefined + const dueTime = + startedAt !== undefined && assessment.targetHours !== null + ? startedAt + assessment.targetHours * 86400000 + : undefined return { operatorUnionId: owner.dingtalkUnionId, creatorUnionId: owner.dingtalkUnionId, executorUnionIds: [owner.dingtalkUnionId], participantUnionIds: [owner.dingtalkUnionId], - title: buildTodoTitle(item, stageName), - description: buildTodoDescription(item, stageName), + title: `【超时预警】${buildTodoTitle(item, stageName)}`, + description: buildTimeoutTodoDescription(item, stageName, assessment), detailUrl, sourceId: buildSourceId(item, stageKey, owner), bizTag: trimToNull(process.env.DINGTALK_TODO_SOURCE_APP) || 'oa-board', dueTime, - priority: item.overallTimeoutFlag ? 30 : 20, + priority: 30, isOnlyShowExecutor: true, } } @@ -315,6 +351,7 @@ type DesiredDispatch = { function buildDesiredDispatches( item: OaBoardProjectListItemDTO, stageName: string, + assessment: OaBoardNodeAssessmentDTO, owners: Awaited>, ): DesiredDispatch[] { const stageKey = toStageKey(stageName) @@ -329,7 +366,7 @@ function buildDesiredDispatches( receiverName: owner.name, receiverDingtalkUserId: owner.dingtalkUserId, receiverDingtalkUnionId: owner.dingtalkUnionId, - payload: buildPayload(item, stageName, stageKey, owner), + payload: buildPayload(item, stageName, stageKey, owner, assessment), status: owner.dingtalkUserId && owner.dingtalkUnionId ? 'PENDING_CREATE' : 'NEEDS_OWNER_MAPPING', dispatchError: owner.dingtalkUserId && owner.dingtalkUnionId ? null : '责任人缺少钉钉 userId / unionId', @@ -518,7 +555,9 @@ export class OaBoardDingtalkTodoDispatchService { return } - if (item.overallStatus !== 'RUNNING' || !stageName || !getSupportedStageNames().has(stageName)) { + const overdueAssessment = pickCurrentOverdueAssessment(item) + + if (item.overallStatus !== 'RUNNING' || !stageName || !overdueAssessment) { for (const row of existingRows) { if (row.todo_id && row.receiver_dingtalk_union_id) { await dingtalkTodoService.deleteTask(row.receiver_dingtalk_union_id, row.todo_id).catch(() => null) @@ -532,7 +571,7 @@ export class OaBoardDingtalkTodoDispatchService { summary.supportedStageItems += 1 const ownerResolution = await resolveOaBoardTodoOwners(item) - const desiredDispatches = buildDesiredDispatches(item, stageName, ownerResolution) + const desiredDispatches = buildDesiredDispatches(item, stageName, overdueAssessment, ownerResolution) const desiredKeys = new Set( desiredDispatches.map((dispatch) => `${dispatch.stageKey}::${dispatch.receiverDingtalkUserId ?? ''}`), ) diff --git a/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts b/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts index d446cd6b..58b89222 100644 --- a/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts @@ -16,21 +16,6 @@ const TIMEOUT_FOLLOWUP_FULL_ACCESS_NAMES = new Set([ .filter(Boolean), ]) -const TIMEOUT_FOLLOWUP_OWNER_FIELD_BY_STAGE: Record = { - exterior: 'appearanceDesigner', - structure: 'structureDesigner', - sample_prepare: 'purchaseOwner', - sample_review: 'structureDesigner', - production: 'merchandiserOwner', - material: 'structureDesigner', - first_order: 'merchandiserOwner', - pre_production: 'merchandiserOwner', - shipment: 'merchandiserOwner', - pricing: 'merchandiserOwner', - art: 'brandPlanningOwner', - pricing_flow: 'pricingOwner', -} - const OA_BOARD_TIMEOUT_FOLLOWUP_TABLE_DDL = ` CREATE TABLE IF NOT EXISTS ${OA_BOARD_TIMEOUT_FOLLOWUP_TABLE} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, @@ -181,6 +166,13 @@ function splitUserNames(value: string | null | undefined) { .filter(Boolean) } +function splitStageValues(value: string | null | undefined) { + return String(value ?? '') + .split(/[;;]/) + .map((entry) => entry.trim()) + .filter(Boolean) +} + function readJsonRecord(value: string | null) { if (!value) return null @@ -194,17 +186,6 @@ function readJsonRecord(value: string | null) { } } -function readJsonArray(value: string | null) { - if (!value) return [] - - try { - const parsed: unknown = JSON.parse(value) - return Array.isArray(parsed) ? parsed : [] - } catch { - return [] - } -} - function readFormSummary(snapshot: Record | null) { const metadata = snapshot?.metadata if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {} @@ -220,40 +201,74 @@ function readText(record: Record, key: string) { return typeof value === 'string' ? value.trim() : '' } -function readTimelineMetrics( - row: TimeoutFollowupPermissionSnapshotRow, - snapshot: Record | null, -) { - const metadata = snapshot?.metadata - const rawMetrics = - metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record).timelineMetrics - : null - const metrics = Array.isArray(rawMetrics) ? rawMetrics : readJsonArray(row.timeline_metrics) - - return Array.isArray(metrics) - ? metrics.filter( - (metric): metric is Record => - Boolean(metric) && typeof metric === 'object' && !Array.isArray(metric), - ) - : [] +function buildCurrentNodeOvertimeStageKey(input: { + flowType?: string | null + nodeName?: string | null +}) { + return `${String(input.flowType ?? '').trim()}:${String(input.nodeName ?? '').trim()}` } -function readOverdueMetric(metrics: Record[], stageKey: string) { - const metric = metrics.find((entry) => readText(entry, 'key') === stageKey) - if (!metric) return null +function readCurrentOverdueNode(snapshot: Record | null, stageKey: string) { + const formSummary = readFormSummary(snapshot) + const stageNames = splitStageValues(readText(formSummary, 'approvalStageDisplay')) + const currentNodeNames = splitStageValues(readText(formSummary, 'currentNodeDisplay')) + const flowInstances = Array.isArray(snapshot?.flowInstances) ? snapshot.flowInstances : [] - const targetDays = Number(metric.targetDays) - const actualDays = Number(metric.actualDays) - if (!Number.isFinite(targetDays) || !Number.isFinite(actualDays) || actualDays <= targetDays) { - return null - } + const taskRecords = flowInstances + .filter((flowInstance): flowInstance is Record => Boolean(flowInstance) && typeof flowInstance === 'object' && !Array.isArray(flowInstance)) + .flatMap((flowInstance) => { + const flowType = readText(flowInstance, 'flowType') + const processName = readText(flowInstance, 'processName') + const tasks = Array.isArray((flowInstance as Record).taskRecords) + ? ((flowInstance as Record).taskRecords as unknown[]) + : [] + + return tasks + .filter((taskRecord): taskRecord is Record => Boolean(taskRecord) && typeof taskRecord === 'object' && !Array.isArray(taskRecord)) + .map((taskRecord) => ({ + flowType, + processName, + stageName: readText(taskRecord, 'stageName'), + nodeName: readText(taskRecord, 'nodeName'), + ownerLabel: readText(taskRecord, 'ownerLabel'), + status: readText(taskRecord, 'status'), + targetHours: Number((taskRecord as Record).targetHours), + actualHours: Number((taskRecord as Record).actualHours), + overtimeHours: Number((taskRecord as Record).overtimeHours), + isOvertime: (taskRecord as Record).isOvertime === true, + stageKey: buildCurrentNodeOvertimeStageKey({ + flowType, + nodeName: readText(taskRecord, 'nodeName'), + }), + })) + }) + .filter((taskRecord) => taskRecord.status === 'RUNNING') + .filter((taskRecord) => taskRecord.stageKey === stageKey) + .filter((taskRecord) => + currentNodeNames.length > 0 ? currentNodeNames.includes(taskRecord.nodeName) : true, + ) + .filter((taskRecord) => + taskRecord.stageName && stageNames.length > 0 ? stageNames.includes(taskRecord.stageName) : true, + ) + .filter( + (taskRecord) => + Number.isFinite(taskRecord.targetHours) && + Number.isFinite(taskRecord.actualHours) && + Number.isFinite(taskRecord.overtimeHours) && + taskRecord.isOvertime && + taskRecord.overtimeHours > 0, + ) + .sort((left, right) => right.overtimeHours - left.overtimeHours) + + const taskRecord = taskRecords[0] + if (!taskRecord) return null return { - stageLabel: readText(metric, 'label') || stageKey, - targetDays: Number(targetDays.toFixed(2)), - actualDays: Number(actualDays.toFixed(2)), - overtimeDays: Number((actualDays - targetDays).toFixed(2)), + stageLabel: `${taskRecord.stageName || taskRecord.processName || '-'} · ${taskRecord.nodeName || stageKey}`, + targetDays: Number(taskRecord.targetHours.toFixed(2)), + actualDays: Number(taskRecord.actualHours.toFixed(2)), + overtimeDays: Number(taskRecord.overtimeHours.toFixed(2)), + ownerLabel: taskRecord.ownerLabel || null, } } @@ -363,9 +378,6 @@ export class OaBoardTimeoutFollowupService { ): Promise { if (!user) return { allowed: false, message: '请先登录后再保存超时跟进' } - const ownerField = TIMEOUT_FOLLOWUP_OWNER_FIELD_BY_STAGE[input.stageKey] - if (!ownerField) return { allowed: false, message: '当前超时阶段不支持跟进编辑' } - const connection = await getPool().getConnection() try { const [rows] = await connection.query( @@ -383,10 +395,9 @@ export class OaBoardTimeoutFollowupService { const snapshot = readJsonRecord(row.raw_snapshot) const formSummary = readFormSummary(snapshot) - const metrics = readTimelineMetrics(row, snapshot) - const currentOverdueStage = readOverdueMetric(metrics, input.stageKey) + const currentOverdueStage = readCurrentOverdueNode(snapshot, input.stageKey) if (!currentOverdueStage) { - return { allowed: false, message: '该阶段当前未超时或已更新,无法保存超时跟进' } + return { allowed: false, message: '当前审批节点未超时或已变化,无法保存超时跟进' } } if (isFullAccessEditor(user)) return { allowed: true, ...currentOverdueStage } @@ -402,13 +413,15 @@ export class OaBoardTimeoutFollowupService { } const actorName = user.name.trim() const productPlanners = splitUserNames(fieldValues.productPlanner) - const stageOwners = splitUserNames(fieldValues[ownerField]) + const currentOwners = splitUserNames(readText(formSummary, 'currentOwnerDisplay')) + const stageOwners = currentOverdueStage.ownerLabel ? splitUserNames(currentOverdueStage.ownerLabel) : [] + const mergedOwners = Array.from(new Set([...currentOwners, ...stageOwners])) - if (productPlanners.includes(actorName) || stageOwners.includes(actorName)) { + if (productPlanners.includes(actorName) || mergedOwners.includes(actorName)) { return { allowed: true, ...currentOverdueStage } } - return { allowed: false, message: '仅管理员、荆川、商品企划或该阶段负责人可保存跟进' } + return { allowed: false, message: '仅管理员、荆川、商品企划或当前节点负责人可保存跟进' } } finally { connection.release() } diff --git a/furniture-3d-system/src/server/services/oa-board.constants.ts b/furniture-3d-system/src/server/services/oa-board.constants.ts index d3f1a70f..4fa35016 100644 --- a/furniture-3d-system/src/server/services/oa-board.constants.ts +++ b/furniture-3d-system/src/server/services/oa-board.constants.ts @@ -631,4 +631,7 @@ export const OA_BOARD_ORDER_COMPLETION_FINAL_DEVELOPMENT_PLANS = new Set([ export const OA_BOARD_MANUAL_OVERALL_STATUS_OVERRIDES = { XP2026061200000171: 'TERMINATED', + XP2026072300000345: 'TERMINATED', + XP2026070300000255: 'TERMINATED', + XP2026070400000261: 'TERMINATED', } as const