merge: 同步最新徐霞客主线

This commit is contained in:
WuYongJie 2026-07-31 11:34:28 +08:00
commit e7de41ce8d
8 changed files with 284 additions and 140 deletions

View File

@ -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;

View File

@ -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 });
});
});

View File

@ -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`,
};
}

View File

@ -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) {

View File

@ -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,

View File

@ -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 ?? ''}`),
)

View File

@ -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()
}

View File

@ -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