diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index 253279b9..e01a9f63 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -79,9 +79,7 @@ DINGTALK_CLIENT_ID= DINGTALK_CLIENT_SECRET= DINGTALK_CALLBACK_ORIGIN= -# 钉钉待办(预留) -# 注意:当前仓库尚未内置待办发送服务,以下变量仅作为接入预留, -# 需要配合后端待办服务与投递表一起开发后才会实际生效。 +# 钉钉待办:OA 阶段超时预警,以及预计出货日前 15 天的品牌策划主管提醒。 DINGTALK_TODO_ENABLED=false DINGTALK_TODO_DETAIL_BASE_URL= DINGTALK_TODO_SOURCE_APP=oa-board diff --git a/furniture-3d-system/scripts/install-oa-board-sync-timer.sh b/furniture-3d-system/scripts/install-oa-board-sync-timer.sh index 037bb6fd..9e2bdf0e 100755 --- a/furniture-3d-system/scripts/install-oa-board-sync-timer.sh +++ b/furniture-3d-system/scripts/install-oa-board-sync-timer.sh @@ -43,7 +43,7 @@ EnvironmentFile=-$HR_SYNC_ENV # Keep dependent snapshots and the todo scan after the OA refresh so each # consumer uses the newest workflow state. # The global switch remains off by default and skips the todo scan until it is explicitly enabled. -ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/sync-oa-board.ts --apply; ./node_modules/.bin/tsx scripts/sync-oa-brand-planning-board.ts --apply; case "\${DINGTALK_TODO_ENABLED:-false}" in 1|true|TRUE|yes|YES|on|ON) ./node_modules/.bin/tsx scripts/sync-oa-board-todos.ts ;; *) echo "DingTalk OA overtime todo dispatch is disabled; skipping." ;; esac' +ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/sync-oa-board.ts --apply; ./node_modules/.bin/tsx scripts/sync-oa-brand-planning-board.ts --apply; case "\${DINGTALK_TODO_ENABLED:-false}" in 1|true|TRUE|yes|YES|on|ON) ./node_modules/.bin/tsx scripts/sync-oa-board-todos.ts ;; *) echo "DingTalk OA todo dispatch is disabled; skipping." ;; esac' StandardOutput=journal StandardError=journal EOF diff --git a/furniture-3d-system/scripts/sync-oa-board-todos.ts b/furniture-3d-system/scripts/sync-oa-board-todos.ts index 5a98def2..0e12f095 100644 --- a/furniture-3d-system/scripts/sync-oa-board-todos.ts +++ b/furniture-3d-system/scripts/sync-oa-board-todos.ts @@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma' import { oaBoardDingtalkTodoDispatchService } from '@/server/services/oa-board-dingtalk-todo-dispatch.service' +import { oaBrandPlanningShipmentTodoService } from '@/server/services/oa-brand-planning-shipment-todo.service' function readArg(name: string) { const index = process.argv.findIndex((arg) => arg === `--${name}`) @@ -14,17 +15,20 @@ async function main() { const owner = readArg('owner')?.trim() || undefined const approvalStage = readArg('approval-stage')?.trim() || undefined - const summary = await oaBoardDingtalkTodoDispatchService.syncDashboard({ - approvalNumber: openingCode, - owner, - approvalStage, - }) + const [overtime, shipmentReminder] = await Promise.all([ + oaBoardDingtalkTodoDispatchService.syncDashboard({ + approvalNumber: openingCode, + owner, + approvalStage, + }), + oaBrandPlanningShipmentTodoService.sync(), + ]) console.log( JSON.stringify( { ok: true, - summary, + summary: { overtime, shipmentReminder }, }, null, 2, diff --git a/furniture-3d-system/src/server/services/__tests__/oa-brand-planning-shipment-todo.service.test.ts b/furniture-3d-system/src/server/services/__tests__/oa-brand-planning-shipment-todo.service.test.ts new file mode 100644 index 00000000..7b691b04 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/oa-brand-planning-shipment-todo.service.test.ts @@ -0,0 +1,28 @@ +jest.mock('server-only', () => ({})) + +import { evaluateShipmentReminder } from '../oa-brand-planning-shipment-todo.service' + +describe('brand planning shipment reminder window', () => { + it('opens the reminder window 15 calendar days before shipment in Shanghai', () => { + expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-10T15:59:59Z'))).toEqual({ + shipmentDate: '2026-08-26', + reminderDate: '2026-08-11', + eligible: false, + }) + expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-10T16:00:00Z'))).toEqual({ + shipmentDate: '2026-08-26', + reminderDate: '2026-08-11', + eligible: true, + }) + }) + + it('closes the reminder window on the shipment date', () => { + expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-25T15:59:59Z')).eligible).toBe(true) + expect(evaluateShipmentReminder('2026-08-26', new Date('2026-08-25T16:00:00Z')).eligible).toBe(false) + }) + + it('rejects invalid or non-date shipment values', () => { + expect(evaluateShipmentReminder('2026-02-30', new Date()).eligible).toBe(false) + expect(evaluateShipmentReminder('待确认', new Date()).eligible).toBe(false) + }) +}) diff --git a/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts b/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts index d36fab65..b0b81d4b 100644 --- a/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-owner-resolution.service.ts @@ -110,6 +110,28 @@ export async function resolveOaBoardTodoOwners( } } + const identityResolution = await resolveDingtalkTodoOwnersByNames(ownerNames) + + return { + stageNames, + ownerDisplay, + ...identityResolution, + } +} + +export async function resolveDingtalkTodoOwnersByNames(inputNames: string[]) { + const ownerNames = Array.from( + new Set(inputNames.flatMap(splitOwnerText).filter((name) => name !== '-')), + ) + + if (ownerNames.length === 0) { + return { + resolvedOwners: [] as OaBoardResolvedTodoOwner[], + unresolvedNames: [] as string[], + ambiguousNames: [] as string[], + } + } + const [users, employees] = await Promise.all([ prisma.user.findMany({ where: { @@ -191,11 +213,5 @@ export async function resolveOaBoardTodoOwners( }) }) - return { - stageNames, - ownerDisplay, - resolvedOwners, - unresolvedNames, - ambiguousNames, - } + return { resolvedOwners, unresolvedNames, ambiguousNames } } diff --git a/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts b/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts index b9da45ec..540758f7 100644 --- a/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts +++ b/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts @@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS ${OA_BRAND_PLANNING_SNAPSHOT_TABLE} ( product_planner VARCHAR(255) NULL, spu_owner VARCHAR(255) NULL, purchase_owner VARCHAR(255) NULL, + brand_planning_supervisor VARCHAR(255) NULL, estimated_shipment_date VARCHAR(255) NULL, product_detail_link TEXT NULL, approval_stage VARCHAR(255) NULL, @@ -84,6 +85,7 @@ type BrandPlanningSnapshotFields = { product_planner: string | null spu_owner: string | null purchase_owner: string | null + brand_planning_supervisor: string | null estimated_shipment_date: string | null product_detail_link: string | null approval_stage: string | null @@ -164,6 +166,7 @@ export interface OaBrandPlanningBoardItemDTO { productPlanner: string | null spuOwner: string | null purchaseOwner: string | null + brandPlanningSupervisor: string | null estimatedShipmentDate: string | null productDetailLink: string | null approvalStage: string | null @@ -445,6 +448,7 @@ function buildSnapshotRows( product_planner: toText(profile?.productPlanning), spu_owner: toText(profile?.spuOwner), purchase_owner: toText(profile?.purchaseOwner), + brand_planning_supervisor: readFlowField(reviewFlow, ['品牌策划主管']), estimated_shipment_date: getFactoryLeadTime(payload, sku), product_detail_link: `/intake/non-integrated/${encodeURIComponent(product.id)}`, approval_stage: graph?.currentApprovalStageName ?? null, @@ -483,6 +487,9 @@ async function createConnection() { async function ensureSnapshotTable(connection: mysql.Connection) { await connection.query(OA_BRAND_PLANNING_SNAPSHOT_TABLE_DDL) + await connection.query( + `ALTER TABLE ${OA_BRAND_PLANNING_SNAPSHOT_TABLE} ADD COLUMN IF NOT EXISTS brand_planning_supervisor VARCHAR(255) NULL AFTER purchase_owner`, + ) } async function loadProducts(): Promise { @@ -547,6 +554,7 @@ async function persistRows( 'product_planner', 'spu_owner', 'purchase_owner', + 'brand_planning_supervisor', 'estimated_shipment_date', 'product_detail_link', 'approval_stage', @@ -619,6 +627,7 @@ function toDTO(row: BrandPlanningSnapshotRow): OaBrandPlanningBoardItemDTO { productPlanner: row.product_planner, spuOwner: row.spu_owner, purchaseOwner: row.purchase_owner, + brandPlanningSupervisor: row.brand_planning_supervisor, estimatedShipmentDate: row.estimated_shipment_date, productDetailLink: row.product_detail_link, approvalStage: row.approval_stage, diff --git a/furniture-3d-system/src/server/services/oa-brand-planning-shipment-todo.service.ts b/furniture-3d-system/src/server/services/oa-brand-planning-shipment-todo.service.ts new file mode 100644 index 00000000..e5e4ef3b --- /dev/null +++ b/furniture-3d-system/src/server/services/oa-brand-planning-shipment-todo.service.ts @@ -0,0 +1,218 @@ +import 'server-only' + +import * as mysql from 'mysql2/promise' +import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise' +import { dingtalkTodoService, type DingtalkTodoCreateInput } from './dingtalk-todo.service' +import { oaBrandPlanningBoardService } from './oa-brand-planning-board.service' +import { resolveDingtalkTodoOwnersByNames } from './oa-board-owner-resolution.service' +import { createPlanningConnectionConfig } from './oa-board-sync.service' + +const TABLE = 'oa_brand_planning_shipment_todo_dispatch' +const REMINDER_DAYS = 15 +const DDL = ` +CREATE TABLE IF NOT EXISTS ${TABLE} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + row_key VARCHAR(191) NOT NULL, + shipment_date DATE NOT NULL, + receiver_dingtalk_user_id VARCHAR(128) NOT NULL, + receiver_dingtalk_union_id VARCHAR(128) NULL, + receiver_name VARCHAR(255) NULL, + todo_id VARCHAR(128) NULL, + status VARCHAR(32) NOT NULL, + dispatch_error TEXT NULL, + payload_json LONGTEXT NULL, + sent_at DATETIME NULL, + closed_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_brand_shipment_todo (row_key, shipment_date, receiver_dingtalk_user_id), + KEY idx_brand_shipment_todo_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +` + +type DispatchRow = RowDataPacket & { + id: number + row_key: string + shipment_date: string + receiver_dingtalk_user_id: string + receiver_dingtalk_union_id: string | null + todo_id: string | null +} + +export type ShipmentReminderDecision = { + shipmentDate: string | null + reminderDate: string | null + eligible: boolean +} + +function dateText(value: string | null | undefined) { + const match = String(value ?? '').trim().match(/^(\d{4})-(\d{2})-(\d{2})/) + if (!match) return null + const [, year, month, day] = match + const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))) + if ( + date.getUTCFullYear() !== Number(year) || + date.getUTCMonth() !== Number(month) - 1 || + date.getUTCDate() !== Number(day) + ) return null + return `${year}-${month}-${day}` +} + +function shiftDate(value: string, days: number) { + const [year, month, day] = value.split('-').map(Number) + const date = new Date(Date.UTC(year, month - 1, day + days)) + return date.toISOString().slice(0, 10) +} + +function shanghaiToday(now: Date) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(now) +} + +export function evaluateShipmentReminder( + estimatedShipmentDate: string | null | undefined, + now = new Date(), +): ShipmentReminderDecision { + const shipmentDate = dateText(estimatedShipmentDate) + if (!shipmentDate) return { shipmentDate: null, reminderDate: null, eligible: false } + const reminderDate = shiftDate(shipmentDate, -REMINDER_DAYS) + const today = shanghaiToday(now) + return { + shipmentDate, + reminderDate, + eligible: today >= reminderDate && today < shipmentDate, + } +} + +function detailUrl(productId: string) { + const base = process.env.DINGTALK_TODO_DETAIL_BASE_URL?.trim() + || process.env.NEXT_PUBLIC_APP_URL?.trim() + || process.env.DINGTALK_CALLBACK_ORIGIN?.trim() + return base ? new URL(`/intake/non-integrated/${encodeURIComponent(productId)}`, base).toString() : null +} + +export interface BrandPlanningShipmentTodoSummary { + scanned: number + eligible: number + created: number + closed: number + failed: number + unresolved: number + skipped: number +} + +export class OaBrandPlanningShipmentTodoService { + async sync(now = new Date()): Promise { + const summary = { scanned: 0, eligible: 0, created: 0, closed: 0, failed: 0, unresolved: 0, skipped: 0 } + if (!dingtalkTodoService.isEnabled()) { + summary.skipped = 1 + return summary + } + + const items = await oaBrandPlanningBoardService.listAll() + summary.scanned = items.length + const connectionConfig = createPlanningConnectionConfig() + const connection = typeof connectionConfig === 'string' + ? await mysql.createConnection(connectionConfig) + : await mysql.createConnection(connectionConfig) + try { + await connection.query(DDL) + const [openRows] = await connection.query( + `SELECT id, row_key, shipment_date, receiver_dingtalk_user_id, receiver_dingtalk_union_id, todo_id FROM ${TABLE} WHERE status IN ('OPEN', 'FAILED', 'NEEDS_OWNER_MAPPING')`, + ) + const desiredKeys = new Set() + const ownerResolutionCache = new Map< + string, + Awaited> + >() + + for (const item of items) { + const decision = evaluateShipmentReminder(item.estimatedShipmentDate, now) + if (!decision.eligible || !decision.shipmentDate || !item.brandPlanningSupervisor) continue + summary.eligible += 1 + let owners = ownerResolutionCache.get(item.brandPlanningSupervisor) + if (!owners) { + owners = await resolveDingtalkTodoOwnersByNames([item.brandPlanningSupervisor]) + ownerResolutionCache.set(item.brandPlanningSupervisor, owners) + } + const unresolved = [...owners.unresolvedNames, ...owners.ambiguousNames] + + if (unresolved.length > 0) { + const receiverId = `UNRESOLVED:${unresolved.join('|')}`.slice(0, 128) + const key = `${item.id}|${decision.shipmentDate}|${receiverId}` + desiredKeys.add(key) + await connection.execute( + `INSERT INTO ${TABLE} (row_key, shipment_date, receiver_dingtalk_user_id, receiver_name, status, dispatch_error) + VALUES (?, ?, ?, ?, 'NEEDS_OWNER_MAPPING', ?) + ON DUPLICATE KEY UPDATE status='NEEDS_OWNER_MAPPING', dispatch_error=VALUES(dispatch_error), closed_at=NULL`, + [item.id, decision.shipmentDate, receiverId, unresolved.join(';'), `未找到唯一钉钉身份:${unresolved.join('、')}`], + ) + summary.unresolved += 1 + } + + for (const owner of owners.resolvedOwners) { + if (!owner.dingtalkUserId || !owner.dingtalkUnionId) continue + const key = `${item.id}|${decision.shipmentDate}|${owner.dingtalkUserId}` + desiredKeys.add(key) + const existing = openRows.find((row) => `${row.row_key}|${dateText(row.shipment_date)}|${row.receiver_dingtalk_user_id}` === key) + if (existing?.todo_id) continue + const url = detailUrl(item.productId) + if (!url) { + summary.failed += 1 + continue + } + const identity = item.sku || item.spu || item.openingCode || item.id + const payload: DingtalkTodoCreateInput = { + operatorUnionId: owner.dingtalkUnionId, + creatorUnionId: owner.dingtalkUnionId, + executorUnionIds: [owner.dingtalkUnionId], + participantUnionIds: [owner.dingtalkUnionId], + title: `【出货提醒】${identity} 距预计出货不足15天`, + description: [`产品:${item.specName || item.spu || '-'}`, `SKU:${item.sku || '-'}`, `预计出货日期:${decision.shipmentDate}`, `品牌策划主管:${owner.name}`].join('\n'), + detailUrl: url, + sourceId: `brand-shipment:${item.id}:${decision.shipmentDate}:${owner.dingtalkUserId}`, + bizTag: process.env.DINGTALK_TODO_SOURCE_APP?.trim() || 'oa-board', + dueTime: new Date(`${decision.shipmentDate}T00:00:00+08:00`).getTime(), + priority: 30, + isOnlyShowExecutor: true, + } + try { + const created = await dingtalkTodoService.createTask(payload) + await connection.execute( + `INSERT INTO ${TABLE} (row_key, shipment_date, receiver_dingtalk_user_id, receiver_dingtalk_union_id, receiver_name, todo_id, status, payload_json, sent_at) + VALUES (?, ?, ?, ?, ?, ?, 'OPEN', ?, NOW()) + ON DUPLICATE KEY UPDATE receiver_dingtalk_union_id=VALUES(receiver_dingtalk_union_id), receiver_name=VALUES(receiver_name), todo_id=VALUES(todo_id), status='OPEN', dispatch_error=NULL, payload_json=VALUES(payload_json), sent_at=NOW(), closed_at=NULL`, + [item.id, decision.shipmentDate, owner.dingtalkUserId, owner.dingtalkUnionId, owner.name, created.taskId, JSON.stringify(payload)], + ) + summary.created += 1 + } catch (error) { + await connection.execute( + `INSERT INTO ${TABLE} (row_key, shipment_date, receiver_dingtalk_user_id, receiver_dingtalk_union_id, receiver_name, status, dispatch_error, payload_json) + VALUES (?, ?, ?, ?, ?, 'FAILED', ?, ?) + ON DUPLICATE KEY UPDATE status='FAILED', dispatch_error=VALUES(dispatch_error), payload_json=VALUES(payload_json)`, + [item.id, decision.shipmentDate, owner.dingtalkUserId, owner.dingtalkUnionId, owner.name, error instanceof Error ? error.message : '钉钉待办发送失败', JSON.stringify(payload)], + ) + summary.failed += 1 + } + } + } + + for (const row of openRows) { + const key = `${row.row_key}|${dateText(row.shipment_date)}|${row.receiver_dingtalk_user_id}` + if (desiredKeys.has(key)) continue + if (row.todo_id && row.receiver_dingtalk_union_id) { + await dingtalkTodoService.deleteTask(row.receiver_dingtalk_union_id, row.todo_id).catch(() => null) + } + await connection.execute(`UPDATE ${TABLE} SET status='CLOSED', closed_at=NOW(), dispatch_error=NULL WHERE id=?`, [row.id]) + summary.closed += 1 + } + return summary + } finally { + await connection.end() + } + } +} + +export const oaBrandPlanningShipmentTodoService = new OaBrandPlanningShipmentTodoService()