merge: 同步最新徐霞客主线
This commit is contained in:
commit
e7de41ce8d
@ -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;
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
@ -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<CSSStyleDeclaration> {
|
||||
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`,
|
||||
};
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<ReturnType<typeof resolveOaBoardTodoOwners>>,
|
||||
): 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 ?? ''}`),
|
||||
)
|
||||
|
||||
@ -16,21 +16,6 @@ const TIMEOUT_FOLLOWUP_FULL_ACCESS_NAMES = new Set([
|
||||
.filter(Boolean),
|
||||
])
|
||||
|
||||
const TIMEOUT_FOLLOWUP_OWNER_FIELD_BY_STAGE: Record<string, string> = {
|
||||
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<string, unknown> | null) {
|
||||
const metadata = snapshot?.metadata
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {}
|
||||
@ -220,40 +201,74 @@ function readText(record: Record<string, unknown>, key: string) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function readTimelineMetrics(
|
||||
row: TimeoutFollowupPermissionSnapshotRow,
|
||||
snapshot: Record<string, unknown> | null,
|
||||
) {
|
||||
const metadata = snapshot?.metadata
|
||||
const rawMetrics =
|
||||
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
|
||||
? (metadata as Record<string, unknown>).timelineMetrics
|
||||
: null
|
||||
const metrics = Array.isArray(rawMetrics) ? rawMetrics : readJsonArray(row.timeline_metrics)
|
||||
|
||||
return Array.isArray(metrics)
|
||||
? metrics.filter(
|
||||
(metric): metric is Record<string, unknown> =>
|
||||
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<string, unknown>[], stageKey: string) {
|
||||
const metric = metrics.find((entry) => readText(entry, 'key') === stageKey)
|
||||
if (!metric) return null
|
||||
function readCurrentOverdueNode(snapshot: Record<string, unknown> | 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<string, unknown> => 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<string, unknown>).taskRecords)
|
||||
? ((flowInstance as Record<string, unknown>).taskRecords as unknown[])
|
||||
: []
|
||||
|
||||
return tasks
|
||||
.filter((taskRecord): taskRecord is Record<string, unknown> => 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<string, unknown>).targetHours),
|
||||
actualHours: Number((taskRecord as Record<string, unknown>).actualHours),
|
||||
overtimeHours: Number((taskRecord as Record<string, unknown>).overtimeHours),
|
||||
isOvertime: (taskRecord as Record<string, unknown>).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<OaBoardTimeoutFollowupEditPermission> {
|
||||
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<TimeoutFollowupPermissionSnapshotRow[]>(
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user