Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/jingchuan-brand-shipment-main-merge

This commit is contained in:
jingchuan 2026-08-11 14:56:35 +08:00
commit 6a19e30527
7 changed files with 290 additions and 17 deletions

View File

@ -79,9 +79,7 @@ DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET= DINGTALK_CLIENT_SECRET=
DINGTALK_CALLBACK_ORIGIN= DINGTALK_CALLBACK_ORIGIN=
# 钉钉待办(预留) # 钉钉待办:OA 阶段超时预警,以及预计出货日前 15 天的品牌策划主管提醒。
# 注意:当前仓库尚未内置待办发送服务,以下变量仅作为接入预留,
# 需要配合后端待办服务与投递表一起开发后才会实际生效。
DINGTALK_TODO_ENABLED=false DINGTALK_TODO_ENABLED=false
DINGTALK_TODO_DETAIL_BASE_URL= DINGTALK_TODO_DETAIL_BASE_URL=
DINGTALK_TODO_SOURCE_APP=oa-board DINGTALK_TODO_SOURCE_APP=oa-board

View File

@ -43,7 +43,7 @@ EnvironmentFile=-$HR_SYNC_ENV
# Keep dependent snapshots and the todo scan after the OA refresh so each # Keep dependent snapshots and the todo scan after the OA refresh so each
# consumer uses the newest workflow state. # consumer uses the newest workflow state.
# The global switch remains off by default and skips the todo scan until it is explicitly enabled. # 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 StandardOutput=journal
StandardError=journal StandardError=journal
EOF EOF

View File

@ -2,6 +2,7 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { oaBoardDingtalkTodoDispatchService } from '@/server/services/oa-board-dingtalk-todo-dispatch.service' 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) { function readArg(name: string) {
const index = process.argv.findIndex((arg) => arg === `--${name}`) const index = process.argv.findIndex((arg) => arg === `--${name}`)
@ -14,17 +15,20 @@ async function main() {
const owner = readArg('owner')?.trim() || undefined const owner = readArg('owner')?.trim() || undefined
const approvalStage = readArg('approval-stage')?.trim() || undefined const approvalStage = readArg('approval-stage')?.trim() || undefined
const summary = await oaBoardDingtalkTodoDispatchService.syncDashboard({ const [overtime, shipmentReminder] = await Promise.all([
approvalNumber: openingCode, oaBoardDingtalkTodoDispatchService.syncDashboard({
owner, approvalNumber: openingCode,
approvalStage, owner,
}) approvalStage,
}),
oaBrandPlanningShipmentTodoService.sync(),
])
console.log( console.log(
JSON.stringify( JSON.stringify(
{ {
ok: true, ok: true,
summary, summary: { overtime, shipmentReminder },
}, },
null, null,
2, 2,

View File

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

View File

@ -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([ const [users, employees] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where: { where: {
@ -191,11 +213,5 @@ export async function resolveOaBoardTodoOwners(
}) })
}) })
return { return { resolvedOwners, unresolvedNames, ambiguousNames }
stageNames,
ownerDisplay,
resolvedOwners,
unresolvedNames,
ambiguousNames,
}
} }

View File

@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS ${OA_BRAND_PLANNING_SNAPSHOT_TABLE} (
product_planner VARCHAR(255) NULL, product_planner VARCHAR(255) NULL,
spu_owner VARCHAR(255) NULL, spu_owner VARCHAR(255) NULL,
purchase_owner VARCHAR(255) NULL, purchase_owner VARCHAR(255) NULL,
brand_planning_supervisor VARCHAR(255) NULL,
estimated_shipment_date VARCHAR(255) NULL, estimated_shipment_date VARCHAR(255) NULL,
product_detail_link TEXT NULL, product_detail_link TEXT NULL,
approval_stage VARCHAR(255) NULL, approval_stage VARCHAR(255) NULL,
@ -84,6 +85,7 @@ type BrandPlanningSnapshotFields = {
product_planner: string | null product_planner: string | null
spu_owner: string | null spu_owner: string | null
purchase_owner: string | null purchase_owner: string | null
brand_planning_supervisor: string | null
estimated_shipment_date: string | null estimated_shipment_date: string | null
product_detail_link: string | null product_detail_link: string | null
approval_stage: string | null approval_stage: string | null
@ -164,6 +166,7 @@ export interface OaBrandPlanningBoardItemDTO {
productPlanner: string | null productPlanner: string | null
spuOwner: string | null spuOwner: string | null
purchaseOwner: string | null purchaseOwner: string | null
brandPlanningSupervisor: string | null
estimatedShipmentDate: string | null estimatedShipmentDate: string | null
productDetailLink: string | null productDetailLink: string | null
approvalStage: string | null approvalStage: string | null
@ -445,6 +448,7 @@ function buildSnapshotRows(
product_planner: toText(profile?.productPlanning), product_planner: toText(profile?.productPlanning),
spu_owner: toText(profile?.spuOwner), spu_owner: toText(profile?.spuOwner),
purchase_owner: toText(profile?.purchaseOwner), purchase_owner: toText(profile?.purchaseOwner),
brand_planning_supervisor: readFlowField(reviewFlow, ['品牌策划主管']),
estimated_shipment_date: getFactoryLeadTime(payload, sku), estimated_shipment_date: getFactoryLeadTime(payload, sku),
product_detail_link: `/intake/non-integrated/${encodeURIComponent(product.id)}`, product_detail_link: `/intake/non-integrated/${encodeURIComponent(product.id)}`,
approval_stage: graph?.currentApprovalStageName ?? null, approval_stage: graph?.currentApprovalStageName ?? null,
@ -483,6 +487,9 @@ async function createConnection() {
async function ensureSnapshotTable(connection: mysql.Connection) { async function ensureSnapshotTable(connection: mysql.Connection) {
await connection.query(OA_BRAND_PLANNING_SNAPSHOT_TABLE_DDL) 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<ProductSource[]> { async function loadProducts(): Promise<ProductSource[]> {
@ -547,6 +554,7 @@ async function persistRows(
'product_planner', 'product_planner',
'spu_owner', 'spu_owner',
'purchase_owner', 'purchase_owner',
'brand_planning_supervisor',
'estimated_shipment_date', 'estimated_shipment_date',
'product_detail_link', 'product_detail_link',
'approval_stage', 'approval_stage',
@ -619,6 +627,7 @@ function toDTO(row: BrandPlanningSnapshotRow): OaBrandPlanningBoardItemDTO {
productPlanner: row.product_planner, productPlanner: row.product_planner,
spuOwner: row.spu_owner, spuOwner: row.spu_owner,
purchaseOwner: row.purchase_owner, purchaseOwner: row.purchase_owner,
brandPlanningSupervisor: row.brand_planning_supervisor,
estimatedShipmentDate: row.estimated_shipment_date, estimatedShipmentDate: row.estimated_shipment_date,
productDetailLink: row.product_detail_link, productDetailLink: row.product_detail_link,
approvalStage: row.approval_stage, approvalStage: row.approval_stage,

View File

@ -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<BrandPlanningShipmentTodoSummary> {
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<DispatchRow[]>(
`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<string>()
const ownerResolutionCache = new Map<
string,
Awaited<ReturnType<typeof resolveDingtalkTodoOwnersByNames>>
>()
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<ResultSetHeader>(
`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()