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_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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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([
|
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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