Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/jingchuan-brand-shipment-main-merge
This commit is contained in:
commit
6a19e30527
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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<ProductSource[]> {
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user