From 1df88cc639f681fd4a26ad8390d0895c93f555f2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 14:59:58 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=B1=A5?= =?UTF-8?q?=E7=BA=A6=E8=B7=AF=E7=94=B1=E3=80=81=E8=AE=A2=E5=8D=95=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=B8=8E=E8=87=AA=E5=8A=A8=E6=A0=B8=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/preflight-fulfillment-routing.ts | 10 + apps/backend/src/config/app-config-keys.ts | 1 + .../129_fulfillment_retry_semantics.sql | 108 +++ .../migrations/130_voucher_consume_jobs.sql | 50 ++ .../order-service-profile-repo.ts | 74 +- .../worker-platform/work-order-query-repo.ts | 5 +- .../src/routes/admin/fulfillment-channel.ts | 104 +++ .../cost-center/cost-snapshot-service.ts | 2 +- .../cost-center/product-normalize-service.ts | 4 +- .../cost-center/template-bootstrap-service.ts | 2 +- .../cost-center/work-order-product-service.ts | 10 +- .../fulfillment/carrier-creation-service.ts | 102 ++- .../carrier-reconciliation-service.ts | 33 +- .../fulfillment/channel-assign-service.ts | 175 +++- .../src/services/fulfillment/channel-types.ts | 4 + .../executors/affiliate-dash-executor.ts | 15 +- .../fulfillment/executors/manual-executor.ts | 8 +- .../fulfillment-routing.integration.test.ts | 91 +- .../fulfillment/internal-sku-resolver.test.ts | 4 + .../pending-channel-repair-service.ts | 9 + .../voucher-consume-policy-service.test.ts | 85 ++ .../voucher-consume-policy-service.ts | 222 +++++ .../voucher-consume-queue-service.ts | 777 ++++++++++++++++++ .../services/open-91/consume-on-deliver.ts | 75 +- .../order/delivery-task-service.test.ts | 82 ++ .../services/order/delivery-task-service.ts | 20 +- .../kuaishou-industry/send-code-service.ts | 27 - .../services/scheduler/config-service.test.ts | 1 + .../src/services/scheduler/config-service.ts | 30 + .../scheduler/finance-pipeline-jobs.ts | 14 +- .../scheduler/finance-pipeline-status.ts | 1 + .../services/scheduler/scheduler-service.ts | 8 +- apps/backend/src/services/watermark-store.ts | 1 + .../admin-mock-work-order-service.ts | 2 + .../admin-work-order-assignment-service.ts | 110 +-- .../admin-work-order-management-service.ts | 74 +- .../consignment-fulfillment-service.ts | 34 +- .../order-service-profile-service.test.ts | 41 +- .../order-service-profile-service.ts | 13 +- .../order-service-public-service.ts | 7 + .../sync-work-orders-from-send-code.ts | 2 +- .../sync-worker-orders-service.ts | 8 +- .../worker-platform-service.test.ts | 5 +- .../admin/AdminFulfillmentChannelPage.tsx | 94 ++- .../pages/admin/panels/OrderServicePanel.tsx | 272 ++++-- .../panels/VoucherConsumePolicyPanel.tsx | 377 +++++++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 16 +- .../src/services/admin/consignment.ts | 4 + .../src/services/admin/fulfillment-channel.ts | 101 +++ .../src/services/admin/worker-platform.ts | 4 + apps/frontend/src/styles/admin.css | 52 ++ apps/frontend/src/utils/voucher-consume.ts | 21 +- ...履约渠道重试与大小写归一修复-2026-11-05.md | 70 ++ docs/订单服务收集与状态页设计-2026-11-03.md | 23 +- 54 files changed, 3166 insertions(+), 318 deletions(-) create mode 100644 apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql create mode 100644 apps/backend/src/db/migrations/130_voucher_consume_jobs.sql create mode 100644 apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts create mode 100644 apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts create mode 100644 apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts create mode 100644 apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx create mode 100644 docs/履约渠道重试与大小写归一修复-2026-11-05.md diff --git a/apps/backend/scripts/preflight-fulfillment-routing.ts b/apps/backend/scripts/preflight-fulfillment-routing.ts index 1255bdb0..3a7eaa91 100644 --- a/apps/backend/scripts/preflight-fulfillment-routing.ts +++ b/apps/backend/scripts/preflight-fulfillment-routing.ts @@ -50,6 +50,14 @@ async function main() { ) ).rows + checks.uppercaseInternalChannels = ( + await query( + `SELECT 'uppercase_internal_sku' AS label, count(*)::int AS total + FROM internal_sku_channels + WHERE internal_sku ~ '[A-Z]'`, + ) + ).rows[0] + checks.pendingWithoutChannel = ( await query( `SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total @@ -306,6 +314,8 @@ async function main() { failed.push('存在同时挂载多个真实履约载体的已分配订单项') if ((checks.internalChannelConflicts as CountRow[]).length > 0) failed.push('存在多个非兜底主渠道') + if ((checks.uppercaseInternalChannels as CountRow).total > 0) + failed.push('内部 SKU 渠道配置仍含未规范化的大写英文') if ((checks.canonicalChannelDuplicates as CountRow[]).length > 0) failed.push('存在规范化后重复的内部 SKU 渠道配置') if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0) diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 27dc02eb..706b01ae 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -11,6 +11,7 @@ export const APP_CONFIG_KEYS = { workerProductNameSuffix: 'worker_product_name_suffix', workerRiskControl: 'worker_risk_control', workerMockShops: 'worker_mock_shops', + voucherConsumePolicy: 'voucher_consume_policy', kuaishouIndustrySource: 'kuaishou_industry_source', affiliateDash: 'affiliate_dash', pddSource: 'pdd_source', diff --git a/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql b/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql new file mode 100644 index 00000000..304a1f34 --- /dev/null +++ b/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql @@ -0,0 +1,108 @@ +-- 129_fulfillment_retry_semantics.sql +-- +-- 履约载体重试语义: +-- 1. 同一订单项/单元只限制一个“活跃”任务,历史 closed/failed 任务保留; +-- 2. 载体补建使用独立水位,避免每次重复扫描最早的 200 条; +-- 3. 回填历史内部 SKU 的大小写/全半角/空白规范化结果,并合并规范后重复渠道。 + +-- 允许同一订单项/单元在旧任务关闭后创建新的履约尝试。 +ALTER TABLE fulfillment_tasks + DROP CONSTRAINT IF EXISTS fulfillment_tasks_order_item_id_unit_index_key; + +DROP INDEX IF EXISTS fulfillment_tasks_order_item_id_unit_index_key; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_fulfillment_tasks_active_unit + ON fulfillment_tasks (order_item_id, unit_index) + WHERE task_status NOT IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ); + +COMMENT ON INDEX uq_fulfillment_tasks_active_unit IS + '同一订单项/单元只允许一个活跃履约载体;关闭、失败、待重试和人工复核任务保留为历史记录'; + +ALTER TABLE work_orders + DROP CONSTRAINT IF EXISTS idx_work_orders_order_item_unique; + +DROP INDEX IF EXISTS idx_work_orders_order_item_unique; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_work_orders_active_order_item + ON work_orders (order_item_id) + WHERE order_item_id IS NOT NULL AND status <> 'cancelled'; + +COMMENT ON INDEX uq_work_orders_active_order_item IS + '同一订单项只允许一个未取消工单;取消工单保留为历史并可重新建单'; + +INSERT INTO finance_watermarks (pipeline, last_item_id, last_success_at) +VALUES ('carrier_reconcile', 0, NULL) +ON CONFLICT (pipeline) DO NOTHING; + +COMMENT ON COLUMN finance_watermarks.pipeline IS + '同步管道:channel_assign / carrier_reconcile / carrier_dispatch / product_normalize / cost_snapshot / cost_snapshot_hot / sales_facts / ledger_post'; + +-- 规范化前先收敛跨渠道主渠道。同一规范 SKU 只能有一个 enabled 主渠道; +-- 优先保留带载体参数的行,再按优先级、id 稳定选择。 +WITH ranked_primary AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY canonicalize_internal_sku(internal_sku) + ORDER BY + CASE WHEN config_json = '{}'::jsonb THEN 1 ELSE 0 END, + priority, + id + ) AS candidate_rank + FROM internal_sku_channels + WHERE enabled = TRUE AND is_fallback = FALSE +) +UPDATE internal_sku_channels channel +SET is_fallback = TRUE, + priority = GREATEST(priority, 90), + updated_at = NOW() +FROM ranked_primary +WHERE channel.id = ranked_primary.id + AND ranked_primary.candidate_rank > 1; + +-- 同一规范 SKU + 渠道只保留一行,优先保留启用、带载体参数的行。 +WITH ranked AS ( + SELECT + internal_sku, + channel, + id, + canonicalize_internal_sku(internal_sku) AS canonical_sku, + ROW_NUMBER() OVER ( + PARTITION BY canonicalize_internal_sku(internal_sku), channel + ORDER BY + enabled DESC, + CASE WHEN config_json = '{}'::jsonb THEN 1 ELSE 0 END, + is_fallback, + priority, + id + ) AS candidate_rank + FROM internal_sku_channels +) +DELETE FROM internal_sku_channels channel +USING ranked +WHERE channel.id = ranked.id + AND ranked.candidate_rank > 1; + +UPDATE internal_sku_channels +SET internal_sku = canonicalize_internal_sku(internal_sku), + updated_at = NOW() +WHERE internal_sku <> canonicalize_internal_sku(internal_sku); + +UPDATE manual_fulfillment_configs +SET sku_key = canonicalize_internal_sku(sku_key), + updated_at = NOW() +WHERE sku_key <> canonicalize_internal_sku(sku_key); + +UPDATE manual_sku_registry +SET sku_key = canonicalize_internal_sku(sku_key), + updated_at = NOW() +WHERE sku_key <> canonicalize_internal_sku(sku_key); + +UPDATE internal_sku_mappings +SET match_value = canonicalize_internal_sku(match_value), + internal_sku = canonicalize_internal_sku(internal_sku), + updated_at = NOW() +WHERE match_value <> canonicalize_internal_sku(match_value) + OR internal_sku <> canonicalize_internal_sku(internal_sku); diff --git a/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql b/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql new file mode 100644 index 00000000..ea668b80 --- /dev/null +++ b/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql @@ -0,0 +1,50 @@ +-- Persistent delayed voucher consumption jobs. +-- +-- Business events only enqueue a job. A scheduler worker performs the external +-- platform call later, so a restart cannot lose a delayed consumption. + +CREATE TABLE IF NOT EXISTS voucher_consume_jobs ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL + CHECK (provider IN ('kuaishou_industry', 'douyin_mashida')), + channel TEXT NOT NULL + CHECK (channel IN ('affiliate_dash', 'work_order', 'manual', 'third_party_voucher')), + trigger_event TEXT NOT NULL, + target_key TEXT NOT NULL, + platform_order_id TEXT NOT NULL DEFAULT '', + order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, + order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, + task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, + work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL, + due_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'cancelled')), + attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 8, + locked_until TIMESTAMPTZ, + last_error TEXT NOT NULL DEFAULT '', + result_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ +); + +-- Keep one active/successful target per provider. Failed jobs may be replaced +-- by a later business event, while succeeded jobs suppress duplicate events. +CREATE UNIQUE INDEX IF NOT EXISTS uq_voucher_consume_jobs_active_target + ON voucher_consume_jobs(provider, target_key) + WHERE status IN ('pending', 'processing', 'succeeded'); + +CREATE INDEX IF NOT EXISTS idx_voucher_consume_jobs_due + ON voucher_consume_jobs(status, due_at, id); + +CREATE INDEX IF NOT EXISTS idx_voucher_consume_jobs_order + ON voucher_consume_jobs(order_id, order_item_id, id DESC); + +CREATE INDEX IF NOT EXISTS idx_voucher_consume_jobs_updated + ON voucher_consume_jobs(updated_at DESC, id DESC); + +COMMENT ON TABLE voucher_consume_jobs IS + 'Delayed voucher verification jobs; events enqueue, scheduler executes'; +COMMENT ON COLUMN voucher_consume_jobs.target_key IS + 'Stable dedupe target: order-item when available, otherwise task/order'; diff --git a/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts index 91d0cd96..8869efe2 100644 --- a/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts +++ b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts @@ -28,6 +28,7 @@ export type OrderServiceProfileRow = { export type OrderServicePublishedProfileRow = { profile_id: number category_id: number + source_category_id: number version_id: number version_no: number config_json: Record @@ -221,12 +222,29 @@ export async function backfillPendingOrderServiceSnapshotsForCategory( ): Promise { const result = await client.query( ` + WITH affected_categories AS ( + SELECT $1::bigint AS category_id + UNION + SELECT child.id + FROM work_categories child + WHERE child.parent_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM order_service_profiles child_profile + JOIN order_service_profile_versions child_version + ON child_version.id = child_profile.current_version_id + WHERE child_profile.category_id = child.id + AND child_profile.status <> 'archived' + ) + ) UPDATE work_orders wo SET service_profile_id = $2, service_profile_version_id = $3, service_profile_snapshot_json = jsonb_build_object( 'profileId', p.id, - 'categoryId', p.category_id, + 'categoryId', wo.category_id, + 'sourceCategoryId', p.category_id, + 'inherited', wo.category_id <> p.category_id, 'versionId', v.id, 'versionNo', v.version_no, 'config', v.config_json @@ -240,7 +258,7 @@ export async function backfillPendingOrderServiceSnapshotsForCategory( WHERE p.id = $2 AND p.category_id = $1 AND v.id = $3 - AND wo.category_id = p.category_id + AND wo.category_id IN (SELECT category_id FROM affected_categories) AND wo.status = 'pending_material' AND wo.assigned_worker_id IS NULL AND NOT EXISTS ( @@ -295,6 +313,7 @@ export async function getPublishedOrderServiceProfileByCategory( SELECT p.id AS profile_id, p.category_id, + p.category_id AS source_category_id, v.id AS version_id, v.version_no, v.config_json @@ -311,6 +330,57 @@ export async function getPublishedOrderServiceProfileByCategory( ? { profile_id: Number(row.profile_id), category_id: Number(row.category_id), + source_category_id: Number(row.source_category_id), + version_id: Number(row.version_id), + version_no: Number(row.version_no), + config_json: row.config_json, + } + : null +} + +/** + * 分类配置解析:先从当前二级分类取已发布版本,没有再逐级继承父分类。 + * 返回值中的 source_category_id 表示最终命中的配置所属分类。 + */ +export async function getPublishedOrderServiceProfileByCategoryWithInheritance( + categoryId: number | string, + client?: Pick, +): Promise { + const db = client || { query } + const result = await db.query( + ` + WITH RECURSIVE category_chain AS ( + SELECT id, parent_id, 0 AS depth + FROM work_categories + WHERE id = $1 + UNION ALL + SELECT parent.id, parent.parent_id, child.depth + 1 + FROM work_categories parent + JOIN category_chain child ON parent.id = child.parent_id + WHERE child.depth < 8 + ) + SELECT + p.id AS profile_id, + p.category_id, + chain.id AS source_category_id, + v.id AS version_id, + v.version_no, + v.config_json + FROM category_chain chain + JOIN order_service_profiles p ON p.category_id = chain.id + JOIN order_service_profile_versions v ON v.id = p.current_version_id + WHERE p.status <> 'archived' + ORDER BY chain.depth ASC + LIMIT 1 + `, + [Number(categoryId)], + ) + const row = result.rows[0] + return row + ? { + profile_id: Number(row.profile_id), + category_id: Number(row.category_id), + source_category_id: Number(row.source_category_id), version_id: Number(row.version_id), version_no: Number(row.version_no), config_json: row.config_json, diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index afc592eb..0a679a55 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -298,7 +298,10 @@ export async function getWorkOrderByOrderItemId( orderItemId: number | string, ): Promise { const result = await query( - `${WORK_ORDER_SELECT} WHERE wo.order_item_id = $1 LIMIT 1`, + `${WORK_ORDER_SELECT} + WHERE wo.order_item_id = $1 + ORDER BY (wo.status <> 'cancelled') DESC, wo.id DESC + LIMIT 1`, [Number(orderItemId)], ) return result.rows[0] || null diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 928b70c9..d9f4f719 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -17,6 +17,7 @@ import { forceDeleteChannelRules, listChannelRulesPage, listPendingChannelGroups, + retryPendingCarrierGroup, switchChannelForOrderItems, unlockChannel, upsertChannelRule, @@ -37,6 +38,15 @@ import { getFinancePipelineStatus, isFinancePipelineName, } from '../../services/scheduler/finance-pipeline-status.js' +import { + getVoucherConsumePolicy, + saveVoucherConsumePolicy, +} from '../../services/fulfillment/voucher-consume-policy-service.js' +import { + listVoucherConsumeJobs, + retryVoucherConsumeJob, +} from '../../services/fulfillment/voucher-consume-queue-service.js' +import { createHttpError } from '../../utils/http.js' import { bodyOf } from './request-helpers.js' import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js' @@ -44,6 +54,78 @@ const router = Router() router.use('/fulfillment', requireAdminRoles(['admin', 'operator', 'finance', 'support'])) +router.get( + '/fulfillment/voucher-consume-policy', + requireAdminPermission('finance.view'), + createJsonHandler(() => getVoucherConsumePolicy(), { + successMessage: 'ok', + errorMessage: '读取核销策略失败', + scope: '[admin/fulfillment/voucher-consume-policy]', + }), +) + +router.post( + '/fulfillment/voucher-consume-policy', + requireAdminPermission('finance.config'), + createJsonHandler((req) => saveVoucherConsumePolicy(bodyOf(req)), { + successMessage: '核销策略已保存', + errorMessage: '保存核销策略失败', + scope: '[admin/fulfillment/voucher-consume-policy]', + audit: (_req, data) => ({ + action: 'fulfillment.save_voucher_consume_policy', + targetType: 'voucher_consume_policy', + targetId: 'default', + data: data as Record, + }), + }), +) + +router.get( + '/fulfillment/voucher-consume-jobs', + requireAdminPermission('finance.view'), + createJsonHandler( + (req) => + listVoucherConsumeJobs({ + status: String(req.query.status || ''), + page: Number(req.query.page), + pageSize: Number(req.query.pageSize), + }), + { + successMessage: 'ok', + errorMessage: '读取核销任务失败', + scope: '[admin/fulfillment/voucher-consume-jobs]', + }, + ), +) + +router.post( + '/fulfillment/voucher-consume-jobs/:id/retry', + requireAdminPermission('finance.config'), + createJsonHandler( + async (req) => { + const job = await retryVoucherConsumeJob(req.params.id) + if (!job) { + throw createHttpError('该核销任务当前不可重试,或已有更新的任务在队列中', { + statusCode: 409, + errorCode: 'voucher_consume_job_not_retryable', + }) + } + return job + }, + { + successMessage: '核销任务已重新排队', + errorMessage: '重试核销任务失败', + scope: '[admin/fulfillment/voucher-consume-jobs/retry]', + audit: (req, data) => ({ + action: 'fulfillment.retry_voucher_consume_job', + targetType: 'voucher_consume_job', + targetId: String(req.params.id || ''), + data: data as Record, + }), + }, + ), +) + router.get( '/fulfillment/pending', requireAdminPermission('finance.view'), @@ -110,6 +192,28 @@ router.post( ), ) +router.post( + '/fulfillment/pending/retry-carriers', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => + retryPendingCarrierGroup({ + internalSku: String(bodyOf(req).internalSku || ''), + }), + { + successMessage: '履约载体重试完成', + errorMessage: '重试履约载体失败', + scope: '[admin/fulfillment/pending/retry-carriers]', + audit: (req, data) => ({ + action: 'fulfillment.retry_pending_carriers', + targetType: 'internal_sku', + targetId: String(bodyOf(req).internalSku || ''), + data: data as Record, + }), + }, + ), +) + router.get( '/fulfillment/internal-sku-mappings', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/services/cost-center/cost-snapshot-service.ts b/apps/backend/src/services/cost-center/cost-snapshot-service.ts index e25874e2..c7368980 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-service.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-service.ts @@ -319,7 +319,7 @@ const CANDIDATE_SQL = ` wo.reward_amount FROM order_items oi JOIN orders o ON o.id = oi.order_id - LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ( diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index e938bdd7..9d0fdf09 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -325,7 +325,7 @@ export async function normalizeOrderItemProducts( cfg.canonical_product_id AS manual_config_product_id FROM order_items oi JOIN orders o ON o.id = oi.order_id - LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id WHERE oi.fulfillment_status = 'assigned' @@ -479,7 +479,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ cfg.canonical_product_id AS manual_config_product_id FROM order_items oi JOIN orders o ON o.id = oi.order_id - LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id WHERE oi.fulfillment_status = 'assigned' diff --git a/apps/backend/src/services/cost-center/template-bootstrap-service.ts b/apps/backend/src/services/cost-center/template-bootstrap-service.ts index a9c64b4f..7f4c2a7b 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -728,7 +728,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat COUNT(DISTINCT oi.id)::int AS item_count FROM order_items oi JOIN orders o ON o.id = oi.order_id - JOIN work_orders wo ON wo.order_item_id = oi.id + JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE WHERE wo.product_rule_id IS NOT NULL AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date diff --git a/apps/backend/src/services/cost-center/work-order-product-service.ts b/apps/backend/src/services/cost-center/work-order-product-service.ts index 4df12ebf..4317bc33 100644 --- a/apps/backend/src/services/cost-center/work-order-product-service.ts +++ b/apps/backend/src/services/cost-center/work-order-product-service.ts @@ -33,7 +33,7 @@ function resolveWorkOrderRange(payload: Record) { export async function backfillAdminWorkOrderProductSkus(payload: Record = {}) { const now = nowIso() const range = resolveWorkOrderRange(payload) - // order_item_id 有唯一索引,这里可以直接 count,避免 DISTINCT ON 被 + // 未取消工单对 order_item_id 有唯一索引,这里可以直接 count,避免 DISTINCT ON 被 // 三个子查询反复物化后撞 15 秒语句超时。 const result = await query( ` @@ -51,6 +51,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record 'cancelled' AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date `, @@ -73,6 +74,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record 'cancelled' AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date AND wo.order_item_id > $3 @@ -125,7 +127,10 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record '' AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $2::date AND $3::date - AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND EXISTS ( + SELECT 1 FROM work_orders wo + WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled' + ) GROUP BY 1 ), missing AS ( @@ -452,6 +457,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) { FROM work_orders wo JOIN rule_version rv ON rv.work_product_rule_id = wo.product_rule_id WHERE cs.order_item_id = wo.order_item_id + AND wo.status <> 'cancelled' AND cs.cost_item_id = rv.cost_item_id AND cs.superseded_at IS NULL AND reversed_at IS NULL AND cs.cost_version_id <> rv.cost_version_id diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index ec3958a6..9611a0a9 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -54,6 +54,53 @@ async function hasAffiliateTask(orderItemId: number): Promise { return result.rows[0]?.exists === true } +const BLOCKED_INACTIVE_CARRIER_STATUSES = new Set(['closed', 'cancelled', 'manual_review']) + +async function findBlockingInactiveCarrier(input: { + orderItemId: number + channel: FulfillmentChannel +}): Promise<{ status: string; reason: string; message: string } | null> { + if (input.channel !== 'affiliate_dash' && input.channel !== 'work_order') return null + + const result = + input.channel === 'affiliate_dash' + ? await query<{ status: string }>( + `SELECT task_status AS status + FROM fulfillment_tasks + WHERE order_item_id = $1 AND executor_key = 'affiliate_dash' + ORDER BY id DESC + LIMIT 1`, + [input.orderItemId], + ) + : await query<{ status: string }>( + `SELECT status + FROM work_orders + WHERE order_item_id = $1 + ORDER BY id DESC + LIMIT 1`, + [input.orderItemId], + ) + + const status = String(result.rows[0]?.status || '') + if (!BLOCKED_INACTIVE_CARRIER_STATUSES.has(status)) return null + + if (status === 'manual_review') { + return { + status, + reason: 'carrier_task_manual_review', + message: '已有履约载体进入人工复核,需先处理后再重试', + } + } + return { + status, + reason: 'carrier_task_closed', + message: + status === 'cancelled' + ? '已有履约载体已取消,需由运营确认后重新创建' + : '已有履约载体已关闭,需由运营确认后重新创建', + } +} + function toResolvedItem(item: OrderItemRow): ResolvedFulfillmentItem { return { ...item, @@ -79,8 +126,16 @@ export async function ensureCarrierForOrderItem(input: { order: OrderRow item: OrderItemRow channel: FulfillmentChannel + retryInactiveCarrier?: boolean + allowInactiveTaskRetry?: boolean }): Promise { - const { order, item, channel } = input + const { + order, + item, + channel, + retryInactiveCarrier = false, + allowInactiveTaskRetry = false, + } = input const existingCarrier = findConflictingCarrier( await loadActiveCarrierEvidenceForItem(Number(item.id)), channel, @@ -131,6 +186,18 @@ export async function ensureCarrierForOrderItem(input: { if (await hasWorkOrder(Number(item.id))) { return { ready: true, created: false, reason: 'work_order_exists', message: '' } } + const inactiveCarrier = await findBlockingInactiveCarrier({ + orderItemId: Number(item.id), + channel, + }) + if (inactiveCarrier && !retryInactiveCarrier) { + return { + ready: false, + created: false, + reason: inactiveCarrier.reason, + message: inactiveCarrier.message, + } + } const internalSku = canonicalizeInternalSku( resolveInternalSkuKey({ manualSkuKey: item.manual_sku_key, @@ -179,6 +246,7 @@ export async function ensureCarrierForOrderItem(input: { autoOnly: true, skipChannelAssign: true, preferredWorkRuleKey, + retryCancelledWorkOrder: retryInactiveCarrier, }) const ready = (await hasWorkOrder(Number(item.id))) || result.created.length > 0 if (ready) { @@ -198,6 +266,18 @@ export async function ensureCarrierForOrderItem(input: { if (await hasAffiliateTask(Number(item.id))) { return { ready: true, created: false, reason: 'affiliate_task_exists', message: '' } } + const inactiveCarrier = await findBlockingInactiveCarrier({ + orderItemId: Number(item.id), + channel, + }) + if (inactiveCarrier && !retryInactiveCarrier) { + return { + ready: false, + created: false, + reason: inactiveCarrier.reason, + message: inactiveCarrier.message, + } + } const resolved = await applyIntakeChannelDecision( toResolvedItem(item), @@ -232,14 +312,18 @@ export async function ensureCarrierForOrderItem(input: { ) const before = await hasAffiliateTask(Number(item.id)) if (!before) { - await syncDeliveryTasksForOrder(order, [ - { - ...item, - sku_code: String(resolved.skuCode || item.sku_code), - spec_json: resolved.spec || {}, - item_snapshot_json: snapshot, - } as OrderItemRow, - ]) + await syncDeliveryTasksForOrder( + order, + [ + { + ...item, + sku_code: String(resolved.skuCode || item.sku_code), + spec_json: resolved.spec || {}, + item_snapshot_json: snapshot, + } as OrderItemRow, + ], + { allowInactiveTaskRetry: retryInactiveCarrier || allowInactiveTaskRetry }, + ) } const ready = await hasAffiliateTask(Number(item.id)) return { diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 3bb6285e..3e582919 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -49,11 +49,21 @@ async function loadCandidateItems(input: { limit: number orderItemIds?: number[] assignedMissingCarrier?: boolean + afterId?: number + includeLocked?: boolean }): Promise { const ids = (input.orderItemIds || []) .map(Number) .filter((value) => Number.isInteger(value) && value > 0) - const idFilter = ids.length > 0 ? 'AND oi.id = ANY($2::bigint[])' : '' + const params: unknown[] = [input.limit] + let idFilter = '' + if (ids.length > 0) { + params.push(ids) + idFilter = `AND oi.id = ANY($${params.length}::bigint[])` + } else if (Number(input.afterId || 0) > 0) { + params.push(Math.max(0, Math.trunc(Number(input.afterId)))) + idFilter = `AND oi.id > $${params.length}` + } const statusFilter = input.assignedMissingCarrier ? `oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') @@ -66,10 +76,9 @@ async function loadCandidateItems(input: { WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')}) )` - : `oi.fulfillment_status = 'pending' AND oi.fulfillment_locked = FALSE` + : `oi.fulfillment_status = 'pending' + ${input.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}` - const params: unknown[] = [input.limit] - if (ids.length > 0) params.push(ids) const result = await query( ` SELECT @@ -119,6 +128,7 @@ async function markPendingWithoutCarrier(input: { internalSku: string attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }> }): Promise { + const carrierIssue = input.attempts[0]?.reason || 'carrier_attempt_failed' await query( ` UPDATE order_items @@ -131,6 +141,8 @@ async function markPendingWithoutCarrier(input: { input.itemId, JSON.stringify({ internalSku: input.internalSku, + carrierIssue, + carrierMessage: input.attempts[0]?.message || '', attempts: input.attempts, }), ], @@ -191,6 +203,9 @@ export async function reconcilePendingCarriers( orderItemIds?: number[] includeAssignedMissingCarrier?: boolean allowFallback?: boolean + afterId?: number + includeLocked?: boolean + retryInactiveCarrier?: boolean } = {}, ): Promise<{ scanned: number @@ -199,11 +214,14 @@ export async function reconcilePendingCarriers( fallbacksSelected: number failed: number remaining: number + nextCursor: number }> { const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200))) const rows = await loadCandidateItems({ limit, assignedMissingCarrier: input.includeAssignedMissingCarrier === true, + ...(input.afterId != null ? { afterId: input.afterId } : {}), + ...(input.includeLocked ? { includeLocked: true } : {}), ...(input.orderItemIds ? { orderItemIds: input.orderItemIds } : {}), }) @@ -273,6 +291,8 @@ export async function reconcilePendingCarriers( order, item, channel: attempt.channel, + retryInactiveCarrier: input.retryInactiveCarrier === true, + allowInactiveTaskRetry: true, }) if (!carrier.ready) { failures.push({ @@ -364,5 +384,10 @@ export async function reconcilePendingCarriers( fallbacksSelected, failed, remaining: Number(remainingResult.rows[0]?.count || 0), + nextCursor: input.orderItemIds?.length + ? 0 + : rows.length < limit + ? 0 + : Number(rows[rows.length - 1]?.id || 0), } } diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 1e37d6ea..d58d020a 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -30,6 +30,7 @@ import { type InternalSkuChannelRow, } from './internal-sku-channel-service.js' import { ensureCarrierForOrderItem } from './carrier-creation-service.js' +import { reconcilePendingCarriers } from './carrier-reconciliation-service.js' import { TASK_STATUS } from '../../domain/task-status.js' import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js' @@ -116,12 +117,23 @@ function resolvePendingReason(item: ChannelCandidateItem): { detail: { internalSku: item.internal_sku }, } } + const primary = plan.primary + const primaryConfig = primary.config_json || {} + const carrierIssue = + primary.channel === 'manual' && !Number(primaryConfig.manualConfigId || 0) + ? 'manual_config_missing' + : primary.channel === 'work_order' && !String(primaryConfig.workRuleKey || '').trim() + ? 'work_rule_missing' + : primary.channel === 'affiliate_dash' && !String(primaryConfig.affiliateSku || '').trim() + ? 'affiliate_mapping_missing' + : 'carrier_not_attempted' return { reason: 'no_carrier', detail: { internalSku: item.internal_sku, - channel: plan.primary.channel, + channel: primary.channel, fallbacks: plan.fallbacks.map((channel) => channel.channel), + carrierIssue, }, } } @@ -234,7 +246,13 @@ export type AssignChannelsResult = { /** 批量判定:默认只扫 pending 且未锁定的订单项。 */ export async function assignPendingOrderItemChannels( - options: { batchSize?: number; maxBatches?: number; afterId?: number } = {}, + options: { + batchSize?: number + maxBatches?: number + afterId?: number + orderItemIds?: number[] + includeLocked?: boolean + } = {}, ): Promise { const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500))) const maxBatches = Math.min(200, Math.max(1, Math.trunc(options.maxBatches || 20))) @@ -257,8 +275,20 @@ export async function assignPendingOrderItemChannels( pendingReasons: {}, } const affectedDates = new Set() + const requestedIds = [ + ...new Set( + (options.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0), + ), + ] + const params: unknown[] = [cursor, batchSize] + let requestedFilter = '' + if (requestedIds.length > 0) { + params.push(requestedIds) + requestedFilter = `AND oi.id = ANY($${params.length}::bigint[])` + } for (let batch = 0; batch < maxBatches; batch += 1) { + params[0] = cursor const rows = await query( ` SELECT @@ -288,12 +318,13 @@ export async function assignPendingOrderItemChannels( FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'pending' - AND oi.fulfillment_locked = FALSE + ${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'} AND oi.id > $1 + ${requestedFilter} ORDER BY oi.id LIMIT $2 `, - [cursor, batchSize], + params, ) if (rows.rows.length === 0) { @@ -353,7 +384,13 @@ export async function assignPendingOrderItemChannels( await query( `UPDATE order_items SET fulfillment_pending_reason = $2, - fulfillment_pending_detail = $3, + fulfillment_pending_detail = CASE + WHEN $2 = 'no_carrier' + AND COALESCE(fulfillment_pending_detail->>'carrierIssue', '') + NOT IN ('', 'carrier_not_attempted') + THEN fulfillment_pending_detail + ELSE $3::jsonb + END, updated_at = NOW() WHERE id = $1 AND fulfillment_status = 'pending'`, [item.id, pending.reason, JSON.stringify(pending.detail)], @@ -445,7 +482,13 @@ export async function assignOrderItemsAfterCarrierReady(input: { let carrier try { - carrier = await ensureCarrierForOrderItem({ order, item, channel: input.channel }) + carrier = await ensureCarrierForOrderItem({ + order, + item, + channel: input.channel, + retryInactiveCarrier: true, + allowInactiveTaskRetry: true, + }) } catch (error) { carrier = { ready: false, @@ -498,6 +541,7 @@ export async function assignOrderItemsAfterCarrierReady(input: { channel: input.channel, reason: carrier.reason, message: carrier.message, + carrierIssue: carrier.reason, }), ], ) @@ -905,6 +949,9 @@ export type PendingGroupRow = { shops: Array<{ id: string; name: string }> sku_codes: string[] pending_reasons: string[] + pending_subreasons: string[] + configured_channels: string[] + locked_item_count: number item_count: number revenue_fen: number first_seen_at: string @@ -963,14 +1010,32 @@ export async function listPendingChannelGroups(input: { array_agg(DISTINCT o.shop_id) AS shop_ids, array_agg(DISTINCT oi.sku_code) AS sku_codes, array_remove(array_agg(DISTINCT oi.fulfillment_pending_reason), NULL) AS pending_reasons, + array_remove( + array_agg( + DISTINCT COALESCE( + oi.fulfillment_pending_detail->>'carrierIssue', + oi.fulfillment_pending_detail->>'reason' + ) + ), + NULL + ) AS pending_subreasons, + COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels, + COUNT(*) FILTER (WHERE oi.fulfillment_locked)::int AS locked_item_count, COUNT(*)::int AS item_count, COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen, MIN(oi.created_at) AS first_seen_at, MAX(oi.created_at) AS last_seen_at FROM order_items oi JOIN orders o ON o.id = oi.order_id + LEFT JOIN LATERAL ( + SELECT array_agg(DISTINCT channel.channel ORDER BY channel.channel) AS channels + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) = + ${INTERNAL_SKU_SQL} + ) cfg ON TRUE WHERE ${where} - GROUP BY ${INTERNAL_SKU_SQL} + GROUP BY ${INTERNAL_SKU_SQL}, cfg.channels ORDER BY item_count DESC, internal_sku LIMIT $${params.length - 1} OFFSET $${params.length} `, @@ -1034,6 +1099,102 @@ export async function listPendingChannelGroups(input: { } } +export type RetryPendingCarrierGroupResult = { + scanned: number + workOrdersCreated: number + affiliateTasksCreated: number + assigned: number + failed: number + remaining: number +} + +/** 运营确认后重试整组载体:允许重开已关闭的历史尝试,并包含运营锁定的订单项。 */ +export async function retryPendingCarrierGroup(input: { + internalSku: string +}): Promise { + const internalSku = canonicalizeInternalSku(input.internalSku) + if (!internalSku || !containsCjk(internalSku)) { + throw createHttpError('必须使用包含中文的内部 SKU', { + statusCode: 400, + errorCode: 'internal_sku_required', + }) + } + + const pending = await query<{ id: number }>( + `SELECT oi.id + FROM order_items oi + WHERE oi.fulfillment_status = 'pending' + AND ${INTERNAL_SKU_SQL} = $1 + ORDER BY oi.id`, + [internalSku], + ) + const ids = pending.rows.map((row) => Number(row.id)) + let workOrdersCreated = 0 + let affiliateTasksCreated = 0 + let failed = 0 + const assignedItemIds: number[] = [] + + for (let offset = 0; offset < ids.length; offset += 500) { + const batchIds = ids.slice(offset, offset + 500) + const carrier = await reconcilePendingCarriers({ + orderItemIds: batchIds, + limit: batchIds.length, + includeLocked: true, + retryInactiveCarrier: true, + }) + workOrdersCreated += carrier.workOrdersCreated + affiliateTasksCreated += carrier.affiliateTasksCreated + failed += carrier.failed + const applied = await assignPendingOrderItemChannels({ + orderItemIds: batchIds, + batchSize: batchIds.length, + maxBatches: 1, + afterId: 0, + includeLocked: true, + }) + assignedItemIds.push(...applied.assignedItemIds) + } + + await query( + `UPDATE order_items oi + SET fulfillment_config_id = cfg.id, + canonical_product_id = COALESCE(oi.canonical_product_id, cfg.canonical_product_id), + updated_at = NOW() + FROM manual_fulfillment_configs cfg + WHERE oi.id = ANY($1::bigint[]) + AND oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + AND cfg.enabled = TRUE + AND canonicalize_internal_sku(cfg.sku_key) = + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)`, + [ids], + ) + if (assignedItemIds.length > 0) { + await normalizeProductsForItems(assignedItemIds).catch(() => undefined) + const cost = await syncCostSnapshotsForItems(assignedItemIds).catch(() => null) + const dates = [ + ...new Set([...(await orderItemBasisDates(assignedItemIds)), ...(cost?.affectedDates || [])]), + ] + await refreshFactsForDates(dates).catch(() => undefined) + } + const final = await query<{ assigned: number; remaining: number }>( + `SELECT + count(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned, + count(*) FILTER (WHERE fulfillment_status = 'pending')::int AS remaining + FROM order_items + WHERE id = ANY($1::bigint[])`, + [ids], + ) + return { + scanned: ids.length, + workOrdersCreated, + affiliateTasksCreated, + assigned: Number(final.rows[0]?.assigned || 0), + failed, + remaining: Number(final.rows[0]?.remaining || 0), + } +} + export type DeletePendingChannelGroupResult = { archivedItems: number preservedOrders: number diff --git a/apps/backend/src/services/fulfillment/channel-types.ts b/apps/backend/src/services/fulfillment/channel-types.ts index 205dd6fd..9b43aeaf 100644 --- a/apps/backend/src/services/fulfillment/channel-types.ts +++ b/apps/backend/src/services/fulfillment/channel-types.ts @@ -30,6 +30,10 @@ export const INACTIVE_CARRIER_TASK_STATUSES = [ 'cancelled', ] as const +export function isInactiveCarrierTaskStatus(value: unknown): boolean { + return (INACTIVE_CARRIER_TASK_STATUSES as readonly string[]).includes(String(value || '')) +} + export const FULFILLMENT_SOURCES = [ 'auto_rule', 'auto_mapping', diff --git a/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts index 617dea4a..0002ed35 100644 --- a/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts +++ b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts @@ -1,6 +1,8 @@ import { buildOrderClaimUrl } from '../../claim/claim-service.js' import { TASK_STATUS } from '../../../domain/task-status.js' +import { logWarn } from '../../../utils/logger.js' import { prepareAffiliateDashTask } from '../affiliate-dash/index.js' +import { scheduleVoucherConsumeForTask } from '../voucher-consume-queue-service.js' import { FULFILLMENT_EXECUTOR_KEYS, type FulfillmentDeliveryLink, @@ -32,7 +34,18 @@ async function preparePaidTask( })) || task } - return await prepareAffiliateDashTask(workingTask) + const prepared = await prepareAffiliateDashTask(workingTask) + const claimToken = String(prepared?.primary_claim_token || prepared?.claim_token || '').trim() + if (prepared && prepared.task_status !== TASK_STATUS.MANUAL_REVIEW && claimToken) { + await scheduleVoucherConsumeForTask(prepared, 'link_generated').catch((error) => { + logWarn('[affiliate-dash-executor]', '领取链接生成后安排自动核销失败', { + taskId: prepared.id, + platformOrderId: prepared.platform_order_id, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + return prepared } catch (error) { const message = error instanceof Error ? error.message : 'affiliate-dash 订单创建失败' const updatedTask = await deps.updateTask(task.id, { diff --git a/apps/backend/src/services/fulfillment/executors/manual-executor.ts b/apps/backend/src/services/fulfillment/executors/manual-executor.ts index 9bf6b17a..4f7add55 100644 --- a/apps/backend/src/services/fulfillment/executors/manual-executor.ts +++ b/apps/backend/src/services/fulfillment/executors/manual-executor.ts @@ -1,6 +1,6 @@ import { logWarn } from '../../../utils/logger.js' import { TASK_STATUS } from '../../../domain/task-status.js' -import { autoConsumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-auto-consume-service.js' +import { scheduleVoucherConsumeForTask } from '../voucher-consume-queue-service.js' import { FULFILLMENT_EXECUTOR_KEYS, type FulfillmentExecutor, @@ -32,10 +32,8 @@ async function preparePaidTask( }) const effectiveTask = updatedTask || task - await autoConsumeKuaishouIndustryVouchersForTask(effectiveTask, { - source: 'manual_dispatch_prepared', - }).catch((error) => { - logWarn('[manual-executor]', '人工派单准备后自动推进电子凭证核销失败', { + await scheduleVoucherConsumeForTask(effectiveTask, 'task_created').catch((error) => { + logWarn('[manual-executor]', '人工派单准备后安排自动核销失败', { taskId: effectiveTask.id, platformOrderId: effectiveTask.platform_order_id, error: error instanceof Error ? error.message : String(error), diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index e6141693..46131fc6 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -27,6 +27,7 @@ test( listChannelRules, listChannelRulesPage, listPendingChannelGroups, + retryPendingCarrierGroup, switchChannelForOrderItems, upsertChannelRule, } = await import('./channel-assign-service.js') @@ -117,15 +118,18 @@ test( internal_sku: string | null platform_only: string | null cjk_extension: string | null + case_variant: string | null }>( `SELECT resolve_internal_sku('platform-code', ' 套装-浪漫 天命 ', 'platform-code') AS internal_sku, resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only, - resolve_internal_sku('', '𠀀', '') AS cjk_extension`, + resolve_internal_sku('', '𠀀', '') AS cjk_extension, + resolve_internal_sku('', '黑色ROCK长袖T恤', '') AS case_variant`, ) assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命') assert.equal(canonicalResolution.rows[0]?.platform_only, null) assert.equal(canonicalResolution.rows[0]?.cjk_extension, '𠀀') + assert.equal(canonicalResolution.rows[0]?.case_variant, '黑色rock长袖t恤') await query( `INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source) @@ -331,6 +335,12 @@ test( )`, [retryWorkOrderRuleId, retryWorkOrderSku], ) + await query( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, config_json, source + ) VALUES ($1, 'work_order', 10, FALSE, TRUE, $2::jsonb, 'integration_test')`, + [retryWorkOrderSku, JSON.stringify({ workRuleKey: retryWorkOrderRuleKey })], + ) const retriedWorkOrders = await retryPendingWorkOrderRouting({ internalSku: retryWorkOrderSku, }) @@ -353,6 +363,34 @@ test( assert.equal(retriedItem.rows[0]?.fulfillment_status, 'assigned') assert.equal(retriedItem.rows[0]?.fulfillment_channel, 'work_order') assert.equal(retriedItem.rows[0]?.work_rule_key, retryWorkOrderRuleKey) + await query( + `UPDATE work_orders SET status = 'cancelled', updated_at = NOW() + WHERE order_item_id = $1`, + [retryWorkOrderItemId], + ) + await query( + `UPDATE order_items + SET fulfillment_status = 'pending', fulfillment_channel = NULL, + fulfillment_source = 'auto_mapping', fulfillment_pending_reason = 'no_carrier', + fulfillment_pending_detail = $2::jsonb, updated_at = NOW() + WHERE id = $1`, + [ + retryWorkOrderItemId, + JSON.stringify({ carrierIssue: 'carrier_task_closed', channel: 'work_order' }), + ], + ) + const retriedCarrier = await retryPendingCarrierGroup({ + internalSku: retryWorkOrderSku, + }) + assert.equal(retriedCarrier.scanned, 1) + assert.equal(retriedCarrier.assigned, 1) + assert.equal(retriedCarrier.remaining, 0) + const activeWorkOrders = await query<{ count: number }>( + `SELECT count(*)::int AS count FROM work_orders + WHERE order_item_id = $1 AND status <> 'cancelled'`, + [retryWorkOrderItemId], + ) + assert.equal(activeWorkOrders.rows[0]?.count, 1) const crossShopA = `it-routing-a-${suffix}` const crossShopB = `it-routing-b-${suffix}` @@ -555,6 +593,57 @@ test( Number(manualFallbackConfig.rows[0]!.id), ) + const retryTaskItemId = await createPendingOrderItem(autoManualSku) + const retryTaskOrder = await query<{ + order_id: number + provider: string + platform: string + shop_id: string + shop_name: string + platform_order_id: string + }>( + `SELECT oi.order_id, o.provider, o.platform, o.shop_id, o.shop_name, o.platform_order_id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = $1`, + [retryTaskItemId], + ) + const retryTaskOrderRow = retryTaskOrder.rows[0]! + const insertRetryTask = (taskNo: string, status: string) => + query( + `INSERT INTO fulfillment_tasks ( + order_id, order_item_id, unit_index, task_no, provider, platform, + shop_id, shop_name, platform_order_id, profile_id, executor_key, + task_status, created_at, updated_at + ) VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', $10, NOW(), NOW())`, + [ + retryTaskOrderRow.order_id, + retryTaskItemId, + taskNo, + retryTaskOrderRow.provider, + retryTaskOrderRow.platform, + retryTaskOrderRow.shop_id, + retryTaskOrderRow.shop_name, + retryTaskOrderRow.platform_order_id, + fallbackProfileId, + status, + ], + ) + await insertRetryTask(`IT-AFF-CLOSED-${suffix}`, 'closed') + await insertRetryTask(`IT-AFF-ACTIVE-${suffix}`, 'paid') + await assert.rejects( + () => insertRetryTask(`IT-AFF-DUPLICATE-${suffix}`, 'claimed'), + /duplicate key value violates unique constraint/, + ) + const retryTaskRows = await query<{ task_status: string }>( + `SELECT task_status FROM fulfillment_tasks WHERE order_item_id = $1 ORDER BY id`, + [retryTaskItemId], + ) + assert.deepEqual( + retryTaskRows.rows.map((row) => row.task_status), + ['closed', 'paid'], + ) + await ensureVoucherRuleForSku(voucherSku) const voucherChannel = await query<{ channel: string; source: string }>( `SELECT channel, source FROM internal_sku_channels diff --git a/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts b/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts index 1562c437..c4ccbe46 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts @@ -52,4 +52,8 @@ test('内部 SKU:没有中文时返回空,平台码和英文码不能作为 test('内部 SKU 规范化:NFKC + 去全部空白 + 小写', () => { assert.equal(canonicalizeInternalSku(' 套装-浪漫天命 '), '套装-浪漫天命') assert.equal(canonicalizeInternalSku(' M416-仓鼠 灰灰 '), 'm416-仓鼠灰灰') + assert.equal( + canonicalizeInternalSku('黑色ROCK长袖T恤'), + canonicalizeInternalSku('黑色rock长袖t恤'), + ) }) diff --git a/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts index 27015f5f..599c0ce0 100644 --- a/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts +++ b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts @@ -37,6 +37,14 @@ export async function retryPendingWorkOrderRouting( `SELECT oi.* FROM order_items oi WHERE oi.fulfillment_status = 'pending' + AND EXISTS ( + SELECT 1 + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND channel.channel = 'work_order' + AND canonicalize_internal_sku(channel.internal_sku) = + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + ) ${skuClause} ORDER BY oi.id LIMIT $1`, @@ -71,6 +79,7 @@ export async function retryPendingWorkOrderRouting( source: 'admin_pending_retry', autoOnly: true, skipChannelAssign: true, + retryCancelledWorkOrder: true, }) createdWorkOrders += result.createdCount for (const skipped of result.skipped) { diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts new file mode 100644 index 00000000..f71600d9 --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + createDefaultVoucherConsumePolicy, + normalizeVoucherConsumePolicy, + resolveVoucherConsumePolicy, +} from './voucher-consume-policy-service.js' + +test('默认核销策略:自动发货 5 秒,工单发布或指派后 10 秒,人工任务 10 秒', () => { + const policy = createDefaultVoucherConsumePolicy() + + assert.deepEqual(policy.platforms.kuaishou_industry.channels.affiliate_dash, { + enabled: true, + mode: 'after_link_generated', + delaySeconds: 5, + }) + assert.deepEqual(policy.platforms.douyin_mashida.channels.work_order, { + enabled: true, + mode: 'after_work_order_published_or_assigned', + delaySeconds: 10, + }) + assert.deepEqual(policy.platforms.kuaishou_industry.channels.manual, { + enabled: true, + mode: 'after_task_created', + delaySeconds: 10, + }) +}) + +test('工单默认策略同时匹配发布和指派,且不匹配链接生成', () => { + const policy = createDefaultVoucherConsumePolicy() + + assert.equal( + resolveVoucherConsumePolicy({ + provider: 'kuaishou_industry', + channel: 'work_order', + event: 'work_order_published', + policy, + }).enabled, + true, + ) + assert.equal( + resolveVoucherConsumePolicy({ + provider: 'douyin_mashida', + channel: 'work_order', + event: 'work_order_assigned', + policy, + }).enabled, + true, + ) + assert.equal( + resolveVoucherConsumePolicy({ + provider: 'douyin_mashida', + channel: 'work_order', + event: 'link_generated', + policy, + }).enabled, + false, + ) +}) + +test('策略归一化会丢弃非法模式并限制延迟范围', () => { + const policy = normalizeVoucherConsumePolicy({ + enabled: true, + platforms: { + kuaishou_industry: { + enabled: true, + channels: { + affiliate_dash: { + enabled: true, + mode: 'after_work_order_assigned', + delaySeconds: 99999, + }, + }, + }, + }, + }) + + assert.deepEqual(policy.platforms.kuaishou_industry.channels.affiliate_dash, { + enabled: true, + mode: 'after_link_generated', + delaySeconds: 3600, + }) + assert.equal(policy.platforms.douyin_mashida.enabled, true) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts new file mode 100644 index 00000000..d872212a --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts @@ -0,0 +1,222 @@ +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import type { JsonObject } from '../../types/json.js' +import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' + +export const VOUCHER_CONSUME_PROVIDERS = ['kuaishou_industry', 'douyin_mashida'] as const +export type VoucherConsumeProvider = (typeof VOUCHER_CONSUME_PROVIDERS)[number] + +export const VOUCHER_CONSUME_CHANNELS = [ + 'affiliate_dash', + 'work_order', + 'manual', + 'third_party_voucher', +] as const +export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number] + +export const VOUCHER_CONSUME_EVENTS = [ + 'link_generated', + 'task_created', + 'work_order_published', + 'work_order_assigned', +] as const +export type VoucherConsumeEvent = (typeof VOUCHER_CONSUME_EVENTS)[number] + +export const VOUCHER_CONSUME_MODES = [ + 'manual', + 'after_link_generated', + 'after_task_created', + 'after_work_order_published', + 'after_work_order_assigned', + 'after_work_order_published_or_assigned', +] as const +export type VoucherConsumeMode = (typeof VOUCHER_CONSUME_MODES)[number] + +export type VoucherChannelConsumePolicy = { + enabled: boolean + mode: VoucherConsumeMode + delaySeconds: number +} + +export type VoucherPlatformConsumePolicy = { + enabled: boolean + channels: Record +} + +export type VoucherConsumePolicy = { + enabled: boolean + platforms: Record +} + +export function createDefaultVoucherConsumePolicy(): VoucherConsumePolicy { + return { + enabled: true, + platforms: { + kuaishou_industry: createDefaultPlatformPolicy(), + douyin_mashida: createDefaultPlatformPolicy(), + }, + } +} + +export function getVoucherConsumePolicy(): VoucherConsumePolicy { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.voucherConsumePolicy, + fallback: createDefaultVoucherConsumePolicy, + normalize: normalizeVoucherConsumePolicy, + }) +} + +export async function saveVoucherConsumePolicy(rawValue: unknown): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.voucherConsumePolicy, + value: rawValue, + normalize: normalizeVoucherConsumePolicy, + }) +} + +export function normalizeVoucherConsumePolicy(rawValue: unknown): VoucherConsumePolicy { + const defaults = createDefaultVoucherConsumePolicy() + const source = isPlainObject(rawValue) ? rawValue : {} + const rawPlatforms = isPlainObject(source.platforms) ? source.platforms : {} + + return { + enabled: normalizeBoolean(source.enabled, defaults.enabled), + platforms: { + kuaishou_industry: normalizePlatformPolicy( + rawPlatforms.kuaishou_industry, + defaults.platforms.kuaishou_industry, + ), + douyin_mashida: normalizePlatformPolicy( + rawPlatforms.douyin_mashida, + defaults.platforms.douyin_mashida, + ), + }, + } +} + +export function resolveVoucherConsumePolicy(input: { + provider: VoucherConsumeProvider + channel: VoucherConsumeChannel + event: VoucherConsumeEvent + policy?: VoucherConsumePolicy +}): { enabled: boolean; delaySeconds: number; mode: VoucherConsumeMode } { + const policy = input.policy || getVoucherConsumePolicy() + const platform = policy.platforms[input.provider] + const channel = platform.channels[input.channel] + + if (!policy.enabled || !platform.enabled || !channel.enabled || channel.mode === 'manual') { + return { enabled: false, delaySeconds: 0, mode: channel.mode } + } + + return { + enabled: modeMatchesEvent(channel.mode, input.event), + delaySeconds: channel.delaySeconds, + mode: channel.mode, + } +} + +export function isVoucherConsumeProvider(value: unknown): value is VoucherConsumeProvider { + return (VOUCHER_CONSUME_PROVIDERS as readonly string[]).includes(String(value || '').trim()) +} + +function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy { + return { + enabled: true, + channels: { + affiliate_dash: { + enabled: true, + mode: 'after_link_generated', + delaySeconds: 5, + }, + work_order: { + enabled: true, + mode: 'after_work_order_published_or_assigned', + delaySeconds: 10, + }, + manual: { + enabled: true, + mode: 'after_task_created', + delaySeconds: 10, + }, + third_party_voucher: { + enabled: false, + mode: 'manual', + delaySeconds: 0, + }, + }, + } +} + +function normalizePlatformPolicy( + rawValue: unknown, + fallback: VoucherPlatformConsumePolicy, +): VoucherPlatformConsumePolicy { + const source = isPlainObject(rawValue) ? rawValue : {} + const rawChannels = isPlainObject(source.channels) ? source.channels : {} + + return { + enabled: normalizeBoolean(source.enabled, fallback.enabled), + channels: { + affiliate_dash: normalizeChannelPolicy( + rawChannels.affiliate_dash, + fallback.channels.affiliate_dash, + ['manual', 'after_link_generated', 'after_task_created'], + ), + work_order: normalizeChannelPolicy(rawChannels.work_order, fallback.channels.work_order, [ + 'manual', + 'after_task_created', + 'after_work_order_published', + 'after_work_order_assigned', + 'after_work_order_published_or_assigned', + ]), + manual: normalizeChannelPolicy(rawChannels.manual, fallback.channels.manual, [ + 'manual', + 'after_task_created', + ]), + third_party_voucher: normalizeChannelPolicy( + rawChannels.third_party_voucher, + fallback.channels.third_party_voucher, + ['manual'], + ), + }, + } +} + +function normalizeChannelPolicy( + rawValue: unknown, + fallback: VoucherChannelConsumePolicy, + allowedModes: readonly VoucherConsumeMode[], +): VoucherChannelConsumePolicy { + const source = isPlainObject(rawValue) ? rawValue : {} + const mode = String(source.mode || '').trim() as VoucherConsumeMode + const fallbackMode = allowedModes.includes(fallback.mode) ? fallback.mode : 'manual' + + return { + enabled: normalizeBoolean(source.enabled, fallback.enabled), + mode: allowedModes.includes(mode) ? mode : fallbackMode, + delaySeconds: normalizeInteger(source.delaySeconds, fallback.delaySeconds, 0, 3600), + } +} + +function modeMatchesEvent(mode: VoucherConsumeMode, event: VoucherConsumeEvent): boolean { + if (mode === 'manual') return false + if (mode === 'after_link_generated') return event === 'link_generated' + if (mode === 'after_task_created') return event === 'task_created' + if (mode === 'after_work_order_published') return event === 'work_order_published' + if (mode === 'after_work_order_assigned') return event === 'work_order_assigned' + return event === 'work_order_published' || event === 'work_order_assigned' +} + +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + if (value === undefined || value === null || value === '') return fallback + return value !== false && String(value) !== 'false' +} + +function normalizeInteger(value: unknown, fallback: number, min: number, max: number): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, Math.trunc(parsed))) +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts new file mode 100644 index 00000000..80155ec7 --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -0,0 +1,777 @@ +import { query } from '../../db/client.js' +import { getOrderById } from '../../repositories/order-repo.js' +import { getOrderItemById, listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' +import { getTaskById, listTasksByOrderId } from '../../repositories/task-repo.js' +import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' +import { + findRedeemCertificateByOrder, + listRedeemCertificatesByLocalOrder, + type RedeemCertificateRow, +} from '../../repositories/redeem-certificate-repo.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/index.js' +import { + DOUYIN_MASHIDA_PROVIDER, + isDouyinMashidaProvider, +} from '../platforms/douyin-mashida/order-mapper.js' +import { + consumeKuaishouIndustryVoucher, + isKuaishouIndustryVoucherSendCallbackSuccess, + normalizeVoucherStatus, +} from '../platforms/kuaishou-industry/voucher-service.js' +import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js' +import { verifyCertificate } from '../redeem/certificate-service.js' +import { logIntegration, logWarn } from '../../utils/logger.js' +import { + getVoucherConsumePolicy, + resolveVoucherConsumePolicy, + type VoucherConsumeChannel, + type VoucherConsumeEvent, + type VoucherConsumeProvider, +} from './voucher-consume-policy-service.js' +import type { + KuaishouIndustryVoucherRow, + OrderItemRow, + OrderRow, + TaskRow, +} from '../../types/repository/rows.js' +import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../open-91/config.js' +import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js' + +export type VoucherConsumeScheduleResult = { + ok: boolean + voucherCount: number + consumedCount: number + alreadyConsumedCount: number + failedCount: number + voucherCodes: string[] + errorMessage: string + queuedCount: number + jobIds: number[] + dueAt: string + status: 'queued' | 'already_scheduled' | 'skipped' | 'failed' +} + +export type VoucherConsumeJobRow = { + id: number + provider: VoucherConsumeProvider + channel: VoucherConsumeChannel + trigger_event: VoucherConsumeEvent + target_key: string + platform_order_id: string + order_id: number | null + order_item_id: number | null + task_id: number | null + work_order_id: number | null + due_at: string + status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled' + attempt_count: number + max_attempts: number + locked_until: string | null + last_error: string + result_json: string | Record + created_at: string + updated_at: string + finished_at: string | null +} + +type VoucherConsumeScheduleTarget = { + provider: VoucherConsumeProvider + channel: VoucherConsumeChannel + event: VoucherConsumeEvent + targetKey: string + platformOrderId: string + orderId: number | null + orderItemId: number | null + taskId: number | null + workOrderId: number | null + voucherCodes: string[] + voucherCount: number +} + +const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800] +const PROCESSING_LOCK_SECONDS = 120 + +export async function scheduleVoucherConsumeForTask( + task: TaskRow, + event: VoucherConsumeEvent, +): Promise { + const order = await getOrderById(task.order_id) + if (!order) { + return buildScheduleResult('skipped', { errorMessage: '订单不存在,无法安排核销' }) + } + + const provider = resolveVoucherProvider(order) + if (!provider) { + return buildScheduleResult('skipped') + } + + const orderItem = await getOrderItemById(task.order_item_id) + const channel = resolveTaskVoucherChannel(task, orderItem) + if (!channel) { + return buildScheduleResult('skipped') + } + + return scheduleVoucherConsumeTarget({ + provider, + channel, + event, + targetKey: buildVoucherConsumeTargetKey(task.order_item_id, `task:${task.id}`), + platformOrderId: String(order.platform_order_id || task.platform_order_id || '').trim(), + orderId: Number(order.id), + orderItemId: Number(task.order_item_id || 0) || null, + taskId: Number(task.id), + workOrderId: null, + }) +} + +export async function scheduleVoucherConsumeForWorkOrder( + workOrder: WorkOrderRow, + event: Extract, +): Promise { + const order = workOrder.order_id ? await getOrderById(workOrder.order_id) : null + if (!order) { + return buildScheduleResult('skipped', { errorMessage: '订单不存在,无法安排核销' }) + } + + const provider = resolveVoucherProvider(order) + if (!provider) { + return buildScheduleResult('skipped') + } + + return scheduleVoucherConsumeTarget({ + provider, + channel: 'work_order', + event, + targetKey: buildVoucherConsumeTargetKey(workOrder.order_item_id, `work_order:${workOrder.id}`), + platformOrderId: String(order.platform_order_id || workOrder.platform_order_id || '').trim(), + orderId: Number(order.id), + orderItemId: Number(workOrder.order_item_id || 0) || null, + // work_orders.task_id is vestigial (migration 091); never use it as a join key. + taskId: null, + workOrderId: Number(workOrder.id), + }) +} + +async function scheduleVoucherConsumeTarget( + target: Omit, +): Promise { + const policy = resolveVoucherConsumePolicy({ + provider: target.provider, + channel: target.channel, + event: target.event, + policy: getVoucherConsumePolicy(), + }) + if (!policy.enabled) { + return buildScheduleResult('skipped') + } + + const candidates = await listVoucherCandidatesForTarget(target) + const dueAt = new Date(Date.now() + policy.delaySeconds * 1000).toISOString() + const inserted = await query( + ` + INSERT INTO voucher_consume_jobs ( + provider, + channel, + trigger_event, + target_key, + platform_order_id, + order_id, + order_item_id, + task_id, + work_order_id, + due_at, + status, + attempt_count, + max_attempts, + updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + 'pending', 0, 8, NOW() + ) + ON CONFLICT (provider, target_key) + WHERE status IN ('pending', 'processing', 'succeeded') + DO NOTHING + RETURNING * + `, + [ + target.provider, + target.channel, + target.event, + target.targetKey, + target.platformOrderId, + target.orderId, + target.orderItemId, + target.taskId, + target.workOrderId, + dueAt, + ], + ) + + const job = inserted.rows[0] + if (!job) { + return buildScheduleResult('already_scheduled', { + voucherCount: candidates.voucherCount, + voucherCodes: candidates.voucherCodes, + }) + } + + logIntegration('[voucher-consume/queue]', '核销任务已安排', { + jobId: job.id, + provider: target.provider, + channel: target.channel, + event: target.event, + dueAt, + orderId: target.orderId, + orderItemId: target.orderItemId, + workOrderId: target.workOrderId, + voucherCount: candidates.voucherCount, + }) + + return buildScheduleResult('queued', { + queuedCount: 1, + jobIds: [Number(job.id)], + dueAt, + voucherCount: candidates.voucherCount, + voucherCodes: candidates.voucherCodes, + }) +} + +export async function runVoucherConsumeDispatchJob( + jobConfig: Record = {}, +): Promise> { + const batchSize = clampInteger(jobConfig.batchSize, 20, 1, 200) + const jobs = await claimDueVoucherConsumeJobs(batchSize) + let okCount = 0 + let failedCount = 0 + let retryCount = 0 + let cancelledCount = 0 + + for (const job of jobs) { + try { + const policy = resolveVoucherConsumePolicy({ + provider: job.provider, + channel: job.channel, + event: job.trigger_event, + policy: getVoucherConsumePolicy(), + }) + if (!policy.enabled) { + await finishVoucherConsumeJob(job.id, 'cancelled', { + reason: 'policy_disabled_or_changed', + }) + cancelledCount += 1 + continue + } + + const result = await executeVoucherConsumeJob(job) + await finishVoucherConsumeJob(job.id, 'succeeded', result) + okCount += 1 + } catch (error) { + const retry = Number(job.attempt_count || 0) < Number(job.max_attempts || 8) + await failVoucherConsumeJob(job, error, retry) + if (retry) { + retryCount += 1 + } else { + failedCount += 1 + } + logWarn('[voucher-consume/dispatch]', '核销任务执行失败', { + jobId: job.id, + provider: job.provider, + channel: job.channel, + event: job.trigger_event, + attemptCount: job.attempt_count, + willRetry: retry, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + return { + status: failedCount > 0 ? 'warning' : 'ok', + message: + `核销任务:领取 ${jobs.length},成功 ${okCount},重试 ${retryCount},` + + `失败 ${failedCount},取消 ${cancelledCount}`, + checkedCount: jobs.length, + okCount, + failedCount, + retryCount, + cancelledCount, + } +} + +export async function listVoucherConsumeJobs( + input: { + status?: string + page?: number + pageSize?: number + } = {}, +) { + const page = clampInteger(input.page, 1, 1, Number.MAX_SAFE_INTEGER) + const pageSize = clampInteger(input.pageSize, 20, 1, 100) + const status = String(input.status || '').trim() + const params: unknown[] = [] + const where: string[] = [] + + if (status) { + params.push(status) + where.push(`status = $${params.length}`) + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '' + const countResult = await query<{ total: string | number }>( + `SELECT COUNT(*) AS total FROM voucher_consume_jobs ${whereSql}`, + params, + ) + const listParams = [...params, pageSize, (page - 1) * pageSize] + const result = await query( + ` + SELECT * + FROM voucher_consume_jobs + ${whereSql} + ORDER BY updated_at DESC, id DESC + LIMIT $${params.length + 1} + OFFSET $${params.length + 2} + `, + listParams, + ) + + return { + items: result.rows, + total: Number(countResult.rows[0]?.total || 0) || 0, + page, + pageSize, + } +} + +export async function retryVoucherConsumeJob(jobId: unknown): Promise { + const id = Number(jobId) + if (!Number.isInteger(id) || id <= 0) { + return null + } + + const result = await query( + ` + UPDATE voucher_consume_jobs job + SET + status = 'pending', + attempt_count = 0, + locked_until = NULL, + last_error = '', + result_json = '{}'::jsonb, + due_at = NOW(), + updated_at = NOW(), + finished_at = NULL + WHERE job.id = $1 + AND job.status IN ('failed', 'cancelled') + AND NOT EXISTS ( + SELECT 1 + FROM voucher_consume_jobs active + WHERE active.provider = job.provider + AND active.target_key = job.target_key + AND active.id <> job.id + AND active.status IN ('pending', 'processing', 'succeeded') + ) + RETURNING job.* + `, + [id], + ) + return result.rows[0] || null +} + +async function executeVoucherConsumeJob(job: VoucherConsumeJobRow) { + if (job.provider === 'kuaishou_industry') { + return executeKuaishouConsumeJob(job) + } + if (job.provider === DOUYIN_MASHIDA_PROVIDER) { + return executeDouyinConsumeJob(job) + } + throw new Error(`不支持的核销平台:${job.provider}`) +} + +async function executeKuaishouConsumeJob(job: VoucherConsumeJobRow) { + const vouchers = await resolveKuaishouVouchersForJob(job) + if (vouchers.length === 0) { + throw new Error('未找到待核销的快手电子凭证') + } + + const task = job.task_id ? await getTaskById(job.task_id) : null + let consumedCount = 0 + let alreadyConsumedCount = 0 + let skippedCount = 0 + + for (const voucher of vouchers) { + const status = normalizeVoucherStatus(voucher.status) + if (status === 'CONSUMED') { + alreadyConsumedCount += 1 + continue + } + if (status === 'DESTROYED') { + skippedCount += 1 + continue + } + if (!isKuaishouIndustryVoucherSendCallbackSuccess(voucher)) { + throw new Error(`电子凭证 ${voucher.voucher_code} 尚未完成发码回调`) + } + + const result = await consumeKuaishouIndustryVoucher(voucher, { + source: `delayed_${job.trigger_event}`, + consumeTime: Date.now(), + ...(task ? { task } : {}), + }) + if (!result.ok || !result.voucher) { + throw new Error(result.errorMessage || `电子凭证 ${voucher.voucher_code} 核销失败`) + } + + consumedCount += 1 + if (task) { + await attachKuaishouIndustryVoucherToTask(task, result.voucher, { + source: `delayed_${job.trigger_event}`, + }) + } + } + + return { + provider: job.provider, + voucherCount: vouchers.length, + consumedCount, + alreadyConsumedCount, + skippedCount, + } +} + +async function executeDouyinConsumeJob(job: VoucherConsumeJobRow) { + const certificates = await resolveDouyinCertificatesForJob(job) + if (certificates.length === 0) { + throw new Error('未找到待核销的抖音券') + } + + let consumedCount = 0 + let alreadyConsumedCount = 0 + let skippedCount = 0 + + for (const certificate of certificates) { + if (certificate.status === 'verified') { + alreadyConsumedCount += 1 + continue + } + if (certificate.status === 'invalid') { + skippedCount += 1 + continue + } + + await verifyCertificate({ certNo: certificate.cert_no }) + consumedCount += 1 + } + + return { + provider: job.provider, + voucherCount: certificates.length, + consumedCount, + alreadyConsumedCount, + skippedCount, + } +} + +async function resolveKuaishouVouchersForJob( + job: VoucherConsumeJobRow, +): Promise { + const oid = String(job.platform_order_id || '').trim() + if (!oid) return [] + const vouchers = await listKuaishouIndustryVouchersByOid(oid) + if (vouchers.length === 0) return [] + + if (job.task_id) { + const task = await getTaskById(job.task_id) + if (task) { + return vouchers.filter( + (voucher) => + Number(voucher.task_id || 0) === Number(task.id) || + Number(voucher.unit_index || 0) === Number(task.unit_index || 0), + ) + } + } + + const orderId = Number(job.order_id || 0) + const orderItemId = Number(job.order_item_id || 0) + if (!orderId) return vouchers + + const orderItems = await listOrderItemsByOrderId(orderId) + if (orderItems.length <= 1 || !orderItemId) { + return vouchers + } + + const tasks = await listTasksByOrderId(orderId) + const itemTaskIds = new Set( + tasks + .filter((task) => Number(task.order_item_id) === orderItemId) + .map((task) => Number(task.id)), + ) + const byTask = vouchers.filter((voucher) => itemTaskIds.has(Number(voucher.task_id || 0))) + if (byTask.length > 0) { + return byTask + } + + const range = resolveOrderItemUnitRange(orderItems, orderItemId) + if (!range) return [] + return vouchers.filter((voucher) => { + const unitIndex = Number(voucher.unit_index || 0) + return unitIndex >= range.start && unitIndex <= range.end + }) +} + +async function resolveDouyinCertificatesForJob( + job: VoucherConsumeJobRow, +): Promise { + const orderId = Number(job.order_id || 0) + const orderItemId = Number(job.order_item_id || 0) + const certificates = orderId ? await listRedeemCertificatesByLocalOrder(orderId) : [] + const filtered = orderItemId + ? certificates.filter( + (certificate) => + !certificate.order_item_id || Number(certificate.order_item_id) === orderItemId, + ) + : certificates + if (filtered.length > 0) { + return filtered + } + + const receiptOrderId = String(job.platform_order_id || '').trim() + if (!receiptOrderId) return [] + const fallback = await findRedeemCertificateByOrder({ + provider: DOUYIN_MASHIDA_PROVIDER, + orderId: receiptOrderId, + }) + return fallback ? [fallback] : [] +} + +async function listVoucherCandidatesForTarget( + target: Pick< + VoucherConsumeScheduleTarget, + 'provider' | 'platformOrderId' | 'orderId' | 'orderItemId' | 'taskId' + >, +): Promise<{ voucherCount: number; voucherCodes: string[] }> { + if (target.provider === 'kuaishou_industry') { + const vouchers = target.platformOrderId + ? await listKuaishouIndustryVouchersByOid(target.platformOrderId) + : [] + return { + voucherCount: vouchers.length, + voucherCodes: vouchers.map((voucher) => String(voucher.voucher_code || '')).filter(Boolean), + } + } + + const certificates = await resolveDouyinCertificatesForJob({ + ...buildSyntheticJob(target), + }) + return { + voucherCount: certificates.length, + voucherCodes: certificates.map((certificate) => certificate.cert_no), + } +} + +function buildSyntheticJob( + target: Pick< + VoucherConsumeScheduleTarget, + 'provider' | 'platformOrderId' | 'orderId' | 'orderItemId' | 'taskId' + >, +): VoucherConsumeJobRow { + return { + id: 0, + provider: target.provider, + channel: 'affiliate_dash', + trigger_event: 'link_generated', + target_key: '', + platform_order_id: target.platformOrderId, + order_id: target.orderId, + order_item_id: target.orderItemId, + task_id: target.taskId, + work_order_id: null, + due_at: '', + status: 'pending', + attempt_count: 0, + max_attempts: 8, + locked_until: null, + last_error: '', + result_json: {}, + created_at: '', + updated_at: '', + finished_at: null, + } +} + +async function claimDueVoucherConsumeJobs(limit: number): Promise { + const result = await query( + ` + WITH due AS ( + SELECT id + FROM voucher_consume_jobs + WHERE attempt_count < max_attempts + AND ( + (status = 'pending' AND due_at <= NOW()) + OR (status = 'processing' AND locked_until < NOW()) + ) + ORDER BY due_at ASC, id ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE voucher_consume_jobs job + SET + status = 'processing', + attempt_count = job.attempt_count + 1, + locked_until = NOW() + ($2 * INTERVAL '1 second'), + updated_at = NOW() + FROM due + WHERE job.id = due.id + RETURNING job.* + `, + [limit, PROCESSING_LOCK_SECONDS], + ) + return result.rows +} + +async function finishVoucherConsumeJob( + jobId: number, + status: 'succeeded' | 'cancelled', + result: Record, +) { + await query( + ` + UPDATE voucher_consume_jobs + SET + status = $1, + locked_until = NULL, + last_error = '', + result_json = $2::jsonb, + updated_at = NOW(), + finished_at = NOW() + WHERE id = $3 + `, + [status, JSON.stringify(result), jobId], + ) +} + +async function failVoucherConsumeJob(job: VoucherConsumeJobRow, error: unknown, retry: boolean) { + const message = error instanceof Error ? error.message : String(error) + if (!retry) { + await query( + ` + UPDATE voucher_consume_jobs + SET + status = 'failed', + locked_until = NULL, + last_error = $1, + result_json = '{}'::jsonb, + updated_at = NOW(), + finished_at = NOW() + WHERE id = $2 + `, + [message, job.id], + ) + return + } + + const attemptIndex = Math.max(0, Number(job.attempt_count || 1) - 1) + const retryDelaySeconds = + RETRY_DELAYS_SECONDS[Math.min(attemptIndex, RETRY_DELAYS_SECONDS.length - 1)] || 60 + await query( + ` + UPDATE voucher_consume_jobs + SET + status = 'pending', + due_at = NOW() + ($1 * INTERVAL '1 second'), + locked_until = NULL, + last_error = $2, + updated_at = NOW() + WHERE id = $3 + `, + [retryDelaySeconds, message, job.id], + ) +} + +function resolveVoucherProvider(order: OrderRow): VoucherConsumeProvider | null { + if (isDouyinMashidaProvider(order.provider)) { + return 'douyin_mashida' + } + const provider = String(order.provider || '').trim() + const platform = String(order.platform || '').trim() + if ( + [OPEN_91_PROVIDER, 'open_91', '91'].includes(provider) || + [OPEN_91_PLATFORM, 'kuaishou'].includes(platform) + ) { + return 'kuaishou_industry' + } + return null +} + +function resolveTaskVoucherChannel( + task: Pick, + orderItem: OrderItemRow | null, +): VoucherConsumeChannel | null { + const executorKey = String(task.executor_key || '').trim() + if (executorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) return 'affiliate_dash' + if ( + executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH || + executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD || + executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI || + executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY + ) { + return 'manual' + } + + const channel = String(orderItem?.fulfillment_channel || '').trim() + if ( + channel === 'affiliate_dash' || + channel === 'work_order' || + channel === 'manual' || + channel === 'third_party_voucher' + ) { + return channel + } + return null +} + +function buildVoucherConsumeTargetKey(orderItemId: unknown, fallback: string): string { + const normalizedItemId = Number(orderItemId || 0) + return normalizedItemId > 0 ? `order_item:${normalizedItemId}` : fallback +} + +function resolveOrderItemUnitRange( + orderItems: OrderItemRow[], + orderItemId: number, +): { start: number; end: number } | null { + let cursor = 0 + for (const item of orderItems) { + const quantity = Math.max(1, Number(item.quantity || 1)) + const start = cursor + 1 + const end = cursor + quantity + if (Number(item.id) === orderItemId) { + return { start, end } + } + cursor = end + } + return null +} + +function buildScheduleResult( + status: VoucherConsumeScheduleResult['status'], + input: Partial = {}, +): VoucherConsumeScheduleResult { + return { + ok: status !== 'failed', + voucherCount: Number(input.voucherCount || 0), + consumedCount: 0, + alreadyConsumedCount: 0, + failedCount: 0, + voucherCodes: input.voucherCodes || [], + errorMessage: String(input.errorMessage || ''), + queuedCount: Number(input.queuedCount || 0), + jobIds: input.jobIds || [], + dueAt: String(input.dueAt || ''), + status, + } +} + +function clampInteger(value: unknown, fallback: number, min: number, max: number): number { + const parsed = Number(value) + if (!Number.isInteger(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} diff --git a/apps/backend/src/services/open-91/consume-on-deliver.ts b/apps/backend/src/services/open-91/consume-on-deliver.ts index 49de935f..d9e0b4b8 100644 --- a/apps/backend/src/services/open-91/consume-on-deliver.ts +++ b/apps/backend/src/services/open-91/consume-on-deliver.ts @@ -1,14 +1,10 @@ /** - * 91 卡券交付前核销: - * 在查询接口即将返回 orderStatus=20 + cards 前,同步核销行业电子凭证, - * 把「核销时间」提前到链接交付时刻,降低「拿链即退」误退窗口。 - * - * 语义:核销 = 领取链接已交付(售出闭环),≠ 游戏内道具已到账。 + * 91 卡券交付时安排延迟核销。 * + * 查询接口仍按任务 ready 状态返回 cards,但平台核销不再同步阻塞交付; + * 这里投递 link_generated 事件,后续由持久化定时任务按配置延迟执行。 */ -import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js' -import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js' -import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' +import { scheduleVoucherConsumeForTask } from '../fulfillment/voucher-consume-queue-service.js' import { logIntegration, logWarn } from '../../utils/logger.js' import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js' @@ -55,18 +51,7 @@ export function resolveIndustryVoucherForTask( return null } -function isVoucherConsumed(voucher: Pick): boolean { - return ( - String(voucher.status || '') - .trim() - .toUpperCase() === 'CONSUMED' - ) -} - -/** - * 对即将交付给 91 的 ready 任务同步核销行业凭证(幂等)。 - * 失败时调用方应返回 orderStatus=10,避免「未核销却已发链」。 - */ +/** 对即将交付给 91 的 ready 任务投递延迟核销任务。 */ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: { order: OrderRow tasks: TaskRow[] @@ -95,59 +80,29 @@ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: { } const oid = String(options.order.platform_order_id || '').trim() - const vouchers = oid ? await listKuaishouIndustryVouchersByOid(oid) : [] - const consumedTaskIds: number[] = [] const alreadyConsumedTaskIds: number[] = [] const failed: Array<{ taskId: number; errorMessage: string }> = [] for (const task of readyTasks) { const taskId = Number(task.id || 0) - const voucher = resolveIndustryVoucherForTask(task, vouchers) - - if (!voucher) { + try { + await scheduleVoucherConsumeForTask(task, 'link_generated') + } catch (error) { failed.push({ taskId, - errorMessage: '交付前核销失败:未找到对应电子凭证', + errorMessage: error instanceof Error ? error.message : '安排自动核销失败', }) - continue - } - - const alreadyConsumed = isVoucherConsumed(voucher) - const result = await consumeKuaishouIndustryVoucher(voucher, { - source, - task, - token: String(voucher.token || '').trim(), - consumeTime: Date.now(), - }) - - if (!result.ok || !result.voucher) { - failed.push({ - taskId, - errorMessage: result.errorMessage || '电子凭证核销回调失败', - }) - continue - } - - await attachKuaishouIndustryVoucherToTask(task, result.voucher, { - source, - }) - - if (alreadyConsumed) { - alreadyConsumedTaskIds.push(taskId) - } else { - consumedTaskIds.push(taskId) } } if (failed.length > 0) { - const errorMessage = failed[0]?.errorMessage || '电子凭证核销失败,暂缓交付领取链接' - logWarn('[open-91/query]', '91交付前核销失败,暂不返回 cards', { + const errorMessage = failed[0]?.errorMessage || '安排自动核销失败' + logWarn('[open-91/query]', '91交付时安排自动核销失败', { requestId: options.requestId || '', orderId: options.order.id, orderNo: oid, failedTaskIds: failed.map((item) => item.taskId), failed, - consumedTaskIds, alreadyConsumedTaskIds, errorMessage, }) @@ -156,24 +111,24 @@ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: { ok: false, errorMessage, failedTaskIds: failed.map((item) => item.taskId), - consumedTaskIds, + consumedTaskIds: [], alreadyConsumedTaskIds, } } - logIntegration('[open-91/query]', '91交付前核销完成', { + logIntegration('[open-91/query]', '91交付时核销任务已投递', { requestId: options.requestId || '', orderId: options.order.id, orderNo: oid, readyTaskCount: readyTasks.length, - consumedTaskIds, + taskCount: readyTasks.length, alreadyConsumedTaskIds, source, }) return { ok: true, - consumedTaskIds, + consumedTaskIds: [], alreadyConsumedTaskIds, } } diff --git a/apps/backend/src/services/order/delivery-task-service.test.ts b/apps/backend/src/services/order/delivery-task-service.test.ts index 4bcfe8f1..9f38cb80 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -197,6 +197,88 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任 assert.deepEqual(taskEvents, [{ taskId: 202, eventType: 'fulfillment_task_planned' }]) }) +test('syncDeliveryTasksForOrder 关闭任务不阻塞新的履约尝试', async () => { + const closedTask = stubCreatedTask(205, { + orderId: 5, + orderItemId: 25, + unitIndex: 1, + executorKey: 'affiliate_dash', + taskStatus: 'closed', + }) + const createdInputs: CreatedTaskInput[] = [] + + await syncDeliveryTasksForOrderWithDeps( + stubOrder({ id: 5, platform_order_id: 'retry-closed-order' }), + [ + stubOrderItem({ + id: 25, + order_id: 5, + sku_code: 'SKU-retry', + item_snapshot_json: buildAffiliateSnapshot('AFF-RETRY', '商品 RETRY'), + }), + ], + { + loadActiveCarrierEvidence: async () => new Map(), + listTasksByOrderId: async () => [closedTask], + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), + createTask: async (input) => { + createdInputs.push(input as CreatedTaskInput) + return stubCreatedTask(206, input as CreatedTaskInput) + }, + createTaskEvent: async () => null, + logInfo: () => null, + logWarn: () => null, + allowInactiveTaskRetry: true, + nowIso: () => '2026-05-29T09:05:46.658Z', + randomId: () => 'DT-retry-closed', + }, + ) + + assert.deepEqual( + createdInputs.map((input) => [input.orderItemId, input.unitIndex]), + [[25, 1]], + ) +}) + +test('syncDeliveryTasksForOrder 默认不会因历史关闭任务自动补建', async () => { + const closedTask = stubCreatedTask(208, { + orderId: 6, + orderItemId: 26, + unitIndex: 1, + executorKey: 'affiliate_dash', + taskStatus: 'closed', + }) + const createdInputs: CreatedTaskInput[] = [] + + await syncDeliveryTasksForOrderWithDeps( + stubOrder({ id: 6, platform_order_id: 'closed-order-no-retry' }), + [ + stubOrderItem({ + id: 26, + order_id: 6, + sku_code: 'SKU-closed-no-retry', + item_snapshot_json: buildAffiliateSnapshot('AFF-CLOSED', '商品 CLOSED'), + }), + ], + { + loadActiveCarrierEvidence: async () => new Map(), + listTasksByOrderId: async () => [closedTask], + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), + createTask: async (input) => { + createdInputs.push(input as CreatedTaskInput) + return stubCreatedTask(209, input as CreatedTaskInput) + }, + createTaskEvent: async () => null, + logInfo: () => null, + logWarn: () => null, + nowIso: () => '2026-05-29T09:05:46.658Z', + randomId: () => 'DT-closed-no-retry', + }, + ) + + assert.equal(createdInputs.length, 0) +}) + test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async () => { const existingTask = stubCreatedTask(301, { orderId: 4, diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index ba1c1595..fb3a26a2 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -22,7 +22,10 @@ import { findConflictingCarrier, loadActiveCarrierEvidence, } from '../fulfillment/carrier-evidence-service.js' -import type { FulfillmentChannel } from '../fulfillment/channel-types.js' +import { + isInactiveCarrierTaskStatus, + type FulfillmentChannel, +} from '../fulfillment/channel-types.js' type ClaimTokenLike = { token: string @@ -52,13 +55,15 @@ type DeliveryTaskDeps = { createTaskEvent?: typeof createTaskEvent logInfo?: typeof logInfo logWarn?: typeof logWarn + allowInactiveTaskRetry?: boolean } export async function syncDeliveryTasksForOrder( order: OrderRow, orderItems: OrderItemRow[], + options: { allowInactiveTaskRetry?: boolean } = {}, ): Promise { - return syncDeliveryTasksForOrderWithDeps(order, orderItems) + return syncDeliveryTasksForOrderWithDeps(order, orderItems, options) } export async function syncDeliveryTasksForOrderWithDeps( @@ -79,6 +84,7 @@ export async function syncDeliveryTasksForOrderWithDeps( createTaskEvent: appendTaskEvent = createTaskEvent, logInfo: writeInfoLog = logInfo, logWarn: writeWarnLog = logWarn, + allowInactiveTaskRetry = false, } = deps const runtimeDeps: FulfillmentPrepareDeps = { @@ -90,7 +96,12 @@ export async function syncDeliveryTasksForOrderWithDeps( const existingTasks = await listTasks(order.id) const tasks: DeliveryTaskRow[] = [...existingTasks] - const existingTaskKeys = new Set(existingTasks.map(buildTaskUnitKey)) + const existingTaskKeys = new Set( + (allowInactiveTaskRetry + ? existingTasks.filter((task) => !isInactiveCarrierTaskStatus(task.task_status)) + : existingTasks + ).map(buildTaskUnitKey), + ) const carrierEvidence = await loadCarrierEvidence(orderItems.map((item) => Number(item.id))) writeInfoLog('[delivery-task-service]', '开始核对订单履约任务完整性', { @@ -205,6 +216,9 @@ export async function syncDeliveryTasksForOrderWithDeps( task = concurrentTasks.find( (candidate) => + (allowInactiveTaskRetry + ? !isInactiveCarrierTaskStatus(candidate.task_status) + : true) && buildTaskUnitKey(candidate) === buildOrderItemUnitKey(item.id, unitIndex), ) || null if (!task) { diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts index d899de85..195a54d1 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts @@ -30,7 +30,6 @@ import { resolveKuaishouIndustryVoucherValidity, } from './voucher-service.js' import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js' -import { autoConsumeKuaishouIndustryVouchersForOrder } from './voucher-auto-consume-service.js' import { parseAmountToFen } from '../../../utils/money.js' import { publishAdminOrderRealtimeChange, @@ -518,32 +517,6 @@ async function syncOpen91OrderAfterSendCallbackSuccess( ) } } - - try { - const autoConsumeResult = await autoConsumeKuaishouIndustryVouchersForOrder(normalizedOid, { - source: `${source}_auto_consume`, - }) - if (autoConsumeResult.consumedCount > 0 || autoConsumeResult.failedCount > 0) { - logIntegration('[kuaishou-industry/send-code]', '发码回调成功后自动推进电子凭证核销完成', { - source, - oid: normalizedOid, - orderId: order.id, - consumedCount: autoConsumeResult.consumedCount, - alreadyConsumedCount: autoConsumeResult.alreadyConsumedCount, - skippedCount: autoConsumeResult.skippedCount, - failedCount: autoConsumeResult.failedCount, - skippedReasons: autoConsumeResult.skippedReasons, - errors: autoConsumeResult.errors, - }) - } - } catch (consumeError) { - logWarn('[kuaishou-industry/send-code]', '发码回调成功后自动推进电子凭证核销异常', { - source, - oid: normalizedOid, - orderId: order.id, - error: consumeError instanceof Error ? consumeError.message : String(consumeError), - }) - } } function isOpen91OrderAwaitingFulfillmentConfig(error: unknown) { diff --git a/apps/backend/src/services/scheduler/config-service.test.ts b/apps/backend/src/services/scheduler/config-service.test.ts index 1f2322b9..4ed9dc14 100644 --- a/apps/backend/src/services/scheduler/config-service.test.ts +++ b/apps/backend/src/services/scheduler/config-service.test.ts @@ -45,6 +45,7 @@ test('normalizeScheduledJobsConfig ensures default jobs exist', () => { assert.equal(ids.includes('cost-finance-sync'), false) assert.ok(ids.includes('channel-assign')) assert.ok(ids.includes('ledger-post')) + assert.ok(ids.includes('voucher-consume-dispatch')) }) test('旧的 cost_finance_sync 任务已彻底移除,不再出现在配置里', () => { diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index 0537c89c..42963cdc 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -19,6 +19,7 @@ const COST_SNAPSHOT_JOB_ID = 'cost-snapshot' const SALES_FACTS_JOB_ID = 'sales-facts' const LEDGER_POST_JOB_ID = 'ledger-post' const ORDER_SERVICE_AUTO_PUBLISH_JOB_ID = 'order-service-auto-publish' +const VOUCHER_CONSUME_DISPATCH_JOB_ID = 'voucher-consume-dispatch' export function getScheduledJobsFilePath() { return SCHEDULED_JOBS_FILE_PATH @@ -93,6 +94,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) { const hasOrderServiceAutoPublish = jobs.some( (item) => item.id === ORDER_SERVICE_AUTO_PUBLISH_JOB_ID, ) + const hasVoucherConsumeDispatch = jobs.some((item) => item.id === VOUCHER_CONSUME_DISPATCH_JOB_ID) if (!hasWorkOrderTimeout) { jobs.push(createDefaultWorkOrderTimeoutJob()) @@ -116,6 +118,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) { if (!hasSalesFacts) jobs.push(createDefaultSalesFactsJob()) if (!hasLedgerPost) jobs.push(createDefaultLedgerPostJob()) if (!hasOrderServiceAutoPublish) jobs.push(createDefaultOrderServiceAutoPublishJob()) + if (!hasVoucherConsumeDispatch) jobs.push(createDefaultVoucherConsumeDispatchJob()) return { enabled: typeof source.enabled === 'boolean' ? source.enabled : true, @@ -166,6 +169,9 @@ function normalizeScheduledJob(rawValue: unknown) { if (type === 'order_service_auto_publish') { return normalizeOrderServiceAutoPublishJob(rawValue) } + if (type === 'voucher_consume_dispatch') { + return normalizeVoucherConsumeDispatchJob(rawValue) + } return null } @@ -237,6 +243,7 @@ function createDefaultScheduledJobsConfig() { createDefaultSalesFactsJob(), createDefaultLedgerPostJob(), createDefaultOrderServiceAutoPublishJob(), + createDefaultVoucherConsumeDispatchJob(), ], } } @@ -322,6 +329,19 @@ function normalizeOrderServiceAutoPublishJob(rawValue: JsonObject) { } } +function normalizeVoucherConsumeDispatchJob(rawValue: JsonObject) { + const config = isPlainObject(rawValue.config) ? rawValue.config : {} + return { + id: VOUCHER_CONSUME_DISPATCH_JOB_ID, + type: 'voucher_consume_dispatch', + enabled: rawValue.enabled !== false, + intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 5, 1, 60), + config: { + batchSize: normalizeRangeInteger(config.batchSize, 20, 1, 200), + }, + } +} + function normalizeLedgerPostJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { @@ -385,6 +405,16 @@ function createDefaultOrderServiceAutoPublishJob() { } } +function createDefaultVoucherConsumeDispatchJob() { + return { + id: VOUCHER_CONSUME_DISPATCH_JOB_ID, + type: 'voucher_consume_dispatch', + enabled: true, + intervalSeconds: 5, + config: { batchSize: 20 }, + } +} + function createDefaultSalesFactsJob() { return { id: SALES_FACTS_JOB_ID, diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index 90b0fd4b..7ff70079 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -86,7 +86,15 @@ export async function runChannelAssignJob(job: JsonObject = {}) { export async function runCarrierDispatchJob(job: JsonObject = {}) { const config = jobConfig(job) const limit = clampInt(config.limit, 200, 1, 2000) - const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200) }) + const reconcileCursor = + config.carrierReconcileAfterId != null + ? Math.max(0, Math.trunc(Number(config.carrierReconcileAfterId))) + : await readWatermarkItemId('carrier_reconcile') + const reconciled = await reconcilePendingCarriers({ + limit: Math.min(limit, 200), + afterId: reconcileCursor, + }) + await touchWatermark('carrier_reconcile', { lastItemId: reconciled.nextCursor }) const repairedAssigned = await reconcilePendingCarriers({ limit: Math.min(limit, 200), includeAssignedMissingCarrier: true, @@ -335,7 +343,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { message: `人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` + `缺履约载体 ${missingCarrier.rows.length},多载体冲突 ${conflictingCarriers.rows.length},异常收口 ${resolved},` + - `自动转人工 ${fallback.switched},链路补载体 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated + repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` + + `自动转人工 ${fallback.switched},本轮补载体扫描 ${reconciled.scanned}、创建 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated}、失败 ${reconciled.failed},` + + `已分配补载体 ${repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` + `兜底 ${reconciled.fallbacksSelected + repairedAssigned.fallbacksSelected}`, attached, missingManual: missingManual.rows.length, @@ -541,6 +550,7 @@ const PIPELINE_RUNNERS: Record< (job?: JsonObject) => Promise> > = { channel_assign: runChannelAssignJob, + carrier_reconcile: runCarrierDispatchJob, carrier_dispatch: runCarrierDispatchJob, product_normalize: runProductNormalizeJob, cost_snapshot: runCostSnapshotJob, diff --git a/apps/backend/src/services/scheduler/finance-pipeline-status.ts b/apps/backend/src/services/scheduler/finance-pipeline-status.ts index 4308ded5..f9571010 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-status.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-status.ts @@ -14,6 +14,7 @@ import type { WatermarkPipeline } from '../watermark-store.js' export const FINANCE_PIPELINES = [ { name: 'channel_assign', label: '渠道判定', jobId: 'channel-assign' }, + { name: 'carrier_reconcile', label: '载体补建', jobId: 'carrier-dispatch' }, { name: 'carrier_dispatch', label: '履约载体', jobId: 'carrier-dispatch' }, { name: 'product_normalize', label: '商品归一', jobId: 'product-normalize' }, { name: 'cost_snapshot', label: '成本快照', jobId: 'cost-snapshot' }, diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index 537d9b92..6f36ca39 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -15,6 +15,7 @@ import { runProductNormalizeJob, runSalesFactsJob, } from './finance-pipeline-jobs.js' +import { runVoucherConsumeDispatchJob } from '../fulfillment/voucher-consume-queue-service.js' const timers = new Map() const jobStates = new Map() @@ -88,7 +89,7 @@ function rescheduleJob(job: JsonObject) { if (!jobId) return const latestJob = getScheduledJobById(jobId) if (!latestJob || latestJob.enabled !== true) return - const intervalMs = Math.max(10, Number(latestJob.intervalSeconds || 20)) * 1000 + const intervalMs = Math.max(1, Number(latestJob.intervalSeconds || 20)) * 1000 scheduleJob(latestJob, intervalMs) } @@ -234,6 +235,11 @@ function dispatchJob(job: JsonObject) { if (job.type === 'order_service_auto_publish') { return runOrderServiceAutoPublishJobs(job) } + if (job.type === 'voucher_consume_dispatch') { + return runVoucherConsumeDispatchJob( + job.config && typeof job.config === 'object' ? (job.config as Record) : {}, + ) + } throw createHttpError(`不支持的定时任务类型:${job.type}`, { statusCode: 400, diff --git a/apps/backend/src/services/watermark-store.ts b/apps/backend/src/services/watermark-store.ts index 4ce7b2c6..932e2a93 100644 --- a/apps/backend/src/services/watermark-store.ts +++ b/apps/backend/src/services/watermark-store.ts @@ -9,6 +9,7 @@ import { nowIso } from '../utils/time.js' export type WatermarkPipeline = | 'channel_assign' + | 'carrier_reconcile' | 'carrier_dispatch' | 'product_normalize' | 'cost_snapshot' diff --git a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts index 18ded96e..9ed89ca5 100644 --- a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts +++ b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts @@ -152,6 +152,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { ? JSON.stringify({ profileId: serviceProfile.profileId, categoryId: serviceProfile.categoryId, + sourceCategoryId: serviceProfile.sourceCategoryId, + inherited: serviceProfile.inherited, versionId: serviceProfile.versionId, versionNo: serviceProfile.versionNo, config: serviceProfile.config, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts index 0038ee98..8be41f98 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts @@ -13,7 +13,10 @@ import { publishWorkerWalletRealtimeChange, publishWorkOrderRealtimeChange, } from '../realtime/realtime-event-service.js' -import { consumeIndustryVouchersBeforeWorkOrderAssign } from './publish-voucher-service.js' +import { + scheduleVoucherConsumeForWorkOrder, + type VoucherConsumeScheduleResult, +} from '../fulfillment/voucher-consume-queue-service.js' import { mapWorkOrderAdmin, normalizeOptionalId, @@ -65,8 +68,7 @@ export async function assignAdminWorkOrderToWorker( }) } - // 已有拼单份额的订单不能再按整单指派;放在核销电子凭证之前, - // 避免“凭证已核销但指派失败”的副作用。 + // 已有拼单份额的订单不能再按整单指派。 const activeShares = (await listWorkOrderShares(workOrder.id)).filter( (share) => share.status !== 'cancelled', ) @@ -77,26 +79,6 @@ export async function assignAdminWorkOrderToWorker( }) } - const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderAssign(workOrder) - if (!voucherConsume.ok) { - const failedAt = nowIso() - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: actorName, - eventType: 'assign_voucher_consume_failed', - fromStatus: workOrder.status, - toStatus: workOrder.status, - payloadJson: JSON.stringify({ workerId: worker.id, voucherConsume }), - now: failedAt, - }) - throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未指派', { - statusCode: 409, - errorCode: 'work_order_assign_voucher_consume_failed', - context: voucherConsume, - }) - } - const now = nowIso() const { order: updated, failureReason } = await assignWorkOrderToWorker({ workOrderId: workOrder.id, @@ -108,38 +90,70 @@ export async function assignAdminWorkOrderToWorker( actorName, }) if (failureReason === 'work_order_not_assignable') { - throw createHttpError( - resolveAssignFailureMessage(voucherConsume.voucherCount, '订单状态已变化,无法指派'), - { statusCode: 409, errorCode: 'work_order_not_assignable' }, - ) + throw createHttpError('订单状态已变化,无法指派', { + statusCode: 409, + errorCode: 'work_order_not_assignable', + }) } if (failureReason === 'worker_active_order_limit') { - throw createHttpError( - resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手已达到最大同时接单量'), - { statusCode: 409, errorCode: 'worker_active_order_limit' }, - ) + throw createHttpError('该打手已达到最大同时接单量', { + statusCode: 409, + errorCode: 'worker_active_order_limit', + }) } if (failureReason === 'worker_deposit_insufficient') { - throw createHttpError( - resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手余额不足,无法冻结所需押金'), - { statusCode: 409, errorCode: 'worker_deposit_insufficient' }, - ) + throw createHttpError('该打手余额不足,无法冻结所需押金', { + statusCode: 409, + errorCode: 'worker_deposit_insufficient', + }) } if (failureReason === 'work_order_has_shares') { - throw createHttpError( - resolveAssignFailureMessage( - voucherConsume.voucherCount, - '该订单已有拼单份额,不能按整单指派;请使用拼单管理', - ), - { statusCode: 409, errorCode: 'work_order_has_shares' }, - ) + throw createHttpError('该订单已有拼单份额,不能按整单指派;请使用拼单管理', { + statusCode: 409, + errorCode: 'work_order_has_shares', + }) } if (!updated) { - throw createHttpError( - resolveAssignFailureMessage(voucherConsume.voucherCount, '指派失败,订单状态可能已变化'), - { statusCode: 409, errorCode: 'work_order_assign_conflict' }, - ) + throw createHttpError('指派失败,订单状态可能已变化', { + statusCode: 409, + errorCode: 'work_order_assign_conflict', + }) } + + let voucherConsume: VoucherConsumeScheduleResult + try { + voucherConsume = await scheduleVoucherConsumeForWorkOrder(updated, 'work_order_assigned') + } catch (error) { + voucherConsume = { + ok: false, + voucherCount: 0, + consumedCount: 0, + alreadyConsumedCount: 0, + failedCount: 1, + voucherCodes: [], + errorMessage: error instanceof Error ? error.message : '安排自动核销失败', + queuedCount: 0, + jobIds: [], + dueAt: '', + status: 'failed', + } + } + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: actorName, + eventType: + voucherConsume.status === 'queued' + ? 'assign_voucher_consume_scheduled' + : voucherConsume.status === 'failed' + ? 'assign_voucher_consume_schedule_failed' + : 'assign_voucher_consume_schedule_skipped', + fromStatus: workOrder.status, + toStatus: updated.status, + payloadJson: JSON.stringify({ workerId: worker.id, voucherConsume }), + now, + }).catch(() => undefined) + publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds: [Number(worker.id)], @@ -149,10 +163,6 @@ export async function assignAdminWorkOrderToWorker( return { order: mapWorkOrderAdmin(updated), voucherConsume } } -function resolveAssignFailureMessage(voucherCount: number, fallback: string) { - return voucherCount > 0 ? `电子凭证已核销,但${fallback},请重试指派` : fallback -} - function resolveAssignedDeadlineAt(timeoutMinutes: number, now: string): string | null { if (timeoutMinutes <= 0) return null return new Date(new Date(now).getTime() + timeoutMinutes * 60 * 1000).toISOString() diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index 030c8fa9..45109562 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -22,9 +22,9 @@ import { resolveAdminNotificationEntity } from '../admin/admin-notification-serv import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js' import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' import { - consumeIndustryVouchersBeforeWorkOrderPublish, - type WorkOrderVoucherConsumeResult, -} from './publish-voucher-service.js' + scheduleVoucherConsumeForWorkOrder, + type VoucherConsumeScheduleResult, +} from '../fulfillment/voucher-consume-queue-service.js' import { mapWorkOrderAdmin, mapWorkOrderShare, @@ -347,7 +347,7 @@ export async function reopenAdminCancelledWorkOrder(workOrderId: number | string export async function publishAdminWorkOrder( workOrderId: number | string, actorName = '', - options: { requireVoucherConsume?: boolean } = {}, + _options: { requireVoucherConsume?: boolean } = {}, ) { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) { @@ -364,39 +364,6 @@ export async function publishAdminWorkOrder( } const now = nowIso() - let voucherConsume: WorkOrderVoucherConsumeResult - try { - voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder) - } catch (error) { - voucherConsume = { - ok: false, - voucherCount: 0, - consumedCount: 0, - alreadyConsumedCount: 0, - failedCount: 1, - voucherCodes: [], - errorMessage: error instanceof Error ? error.message : '电子凭证核销失败', - } - } - if (!voucherConsume.ok) { - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'system', - actorId: 'kuaishou_send_code', - eventType: 'publish_voucher_consume_failed', - fromStatus: workOrder.status, - toStatus: workOrder.status, - payloadJson: JSON.stringify(voucherConsume), - now: nowIso(), - }) - if (options.requireVoucherConsume === true) { - throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', { - statusCode: 409, - errorCode: 'work_order_publish_voucher_consume_failed', - context: { voucherConsume }, - }) - } - } const published = await publishUnassignedWorkOrder({ workOrderId: workOrder.id, @@ -411,6 +378,39 @@ export async function publishAdminWorkOrder( } const currentOrder = await getRequiredWorkOrder(workOrder.id) + let voucherConsume: VoucherConsumeScheduleResult + try { + voucherConsume = await scheduleVoucherConsumeForWorkOrder(currentOrder, 'work_order_published') + } catch (error) { + voucherConsume = { + ok: false, + voucherCount: 0, + consumedCount: 0, + alreadyConsumedCount: 0, + failedCount: 1, + voucherCodes: [], + errorMessage: error instanceof Error ? error.message : '安排自动核销失败', + queuedCount: 0, + jobIds: [], + dueAt: '', + status: 'failed', + } + } + await createWorkOrderEvent({ + workOrderId: currentOrder.id, + actorType: 'system', + actorId: 'voucher_consume_policy', + eventType: + voucherConsume.status === 'queued' + ? 'publish_voucher_consume_scheduled' + : voucherConsume.status === 'failed' + ? 'publish_voucher_consume_schedule_failed' + : 'publish_voucher_consume_schedule_skipped', + fromStatus: currentOrder.status, + toStatus: currentOrder.status, + payloadJson: JSON.stringify(voucherConsume), + now: nowIso(), + }).catch(() => undefined) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true }) await enqueueCustomerOrderNotification({ orderId: currentOrder.order_id, diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index b1bf46db..f81f77d6 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -47,9 +47,9 @@ import { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-serv import { unassignAdminWorkOrder } from './admin-work-order-cancellation-service.js' import { mapWorkOrderAdmin } from './mappers.js' import { - consumeIndustryVouchersBeforeWorkOrderPublish, - type WorkOrderVoucherConsumeResult, -} from './publish-voucher-service.js' + scheduleVoucherConsumeForWorkOrder, + type VoucherConsumeScheduleResult, +} from '../fulfillment/voucher-consume-queue-service.js' import { type ConsignmentMatchOption, type ConsignmentMatchResult, @@ -383,7 +383,7 @@ export async function fulfillWorkOrderWithConsignment(input: { }): Promise<{ order: ReturnType inventory: ReturnType - voucherConsume: WorkOrderVoucherConsumeResult + voucherConsume: VoucherConsumeScheduleResult }> { const workOrder = await getWorkOrderById(input.workOrderId) if (!workOrder) { @@ -576,13 +576,10 @@ export async function fulfillWorkOrderWithConsignment(input: { return { updatedOrder: wo, updatedInventory: inv } }) - // 顺带核销快手电子凭证。 - // 核销要走快手外部接口、无法和上面的数据库事务合并回滚,因此这里只能做两件事: - // 1) 失败时不吞错:写事件留痕,并把 voucherConsume.ok=false 交给前端提示运营人工核销; - // 2) 绝不因为"凭证没核销"就谎报成功(此前前端无条件弹成功提示,属于假成功)。 - let voucherConsume: WorkOrderVoucherConsumeResult + // 行内发货完成后安排延迟核销,外部平台调用不阻塞当前履约事务。 + let voucherConsume: VoucherConsumeScheduleResult try { - voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(updatedOrder) + voucherConsume = await scheduleVoucherConsumeForWorkOrder(updatedOrder, 'work_order_assigned') } catch (error) { voucherConsume = { ok: false, @@ -591,7 +588,11 @@ export async function fulfillWorkOrderWithConsignment(input: { alreadyConsumedCount: 0, failedCount: 1, voucherCodes: [], - errorMessage: error instanceof Error ? error.message : '电子凭证核销失败', + errorMessage: error instanceof Error ? error.message : '安排自动核销失败', + queuedCount: 0, + jobIds: [], + dueAt: '', + status: 'failed', } } if (!voucherConsume.ok) { @@ -599,8 +600,13 @@ export async function fulfillWorkOrderWithConsignment(input: { await createWorkOrderEvent({ workOrderId: Number(updatedOrder.id), actorType: 'system', - actorId: 'kuaishou_send_code', - eventType: 'consignment_fulfill_voucher_consume_failed', + actorId: 'voucher_consume_policy', + eventType: + voucherConsume.status === 'queued' + ? 'consignment_fulfill_voucher_consume_scheduled' + : voucherConsume.status === 'failed' + ? 'consignment_fulfill_voucher_consume_schedule_failed' + : 'consignment_fulfill_voucher_consume_schedule_skipped', fromStatus: String(updatedOrder.status), toStatus: String(updatedOrder.status), payloadJson: JSON.stringify(voucherConsume), @@ -668,7 +674,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: { }): Promise<{ order: ReturnType inventory: ReturnType - voucherConsume: WorkOrderVoucherConsumeResult + voucherConsume: VoucherConsumeScheduleResult }> { const workOrder = await getWorkOrderById(input.workOrderId) if (!workOrder) { diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts index df0845e1..1080d054 100644 --- a/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts @@ -5,7 +5,10 @@ import { isOrderServiceMaterialRequired, normalizeProfileConfig, } from './order-service-profile-service.js' -import { backfillPendingOrderServiceSnapshotsForCategory } from '../../repositories/worker-platform/order-service-profile-repo.js' +import { + backfillPendingOrderServiceSnapshotsForCategory, + getPublishedOrderServiceProfileByCategoryWithInheritance, +} from '../../repositories/worker-platform/order-service-profile-repo.js' function config(patch: Record = {}) { return normalizeProfileConfig({ @@ -181,4 +184,40 @@ test('发布配置:待提交且无有效拼单份额的工单可更新到最 assert.equal(count, 2) assert.doesNotMatch(capturedSql, /service_profile_snapshot_json IS NULL/) assert.match(capturedSql, /share\.status <> 'cancelled'/) + assert.match(capturedSql, /WITH affected_categories/) + assert.match(capturedSql, /child_profile\.status <> 'archived'/) + assert.match(capturedSql, /wo\.category_id IN/) + assert.match(capturedSql, /'sourceCategoryId', p\.category_id/) + assert.match(capturedSql, /'inherited', wo\.category_id <> p\.category_id/) +}) + +test('订单服务配置:二级分类优先,未发布时沿分类链继承父级', async () => { + let capturedSql = '' + const client = { + query: async (sql: string) => { + capturedSql = sql + return { + rows: [ + { + profile_id: 9, + category_id: 3, + source_category_id: 3, + version_id: 19, + version_no: 4, + config_json: { fields: [] }, + }, + ], + } + }, + } as unknown as Parameters[1] + + const resolved = await getPublishedOrderServiceProfileByCategoryWithInheritance(81, client) + + assert.equal(resolved?.profile_id, 9) + assert.equal(resolved?.category_id, 3) + assert.equal(resolved?.source_category_id, 3) + assert.equal(resolved?.version_no, 4) + assert.match(capturedSql, /WITH RECURSIVE category_chain/) + assert.match(capturedSql, /ORDER BY chain\.depth ASC/) + assert.match(capturedSql, /p\.status <> 'archived'/) }) diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.ts index d2c4d94b..f47712e5 100644 --- a/apps/backend/src/services/worker-platform/order-service-profile-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.ts @@ -10,7 +10,7 @@ import { backfillPendingOrderServiceSnapshotsForCategory, createOrderServiceProfileVersion, getOrderServiceProfileVersion, - getPublishedOrderServiceProfileByCategory, + getPublishedOrderServiceProfileByCategoryWithInheritance, listOrderServiceProfiles, setOrderServiceProfileCurrentVersion, upsertOrderServiceProfile, @@ -83,6 +83,8 @@ export type OrderServiceProfileConfig = { export type OrderServiceProfileSnapshot = { profileId: number categoryId: number + sourceCategoryId: number + inherited: boolean versionId: number versionNo: number config: OrderServiceProfileConfig @@ -307,11 +309,14 @@ export async function getPublishedOrderServiceProfileSnapshotByCategory( categoryId: number | string, client?: Pick, ): Promise { - const row = await getPublishedOrderServiceProfileByCategory(categoryId, client) + const row = await getPublishedOrderServiceProfileByCategoryWithInheritance(categoryId, client) if (!row) return null + const targetCategoryId = Number(categoryId) return { profileId: Number(row.profile_id), - categoryId: Number(row.category_id), + categoryId: targetCategoryId, + sourceCategoryId: Number(row.source_category_id), + inherited: Number(row.source_category_id) !== targetCategoryId, versionId: Number(row.version_id), versionNo: Number(row.version_no), config: normalizeProfileConfig(row.config_json), @@ -329,6 +334,8 @@ export async function getOrderServiceProfileSnapshotByVersion(input: { return { profileId: Number(input.profileId), categoryId: Number(input.categoryId), + sourceCategoryId: Number(input.categoryId), + inherited: false, versionId: Number(version.id), versionNo: Number(version.version_no), config: normalizeProfileConfig(version.config_json), diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts index e18f2e2f..d736cccc 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -560,6 +560,13 @@ async function resolveProfileConfig( }) if (snapshotFromVersion) return snapshotFromVersion.config } + if (order.category_id) { + const published = await getPublishedOrderServiceProfileSnapshotByCategory( + Number(order.category_id), + client, + ) + if (published) return published.config + } const legacy = safeParseJson(order.requirement_json) if (Array.isArray(legacy.fields)) { return { diff --git a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts index bfcb10c7..4402c648 100644 --- a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts +++ b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts @@ -8,7 +8,7 @@ import { query } from '../../db/client.js' * - 订单不存在(91 未到/缺失):用快手数据兜底建单 + 建工单 + 凭证回填 * * 幂等依据:orders 唯一键 provider+platform+shop_id+platform_order_id - * (由 91 侧查重逻辑保证同 oid 单条记录)、work_orders.order_item_id 唯一索引、 + * (由 91 侧查重逻辑保证同 oid 单条记录)、work_orders 未取消工单对 order_item_id 的唯一索引、 * listWorkOrdersByPlatformOrderId 查重。 * * 本服务只写 orders / order_items / work_orders,不触碰 diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 42b77a61..3630f977 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -125,6 +125,7 @@ export async function syncWorkerOrdersForSourceOrder( sourceMetadata?: JsonObject skipChannelAssign?: boolean preferredWorkRuleKey?: string + retryCancelledWorkOrder?: boolean } = {}, ) { const [rules, ruleMappings] = await Promise.all([ @@ -252,7 +253,10 @@ export async function syncWorkerOrdersForSourceOrder( // 已有工单:跳过匹配日志,避免 PDD 定时同步对旧单反复刷「命中」日志。 const existingWorkOrder = await getWorkOrderByOrderItemId(item.id) - if (existingWorkOrder) { + if ( + existingWorkOrder && + !(options.retryCancelledWorkOrder === true && existingWorkOrder.status === 'cancelled') + ) { skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' }) decisions.push( buildWorkerConsumerDecision(item, { @@ -425,6 +429,8 @@ export async function syncWorkerOrdersForSourceOrder( ? JSON.stringify({ profileId: serviceProfile.profileId, categoryId: serviceProfile.categoryId, + sourceCategoryId: serviceProfile.sourceCategoryId, + inherited: serviceProfile.inherited, versionId: serviceProfile.versionId, versionNo: serviceProfile.versionNo, config: serviceProfile.config, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 2585ce56..203e2e81 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -2554,14 +2554,15 @@ test('履约与指派两条路径都要过服务端规格复检(不只依赖 assert.ok(src.includes('consignment_match_note_required')) }) -test('内部履约的电子凭证核销失败要写事件留痕(不能静默假成功)', async () => { +test('内部履约要投递延迟核销任务并写事件留痕', async () => { const readFile = await import('node:fs/promises') const src = await readFile.readFile( new URL('./consignment-fulfillment-service.ts', import.meta.url), 'utf8', ) - assert.ok(src.includes('consignment_fulfill_voucher_consume_failed')) + assert.ok(src.includes('consignment_fulfill_voucher_consume_scheduled')) + assert.ok(src.includes('scheduleVoucherConsumeForWorkOrder')) assert.ok(src.includes('if (!voucherConsume.ok)')) }) diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 8567ad9a..21abce6f 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -36,6 +36,7 @@ import { useEffect, useState } from 'react' import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' +import VoucherConsumePolicyPanel from './panels/VoucherConsumePolicyPanel' import { assignChannelByInternalSku, deletePendingChannelGroup, @@ -47,6 +48,7 @@ import { fetchInternalSkuMappings, fetchPendingChannelGroups, fetchPipelineStatus, + retryPendingCarriers, retryPendingWorkOrderRouting, runFinancePipeline, type FinancePipelineStatus, @@ -118,6 +120,7 @@ export default function AdminFulfillmentChannelPage() { { key: 'rules', label: '渠道规则', children: }, { key: 'exceptions', label: '问题队列', children: }, { key: 'pipelines', label: '管道状态', children: }, + { key: 'voucher-consume', label: '自动核销', children: }, ]} /> @@ -178,6 +181,28 @@ function PendingPanel() { message.error(error instanceof Error ? error.message : '重新匹配接单模板失败'), }) + const retryCarrierMutation = useMutation({ + mutationFn: (row: PendingChannelGroup) => + retryPendingCarriers({ internalSku: row.internal_sku }), + onSuccess: (result) => { + const data = result.data + const detail = [ + `扫描 ${data.scanned} 个订单项`, + `创建工单 ${data.workOrdersCreated} 个`, + `创建自动任务 ${data.affiliateTasksCreated} 个`, + `已分流 ${data.assigned} 个`, + ].join(',') + if (data.remaining > 0) { + message.warning(`${detail},仍有 ${data.remaining} 个待处理`) + } else { + message.success(detail) + } + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] }) + }, + onError: (error) => message.error(error instanceof Error ? error.message : '重试履约载体失败'), + }) + const deleteMutation = useMutation({ mutationFn: (row: PendingChannelGroup) => deletePendingChannelGroup({ internalSku: row.internal_sku }), @@ -232,7 +257,25 @@ function PendingPanel() { ) }, }, - { title: '内部SKU', dataIndex: 'internal_sku', width: 220 }, + { + title: '内部SKU', + dataIndex: 'internal_sku', + width: 220, + render: (value: string, row: PendingChannelGroup) => ( + + {value || '未识别内部 SKU'} + {row.configured_channels?.length ? ( + + {row.configured_channels.map((channel) => ( + + {CHANNEL_OPTIONS.find((option) => option.value === channel)?.label || channel} + + ))} + + ) : null} + + ), + }, { title: '平台SKU', dataIndex: 'sku_codes', @@ -243,18 +286,45 @@ function PendingPanel() { { title: '待分类原因', dataIndex: 'pending_reasons', - width: 160, - render: (value: string[] | undefined) => { + width: 240, + render: (value: string[] | undefined, row: PendingChannelGroup) => { const labels: Record = { no_internal_sku: '无内部SKU', no_channel_capability: '无渠道能力', no_carrier: '无履约载体', routing_conflict: '渠道冲突', } - return value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-' + const subreasonLabels: Record = { + carrier_task_closed: '载体已关闭', + carrier_task_manual_review: '载体人工复核', + carrier_not_attempted: '尚未尝试补载体', + carrier_attempt_failed: '载体创建失败', + manual_config_missing: '缺人工配置', + work_rule_missing: '缺接单模板', + affiliate_mapping_missing: '缺自动发货映射', + existing_carrier_conflict: '已有其它载体', + carrier_config_ambiguous: '载体配置冲突', + } + return ( + + + {value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'} + + {row.pending_subreasons?.length ? ( + + {row.pending_subreasons + .map((reason) => subreasonLabels[reason] || reason) + .join(' / ')} + + ) : null} + {row.locked_item_count > 0 ? ( + 运营锁定 {row.locked_item_count} 项 + ) : null} + + ) }, }, - { title: '订单数', dataIndex: 'item_count', width: 90 }, + { title: '订单项', dataIndex: 'item_count', width: 90 }, { title: '收入', dataIndex: 'revenue_fen', @@ -269,7 +339,7 @@ function PendingPanel() { }, { title: '指定渠道', - width: 360, + width: 440, render: (_: unknown, row: PendingChannelGroup) => (
- {filteredCategories.map((category) => { + {categoryTree.map(({ category, children }) => { const rowProfile = profileByCategoryId.get(category.categoryId) const status = resolveProfileStatus(rowProfile) + const expanded = + Boolean(normalizedKeyword) || expandedCategoryIds.has(category.categoryId) return ( - + + {expanded ? ( +
+ {children.map((child) => { + const childProfile = profileByCategoryId.get(child.categoryId) + const childStatus = resolveInheritedProfileStatus( + childProfile, + rowProfile, + ) + return ( + + ) + })} +
+ ) : null} +
) })} - {filteredCategories.length === 0 ? ( + {categoryTree.length === 0 ? ( ) : null} @@ -627,21 +740,39 @@ export default function OrderServicePanel() { {selectedCategory.status === 'active' ? '启用' : '停用'} - - {profile?.status === 'published' - ? `线上 v${profile.currentVersionNo || 1}` - : profile?.currentVersionNo - ? `线上 v${profile.currentVersionNo} · 有草稿` - : '尚未发布'} + + {ownProfile + ? resolveProfileStatus(ownProfile).label + : inheritsParentProfile + ? `继承「${parentCategory?.name || '父级'}」v${parentProfile?.currentVersionNo}` + : '尚未配置'} + {ownProfile && runtimeInheritsParent ? ( + + 线上继承「{parentCategory?.name || '父级'}」v + {parentProfile?.currentVersionNo} + + ) : null}
分类标识:{selectedCategory.categoryKey} - - {profile ? ( + {ownProfile ? ( @@ -651,9 +782,17 @@ export default function OrderServicePanel() {
@@ -1204,6 +1343,33 @@ function resolveProfileStatus(profile: AdminOrderServiceProfile | undefined) { return { label: '已归档', tone: 'muted' } } +function resolveInheritedProfileStatus( + profile: AdminOrderServiceProfile | undefined, + parentProfile: AdminOrderServiceProfile | undefined, +) { + if (profile) { + const status = resolveProfileStatus(profile) + if (profile.status === 'archived' && parentProfile?.currentVersionNo) { + return { label: `已归档 · 继承 v${parentProfile.currentVersionNo}`, tone: 'muted' } + } + if ( + !profile.currentVersionNo && + profile.status !== 'archived' && + parentProfile?.currentVersionNo + ) { + return { label: `草稿 · 继承 v${parentProfile.currentVersionNo}`, tone: 'warning' } + } + return status + } + if (parentProfile?.currentVersionNo && parentProfile.status !== 'archived') { + return { + label: `继承 v${parentProfile.currentVersionNo}`, + tone: 'inherited', + } + } + return { label: '未配置', tone: 'muted' } +} + function OrderServiceSectionTitle({ index, title, diff --git a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx new file mode 100644 index 00000000..7a2bf780 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx @@ -0,0 +1,377 @@ +import { ReloadOutlined, SaveOutlined } from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + InputNumber, + Select, + Space, + Switch, + Tag, + Tooltip, + Typography, + message, + type TableColumnsType, +} from 'antd' +import { useEffect, useMemo, useState } from 'react' + +import PercentTable from '@/components/common/PercentTable' +import { + fetchVoucherConsumeJobs, + fetchVoucherConsumePolicy, + retryVoucherConsumeJob, + saveVoucherConsumePolicy, + type VoucherChannelConsumePolicy, + type VoucherConsumeChannel, + type VoucherConsumeJob, + type VoucherConsumeMode, + type VoucherConsumePolicy, + type VoucherConsumeProvider, +} from '@/services/admin' +import { formatDateTime } from '@/utils/date-time' + +const PROVIDER_LABEL: Record = { + kuaishou_industry: '快手电子凭证', + douyin_mashida: '抖音码速达', +} + +const CHANNEL_LABEL: Record = { + affiliate_dash: '自动发货', + work_order: '接单工单', + manual: '人工客服', + third_party_voucher: '第三方卡券', +} + +const MODE_OPTIONS: Record< + VoucherConsumeChannel, + Array<{ value: VoucherConsumeMode; label: string }> +> = { + affiliate_dash: [ + { value: 'after_link_generated', label: '领取链接生成后' }, + { value: 'after_task_created', label: '履约任务创建后' }, + { value: 'manual', label: '人工核销' }, + ], + work_order: [ + { value: 'after_work_order_published_or_assigned', label: '发布或指派后' }, + { value: 'after_work_order_published', label: '仅发布后' }, + { value: 'after_work_order_assigned', label: '仅指派后' }, + { value: 'after_task_created', label: '履约任务创建后' }, + { value: 'manual', label: '人工核销' }, + ], + manual: [ + { value: 'after_task_created', label: '人工任务创建后' }, + { value: 'manual', label: '人工核销' }, + ], + third_party_voucher: [{ value: 'manual', label: '未接入自动核销' }], +} + +type PolicyRow = { + key: string + provider: VoucherConsumeProvider + channel: VoucherConsumeChannel + policy: VoucherChannelConsumePolicy +} + +const JOB_STATUS: Record = { + pending: { color: 'gold', label: '待执行' }, + processing: { color: 'processing', label: '执行中' }, + succeeded: { color: 'green', label: '成功' }, + failed: { color: 'red', label: '失败' }, + cancelled: { color: 'default', label: '已取消' }, +} + +export default function VoucherConsumePolicyPanel() { + const queryClient = useQueryClient() + const [draft, setDraft] = useState(null) + const policyQuery = useQuery({ + queryKey: ['voucher-consume-policy'], + queryFn: fetchVoucherConsumePolicy, + }) + const jobsQuery = useQuery({ + queryKey: ['voucher-consume-jobs'], + queryFn: () => fetchVoucherConsumeJobs({ page: 1, pageSize: 20 }), + refetchInterval: 10_000, + }) + + useEffect(() => { + const policy = policyQuery.data?.data + if (policy) { + setDraft(structuredClone(policy)) + } + }, [policyQuery.data]) + + const saveMutation = useMutation({ + mutationFn: (policy: VoucherConsumePolicy) => saveVoucherConsumePolicy(policy), + onSuccess: (response) => { + setDraft(structuredClone(response.data)) + message.success('核销策略已保存') + void queryClient.invalidateQueries({ queryKey: ['voucher-consume-policy'] }) + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : '保存核销策略失败') + }, + }) + + const retryMutation = useMutation({ + mutationFn: retryVoucherConsumeJob, + onSuccess: () => { + message.success('核销任务已重新排队') + void queryClient.invalidateQueries({ queryKey: ['voucher-consume-jobs'] }) + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : '重试核销任务失败') + }, + }) + + const rows = useMemo(() => buildPolicyRows(draft), [draft]) + + function updateChannel( + provider: VoucherConsumeProvider, + channel: VoucherConsumeChannel, + patch: Partial, + ) { + setDraft((current) => { + if (!current) return current + return { + ...current, + platforms: { + ...current.platforms, + [provider]: { + ...current.platforms[provider], + channels: { + ...current.platforms[provider].channels, + [channel]: { + ...current.platforms[provider].channels[channel], + ...patch, + }, + }, + }, + }, + } + }) + } + + function updatePlatformEnabled(provider: VoucherConsumeProvider, enabled: boolean) { + setDraft((current) => { + if (!current) return current + return { + ...current, + platforms: { + ...current.platforms, + [provider]: { + ...current.platforms[provider], + enabled, + }, + }, + } + }) + } + + const policyColumns: TableColumnsType = [ + { + title: '平台', + dataIndex: 'provider', + width: 150, + render: (provider: VoucherConsumeProvider) => PROVIDER_LABEL[provider], + }, + { + title: '履约渠道', + dataIndex: 'channel', + width: 140, + render: (channel: VoucherConsumeChannel) => CHANNEL_LABEL[channel], + }, + { + title: '自动核销', + width: 100, + render: (_, row) => ( + updateChannel(row.provider, row.channel, { enabled })} + /> + ), + }, + { + title: '触发时间', + width: 220, + render: (_, row) => ( + sku_codes: string[] pending_reasons: string[] + pending_subreasons: string[] + configured_channels: string[] + locked_item_count: number item_count: number revenue_fen: number first_seen_at: string @@ -100,6 +103,15 @@ export type PendingWorkOrderRetryResult = { skipReasons: Record } +export type PendingCarrierRetryResult = { + scanned: number + workOrdersCreated: number + affiliateTasksCreated: number + assigned: number + failed: number + remaining: number +} + export type DeletePendingChannelGroupResult = { archivedItems: number preservedOrders: number @@ -128,6 +140,64 @@ export type FinancePipelineStatus = { } | null } +export type VoucherConsumeProvider = 'kuaishou_industry' | 'douyin_mashida' +export type VoucherConsumeChannel = + | 'affiliate_dash' + | 'work_order' + | 'manual' + | 'third_party_voucher' +export type VoucherConsumeMode = + | 'manual' + | 'after_link_generated' + | 'after_task_created' + | 'after_work_order_published' + | 'after_work_order_assigned' + | 'after_work_order_published_or_assigned' + +export type VoucherChannelConsumePolicy = { + enabled: boolean + mode: VoucherConsumeMode + delaySeconds: number +} + +export type VoucherConsumePolicy = { + enabled: boolean + platforms: Record< + VoucherConsumeProvider, + { + enabled: boolean + channels: Record + } + > +} + +export type VoucherConsumeJob = { + id: number + provider: VoucherConsumeProvider + channel: VoucherConsumeChannel + trigger_event: string + platform_order_id: string + order_id: number | null + order_item_id: number | null + task_id: number | null + work_order_id: number | null + due_at: string + status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled' + attempt_count: number + max_attempts: number + last_error: string + created_at: string + updated_at: string + finished_at: string | null +} + +export type VoucherConsumeJobListResult = { + items: VoucherConsumeJob[] + total: number + page: number + pageSize: number +} + export function fetchPendingChannelGroups( params: { page?: number @@ -138,6 +208,30 @@ export function fetchPendingChannelGroups( return apiGet('/api/v1/admin/fulfillment/pending', params) } +export function fetchVoucherConsumePolicy() { + return apiGet('/api/v1/admin/fulfillment/voucher-consume-policy') +} + +export function saveVoucherConsumePolicy(payload: VoucherConsumePolicy) { + return apiPost('/api/v1/admin/fulfillment/voucher-consume-policy', payload) +} + +export function fetchVoucherConsumeJobs( + params: { status?: string; page?: number; pageSize?: number } = {}, +) { + return apiGet( + '/api/v1/admin/fulfillment/voucher-consume-jobs', + params, + ) +} + +export function retryVoucherConsumeJob(id: number) { + return apiPost( + `/api/v1/admin/fulfillment/voucher-consume-jobs/${id}/retry`, + {}, + ) +} + export function fetchChannelExceptions( params: { reason?: string @@ -158,6 +252,13 @@ export function retryPendingWorkOrderRouting( ) } +export function retryPendingCarriers(payload: { internalSku: string }) { + return apiPost( + '/api/v1/admin/fulfillment/pending/retry-carriers', + payload, + ) +} + export function deletePendingChannelGroup(payload: { internalSku: string }) { return apiPost( '/api/v1/admin/fulfillment/pending/delete', diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index c3c3acb1..076b1418 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -46,6 +46,10 @@ type WorkOrderVoucherConsumeResult = { failedCount: number voucherCodes: string[] errorMessage: string + queuedCount: number + jobIds: number[] + dueAt: string + status: 'queued' | 'already_scheduled' | 'skipped' | 'failed' } export type AdminLeaderboardPeriod = 'all' | 'daily' | 'weekly' | 'monthly' diff --git a/apps/frontend/src/styles/admin.css b/apps/frontend/src/styles/admin.css index 502fcc98..a17259a1 100644 --- a/apps/frontend/src/styles/admin.css +++ b/apps/frontend/src/styles/admin.css @@ -1694,6 +1694,12 @@ overflow: auto; } +.order-service-category-group { + display: grid; + gap: 2px; + min-width: 0; +} + .order-service-category-item { display: grid; gap: 4px; @@ -1724,9 +1730,25 @@ .order-service-category-main { display: flex; + gap: 4px; align-items: flex-start; } +.order-service-category-toggle { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 16px; + height: 19px; + color: #7a8699; + font-size: 10px; +} + +.order-service-category-toggle.is-empty { + visibility: hidden; +} + .order-service-category-name { min-width: 0; font-size: 13px; @@ -1736,6 +1758,30 @@ white-space: normal; } +.order-service-category-count { + flex: none; + margin: 0 0 0 auto; + font-size: 10px; + line-height: 18px; +} + +.order-service-subcategory-list { + display: grid; + gap: 2px; + padding-left: 12px; +} + +.order-service-category-item.is-child { + padding: 8px 9px 8px 12px; + border-left: 2px solid #e5eaf2; + border-radius: 6px 9px 9px 6px; +} + +.order-service-category-item.is-child:hover, +.order-service-category-item.is-child.is-active { + border-left-color: #69b1ff; +} + .order-service-category-meta { display: flex; gap: 8px; @@ -1774,6 +1820,12 @@ border-color: #ffe58f; } +.order-service-status-pill.is-inherited { + color: #0958d9; + background: #e6f4ff; + border-color: #91caff; +} + .order-service-status-pill.is-muted { color: #8c8c8c; background: #fafafa; diff --git a/apps/frontend/src/utils/voucher-consume.ts b/apps/frontend/src/utils/voucher-consume.ts index c27b26cc..07e0ba79 100644 --- a/apps/frontend/src/utils/voucher-consume.ts +++ b/apps/frontend/src/utils/voucher-consume.ts @@ -2,22 +2,21 @@ import { message } from 'antd' import type { WorkOrderVoucherConsumeResult } from '@/services/admin/consignment' -/** - * 电子凭证核销结果必须如实反馈:核销失败时后端已经改完状态,但凭证没消耗, - * 之前有调用方无条件弹"成功",运营会以为凭证已核销而漏掉人工处理。 - * 统一出口:所有履约/指派/手动匹配的调用方都用这一个,避免文案与口径各自漂移。 - */ export function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeResult) { - if (!voucherConsume || voucherConsume.ok) { - if (voucherConsume?.alreadyConsumedCount) { - message.info(`电子凭证已提前核销 ${voucherConsume.alreadyConsumedCount} 张,本次未重复核销`) - } + if (!voucherConsume) return + if (voucherConsume.queuedCount > 0) { + message.success(`自动核销已排队 ${voucherConsume.queuedCount} 个任务`) return } + if (voucherConsume.status === 'already_scheduled') { + message.info('核销任务已在队列中,本次未重复安排') + return + } + if (voucherConsume.ok) return message.warning({ - content: `状态已变更,但电子凭证核销失败:${ + content: `状态已变更,但自动核销未安排成功:${ voucherConsume.errorMessage || '未知错误' - }。请到「抖音券核销」或快手电子凭证后台人工核销后再发货`, + }。请在核销任务或平台券后台人工处理`, duration: 8, }) } diff --git a/docs/履约渠道重试与大小写归一修复-2026-11-05.md b/docs/履约渠道重试与大小写归一修复-2026-11-05.md new file mode 100644 index 00000000..3f81d16f --- /dev/null +++ b/docs/履约渠道重试与大小写归一修复-2026-11-05.md @@ -0,0 +1,70 @@ +# 履约渠道重试与大小写归一修复 + +日期:2026-11-05 + +## 修复范围 + +1. `fulfillment_tasks` 的历史 `closed / failed / manual_review / retry_pending / expired / + cancelled` 记录不再占用唯一履约单元;`work_orders` 的 `cancelled` 记录也不再占用订单项 + 唯一键。旧记录保留,同时允许创建新的活跃履约尝试。 +2. 自动补载体改为独立水位 `carrier_reconcile`,每轮从上次位置继续,不再重复扫描最早 200 条。 +3. `closed / cancelled / manual_review` 载体不再被自动重试,必须先由运营在待分类队列点击 + “补载体”确认,避免系统擅自重开已经人工关闭的任务。 +4. `重试接单匹配` 只处理明确配置了 `work_order` 渠道的 SKU,禁止自动发货或人工 SKU 被误分流。 +5. 待分类队列显示 `pending_subreasons`、已配置渠道和运营锁定数量,不再把所有情况都显示成 + “无履约载体”。 +6. 迁移 129 回填内部 SKU 的大小写、全半角、空白规范化结果,并合并规范化后重复的渠道配置。 + +## 大小写口径 + +内部 SKU 统一使用: + +```text +NFKC -> 删除全部空白 -> 转小写 +``` + +因此: + +```text +黑色ROCK长袖T恤 +黑色rock长袖t恤 +``` + +会归一到同一个渠道配置键。平台 SKU 和内部 SKU 的精确/模糊查询都继续使用该口径。 + +## 部署后核对 + +部署生产时把 `PUBLISHED_MIGRATION_MAX_VERSION` 提升到 `129`。 + +```sql +SELECT pipeline, last_item_id, last_success_at, last_error +FROM finance_watermarks +WHERE pipeline IN ('carrier_reconcile', 'carrier_dispatch', 'channel_assign'); + +SELECT count(*) AS uppercase_internal_skus +FROM internal_sku_channels +WHERE internal_sku ~ '[A-Z]'; + +SELECT canonicalize_internal_sku(internal_sku), channel, count(*) +FROM internal_sku_channels +WHERE enabled +GROUP BY 1, 2 +HAVING count(*) > 1; + +SELECT + fulfillment_pending_reason, + fulfillment_pending_detail->>'carrierIssue' AS carrier_issue, + count(*) +FROM order_items +WHERE fulfillment_status = 'pending' +GROUP BY 1, 2 +ORDER BY 3 DESC; +``` + +预期: + +- `uppercase_internal_skus = 0`; +- 规范化后的渠道重复数 = 0; +- `carrier_reconcile` 持续更新; +- `carrier_task_closed` 只能通过运营点击“补载体”处理; +- `no_channel_capability` 仍需补齐商品与渠道配置,不能靠重试载体解决。 diff --git a/docs/订单服务收集与状态页设计-2026-11-03.md b/docs/订单服务收集与状态页设计-2026-11-03.md index 35f3410b..d2eacdc7 100644 --- a/docs/订单服务收集与状态页设计-2026-11-03.md +++ b/docs/订单服务收集与状态页设计-2026-11-03.md @@ -7,7 +7,7 @@ status: stable # 订单服务收集与状态页设计 -> 状态:现行。最后校准:2026-11-04。代码分支:`dev_11.3`。 +> 状态:现行。最后校准:2026-11-05。代码分支:`dev_11.3`。 ## 1. 目标 @@ -19,7 +19,7 @@ status: stable /api/v1/o/* 公开 API ``` -一个接单分类对应一套订单服务配置。接单模板继续通过 `work_product_rules.category_id` 绑定分类,同一分类下的所有模板复用该分类的客户资料配置。 +一个接单分类对应一套订单服务配置。接单模板继续通过 `work_product_rules.category_id` 绑定分类,同一分类下的所有模板复用该分类的客户资料配置。二级分类优先使用自己的已发布配置;没有独立已发布配置时,自动继承一级分类模板。 ## 2. 配置真源 @@ -46,6 +46,15 @@ order_service_profile_versions 订单项 → 接单模板 → category_id → 当前发布的订单服务版本 ``` +分类配置解析按分类链由近到远执行: + +```text +二级分类已发布配置 + > 一级分类已发布配置 +``` + +二级分类只有草稿、配置已归档或尚未创建时,均继续继承一级分类的已发布版本。后台接单分类默认只展示一级分类,二级分类折叠展示,并标记“继承 vN”。 + 这样不会出现“先确定分类才能选默认模板、但分类又来自模板”的循环依赖。 ## 3. 工单快照 @@ -64,6 +73,8 @@ work_orders.service_profile_snapshot_json { "profileId": 1, "categoryId": 1, + "sourceCategoryId": 1, + "inherited": false, "versionId": 2, "versionNo": 2, "config": {} @@ -79,13 +90,19 @@ work_orders.service_profile_snapshot_json 历史工单没有快照时继续读取其 `requirement_json`,不会突然切换到新分类配置。 -发布新版本时,会同步该分类下仍未提交资料且未指派打手的旧工单: +发布新版本时,会同步该分类下仍未提交资料且未指派打手的旧工单。发布一级分类配置时,还会同步其下尚未独立发布配置的二级分类工单: - 写入最新配置版本和快照; - 同步 `requirement_json.fields`; - 有有效拼单份额的工单不会覆盖; - 已提交、已指派、已进入履约的工单保持不变。 +继承快照会记录: + +- `categoryId`:工单实际所属分类; +- `sourceCategoryId`:最终命中的配置分类; +- `inherited`:是否继承父级配置。 + 字段配置优先使用标准字段: - `gameId`:游戏 ID,按字符串保存并校验为纯数字,避免超长 ID 精度丢失; From 92ceb9697b1d5cb3a36482cac7db31d06fd64162 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 15:19:39 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E5=8D=A1=E5=88=B8=E6=94=B6=E5=88=B0=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=90=8E=E8=87=AA=E5=8A=A8=E6=A0=B8=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../finance/manual-fulfillment-service.ts | 2 + .../fulfillment/affiliate-fallback-service.ts | 2 + .../carrier-reconciliation-service.ts | 9 +- .../fulfillment/channel-assign-service.ts | 27 ++- .../src/services/fulfillment/channel-types.ts | 2 +- .../voucher-consume-policy-service.test.ts | 80 ++++++++ .../voucher-consume-policy-service.ts | 32 +++- .../voucher-consume-queue-service.ts | 180 ++++++++++++++++-- .../admin/AdminFulfillmentChannelPage.tsx | 4 +- .../panels/VoucherConsumePolicyPanel.tsx | 13 +- .../src/services/admin/fulfillment-channel.ts | 2 + 11 files changed, 323 insertions(+), 30 deletions(-) diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index 25c996a8..e51f3a4f 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -16,6 +16,7 @@ import { } from '../cost-center/platform-credit-service.js' import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js' import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js' +import { scheduleVoucherConsumeForOrderItems } from '../fulfillment/voucher-consume-queue-service.js' /** * 人工履约待办与「确认履约」。 @@ -510,6 +511,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm } }) + await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined) // 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。 await refreshFactsForOrderItem(orderItemId) return result diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index 9cb83715..f68c6cfc 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -1,6 +1,7 @@ import { query, withTransaction } from '../../db/client.js' import { syncCostSnapshotsForItems } from '../cost-center/cost-snapshot-service.js' import { refreshFactsForDates } from '../finance/refresh-facts.js' +import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js' /** * 自动发货失败 → 人工兜底。 @@ -156,6 +157,7 @@ export async function runAffiliateFallback( for (const id of switchedIds) { await syncCostSnapshotsForItems([id]).catch(() => undefined) } + await scheduleVoucherConsumeForOrderItems(switchedIds, 'order_received').catch(() => undefined) await refreshFactsForDates([...affectedDates]).catch(() => undefined) return { scanned: candidates.rows.length, switched: switchedIds.length } diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 3e582919..1fcc4782 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -12,6 +12,7 @@ import { planInternalSkuChannels, type InternalSkuChannelRow, } from './internal-sku-channel-service.js' +import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js' type CarrierCandidate = { id: number @@ -188,7 +189,13 @@ async function selectSafeFallback(input: { input.includeAssignedMissingCarrier, ], ) - return (result.rowCount || 0) > 0 + const updated = (result.rowCount || 0) > 0 + if (updated) { + await scheduleVoucherConsumeForOrderItems([input.orderItemId], 'order_received').catch( + () => undefined, + ) + } + return updated } /** diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index d58d020a..f2baa987 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -5,6 +5,7 @@ import { createTaskEvent } from '../../repositories/task-event-repo.js' import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-event-repo.js' import type { OrderItemRow } from '../../types/repository/rows.js' import { createHttpError } from '../../utils/http.js' +import { logWarn } from '../../utils/logger.js' import { nowIso } from '../../utils/time.js' import { upsertAggregatedException } from '../cost-center/cost-exception-service.js' import { readWatermarkItemId } from '../watermark-store.js' @@ -33,6 +34,7 @@ import { ensureCarrierForOrderItem } from './carrier-creation-service.js' import { reconcilePendingCarriers } from './carrier-reconciliation-service.js' import { TASK_STATUS } from '../../domain/task-status.js' import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js' +import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js' /** * 管道 A:渠道判定。 @@ -402,7 +404,7 @@ export async function assignPendingOrderItemChannels( } if (assignments.length > 0) { - const updated = await query( + const updated = await query<{ id: number }>( ` UPDATE order_items oi SET @@ -418,6 +420,7 @@ export async function assignPendingOrderItemChannels( WHERE oi.id = input.id AND oi.fulfillment_status = 'pending' AND oi.fulfillment_locked = FALSE + RETURNING oi.id `, [ assignments.map((row) => row.id), @@ -425,8 +428,20 @@ export async function assignPendingOrderItemChannels( assignments.map((row) => row.source), ], ) - result.assigned += updated.rowCount || 0 - result.assignedItemIds = assignments.map((row) => row.id).slice(0, 2000) + const assignedItemIds = updated.rows + .map((row) => Number(row.id)) + .filter((id) => id > 0) + .slice(0, 2000) + result.assigned += assignedItemIds.length + result.assignedItemIds = assignedItemIds + await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch( + (error) => { + logWarn('[channel-assign]', '订单收到后安排自动核销失败', { + orderItemIds: assignedItemIds, + error: error instanceof Error ? error.message : String(error), + }) + }, + ) } } @@ -566,6 +581,12 @@ export async function assignOrderItemsAfterCarrierReady(input: { } if (assignedIds.length > 0) { + await scheduleVoucherConsumeForOrderItems(assignedIds, 'order_received').catch((error) => { + logWarn('[channel-assign]', '指定渠道后安排自动核销失败', { + orderItemIds: assignedIds, + error: error instanceof Error ? error.message : String(error), + }) + }) await normalizeProductsForItems(assignedIds).catch(() => undefined) const cost = await syncCostSnapshotsForItems(assignedIds).catch(() => null) const dates = [ diff --git a/apps/backend/src/services/fulfillment/channel-types.ts b/apps/backend/src/services/fulfillment/channel-types.ts index 9b43aeaf..34513644 100644 --- a/apps/backend/src/services/fulfillment/channel-types.ts +++ b/apps/backend/src/services/fulfillment/channel-types.ts @@ -4,7 +4,7 @@ * 与旧 `channel` 的区别: * · 渠道只表达「谁来做」,不包含 pending/archived 这类状态; * · 「待分类」是状态,不是渠道; - * · 「第三方卡券」是渠道,但没有履约载体,只做统计。 + * · 「第三方卡券」由外部卡券库履约,不建内部履约载体;平台电子凭证仍可配置核销。 */ export const FULFILLMENT_CHANNELS = [ diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts index f71600d9..63a9706e 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts @@ -25,6 +25,11 @@ test('默认核销策略:自动发货 5 秒,工单发布或指派后 10 秒 mode: 'after_task_created', delaySeconds: 10, }) + assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, { + enabled: true, + mode: 'after_order_received', + delaySeconds: 5, + }) }) test('工单默认策略同时匹配发布和指派,且不匹配链接生成', () => { @@ -59,6 +64,31 @@ test('工单默认策略同时匹配发布和指派,且不匹配链接生成', ) }) +test('收到订单后事件可匹配四个渠道,第三方卡券默认延迟 5 秒', () => { + const policy = createDefaultVoucherConsumePolicy() + for (const channel of [ + 'affiliate_dash', + 'work_order', + 'manual', + 'third_party_voucher', + ] as const) { + const configured = structuredClone(policy) + configured.platforms.kuaishou_industry.channels[channel] = { + enabled: true, + mode: 'after_order_received', + delaySeconds: channel === 'third_party_voucher' ? 5 : 15, + } + const resolved = resolveVoucherConsumePolicy({ + provider: 'kuaishou_industry', + channel, + event: 'order_received', + policy: configured, + }) + assert.equal(resolved.enabled, true) + assert.equal(resolved.delaySeconds, channel === 'third_party_voucher' ? 5 : 15) + } +}) + test('策略归一化会丢弃非法模式并限制延迟范围', () => { const policy = normalizeVoucherConsumePolicy({ enabled: true, @@ -83,3 +113,53 @@ test('策略归一化会丢弃非法模式并限制延迟范围', () => { }) assert.equal(policy.platforms.douyin_mashida.enabled, true) }) + +test('旧版第三方卡券停用配置自动升级为收到订单后 5 秒', () => { + const policy = normalizeVoucherConsumePolicy({ + enabled: true, + platforms: { + kuaishou_industry: { + enabled: true, + channels: { + third_party_voucher: { + enabled: false, + mode: 'manual', + delaySeconds: 0, + }, + }, + }, + }, + }) + + assert.equal(policy.version, 2) + assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, { + enabled: true, + mode: 'after_order_received', + delaySeconds: 5, + }) +}) + +test('新版明确关闭第三方卡券时继续尊重用户配置', () => { + const policy = normalizeVoucherConsumePolicy({ + version: 2, + enabled: true, + platforms: { + kuaishou_industry: { + enabled: true, + channels: { + third_party_voucher: { + enabled: false, + mode: 'manual', + delaySeconds: 0, + }, + }, + }, + }, + }) + + assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, { + enabled: false, + mode: 'manual', + delaySeconds: 0, + }) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts index d872212a..356db20c 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts @@ -14,6 +14,7 @@ export const VOUCHER_CONSUME_CHANNELS = [ export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number] export const VOUCHER_CONSUME_EVENTS = [ + 'order_received', 'link_generated', 'task_created', 'work_order_published', @@ -23,6 +24,7 @@ export type VoucherConsumeEvent = (typeof VOUCHER_CONSUME_EVENTS)[number] export const VOUCHER_CONSUME_MODES = [ 'manual', + 'after_order_received', 'after_link_generated', 'after_task_created', 'after_work_order_published', @@ -43,12 +45,14 @@ export type VoucherPlatformConsumePolicy = { } export type VoucherConsumePolicy = { + version: number enabled: boolean platforms: Record } export function createDefaultVoucherConsumePolicy(): VoucherConsumePolicy { return { + version: 2, enabled: true, platforms: { kuaishou_industry: createDefaultPlatformPolicy(), @@ -77,17 +81,21 @@ export function normalizeVoucherConsumePolicy(rawValue: unknown): VoucherConsume const defaults = createDefaultVoucherConsumePolicy() const source = isPlainObject(rawValue) ? rawValue : {} const rawPlatforms = isPlainObject(source.platforms) ? source.platforms : {} + const version = normalizeInteger(source.version, 1, 1, 2) return { + version: 2, enabled: normalizeBoolean(source.enabled, defaults.enabled), platforms: { kuaishou_industry: normalizePlatformPolicy( rawPlatforms.kuaishou_industry, defaults.platforms.kuaishou_industry, + version, ), douyin_mashida: normalizePlatformPolicy( rawPlatforms.douyin_mashida, defaults.platforms.douyin_mashida, + version, ), }, } @@ -138,9 +146,9 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy { delaySeconds: 10, }, third_party_voucher: { - enabled: false, - mode: 'manual', - delaySeconds: 0, + enabled: true, + mode: 'after_order_received', + delaySeconds: 5, }, }, } @@ -149,9 +157,11 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy { function normalizePlatformPolicy( rawValue: unknown, fallback: VoucherPlatformConsumePolicy, + version: number, ): VoucherPlatformConsumePolicy { const source = isPlainObject(rawValue) ? rawValue : {} const rawChannels = isPlainObject(source.channels) ? source.channels : {} + const legacyThirdPartyDisabled = isLegacyThirdPartyDisabled(rawValue, version) return { enabled: normalizeBoolean(source.enabled, fallback.enabled), @@ -159,10 +169,11 @@ function normalizePlatformPolicy( affiliate_dash: normalizeChannelPolicy( rawChannels.affiliate_dash, fallback.channels.affiliate_dash, - ['manual', 'after_link_generated', 'after_task_created'], + ['manual', 'after_order_received', 'after_link_generated', 'after_task_created'], ), work_order: normalizeChannelPolicy(rawChannels.work_order, fallback.channels.work_order, [ 'manual', + 'after_order_received', 'after_task_created', 'after_work_order_published', 'after_work_order_assigned', @@ -170,17 +181,25 @@ function normalizePlatformPolicy( ]), manual: normalizeChannelPolicy(rawChannels.manual, fallback.channels.manual, [ 'manual', + 'after_order_received', 'after_task_created', ]), third_party_voucher: normalizeChannelPolicy( - rawChannels.third_party_voucher, + legacyThirdPartyDisabled ? {} : rawChannels.third_party_voucher, fallback.channels.third_party_voucher, - ['manual'], + ['manual', 'after_order_received'], ), }, } } +function isLegacyThirdPartyDisabled(rawPlatform: unknown, version: number): boolean { + if (version >= 2 || !isPlainObject(rawPlatform)) return false + const rawChannels = isPlainObject(rawPlatform.channels) ? rawPlatform.channels : {} + const raw = isPlainObject(rawChannels.third_party_voucher) ? rawChannels.third_party_voucher : {} + return raw.enabled === false && String(raw.mode || '').trim() === 'manual' +} + function normalizeChannelPolicy( rawValue: unknown, fallback: VoucherChannelConsumePolicy, @@ -199,6 +218,7 @@ function normalizeChannelPolicy( function modeMatchesEvent(mode: VoucherConsumeMode, event: VoucherConsumeEvent): boolean { if (mode === 'manual') return false + if (mode === 'after_order_received') return event === 'order_received' if (mode === 'after_link_generated') return event === 'link_generated' if (mode === 'after_task_created') return event === 'task_created' if (mode === 'after_work_order_published') return event === 'work_order_published' diff --git a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts index 80155ec7..b9042aa9 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -88,6 +88,15 @@ type VoucherConsumeScheduleTarget = { voucherCount: number } +type VoucherOrderItemTargetRow = { + id: number + order_id: number + provider: string + platform: string + platform_order_id: string + fulfillment_channel: string +} + const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800] const PROCESSING_LOCK_SECONDS = 120 @@ -152,6 +161,140 @@ export async function scheduleVoucherConsumeForWorkOrder( }) } +/** + * Schedule after an order item has a fulfillment channel. + * + * The platform voucher may not exist yet when this runs. The queued job resolves + * the voucher again at execution time and retries while the incoming voucher is + * still being created or synchronized. + */ +export async function scheduleVoucherConsumeForOrderItems( + orderItemIds: number[], + event: VoucherConsumeEvent, +): Promise<{ targetCount: number; queuedCount: number; jobIds: number[] }> { + const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + if (ids.length === 0) { + return { targetCount: 0, queuedCount: 0, jobIds: [] } + } + + const rows = await query( + ` + SELECT + oi.id, + oi.order_id, + oi.fulfillment_channel, + o.provider, + o.platform, + o.platform_order_id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = ANY($1::bigint[]) + AND oi.fulfillment_status = 'assigned' + `, + [ids], + ) + const policy = getVoucherConsumePolicy() + const targets: Array<{ + provider: VoucherConsumeProvider + channel: VoucherConsumeChannel + trigger_event: VoucherConsumeEvent + target_key: string + platform_order_id: string + order_id: number + order_item_id: number + due_at: string + }> = [] + + for (const row of rows.rows) { + const provider = resolveVoucherProviderFromFields(row.provider, row.platform) + const channel = normalizeVoucherChannel(row.fulfillment_channel) + if (!provider || !channel) continue + + const resolved = resolveVoucherConsumePolicy({ + provider, + channel, + event, + policy, + }) + if (!resolved.enabled) continue + + targets.push({ + provider, + channel, + trigger_event: event, + target_key: buildVoucherConsumeTargetKey(row.id, `order_item:${row.id}`), + platform_order_id: String(row.platform_order_id || '').trim(), + order_id: Number(row.order_id), + order_item_id: Number(row.id), + due_at: new Date(Date.now() + resolved.delaySeconds * 1000).toISOString(), + }) + } + + if (targets.length === 0) { + return { targetCount: 0, queuedCount: 0, jobIds: [] } + } + + const inserted = await query<{ id: number }>( + ` + INSERT INTO voucher_consume_jobs ( + provider, + channel, + trigger_event, + target_key, + platform_order_id, + order_id, + order_item_id, + due_at, + status, + attempt_count, + max_attempts, + updated_at + ) + SELECT + target.provider, + target.channel, + target.trigger_event, + target.target_key, + target.platform_order_id, + target.order_id, + target.order_item_id, + target.due_at, + 'pending', + 0, + 8, + NOW() + FROM jsonb_to_recordset($1::jsonb) AS target( + provider text, + channel text, + trigger_event text, + target_key text, + platform_order_id text, + order_id bigint, + order_item_id bigint, + due_at timestamptz + ) + ON CONFLICT (provider, target_key) + WHERE status IN ('pending', 'processing', 'succeeded') + DO NOTHING + RETURNING id + `, + [JSON.stringify(targets)], + ) + + logIntegration('[voucher-consume/queue]', '订单渠道确定后批量安排核销', { + event, + targetCount: targets.length, + queuedCount: inserted.rowCount || 0, + jobIds: inserted.rows.map((row) => Number(row.id)), + }) + + return { + targetCount: targets.length, + queuedCount: inserted.rowCount || 0, + jobIds: inserted.rows.map((row) => Number(row.id)), + } +} + async function scheduleVoucherConsumeTarget( target: Omit, ): Promise { @@ -688,11 +831,18 @@ async function failVoucherConsumeJob(job: VoucherConsumeJobRow, error: unknown, } function resolveVoucherProvider(order: OrderRow): VoucherConsumeProvider | null { - if (isDouyinMashidaProvider(order.provider)) { + return resolveVoucherProviderFromFields(order.provider, order.platform) +} + +function resolveVoucherProviderFromFields( + orderProvider: unknown, + orderPlatform: unknown, +): VoucherConsumeProvider | null { + if (isDouyinMashidaProvider(orderProvider)) { return 'douyin_mashida' } - const provider = String(order.provider || '').trim() - const platform = String(order.platform || '').trim() + const provider = String(orderProvider || '').trim() + const platform = String(orderPlatform || '').trim() if ( [OPEN_91_PROVIDER, 'open_91', '91'].includes(provider) || [OPEN_91_PLATFORM, 'kuaishou'].includes(platform) @@ -702,6 +852,19 @@ function resolveVoucherProvider(order: OrderRow): VoucherConsumeProvider | null return null } +function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null { + const channel = String(value || '').trim() + if ( + channel === 'affiliate_dash' || + channel === 'work_order' || + channel === 'manual' || + channel === 'third_party_voucher' + ) { + return channel + } + return null +} + function resolveTaskVoucherChannel( task: Pick, orderItem: OrderItemRow | null, @@ -717,16 +880,7 @@ function resolveTaskVoucherChannel( return 'manual' } - const channel = String(orderItem?.fulfillment_channel || '').trim() - if ( - channel === 'affiliate_dash' || - channel === 'work_order' || - channel === 'manual' || - channel === 'third_party_voucher' - ) { - return channel - } - return null + return normalizeVoucherChannel(orderItem?.fulfillment_channel) } function buildVoucherConsumeTargetKey(orderItemId: unknown, fallback: string): string { diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 21abce6f..96d92f0e 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -4,7 +4,7 @@ * 设计口径(2026-10-31 v2): * · 渠道由 L1 规则决定,不再由「有没有任务」反推; * · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音; - * · 第三方卡券是「只统计」渠道:不建任务、不发领取页。 + * · 第三方卡券由外部卡券库履约:不建内部任务、不发领取页,但支持平台券自动核销。 */ import { DeleteOutlined, @@ -66,7 +66,7 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi const CHANNEL_OPTIONS = [ { value: 'work_order', label: '接单平台' }, { value: 'affiliate_dash', label: '自动发货' }, - { value: 'third_party_voucher', label: '第三方卡券(仅统计)' }, + { value: 'third_party_voucher', label: '第三方卡券' }, { value: 'manual', label: '人工客服' }, ] diff --git a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx index 7a2bf780..d80319ae 100644 --- a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx @@ -46,11 +46,13 @@ const MODE_OPTIONS: Record< Array<{ value: VoucherConsumeMode; label: string }> > = { affiliate_dash: [ + { value: 'after_order_received', label: '收到订单后' }, { value: 'after_link_generated', label: '领取链接生成后' }, { value: 'after_task_created', label: '履约任务创建后' }, { value: 'manual', label: '人工核销' }, ], work_order: [ + { value: 'after_order_received', label: '收到订单后' }, { value: 'after_work_order_published_or_assigned', label: '发布或指派后' }, { value: 'after_work_order_published', label: '仅发布后' }, { value: 'after_work_order_assigned', label: '仅指派后' }, @@ -58,10 +60,14 @@ const MODE_OPTIONS: Record< { value: 'manual', label: '人工核销' }, ], manual: [ + { value: 'after_order_received', label: '收到订单后' }, { value: 'after_task_created', label: '人工任务创建后' }, { value: 'manual', label: '人工核销' }, ], - third_party_voucher: [{ value: 'manual', label: '未接入自动核销' }], + third_party_voucher: [ + { value: 'after_order_received', label: '收到订单后' }, + { value: 'manual', label: '人工核销' }, + ], } type PolicyRow = { @@ -186,7 +192,6 @@ export default function VoucherConsumePolicyPanel() { updateChannel(row.provider, row.channel, { enabled })} /> ), @@ -198,7 +203,7 @@ export default function VoucherConsumePolicyPanel() { { + setSelectedPlatformKey(value) + form.setFieldValue('costYuan', undefined) + }} + options={configuredChannels.map((channel) => ({ + value: channel.platformKey, + label: channel.platformLabel, + }))} + style={{ width: '100%', marginTop: 6 }} + /> + ) : ( + + {selectedChannel?.platformLabel || '-'} + + )} + {selectedChannel && !selectedChannel.isFundCash && !selectedChannel.isOnAccount ? ( + 消耗 {selectedChannel.creditQuantity} 积分 + ) : null} + {selectedChannel?.isOnAccount ? ( + 只生成虚拟账单,不扣积分或备用金 ) : null}
@@ -240,8 +297,8 @@ export function ManualFulfillmentConfirmModal({ ¥{((selected?.revenueFen || 0) / 100).toFixed(2)}
- {privileged && selected ? ( - selected.isFundCash ? ( + {privileged && selected && selectedChannel ? ( + selectedChannel.isFundCash ? (
代录到客服备用金账户: + ) : ( @@ -295,11 +360,11 @@ export function ManualFulfillmentConfirmModal({ {showCostInput ? ( = { @@ -75,6 +76,7 @@ const CHANNEL_COLOR: Record = { affiliate_dash: 'green', third_party_voucher: 'purple', manual: 'orange', + on_account: 'gold', } const CHANNEL_SOURCE_LABEL: Record = { @@ -133,6 +135,7 @@ function PendingPanel() { const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [selections, setSelections] = useState>({}) + const [internalSkuDrafts, setInternalSkuDrafts] = useState>({}) const query = useQuery({ queryKey: ['fulfillment-pending', keyword, page, pageSize], @@ -142,16 +145,26 @@ function PendingPanel() { const assignMutation = useMutation({ mutationFn: (row: PendingChannelGroup) => assignChannelByInternalSku({ - internalSku: row.internal_sku, + groupKey: row.group_key, + internalSku: row.internal_sku || '', + mappedInternalSku: row.internal_sku + ? '' + : String(internalSkuDrafts[rowKey(row)] || '').trim(), channel: selections[rowKey(row)] || '', }), onSuccess: (result) => { + const productMessage = result.data.draftProductId ? '和标准商品' : '' if (result.data.carrierFailed > 0) { + const firstFailure = result.data.carrierFailures?.[0] message.warning( - `已指定 ${result.data.updated} 个订单项,${result.data.carrierFailed} 个因载体创建失败仍待处理`, + `渠道${productMessage}已保存,${result.data.carrierFailed} 个订单项仍待补配置${ + firstFailure?.message ? `:${firstFailure.message}` : '' + }`, ) } else { - message.success(`已指定 ${result.data.updated} 个订单项`) + message.success( + `已指定 ${result.data.updated} 个订单项${productMessage ? `并创建${productMessage.slice(1)}` : ''}`, + ) } void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) }, @@ -183,7 +196,7 @@ function PendingPanel() { const retryCarrierMutation = useMutation({ mutationFn: (row: PendingChannelGroup) => - retryPendingCarriers({ internalSku: row.internal_sku }), + retryPendingCarriers({ internalSku: row.internal_sku || '' }), onSuccess: (result) => { const data = result.data const detail = [ @@ -205,11 +218,11 @@ function PendingPanel() { const deleteMutation = useMutation({ mutationFn: (row: PendingChannelGroup) => - deletePendingChannelGroup({ internalSku: row.internal_sku }), + deletePendingChannelGroup({ groupKey: row.group_key }), onSuccess: (result) => { const data = result.data message.success( - `已删除 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`, + `已忽略归档 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`, ) void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] }) @@ -261,9 +274,23 @@ function PendingPanel() { title: '内部SKU', dataIndex: 'internal_sku', width: 220, - render: (value: string, row: PendingChannelGroup) => ( - - {value || '未识别内部 SKU'} + render: (value: string | null, row: PendingChannelGroup) => ( + + {value ? ( + {value} + ) : ( + + setInternalSkuDrafts((current) => ({ + ...current, + [rowKey(row)]: event.target.value, + })) + } + /> + )} {row.configured_channels?.length ? ( {row.configured_channels.map((channel) => ( @@ -345,7 +372,9 @@ function PendingPanel() { ({ - value: item.key, - label: `${item.label}(${unitLabel(item.key, item.creditUnitPriceFen)})`, - }))} - onChange={() => form.validateFields(['creditQuantity'])} - /> - - - - - - - {previewCostFen > 0 - ? `¥${(previewCostFen / 100).toFixed(2)}(${previewCostFen} 分)` - : '成本不固定,由客服手填'} - - + {(fields, { add, remove }, { errors }) => ( + + {fields.map((field, index) => ( +
+
+ 履约渠道 {index + 1} +
+ + ({ value: item.key, - label: `${item.label}(1积分=${item.creditUnitPriceFen >= 100 ? '1元' : '1分'})`, + label: `${item.label} · ${formatCreditUnitPriceLabel(item.creditUnitPriceFen)}`, }))} /> diff --git a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx index d80319ae..54e242dd 100644 --- a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx @@ -38,6 +38,7 @@ const CHANNEL_LABEL: Record = { affiliate_dash: '自动发货', work_order: '接单工单', manual: '人工客服', + on_account: '统一结算', third_party_voucher: '第三方卡券', } @@ -68,6 +69,10 @@ const MODE_OPTIONS: Record< { value: 'after_order_received', label: '收到订单后' }, { value: 'manual', label: '人工核销' }, ], + on_account: [ + { value: 'after_order_received', label: '收到订单后' }, + { value: 'manual', label: '人工核销' }, + ], } type PolicyRow = { diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index f637bc1d..8adbb34c 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -378,8 +378,11 @@ export function scanAdminManualSkus(payload: { fromDate: string; toDate: string export function saveAdminManualConfig(payload: { skuKey: string - platformKey: string - creditQuantity: number + channels: Array<{ + platformKey: string + creditQuantity: number + enabled?: boolean + }> canonicalProductId?: number | null enabled?: boolean note?: string @@ -493,6 +496,7 @@ export function fetchAdminManualFulfillmentPending( export function confirmAdminManualFulfillment(payload: { orderItemId: number + platformKey: string costFen?: number payeeName?: string note?: string diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index f894b916..a9284d78 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -2,7 +2,9 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http' /** 待分类队列(按内部 SKU 聚合)。 */ export type PendingChannelGroup = { - internal_sku: string + group_key: string + scope_type: 'internal_sku' | 'source_sku' + internal_sku: string | null sku_name: string providers: string[] shop_ids: string[] @@ -30,7 +32,7 @@ export type ChannelRule = { id: number match_key_type: 'internal_sku' match_key: string - channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual' + channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual' | 'on_account' priority: number enabled: boolean is_fallback: boolean @@ -145,6 +147,7 @@ export type VoucherConsumeChannel = | 'affiliate_dash' | 'work_order' | 'manual' + | 'on_account' | 'third_party_voucher' export type VoucherConsumeMode = | 'manual' @@ -261,7 +264,7 @@ export function retryPendingCarriers(payload: { internalSku: string }) { ) } -export function deletePendingChannelGroup(payload: { internalSku: string }) { +export function deletePendingChannelGroup(payload: { groupKey: string }) { return apiPost( '/api/v1/admin/fulfillment/pending/delete', payload, @@ -269,14 +272,20 @@ export function deletePendingChannelGroup(payload: { internalSku: string }) { } export function assignChannelByInternalSku(payload: { + groupKey?: string internalSku: string + mappedInternalSku?: string channel: string note?: string }) { - return apiPost<{ updated: number; carrierFailed: number; ruleCreated: boolean }>( - '/api/v1/admin/fulfillment/assign', - payload, - ) + return apiPost<{ + updated: number + carrierFailed: number + ruleCreated: boolean + channelSaved: boolean + draftProductId: number | null + carrierFailures: Array<{ orderItemId: number; reason: string; message: string }> + }>('/api/v1/admin/fulfillment/assign', payload) } export function assignChannelToItems(payload: { diff --git a/apps/frontend/src/styles/admin.css b/apps/frontend/src/styles/admin.css index a17259a1..c6f8a0f3 100644 --- a/apps/frontend/src/styles/admin.css +++ b/apps/frontend/src/styles/admin.css @@ -15,6 +15,48 @@ linear-gradient(315deg, rgba(31, 41, 55, 0.045), transparent 38%), #f5f7fb; } +.manual-channel-config-item { + width: 100%; + padding: 14px; + border: 1px solid #e6eaf0; + border-radius: 8px; + background: #fafbfc; +} + +.manual-channel-config-head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + margin-bottom: 8px; +} + +.manual-channel-cost-preview { + display: flex; + min-height: 32px; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 4px 11px; + border: 1px solid #e6eaf0; + border-radius: 6px; + background: #fff; +} + +.manual-channel-cost-preview .ant-typography { + line-height: 1.45; +} + +.support-wallet-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; +} + +.support-wallet-grid > .ant-card { + height: 100%; +} + .auth-shell { width: min(940px, 100%); min-height: 560px; diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index fed46143..2d9482df 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -1,7 +1,12 @@ /** * 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。 */ -export type FulfillmentChannel = 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual' +export type FulfillmentChannel = + | 'work_order' + | 'affiliate_dash' + | 'third_party_voucher' + | 'manual' + | 'on_account' /** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */ export type ProductSource = 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' | '' @@ -371,10 +376,9 @@ export interface CostExceptionSummary { } /** - * 人工履约渠道:客服人工有四个钱包。 - * 南航/乐玩/ROCK 是积分(三平台积分互不通用),备用金是现金。 + * 人工履约渠道:南航/乐玩/ROCK 积分、备用金现金、统一结算。 */ -export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash' +export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash' | 'on_account' /** 成本模式:fixed=按积分固定;variable=成本不固定,客服确认履约时手填。 */ export type ManualCostMode = 'fixed' | 'variable' @@ -424,6 +428,19 @@ export interface ManualFulfillmentConfig { pendingAssigned: number rematchedItems: number } + channels: ManualConfiguredChannel[] +} + +export interface ManualConfiguredChannel { + platformKey: ManualPlatformKey + platformLabel: string + creditQuantity: number + costMode: ManualCostMode + costFen: number | null + creditUnitPriceFen: number + isFundCash: boolean + isOnAccount: boolean + enabled: boolean } export interface ManualSkuListResult { @@ -525,9 +542,10 @@ export interface ManualFulfillmentPendingItem { revenueFen: number paidAt: string configId: number - platformKey: PlatformCreditKey | 'fund_cash' + platformKey: PlatformCreditKey | 'fund_cash' | 'on_account' platformLabel: string isFundCash: boolean + isOnAccount: boolean creditQuantity: number creditUnitPriceFen: number costMode: ManualCostMode @@ -535,6 +553,7 @@ export interface ManualFulfillmentPendingItem { costFen: number | null fulfilled: boolean fulfilledAt: string | null + channels: ManualConfiguredChannel[] } export interface ManualFulfillmentPendingListResult { @@ -553,4 +572,5 @@ export interface ManualFulfillmentConfirmResult { fundExpenseId: number | null creditEntryId: number | null balanceAfter: number | null + onAccountBillId: number | null } diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 8d5498c2..7f643a47 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -13,6 +13,7 @@ export type AdminOrderItemFulfillmentMode = | 'affiliate_dash' | 'third_party_voucher' | 'manual' + | 'on_account' | 'pending' | 'archived' diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index e4e62d8e..f07b6db0 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -225,6 +225,7 @@ export function resolveOrderFulfillmentMode(row: { | 'affiliate_dash' | 'third_party_voucher' | 'manual' + | 'on_account' | 'pending' | 'archived' | 'mixed' @@ -240,6 +241,8 @@ export function resolveOrderFulfillmentMode(row: { return { label: '🗄 历史归档', color: 'default' } case 'manual': return { label: '🙋 人工履约', color: 'orange' } + case 'on_account': + return { label: '统一结算', color: 'gold' } case 'mixed': return { label: '🔀 混合履约', color: 'geekblue' } default: diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 06473583..1dd1a838 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -34,6 +34,7 @@ export const adminOrderFulfillmentModeOptions = [ { label: '接单平台', value: 'work_order' }, { label: '第三方卡券', value: 'third_party_voucher' }, { label: '人工履约', value: 'manual' }, + { label: '统一结算', value: 'on_account' }, { label: '混合履约', value: 'mixed' }, { label: '待判定', value: 'pending' }, { label: '历史归档', value: 'archived' }, diff --git a/apps/frontend/src/utils/credit-unit-price.ts b/apps/frontend/src/utils/credit-unit-price.ts new file mode 100644 index 00000000..5718760d --- /dev/null +++ b/apps/frontend/src/utils/credit-unit-price.ts @@ -0,0 +1,16 @@ +/** 分 → 元,用于统一展示积分换算率。 */ +export function formatCreditUnitPriceYuan(value: number | string | null | undefined) { + const fen = Number(value) + return `${(Number.isFinite(fen) ? fen / 100 : 0).toFixed(2)}元` +} + +export function formatCreditUnitPriceLabel(value: number | string | null | undefined) { + return `1积分=${formatCreditUnitPriceYuan(value)}` +} + +/** 配置金额最多 2 位小数,去掉无意义的尾随 0。 */ +export function formatCreditQuantity(value: number | string | null | undefined) { + const quantity = Number(value) + if (!Number.isFinite(quantity)) return '0' + return String(Number(quantity.toFixed(2))) +} diff --git a/apps/frontend/tests/credit-unit-price.test.ts b/apps/frontend/tests/credit-unit-price.test.ts new file mode 100644 index 00000000..8ba5c985 --- /dev/null +++ b/apps/frontend/tests/credit-unit-price.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + formatCreditQuantity, + formatCreditUnitPriceLabel, + formatCreditUnitPriceYuan, +} from '../src/utils/credit-unit-price.ts' + +test('积分换算率统一按元展示,不再使用“分”', () => { + assert.equal(formatCreditUnitPriceYuan(1), '0.01元') + assert.equal(formatCreditUnitPriceYuan(100), '1.00元') + assert.equal(formatCreditUnitPriceLabel(125), '1积分=1.25元') +}) + +test('人工履约配置量支持小数并去掉无意义尾零', () => { + assert.equal(formatCreditQuantity(1.5), '1.5') + assert.equal(formatCreditQuantity('2.2500'), '2.25') + assert.equal(formatCreditQuantity(0), '0') +}) diff --git a/apps/frontend/tests/fund-support-wallet-on-account.test.ts b/apps/frontend/tests/fund-support-wallet-on-account.test.ts new file mode 100644 index 00000000..5b7e40f1 --- /dev/null +++ b/apps/frontend/tests/fund-support-wallet-on-account.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('客服我的钱包展示统一结算虚拟账单入口', async () => { + const source = await readFile( + new URL('../src/pages/admin/fund/FundSupportView.tsx', import.meta.url), + 'utf8', + ) + assert.ok(source.includes('统一结算'), '钱包页必须展示统一结算卡片') + assert.ok(source.includes('生成虚拟账单,不扣积分或备用金')) + assert.ok(source.includes('support-wallet-grid')) +}) From b72d5877da03004a114ebcd5323e63643fa38f4e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 16:26:57 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(deploy):=20=E4=BF=AE=E5=A4=8D=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=20SKU=20=E8=A7=84=E8=8C=83=E5=8C=96=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../129_fulfillment_retry_semantics.sql | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql b/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql index 304a1f34..b1851b38 100644 --- a/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql +++ b/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql @@ -90,6 +90,27 @@ SET internal_sku = canonicalize_internal_sku(internal_sku), updated_at = NOW() WHERE internal_sku <> canonicalize_internal_sku(internal_sku); +-- 人工 SKU 登记表是扫描派生数据。历史数据可能因大小写/空白差异登记了多行, +-- 规范化后会撞唯一键;先去重,优先保留有配置、统计更完整且更新时间更晚的行。 +WITH ranked_registry AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY canonicalize_internal_sku(sku_key) + ORDER BY + (config_id IS NOT NULL) DESC, + item_count DESC, + updated_at DESC, + id DESC + ) AS candidate_rank + FROM manual_sku_registry + WHERE canonicalize_internal_sku(sku_key) IS NOT NULL +) +DELETE FROM manual_sku_registry registry +USING ranked_registry ranked +WHERE registry.id = ranked.id + AND ranked.candidate_rank > 1; + UPDATE manual_fulfillment_configs SET sku_key = canonicalize_internal_sku(sku_key), updated_at = NOW() From fc928c6b18b76f24980b48bf5b5e8d3a8296c4d9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 17:15:41 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(worker-platform):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E9=80=80=E6=AC=BE=E5=86=B2=E6=AD=A3=E5=B9=B6?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../134_customer_refund_operations.sql | 98 ++ .../routes/admin/worker-platform/orders.ts | 38 + .../src/services/admin/admin-rbac-service.ts | 1 + .../finance/customer-refund-service.ts | 1222 +++++++++++++++++ .../services/finance/customer-refund.test.ts | 67 + .../services/finance/order-refund-service.ts | 4 + .../douyin-mashida/gateway-client.ts | 37 + .../douyin-mashida/notify-service.test.ts | 19 +- .../douyin-mashida/notify-service.ts | 135 +- .../douyin-mashida/source-config-service.ts | 12 +- .../platforms/platform-after-sales-service.ts | 283 ++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 153 +++ .../admin/platform/DouyinMashidaPanel.tsx | 8 +- .../src/services/admin/worker-platform.ts | 41 + .../frontend/tests/customer-refund-ui.test.ts | 17 + 15 files changed, 2129 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/db/migrations/134_customer_refund_operations.sql create mode 100644 apps/backend/src/services/finance/customer-refund-service.ts create mode 100644 apps/backend/src/services/finance/customer-refund.test.ts create mode 100644 apps/backend/src/services/platforms/platform-after-sales-service.ts create mode 100644 apps/frontend/tests/customer-refund-ui.test.ts diff --git a/apps/backend/src/db/migrations/134_customer_refund_operations.sql b/apps/backend/src/db/migrations/134_customer_refund_operations.sql new file mode 100644 index 00000000..92ec5c01 --- /dev/null +++ b/apps/backend/src/db/migrations/134_customer_refund_operations.sql @@ -0,0 +1,98 @@ +-- 134_customer_refund_operations.sql +-- +-- 客户退款编排: +-- 1. 平台售后单落库,支持抖音从 refund_RefundCreated 消息自动发现; +-- 2. 一键退款操作记录平台调用、本地退款和业务关闭步骤,保证可重试且不重复退款。 + +INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description) +VALUES ( + 'worker_order.customer_refund', + '客户退款并关闭订单', + '接单管理', + '同意平台用户售后,冲正核销,并关闭订单关联履约、资料和领取任务' +) +ON CONFLICT (permission_key) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + permission_group = EXCLUDED.permission_group, + description = EXCLUDED.description, + updated_at = NOW(); + +-- 客服和运营默认具备前台退款处置能力;高风险管理员仍可在角色权限里收回。 +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT role_key, 'worker_order.customer_refund' +FROM (VALUES ('admin'), ('operator'), ('support')) AS roles(role_key) +ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS platform_after_sales ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + platform_order_id TEXT NOT NULL, + sub_order_id TEXT NOT NULL DEFAULT '', + external_after_sale_id TEXT NOT NULL, + local_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, + local_order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL, + amount_fen BIGINT NOT NULL DEFAULT 0 CHECK (amount_fen >= 0), + after_sale_status INTEGER, + after_sale_type INTEGER, + refund_status INTEGER, + platform_update_time TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'open' + CHECK (state IN ('open', 'agreed', 'refused', 'closed', 'success', 'failed')), + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + applied_at TIMESTAMPTZ, + last_event_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_after_sales_external + ON platform_after_sales(provider, external_after_sale_id); + +CREATE INDEX IF NOT EXISTS idx_platform_after_sales_order + ON platform_after_sales(provider, platform_order_id, state, last_event_at DESC); + +CREATE INDEX IF NOT EXISTS idx_platform_after_sales_local_order + ON platform_after_sales(local_order_id, state, last_event_at DESC) + WHERE local_order_id IS NOT NULL; + +COMMENT ON TABLE platform_after_sales IS + '平台电商售后单:抖音由退款消息落库,供后台自动选择待处理退款;不是打手履约售后单'; +COMMENT ON COLUMN platform_after_sales.platform_update_time IS + '平台售后版本时间;抖音 afterSale.operate 必须回传该值防止版本过期'; + +CREATE TABLE IF NOT EXISTS refund_cancel_operations ( + id BIGSERIAL PRIMARY KEY, + operation_no TEXT NOT NULL UNIQUE, + order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE, + work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL, + provider TEXT NOT NULL, + external_after_sale_id TEXT NOT NULL, + amount_fen BIGINT NOT NULL CHECK (amount_fen > 0), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ( + 'pending', 'platform_approved', 'refund_recorded', 'closed', 'failed' + )), + current_step TEXT NOT NULL DEFAULT 'created', + error_message TEXT NOT NULL DEFAULT '', + result_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_refund_cancel_operations_external + ON refund_cancel_operations(provider, external_after_sale_id, order_item_id); + +CREATE INDEX IF NOT EXISTS idx_refund_cancel_operations_order + ON refund_cancel_operations(order_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_refund_cancel_operations_status + ON refund_cancel_operations(status, updated_at DESC); + +COMMENT ON TABLE refund_cancel_operations IS + '客户退款并关闭订单的幂等编排记录:平台同意、本地退款、业务关闭分步留痕,可安全重试'; + +COMMENT ON COLUMN work_order_shares.cancelled_reason IS + '取消来源:timeout/worker_request/admin/admin_partial/problem/settlement/customer_refund'; diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index d89a7649..12da7a25 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -32,6 +32,10 @@ import { updateAdminWorkOrderPaymentAmount, updateAdminWorkOrderSharing, } from '../../../services/worker-platform/index.js' +import { + executeCustomerRefund, + previewCustomerRefund, +} from '../../../services/finance/customer-refund-service.js' import { buildWorkOrderPaymentAmountAuditData } from '../../../services/worker-platform/work-order-payment-amount.js' import { createJsonHandler, @@ -359,6 +363,40 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/customer-refund/preview', + requireAdminPermission('worker_order.customer_refund'), + createJsonHandler((req) => previewCustomerRefund(String(req.params.workOrderId || '')), { + successMessage: '客户退款预检完成', + errorMessage: '客户退款预检失败', + scope: '[admin/worker-platform/orders/:workOrderId/customer-refund/preview]', + }), +) + +router.post( + '/worker-platform/orders/:workOrderId/customer-refund', + requireAdminPermission('worker_order.customer_refund'), + createJsonHandler( + (req) => + executeCustomerRefund( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession || null, + ), + { + successMessage: '客户退款已完成,订单相关操作已关闭', + errorMessage: '客户退款失败', + scope: '[admin/worker-platform/orders/:workOrderId/customer-refund]', + audit: (req, data) => ({ + action: 'customer_refund_completed', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.delete( '/worker-platform/orders/:workOrderId', requireAdminPermission('worker_order.delete'), diff --git a/apps/backend/src/services/admin/admin-rbac-service.ts b/apps/backend/src/services/admin/admin-rbac-service.ts index e287f055..f286b21c 100644 --- a/apps/backend/src/services/admin/admin-rbac-service.ts +++ b/apps/backend/src/services/admin/admin-rbac-service.ts @@ -21,6 +21,7 @@ export const ADMIN_PERMISSION_KEYS = [ 'worker_order.assign', 'worker_order.return_to_hall', 'worker_order.cancel', + 'worker_order.customer_refund', 'worker_order.cancel_share', 'worker_order.delete', 'worker_order.publish', diff --git a/apps/backend/src/services/finance/customer-refund-service.ts b/apps/backend/src/services/finance/customer-refund-service.ts new file mode 100644 index 00000000..5d649c92 --- /dev/null +++ b/apps/backend/src/services/finance/customer-refund-service.ts @@ -0,0 +1,1222 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import type { JsonObject } from '../../types/json.js' +import { + ensureWorkerWalletWithClient, + getWorkerWalletWithClient, +} from '../../repositories/worker-platform/shared.js' +import { getOutstandingDepositAmountWithClient } from '../../repositories/worker-platform/work-order-deposit-repo.js' +import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js' +import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from '../../repositories/worker-platform/consignment-repo.js' +import { createWorkOrderEventWithClient } from '../../repositories/worker-platform/work-order-event-repo.js' +import { + CLEAR_DELIVERY_RESIDUE_SQL, + RESET_GIFT_FLOW_SQL, +} from '../../repositories/worker-platform/work-order-gift-repo.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' +import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' +import { listKuaishouIndustryRefunds } from '../platforms/kuaishou-industry/refund-service.js' +import { listKuaishouIndustryShopConfigs } from '../platforms/kuaishou-industry/source-config-service.js' +import { reverseAdminKuaishouIndustryVoucher } from '../admin/kuaishou-industry-admin-service.js' +import { + approveDouyinMashidaAfterSale, + cancelDouyinMashidaCouponVerify, +} from '../platforms/douyin-mashida/gateway-client.js' +import { + findPlatformAfterSaleByExternalId, + findOpenPlatformAfterSale, + markPlatformAfterSaleState, +} from '../platforms/platform-after-sales-service.js' +import { approveAndRecordKuaishouRefund, createManualRefund } from './order-refund-service.js' + +const FIRST_VERSION_STATUSES = new Set([ + 'pending_material', + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', +]) + +const DOUYIN_PROVIDER = 'douyin_mashida' +const KUISHOU_PROVIDERS = new Set(['91kaquan', 'kuaishou-91']) + +type RefundContext = { + workOrderId: number + workOrderStatus: string + orderId: number + orderItemId: number + provider: string + platform: string + platformOrderId: string + shopId: string + payStatus: string + orderStatus: string + paidAt: string + remainingFen: number + itemCount: number + workOrderCount: number +} + +type RefundAfterSaleCandidate = { + provider: '91kaquan' | 'douyin_mashida' + externalAfterSaleId: string + platformUpdateTime: string + platformAmountFen: number + state: string + sellerId?: string + refundHandingWay?: number + raw: JsonObject +} + +type RefundCancelOperation = { + id: number + status: 'pending' | 'platform_approved' | 'refund_recorded' | 'closed' | 'failed' + currentStep: string + provider: string + externalAfterSaleId: string + amountFen: number + updatedAt: string +} + +export type CustomerRefundPreview = { + workOrderId: number + orderId: number + provider: string + platform: string + platformOrderId: string + workOrderStatus: string + remainingFen: number + itemCount: number + workOrderCount: number + externalAfterSaleId: string + platformUpdateTime: string + platformAmountFen: number + platformState: string + canExecute: boolean + notes: string[] +} + +export async function previewCustomerRefund( + workOrderId: number | string, +): Promise { + const context = await loadRefundContext(workOrderId) + const resumableOperation = await findResumableRefundCancelOperation(context) + if (resumableOperation?.status === 'closed') { + throw createHttpError('该客户退款已经处理完成,无需重复操作', { + statusCode: 409, + errorCode: 'customer_refund_already_completed', + }) + } + await assertRefundClosureEligible(context.orderId) + const candidate = resumableOperation + ? await buildCandidateFromRefundOperation(context, resumableOperation) + : await findRefundAfterSale(context) + if (!candidate) { + throw createHttpError('抖音没有找到用户售后申请;快手没有查到待处理退款单', { + statusCode: 409, + errorCode: 'customer_refund_after_sale_not_found', + context: { + provider: context.provider, + platformOrderId: context.platformOrderId, + }, + }) + } + const localRefundRecorded = Boolean( + await findRecordedPlatformRefund(context.orderId, candidate.externalAfterSaleId), + ) + if (!localRefundRecorded) { + await assertFreshRefundEligible(context) + } + const effectiveRemainingFen = localRefundRecorded + ? candidate.platformAmountFen || context.remainingFen + : context.remainingFen + const notes: string[] = [] + let canExecute = true + if (resumableOperation) { + notes.push( + resumableOperation.status === 'failed' + ? '检测到上次中断的客户退款,本次会从上次失败步骤继续,不会重复退款' + : '检测到进行中的客户退款,本次会继续完成剩余关闭步骤', + ) + } + if ( + !localRefundRecorded && + candidate.platformAmountFen > 0 && + candidate.platformAmountFen !== context.remainingFen + ) { + canExecute = false + notes.push( + `平台售后金额 ${formatFen(candidate.platformAmountFen)} 与本地剩余可退 ${formatFen(context.remainingFen)} 不一致`, + ) + } + if ( + !localRefundRecorded && + !(await isDouyinAfterSaleAlreadyApproved(candidate, resumableOperation?.status)) && + !candidate.platformUpdateTime && + candidate.provider === DOUYIN_PROVIDER + ) { + canExecute = false + notes.push('抖音售后单缺少 update_time,需重新接收 refund_RefundCreated 消息') + } + if ( + !localRefundRecorded && + candidate.provider === DOUYIN_PROVIDER && + ['closed', 'refused', 'failed'].includes(candidate.state) + ) { + canExecute = false + notes.push('抖音售后单已关闭或不可处理,不能再自动同意退款') + } + return { + workOrderId: context.workOrderId, + orderId: context.orderId, + provider: context.provider, + platform: context.platform, + platformOrderId: context.platformOrderId, + workOrderStatus: context.workOrderStatus, + remainingFen: effectiveRemainingFen, + itemCount: context.itemCount, + workOrderCount: context.workOrderCount, + externalAfterSaleId: candidate.externalAfterSaleId, + platformUpdateTime: candidate.platformUpdateTime, + platformAmountFen: candidate.platformAmountFen, + platformState: candidate.state, + canExecute, + notes, + } +} + +export async function executeCustomerRefund( + workOrderId: number | string, + payload: JsonObject, + session: AdminSession | null, +) { + const context = await loadRefundContext(workOrderId) + const resumableOperation = await findResumableRefundCancelOperation(context) + if (resumableOperation?.status === 'closed') { + return { + idempotent: true, + operationId: resumableOperation.id, + } + } + await assertRefundClosureEligible(context.orderId) + const candidate = resumableOperation + ? await buildCandidateFromRefundOperation(context, resumableOperation) + : await findRefundAfterSale(context) + if (!candidate) { + throw createHttpError('没有找到可操作的用户售后申请', { + statusCode: 409, + errorCode: 'customer_refund_after_sale_not_found', + }) + } + let localRefundRecorded = Boolean( + await findRecordedPlatformRefund(context.orderId, candidate.externalAfterSaleId), + ) + if (!localRefundRecorded) { + await assertFreshRefundEligible(context) + } + if ( + !localRefundRecorded && + candidate.platformAmountFen > 0 && + candidate.platformAmountFen !== context.remainingFen + ) { + throw createHttpError('平台售后金额与本地可退金额不一致,请先人工核对', { + statusCode: 409, + errorCode: 'customer_refund_amount_mismatch', + context: { + platformAmountFen: candidate.platformAmountFen, + remainingFen: context.remainingFen, + }, + }) + } + if ( + !localRefundRecorded && + candidate.provider === DOUYIN_PROVIDER && + ['closed', 'refused', 'failed'].includes(candidate.state) + ) { + throw createHttpError('抖音售后单已关闭或不可处理,不能再自动同意退款', { + statusCode: 409, + errorCode: 'customer_refund_after_sale_not_open', + }) + } + const reason = String(payload.reason || '').trim() || '客户退款' + const note = String(payload.note || '') + .trim() + .slice(0, 500) + const operation = await beginRefundCancelOperation({ + context, + candidate, + actorUserId: session?.userId || null, + }) + if (operation.status === 'closed') { + return { + idempotent: true, + operationId: operation.id, + } + } + + localRefundRecorded = Boolean( + await findRecordedPlatformRefund(context.orderId, candidate.externalAfterSaleId), + ) + if (!localRefundRecorded) { + try { + if (candidate.provider === '91kaquan') { + if (!candidate.sellerId) { + throw createHttpError('未找到快手店铺授权,无法自动同意退款', { + statusCode: 409, + errorCode: 'customer_refund_kuaishou_seller_missing', + }) + } + await approveAndRecordKuaishouRefund( + { + orderId: context.orderId, + orderItemId: context.orderItemId, + amountFen: context.remainingFen, + platformRefundId: candidate.externalAfterSaleId, + sellerId: candidate.sellerId, + refundHandingWay: candidate.refundHandingWay, + status: 10, + negotiateStatus: 1, + reason, + note, + refundedAt: nowIso(), + }, + session, + ) + } else { + if (!(await isDouyinAfterSaleAlreadyApproved(candidate, operation.previousStatus))) { + await approveDouyinMashidaAfterSale({ + afterSaleId: candidate.externalAfterSaleId, + updateTime: candidate.platformUpdateTime, + reason, + remark: note, + }) + await markPlatformAfterSaleState({ + provider: DOUYIN_PROVIDER, + externalAfterSaleId: candidate.externalAfterSaleId, + state: 'agreed', + }) + await updateRefundCancelOperation(operation.id, { + status: 'platform_approved', + currentStep: 'platform_approved', + errorMessage: '', + }) + } + await createManualRefund( + { + orderId: context.orderId, + amountFen: context.remainingFen, + reason, + note, + refundedAt: nowIso(), + source: 'platform', + externalRef: candidate.externalAfterSaleId, + }, + session, + ) + } + localRefundRecorded = true + await updateRefundCancelOperation(operation.id, { + status: 'refund_recorded', + currentStep: 'refund_recorded', + errorMessage: '', + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await updateRefundCancelOperation(operation.id, { + status: 'failed', + currentStep: 'platform_or_refund', + errorMessage: message, + }) + throw error + } + } + + try { + const closed = await closeRefundedOrderOperationally({ + context, + candidate, + reason, + actorName: session?.displayName || session?.username || '', + }) + await updateRefundCancelOperation(operation.id, { + status: 'closed', + currentStep: 'closed', + errorMessage: '', + result: closed, + completedAt: nowIso(), + }) + return { + idempotent: false, + operationId: operation.id, + localRefundRecorded, + ...closed, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await updateRefundCancelOperation(operation.id, { + status: 'failed', + currentStep: 'close_operational_state', + errorMessage: message, + }) + throw createHttpError( + `平台退款已完成,本地退款已记录,但业务关闭失败:${message}。可再次点击继续重试`, + { + statusCode: 502, + errorCode: 'customer_refund_close_failed', + }, + ) + } +} + +async function loadRefundContext(workOrderIdValue: number | string): Promise { + const workOrderId = normalizeId(workOrderIdValue, '工单 ID') + const result = await query<{ + work_order_id: number + work_order_status: string + order_id: number + order_item_id: number + provider: string + platform: string + platform_order_id: string + shop_id: string + pay_status: string + order_status: string + paid_at: string + remaining_fen: number | string + item_count: number + work_order_count: number + }>( + ` + SELECT + wo.id AS work_order_id, + wo.status AS work_order_status, + o.id AS order_id, + wo.order_item_id, + o.provider, + o.platform, + o.platform_order_id, + o.shop_id, + o.pay_status, + o.order_status, + COALESCE(o.paid_at, o.created_at) AS paid_at, + COALESCE(( + SELECT SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)) + - COALESCE(SUM(( + SELECT SUM(r.amount_fen) + FROM order_refunds r + WHERE r.order_item_id = oi.id AND r.status = 'confirmed' + )), 0) + FROM order_items oi + WHERE oi.order_id = o.id + ), 0)::bigint AS remaining_fen, + (SELECT COUNT(*)::int FROM order_items oi WHERE oi.order_id = o.id) AS item_count, + (SELECT COUNT(*)::int FROM work_orders w WHERE w.order_id = o.id) AS work_order_count + FROM work_orders wo + JOIN orders o ON o.id = wo.order_id + WHERE wo.id = $1 + LIMIT 1 + `, + [workOrderId], + ) + const row = result.rows[0] + if (!row) { + throw createHttpError('工单不存在', { + statusCode: 404, + errorCode: 'customer_refund_work_order_not_found', + }) + } + if (Number(row.item_count || 0) !== 1) { + throw createHttpError('第一版客户退款只支持单订单项订单', { + statusCode: 409, + errorCode: 'customer_refund_multi_item_not_supported', + }) + } + return { + workOrderId: Number(row.work_order_id), + workOrderStatus: row.work_order_status, + orderId: Number(row.order_id), + orderItemId: Number(row.order_item_id), + provider: row.provider, + platform: row.platform, + platformOrderId: row.platform_order_id, + shopId: row.shop_id, + payStatus: row.pay_status, + orderStatus: row.order_status, + paidAt: row.paid_at, + remainingFen: Number(row.remaining_fen || 0), + itemCount: Number(row.item_count || 0), + workOrderCount: Number(row.work_order_count || 0), + } +} + +async function assertFreshRefundEligible(context: RefundContext) { + if (!FIRST_VERSION_STATUSES.has(context.workOrderStatus)) { + throw createHttpError('当前工单状态不支持客户退款一键关闭', { + statusCode: 409, + errorCode: 'customer_refund_work_order_status_invalid', + context: { status: context.workOrderStatus }, + }) + } + if (context.payStatus === 'refunded') { + throw createHttpError('订单已退款,不能重复操作', { + statusCode: 409, + errorCode: 'customer_refund_already_refunded', + }) + } + if (context.remainingFen <= 0) { + throw createHttpError('订单没有可退金额', { + statusCode: 409, + errorCode: 'customer_refund_nothing_left', + }) + } +} + +async function findRefundAfterSale( + context: RefundContext, +): Promise { + if (KUISHOU_PROVIDERS.has(context.provider)) { + return findKuaishouAfterSale(context) + } + if (context.provider === DOUYIN_PROVIDER) { + const afterSale = await findOpenPlatformAfterSale({ + provider: DOUYIN_PROVIDER, + localOrderId: context.orderId, + }) + return afterSale + ? { + provider: DOUYIN_PROVIDER, + externalAfterSaleId: afterSale.externalAfterSaleId, + platformUpdateTime: afterSale.platformUpdateTime, + platformAmountFen: afterSale.amountFen, + state: afterSale.state, + raw: afterSale.raw, + } + : null + } + throw createHttpError('当前平台暂未接入客户退款自动审核', { + statusCode: 501, + errorCode: 'customer_refund_platform_not_supported', + context: { provider: context.provider }, + }) +} + +async function findKuaishouAfterSale( + context: RefundContext, +): Promise { + const configs = listKuaishouIndustryShopConfigs() + const matchedShop = configs.find( + (shop) => + shop.enabled !== false && + String(shop.shopId || '').trim() === String(context.shopId || '').trim(), + ) + const sellerId = String(matchedShop?.sellerId || '').trim() + if (!sellerId) { + throw createHttpError('未找到快手店铺授权,无法自动查询售后单', { + statusCode: 409, + errorCode: 'customer_refund_kuaishou_seller_missing', + }) + } + const now = Date.now() + const paidAt = new Date(context.paidAt).getTime() + // 快手接口要求单次查询时间窗不超过 1 天;待退款通常会在 48 小时内处理完。 + const earliest = Math.max( + Number.isFinite(paidAt) ? paidAt : now - 48 * 60 * 60 * 1000, + now - 48 * 60 * 60 * 1000, + ) + const rowsById = new Map() + for (let windowEnd = now; windowEnd > earliest; ) { + const windowBegin = Math.max(earliest, windowEnd - 24 * 60 * 60 * 1000) + const result = await listKuaishouIndustryRefunds({ + sellerId, + orderId: context.platformOrderId, + beginTime: windowBegin, + endTime: windowEnd, + type: 8, + status: 10, + negotiateStatus: 1, + queryType: 2, + pageSize: 100, + }) + if (!result.success) { + throw createHttpError(result.error || '查询快手售后单失败', { + statusCode: 502, + errorCode: 'customer_refund_kuaishou_list_failed', + }) + } + for (const row of collectObjects(result.response || {})) { + const refundId = pickString(row, ['refundId', 'refund_id']) + if (refundId) rowsById.set(refundId, row) + } + windowEnd = windowBegin + } + const pendingRows = [...rowsById.values()] + .filter((row) => { + const oid = pickString(row, ['oid', 'orderId', 'order_id']) + const status = pickNumber(row, ['status', 'refundStatus', 'refund_status']) + return (!oid || oid === context.platformOrderId) && (!status || status === 10) + }) + .sort((left, right) => numericTime(right) - numericTime(left)) + const rows = pendingRows.filter((row) => { + const handlingWay = pickNumber(row, ['handlingWay', 'refundHandingWay']) + return !handlingWay || handlingWay === 10 + }) + const row = rows[0] + if (!row) { + if (pendingRows.length > 0) { + throw createHttpError('当前快手售后不是仅退款类型,第一版不能自动同意', { + statusCode: 409, + errorCode: 'customer_refund_kuaishou_return_goods_not_supported', + context: { + refundId: pickString(pendingRows[0] || {}, ['refundId', 'refund_id']), + refundHandingWay: pickNumber(pendingRows[0] || {}, ['handlingWay', 'refundHandingWay']), + }, + }) + } + return null + } + const refundId = pickString(row, ['refundId', 'refund_id']) + return { + provider: '91kaquan', + externalAfterSaleId: refundId, + platformUpdateTime: pickString(row, ['negotiateUpdateTime', 'updateTime', 'update_time']), + platformAmountFen: pickNumber(row, ['refundFee', 'refundAmount', 'refund_amount', 'amount']), + state: pickString(row, ['status', 'refundStatus', 'refund_status']) || 'pending', + sellerId, + refundHandingWay: 10, + raw: row, + } +} + +async function assertRefundClosureEligible(orderId: number) { + const accepted = await query<{ count: number }>( + ` + SELECT ( + (SELECT COUNT(*) FROM work_orders + WHERE order_id = $1 AND status = 'accepted') + + + (SELECT COUNT(*) FROM work_order_shares s + JOIN work_orders wo ON wo.id = s.work_order_id + WHERE wo.order_id = $1 AND s.status = 'accepted') + )::int AS count + `, + [orderId], + ) + if (Number(accepted.rows[0]?.count || 0) > 0) { + throw createHttpError('订单存在已验收或已结算内容,请走售后追缴,不能直接一键退款', { + statusCode: 409, + errorCode: 'customer_refund_settled_content_exists', + }) + } +} + +async function findResumableRefundCancelOperation( + context: RefundContext, +): Promise { + const result = await query<{ + id: number + status: RefundCancelOperation['status'] + current_step: string + provider: string + external_after_sale_id: string + amount_fen: number | string + updated_at: string + }>( + ` + SELECT + id, status, current_step, provider, external_after_sale_id, amount_fen, updated_at + FROM refund_cancel_operations + WHERE order_id = $1 AND order_item_id = $2 + ORDER BY id DESC + LIMIT 1 + `, + [context.orderId, context.orderItemId], + ) + const row = result.rows[0] + return row + ? { + id: Number(row.id), + status: row.status, + currentStep: row.current_step, + provider: row.provider, + externalAfterSaleId: row.external_after_sale_id, + amountFen: Number(row.amount_fen || 0), + updatedAt: row.updated_at, + } + : null +} + +async function buildCandidateFromRefundOperation( + context: RefundContext, + operation: RefundCancelOperation, +): Promise { + if (operation.provider === DOUYIN_PROVIDER) { + const afterSale = await findPlatformAfterSaleByExternalId({ + provider: DOUYIN_PROVIDER, + externalAfterSaleId: operation.externalAfterSaleId, + }) + return { + provider: DOUYIN_PROVIDER, + externalAfterSaleId: operation.externalAfterSaleId, + platformUpdateTime: afterSale?.platformUpdateTime || '', + platformAmountFen: afterSale?.amountFen || operation.amountFen, + state: afterSale?.state || operation.status, + raw: afterSale?.raw || {}, + } + } + return { + provider: '91kaquan', + externalAfterSaleId: operation.externalAfterSaleId, + platformUpdateTime: '', + platformAmountFen: operation.amountFen, + state: operation.status, + sellerId: findKuaishouSellerId(context), + refundHandingWay: 10, + raw: {}, + } +} + +async function isDouyinAfterSaleAlreadyApproved( + candidate: RefundAfterSaleCandidate, + operationStatus?: RefundCancelOperation['status'] | null, +) { + if (candidate.provider !== DOUYIN_PROVIDER) return false + if (operationStatus === 'platform_approved' || operationStatus === 'refund_recorded') { + return true + } + const afterSale = await findPlatformAfterSaleByExternalId({ + provider: DOUYIN_PROVIDER, + externalAfterSaleId: candidate.externalAfterSaleId, + }) + return afterSale?.state === 'agreed' || afterSale?.state === 'success' +} + +function findKuaishouSellerId(context: RefundContext) { + const matchedShop = listKuaishouIndustryShopConfigs().find( + (shop) => + shop.enabled !== false && + String(shop.shopId || '').trim() === String(context.shopId || '').trim(), + ) + return String(matchedShop?.sellerId || '').trim() +} + +async function beginRefundCancelOperation(input: { + context: RefundContext + candidate: RefundAfterSaleCandidate + actorUserId: number | null +}) { + return withTransaction(async (client) => { + type ClaimRow = { + id: number + status: RefundCancelOperation['status'] + current_step: string + updated_at: string + } + const now = nowIso() + const staleBefore = Date.now() - 2 * 60 * 1000 + const claimExisting = async (row: ClaimRow) => { + if (row.status === 'closed') { + return { + id: Number(row.id), + status: row.status, + currentStep: row.current_step, + previousStatus: row.status, + } + } + if (row.status === 'pending' && Date.parse(row.updated_at) >= staleBefore) { + throw createHttpError('该客户退款正在处理中,请稍后刷新', { + statusCode: 409, + errorCode: 'customer_refund_processing', + }) + } + const previousStatus = row.status + const claimed = await client.query( + `UPDATE refund_cancel_operations + SET status = 'pending', + current_step = 'claimed', + error_message = '', + completed_at = NULL, + updated_at = $2 + WHERE id = $1 + RETURNING id, status, current_step, updated_at`, + [row.id, now], + ) + const claimedRow = claimed.rows[0] + if (!claimedRow) { + throw createHttpError('客户退款操作记录锁定失败', { + statusCode: 500, + errorCode: 'customer_refund_operation_lock_failed', + }) + } + return { + id: Number(claimedRow.id), + status: claimedRow.status, + currentStep: claimedRow.current_step, + previousStatus, + } + } + + const existing = await client.query( + `SELECT id, status, current_step, updated_at + FROM refund_cancel_operations + WHERE provider = $1 AND external_after_sale_id = $2 AND order_item_id = $3 + FOR UPDATE`, + [input.candidate.provider, input.candidate.externalAfterSaleId, input.context.orderItemId], + ) + if (existing.rows[0]) { + return claimExisting(existing.rows[0]) + } + const operationNo = `RCO${Date.now()}${input.context.workOrderId}` + const inserted = await client.query( + ` + INSERT INTO refund_cancel_operations ( + operation_no, order_id, order_item_id, work_order_id, provider, + external_after_sale_id, amount_fen, status, current_step, + created_by_admin_user_id, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 'claimed', $8, $9, $9) + ON CONFLICT (provider, external_after_sale_id, order_item_id) DO NOTHING + RETURNING id, status, current_step, updated_at + `, + [ + operationNo, + input.context.orderId, + input.context.orderItemId, + input.context.workOrderId, + input.candidate.provider, + input.candidate.externalAfterSaleId, + input.context.remainingFen, + input.actorUserId, + now, + ], + ) + if (inserted.rows[0]) { + return { + id: Number(inserted.rows[0].id), + status: inserted.rows[0].status, + currentStep: inserted.rows[0].current_step, + previousStatus: null, + } + } + const conflicted = await client.query( + `SELECT id, status, current_step, updated_at + FROM refund_cancel_operations + WHERE provider = $1 AND external_after_sale_id = $2 AND order_item_id = $3 + FOR UPDATE`, + [input.candidate.provider, input.candidate.externalAfterSaleId, input.context.orderItemId], + ) + if (!conflicted.rows[0]) { + throw createHttpError('创建客户退款操作记录失败', { + statusCode: 500, + errorCode: 'customer_refund_operation_create_failed', + }) + } + return claimExisting(conflicted.rows[0]) + }) +} + +async function updateRefundCancelOperation( + operationId: number, + input: { + status: 'platform_approved' | 'refund_recorded' | 'closed' | 'failed' + currentStep: string + errorMessage: string + result?: unknown + completedAt?: string + }, +) { + await query( + ` + UPDATE refund_cancel_operations + SET status = $2, + current_step = $3, + error_message = $4, + result_json = COALESCE($5::jsonb, result_json), + updated_at = $6, + completed_at = $7 + WHERE id = $1 + `, + [ + operationId, + input.status, + input.currentStep, + input.errorMessage, + input.result === undefined ? null : JSON.stringify(input.result), + nowIso(), + input.completedAt || null, + ], + ) +} + +async function findRecordedPlatformRefund(orderId: number, externalRef: string) { + const result = await query<{ id: number }>( + `SELECT id FROM order_refunds + WHERE order_id = $1 AND source = 'platform' AND external_ref = $2 + LIMIT 1`, + [orderId, externalRef], + ) + return result.rows[0] || null +} + +async function closeRefundedOrderOperationally(input: { + context: RefundContext + candidate: RefundAfterSaleCandidate + reason: string + actorName: string +}) { + const closed = await withTransaction((client) => closeRefundedOrderWithClient(client, input)) + await reversePlatformVoucherState(input.context, input.reason) + return closed +} + +async function closeRefundedOrderWithClient( + client: PoolClient, + input: { + context: RefundContext + candidate: RefundAfterSaleCandidate + reason: string + actorName: string + }, +) { + const now = nowIso() + const workOrders = await client.query( + `SELECT * FROM work_orders WHERE order_id = $1 ORDER BY id FOR UPDATE`, + [input.context.orderId], + ) + let cancelledWorkOrders = 0 + let cancelledShares = 0 + let releasedDepositFen = 0 + + for (const workOrder of workOrders.rows) { + if (workOrder.status === 'cancelled') continue + const shares = await client.query<{ + id: number + worker_id: number + status: string + share_deposit: number + }>( + `SELECT id, worker_id, status, share_deposit + FROM work_order_shares + WHERE work_order_id = $1 AND status <> 'cancelled' + ORDER BY id + FOR UPDATE`, + [workOrder.id], + ) + if (shares.rows.some((share) => share.status === 'accepted')) { + throw createHttpError('订单存在已验收拼单,不能自动关闭', { + statusCode: 409, + errorCode: 'customer_refund_accepted_share_exists', + }) + } + for (const share of shares.rows) { + const workerId = Number(share.worker_id || 0) + if (!workerId) continue + await ensureWorkerWalletWithClient(client, workerId, now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, now, workerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '客户退款关闭订单退还押金', $6::jsonb, $7)`, + [ + workerId, + releaseAmount, + nextAvailable, + nextFrozen, + workOrder.id, + JSON.stringify({ shareId: share.id, reason: input.reason }), + now, + ], + ) + releasedDepositFen += releaseAmount + } + } + if (shares.rows.length > 0) { + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + cancelled_reason = 'customer_refund', + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + updated_at = $1 + WHERE id = ANY($2::bigint[])`, + [now, shares.rows.map((share) => Number(share.id))], + ) + cancelledShares += shares.rows.length + } + + const assignedWorkerId = Number(workOrder.assigned_worker_id || 0) + if (assignedWorkerId) { + const forfeit = isWorkOrderGiftDepositForfeitable(workOrder) + if (forfeit) { + await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { + workOrderId: Number(workOrder.id), + note: '客户退款关闭订单,没收寄卖发布押金', + now, + }) + } + await ensureWorkerWalletWithClient(client, assignedWorkerId, now) + const wallet = await getWorkerWalletWithClient(client, assignedWorkerId) + const releaseAmount = Math.min( + await getOutstandingDepositAmountWithClient(client, assignedWorkerId, workOrder.id), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + (forfeit ? 0 : releaseAmount) + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, now, assignedWorkerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, + [ + assignedWorkerId, + forfeit ? 'deposit_deduction' : 'deposit_release', + forfeit ? -releaseAmount : releaseAmount, + nextAvailable, + nextFrozen, + workOrder.id, + forfeit ? '客户退款关闭订单扣除押金' : '客户退款关闭订单退还押金', + JSON.stringify({ reason: input.reason }), + now, + ], + ) + if (!forfeit) releasedDepositFen += releaseAmount + } + } + + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, workOrder.id, { + now, + reason: '客户退款关闭订单', + }) + await client.query( + `UPDATE work_orders + SET status = 'cancelled', + assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + published_at = NULL, + pinned_at = NULL, + ${CLEAR_DELIVERY_RESIDUE_SQL}, + ${RESET_GIFT_FLOW_SQL}, + updated_at = $1 + WHERE id = $2`, + [now, workOrder.id], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: input.actorName, + eventType: 'customer_refund_cancelled', + fromStatus: workOrder.status, + toStatus: 'cancelled', + payloadJson: JSON.stringify({ + reason: input.reason, + afterSaleId: input.candidate.externalAfterSaleId, + }), + now, + }) + cancelledWorkOrders += 1 + } + + const tasks = await client.query<{ id: number }>( + `UPDATE fulfillment_tasks + SET task_status = 'closed', + delivery_status = 'closed', + user_action_status = 'closed', + result_code = 'customer_refund', + result_message = $2, + claim_expires_at = $1, + updated_at = $1 + WHERE order_id = $3 + AND task_status NOT IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + RETURNING id`, + [now, '客户退款,履约任务关闭', input.context.orderId], + ) + const taskIds = tasks.rows.map((task) => Number(task.id)) + if (taskIds.length > 0) { + await client.query( + `UPDATE claim_tokens + SET status = 'closed', expired_at = $1, updated_at = $1 + WHERE task_id = ANY($2::bigint[]) + AND status NOT IN ('closed', 'expired')`, + [now, taskIds], + ) + await client.query( + `INSERT INTO task_events (task_id, event_type, payload_json, created_at) + SELECT task_id, 'customer_refund_closed', $1::jsonb, $2 + FROM unnest($3::bigint[]) AS task_id`, + [ + JSON.stringify({ + reason: input.reason, + afterSaleId: input.candidate.externalAfterSaleId, + }), + now, + taskIds, + ], + ) + } + await client.query( + `UPDATE voucher_consume_jobs + SET status = 'cancelled', + last_error = '客户退款,取消待核销任务', + finished_at = $1, + updated_at = $1 + WHERE order_id = $2 AND status IN ('pending', 'processing')`, + [now, input.context.orderId], + ) + await client.query( + `UPDATE on_account_bills + SET status = 'void', updated_at = $1 + WHERE order_id = $2 AND status = 'pending'`, + [now, input.context.orderId], + ) + await client.query( + `UPDATE orders + SET pay_status = 'refunded', + order_status = 'refunded', + updated_at = $1 + WHERE id = $2`, + [now, input.context.orderId], + ) + return { + workOrderId: input.context.workOrderId, + orderId: input.context.orderId, + cancelledWorkOrders, + cancelledShares, + releasedDepositFen, + closedTaskCount: tasks.rowCount || 0, + } +} + +async function reversePlatformVoucherState(context: RefundContext, reason: string) { + if (KUISHOU_PROVIDERS.has(context.provider)) { + const vouchers = await query<{ voucher_code: string; status: string; seller_id: string }>( + `SELECT voucher_code, status, seller_id + FROM kuaishou_industry_vouchers + WHERE order_id = $1 AND status = 'CONSUMED'`, + [context.orderId], + ) + if (vouchers.rows.length > 0) { + await reverseAdminKuaishouIndustryVoucher({ + orderId: context.platformOrderId, + sellerId: vouchers.rows[0]?.seller_id || '', + etickets: vouchers.rows.map((voucher) => ({ + id: voucher.voucher_code, + code: voucher.voucher_code, + num: 1, + })), + reason, + }) + } + return + } + if (context.provider === DOUYIN_PROVIDER) { + const certificates = await query<{ id: number; cert_no: string; status: string }>( + `SELECT id, cert_no, status + FROM redeem_certificates + WHERE local_order_id = $1 AND status = 'verified'`, + [context.orderId], + ) + for (const certificate of certificates.rows) { + await cancelDouyinMashidaCouponVerify({ + certNo: certificate.cert_no, + batchNo: `customer_refund:${context.orderId}:${certificate.id}`, + }) + } + await query( + `UPDATE redeem_certificates + SET status = 'invalid', updated_at = $1 + WHERE local_order_id = $2 AND status <> 'invalid'`, + [nowIso(), context.orderId], + ) + } +} + +function collectObjects(value: unknown): JsonObject[] { + const output: JsonObject[] = [] + const visit = (current: unknown) => { + if (Array.isArray(current)) { + for (const item of current) visit(item) + return + } + if (!current || typeof current !== 'object') return + const row = current as JsonObject + if ( + 'refundId' in row || + 'refund_id' in row || + 'aftersale_id' in row || + 'orderId' in row || + 'oid' in row + ) { + output.push(row) + } + for (const child of Object.values(row)) visit(child) + } + visit(value) + return output +} + +function pickString(row: JsonObject, keys: string[]) { + for (const key of keys) { + const text = String(row[key] ?? '').trim() + if (text) return text + } + return '' +} + +function pickNumber(row: JsonObject, keys: string[]) { + for (const key of keys) { + const number = Number(row[key]) + if (Number.isFinite(number) && number > 0) return Math.round(number) + } + return 0 +} + +function numericTime(row: JsonObject) { + for (const key of ['updateTime', 'update_time', 'createTime', 'create_time', 'applyTime']) { + const value = row[key] + if (typeof value === 'number' && Number.isFinite(value)) return value + const parsed = Date.parse(String(value || '')) + if (Number.isFinite(parsed)) return parsed + } + return 0 +} + +function normalizeId(value: unknown, label: string) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw createHttpError(`${label}无效`, { statusCode: 400, errorCode: 'invalid_id' }) + } + return parsed +} + +function formatFen(value: number) { + return `¥${(value / 100).toFixed(2)}` +} diff --git a/apps/backend/src/services/finance/customer-refund.test.ts b/apps/backend/src/services/finance/customer-refund.test.ts new file mode 100644 index 00000000..a812b4b8 --- /dev/null +++ b/apps/backend/src/services/finance/customer-refund.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('客户退款第一版覆盖范围与原子关闭动作已声明', async () => { + const source = await readFile(new URL('./customer-refund-service.ts', import.meta.url), 'utf8') + for (const status of [ + 'pending_material', + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', + ]) { + assert.ok(source.includes(`'${status}'`), `缺少工单状态 ${status}`) + } + assert.ok(source.includes("pay_status = 'refunded'")) + assert.ok(source.includes("order_status = 'refunded'")) + assert.ok(source.includes('work_order_shares')) + assert.ok(source.includes('voucher_consume_jobs')) + assert.ok(source.includes('redeem_certificates')) + assert.ok(source.includes('findResumableRefundCancelOperation')) + assert.ok(source.includes('customer_refund_processing')) + assert.ok(source.includes("status: 'platform_approved'")) + assert.ok(source.includes('refundFee')) + assert.ok(source.includes('queryType: 2')) + assert.ok( + source.includes('afterSale.operate') || source.includes('approveDouyinMashidaAfterSale'), + ) +}) + +test('抖音退款创建和关闭消息会进入自动发现白名单', async () => { + const [configSource, notifySource] = await Promise.all([ + readFile( + new URL('../platforms/douyin-mashida/source-config-service.ts', import.meta.url), + 'utf8', + ), + readFile(new URL('../platforms/douyin-mashida/notify-service.ts', import.meta.url), 'utf8'), + ]) + assert.ok(configSource.includes('refund_RefundCreated')) + assert.ok(configSource.includes('refund_RefundClosed')) + assert.ok(notifySource.includes('bindPlatformAfterSalesToLocalOrder')) +}) + +test('客户退款操作表和平台售后表具备幂等唯一键', async () => { + const migration = await readFile( + new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url), + 'utf8', + ) + assert.ok(migration.includes('uq_platform_after_sales_external')) + assert.ok(migration.includes('uq_refund_cancel_operations_external')) + assert.ok(migration.includes('afterSale.operate') || migration.includes('平台售后')) + assert.ok(migration.includes('平台同意、本地退款、业务关闭')) +}) + +test('客户退款使用独立权限,客服默认可用且可由管理员收回', async () => { + const [routeSource, migration] = await Promise.all([ + readFile(new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url), 'utf8'), + readFile( + new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url), + 'utf8', + ), + ]) + assert.ok(routeSource.includes("requireAdminPermission('worker_order.customer_refund')")) + assert.ok(migration.includes("'worker_order.customer_refund'")) + assert.ok(migration.includes("('support')")) +}) diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index 13ed3ad4..d147de04 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -635,6 +635,8 @@ export async function approveAndRecordKuaishouRefund( refundedAt?: string | undefined sellerId?: string | undefined refundHandingWay?: unknown + status?: unknown + negotiateStatus?: unknown desc?: string | undefined }, session: AdminSession | null, @@ -770,6 +772,8 @@ export async function approveAndRecordKuaishouRefund( refundAmount: amountFen, desc: input.desc || input.note || '', refundHandingWay: input.refundHandingWay, + status: input.status, + negotiateStatus: input.negotiateStatus, ...(input.sellerId ? { sellerId: input.sellerId } : {}), }) await updatePlatformRefundIntent(intent.id, 'platform_approved') diff --git a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts index 6e09e3e6..f2a5221d 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts @@ -124,6 +124,43 @@ export async function cancelDouyinMashidaCouponVerify( }) } +/** + * 同意用户售后(`afterSale.operate`,type=201)。 + * update_time 必须使用 refund_RefundCreated 消息中的原值,平台用它做版本校验。 + */ +export async function approveDouyinMashidaAfterSale( + input: { + afterSaleId: string + updateTime: string + reason?: string | undefined + remark?: string | undefined + } & DouyinMashidaCallOptions, +): Promise { + const afterSaleId = String(input.afterSaleId || '').trim() + const updateTime = String(input.updateTime || '').trim() + if (!afterSaleId || !updateTime) { + throw new Error('抖音售后审核缺少 aftersale_id 或 update_time') + } + const response = await douyinMashidaGatewayRequest({ + method: 'afterSale.operate', + params: { + type: 201, + items: JSON.stringify([ + { + repair_type: 1, + aftersale_id: afterSaleId, + reason: String(input.reason || '').trim(), + remark: String(input.remark || '').trim(), + update_time: updateTime, + }, + ]), + }, + config: input.config, + timeoutMs: input.timeoutMs, + }) + return isPlainObject(response.data) ? response.data : { data: response.data } +} + /** 订单消息(把「去使用链接」发给买家)。 */ export async function sendDouyinMashidaOrderMessage( input: { diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts index 91a812d9..cb66b8fc 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts @@ -61,7 +61,7 @@ test('data 解析:坏 JSON 不抛错,返回空对象', () => { assert.deepEqual(parseMessageData(''), {}) }) -test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => { +test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids:aftersale_id 派生', () => { const eventId = buildDouyinMashidaMessageEventId({ topic: 'trade_TradePaid', pubTime: '1780000000', @@ -76,6 +76,23 @@ test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => { ) }) +test('幂等键:同一订单同一秒的两笔售后不会互相去重', () => { + const first = buildDouyinMashidaMessageEventId({ + topic: 'refund_RefundCreated', + pubTime: '1780000000', + data: '{}', + message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '1' }, + }) + const second = buildDouyinMashidaMessageEventId({ + topic: 'refund_RefundCreated', + pubTime: '1780000000', + data: '{}', + message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '2' }, + }) + + assert.notEqual(first, second) +}) + test('幂等键:拿不到订单号时退回 data 摘要(同一内容稳定、内容变则键变)', () => { const base = { topic: 'refund_RefundSuccess', pubTime: '1780000000' } const first = buildDouyinMashidaMessageEventId({ ...base, data: '{"a":1}', message: {} }) diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts index 10eb2258..96961d63 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -2,6 +2,7 @@ import { isPlainObject, type JsonObject } from '../../../types/json.js' import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js' import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' +import { query } from '../../../db/client.js' import { upsertOrderFromSource } from '../../order/order-service.js' import { ensureCertificateForOrderItem, @@ -26,6 +27,11 @@ import { } from './source-config-service.js' import { verifyMessageSignature } from './sign.js' import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' +import { + bindPlatformAfterSalesToLocalOrder, + markPlatformAfterSaleState, + upsertPlatformAfterSale, +} from '../platform-after-sales-service.js' /** * 抖音码速达消息推送入口(验签 → 幂等 → 分派)。 @@ -113,14 +119,96 @@ export async function handleDouyinMashidaMessage(input: { return handlePaidMessage({ message, topic, eventId, config }) } + if (topic === 'refund_RefundCreated') { + return handleAfterSaleCreated({ message, topic, eventId, pubTime }) + } + + if (topic === 'refund_RefundClosed') { + return handleAfterSaleClosed({ message, topic, eventId, pubTime }) + } + if (topic === 'trade_TradeCanceled' || topic === 'refund_RefundSuccess') { - return handleInvalidateMessage({ message, topic, eventId }) + return handleInvalidateMessage({ message, topic, eventId, pubTime }) } logIntegration('[douyin-mashida/notify]', 'topic 暂无处理逻辑', { topic, eventId }) return { ok: true, duplicate: false, topic, action: 'noop', eventId } } +async function handleAfterSaleCreated(input: { + message: JsonObject + topic: string + eventId: string + pubTime: string +}): Promise { + const afterSaleId = String(input.message.aftersale_id || '').trim() + const parentOrderId = resolveDouyinMashidaParentOrderId(input.message) + if (!afterSaleId || !parentOrderId) { + throw createHttpError('抖音售后消息缺少 aftersale_id 或 p_id', { + statusCode: 400, + errorCode: 'douyin_mashida_after_sale_id_missing', + context: { topic: input.topic, eventId: input.eventId }, + }) + } + const order = await findLatestOrderByPlatformOrderId({ + provider: DOUYIN_MASHIDA_PROVIDER, + platform: DOUYIN_MASHIDA_PLATFORM, + platformOrderId: parentOrderId, + }) + const orderItems = order + ? await queryOrderItemsForAfterSale(order.id, String(input.message.s_id || '').trim()) + : [] + const afterSale = await upsertPlatformAfterSale({ + provider: DOUYIN_MASHIDA_PROVIDER, + platformOrderId: parentOrderId, + subOrderId: String(input.message.s_id || ''), + externalAfterSaleId: afterSaleId, + localOrderId: order?.id || null, + localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null, + amountFen: Number(input.message.refund_amount || 0), + afterSaleStatus: numberOrNull(input.message.aftersale_status), + afterSaleType: numberOrNull(input.message.aftersale_type), + refundStatus: numberOrNull(input.message.refund_status), + platformUpdateTime: String(input.message.update_time || ''), + state: 'open', + raw: input.message, + appliedAt: numberToIso(input.message.apply_time), + eventAt: numberToIso(input.pubTime), + }) + return { + ok: true, + duplicate: false, + topic: input.topic, + action: afterSale ? 'after-sale-opened' : 'after-sale-ignored', + eventId: input.eventId, + orderId: order?.id || null, + } +} + +async function handleAfterSaleClosed(input: { + message: JsonObject + topic: string + eventId: string + pubTime: string +}): Promise { + const afterSaleId = String(input.message.aftersale_id || '').trim() + if (afterSaleId) { + await markPlatformAfterSaleState({ + provider: DOUYIN_MASHIDA_PROVIDER, + externalAfterSaleId: afterSaleId, + state: 'closed', + eventAt: numberToIso(input.pubTime), + }) + } + return { + ok: true, + duplicate: false, + topic: input.topic, + action: afterSaleId ? 'after-sale-closed' : 'after-sale-close-id-missing', + eventId: input.eventId, + } +} + /** `trade_TradePaid`:拉订单详情 → 建单 → 生成券码并同步给平台。 */ async function handlePaidMessage(input: { message: JsonObject @@ -171,6 +259,14 @@ async function handlePaidMessage(input: { } } + const orderItems = result.orderItems || [] + await bindPlatformAfterSalesToLocalOrder({ + provider: DOUYIN_MASHIDA_PROVIDER, + platformOrderId: parentOrderId, + localOrderId: order.id, + localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null, + }) + // 只有真正付款的单才发券(详情可能显示已取消/退款) if (String(order.pay_status || '') !== 'paid') { logIntegration('[douyin-mashida/notify]', '订单非已支付状态,跳过发券', { @@ -315,6 +411,7 @@ async function handleInvalidateMessage(input: { message: JsonObject topic: string eventId: string + pubTime: string }): Promise { const parentOrderId = resolveDouyinMashidaParentOrderId(input.message) if (!parentOrderId) { @@ -364,6 +461,15 @@ async function handleInvalidateMessage(input: { localOrderId: order.id, reason: input.topic, }) + const afterSaleId = String(input.message.refund_id || input.message.aftersale_id || '').trim() + if (afterSaleId) { + await markPlatformAfterSaleState({ + provider: DOUYIN_MASHIDA_PROVIDER, + externalAfterSaleId: afterSaleId, + state: input.topic === 'refund_RefundSuccess' ? 'success' : 'closed', + eventAt: numberToIso(input.message.success_time || input.pubTime), + }) + } return { ok: true, @@ -376,6 +482,28 @@ async function handleInvalidateMessage(input: { } } +async function queryOrderItemsForAfterSale(orderId: number, subOrderId: string) { + const result = await query<{ id: number; sku_code: string }>( + `SELECT id, sku_code FROM order_items WHERE order_id = $1 ORDER BY id`, + [orderId], + ) + if (subOrderId) { + const matched = result.rows.find((row) => String(row.sku_code || '').trim() === subOrderId) + if (matched) return [matched] + } + return result.rows.length === 1 ? result.rows : [] +} + +function numberOrNull(value: unknown) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : null +} + +function numberToIso(value: unknown) { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null +} + /** * 从 POST body 取 `data` 原文(必须与平台签名时使用的字符串完全一致)。 * 兼容两种发法:body 直接是 JSON 字符串;或 `data=` 的 urlencoded 表单。 @@ -474,9 +602,12 @@ export function buildDouyinMashidaMessageEventId(input: { const subIds = Array.isArray(input.message.s_ids) ? input.message.s_ids.map((id) => String(id || '').trim()).filter(Boolean) : [] + const afterSaleId = String(input.message.aftersale_id || input.message.refund_id || '').trim() if (parentOrderId) { - return [input.topic, input.pubTime, parentOrderId, subIds.join(',')].join(':') + return [input.topic, input.pubTime, parentOrderId, subIds.join(','), afterSaleId] + .filter(Boolean) + .join(':') } const digest = createHash('sha256') diff --git a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts index e2cc541a..8f3349f6 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts @@ -30,7 +30,7 @@ const DOUYIN_MASHIDA_CONFIG_KEY = APP_CONFIG_KEYS.douyinMashida export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com' -/** 最小接入只处理这几条:付款进单、取消/退款作废券码。 */ +/** 付款进单、售后自动发现、取消/退款作废券码。 */ /** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */ export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功' @@ -40,6 +40,8 @@ export const DEFAULT_CLAIM_MESSAGE_TEMPLATE = '您的订单已发货,请点击 export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [ 'trade_TradePaid', 'trade_TradeCanceled', + 'refund_RefundCreated', + 'refund_RefundClosed', 'refund_RefundSuccess', ] as const @@ -165,7 +167,13 @@ function normalizeHandledTopics(value: unknown): string[] { } const topics = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))] - return topics.length > 0 ? topics : [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS] + return [ + ...new Set([ + ...(topics.length > 0 ? topics : DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS), + 'refund_RefundCreated', + 'refund_RefundClosed', + ]), + ] } function normalizePositiveInteger(value: unknown, fallback: number) { diff --git a/apps/backend/src/services/platforms/platform-after-sales-service.ts b/apps/backend/src/services/platforms/platform-after-sales-service.ts new file mode 100644 index 00000000..13d0aef3 --- /dev/null +++ b/apps/backend/src/services/platforms/platform-after-sales-service.ts @@ -0,0 +1,283 @@ +import { query } from '../../db/client.js' +import { nowIso } from '../../utils/time.js' +import type { JsonObject } from '../../types/json.js' + +export type PlatformAfterSaleState = 'open' | 'agreed' | 'refused' | 'closed' | 'success' | 'failed' + +export type PlatformAfterSaleInput = { + provider: string + platformOrderId: string + subOrderId?: string + externalAfterSaleId: string + localOrderId?: number | null + localOrderItemId?: number | null + amountFen?: number + afterSaleStatus?: number | null + afterSaleType?: number | null + refundStatus?: number | null + platformUpdateTime?: string + state?: PlatformAfterSaleState + raw?: JsonObject + appliedAt?: string | null + eventAt?: string | null +} + +export type PlatformAfterSale = { + id: number + provider: string + platformOrderId: string + subOrderId: string + externalAfterSaleId: string + localOrderId: number | null + localOrderItemId: number | null + amountFen: number + afterSaleStatus: number | null + afterSaleType: number | null + refundStatus: number | null + platformUpdateTime: string + state: PlatformAfterSaleState + raw: JsonObject + appliedAt: string | null + lastEventAt: string | null + createdAt: string + updatedAt: string +} + +type PlatformAfterSaleRow = { + id: number + provider: string + platform_order_id: string + sub_order_id: string + external_after_sale_id: string + local_order_id: number | null + local_order_item_id: number | null + amount_fen: number | string + after_sale_status: number | null + after_sale_type: number | null + refund_status: number | null + platform_update_time: string + state: PlatformAfterSaleState + raw_json: unknown + applied_at: string | null + last_event_at: string | null + created_at: string + updated_at: string +} + +export async function upsertPlatformAfterSale( + input: PlatformAfterSaleInput, +): Promise { + const provider = normalizeText(input.provider, 80) + const platformOrderId = normalizeText(input.platformOrderId, 120) + const externalAfterSaleId = normalizeText(input.externalAfterSaleId, 120) + if (!provider || !platformOrderId || !externalAfterSaleId) return null + const now = nowIso() + const result = await query( + ` + INSERT INTO platform_after_sales ( + provider, platform_order_id, sub_order_id, external_after_sale_id, + local_order_id, local_order_item_id, amount_fen, + after_sale_status, after_sale_type, refund_status, platform_update_time, + state, raw_json, applied_at, last_event_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, + $8, $9, $10, $11, + $12, $13::jsonb, $14, $15, $16, $16 + ) + ON CONFLICT (provider, external_after_sale_id) + DO UPDATE SET + platform_order_id = EXCLUDED.platform_order_id, + sub_order_id = EXCLUDED.sub_order_id, + local_order_id = COALESCE(EXCLUDED.local_order_id, platform_after_sales.local_order_id), + local_order_item_id = COALESCE( + EXCLUDED.local_order_item_id, + platform_after_sales.local_order_item_id + ), + amount_fen = CASE + WHEN EXCLUDED.amount_fen > 0 THEN EXCLUDED.amount_fen + ELSE platform_after_sales.amount_fen + END, + after_sale_status = COALESCE( + EXCLUDED.after_sale_status, + platform_after_sales.after_sale_status + ), + after_sale_type = COALESCE(EXCLUDED.after_sale_type, platform_after_sales.after_sale_type), + refund_status = COALESCE(EXCLUDED.refund_status, platform_after_sales.refund_status), + platform_update_time = CASE + WHEN EXCLUDED.platform_update_time <> '' THEN EXCLUDED.platform_update_time + ELSE platform_after_sales.platform_update_time + END, + state = CASE + WHEN platform_after_sales.state IN ('agreed', 'success', 'closed', 'refused') + AND EXCLUDED.state = 'open' + THEN platform_after_sales.state + ELSE EXCLUDED.state + END, + raw_json = EXCLUDED.raw_json, + applied_at = COALESCE(EXCLUDED.applied_at, platform_after_sales.applied_at), + last_event_at = COALESCE(EXCLUDED.last_event_at, platform_after_sales.last_event_at), + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + provider, + platformOrderId, + normalizeText(input.subOrderId, 120), + externalAfterSaleId, + normalizeOptionalId(input.localOrderId), + normalizeOptionalId(input.localOrderItemId), + normalizeAmountFen(input.amountFen), + normalizeOptionalInteger(input.afterSaleStatus), + normalizeOptionalInteger(input.afterSaleType), + normalizeOptionalInteger(input.refundStatus), + normalizeText(input.platformUpdateTime, 80), + input.state || 'open', + JSON.stringify(input.raw || {}), + input.appliedAt || null, + input.eventAt || now, + now, + ], + ) + return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null +} + +export async function findOpenPlatformAfterSale(input: { + provider: string + localOrderId: number + localOrderItemId?: number | null +}): Promise { + const params: unknown[] = [input.provider, input.localOrderId] + let itemFilter = '' + if (input.localOrderItemId) { + params.push(input.localOrderItemId) + itemFilter = `AND local_order_item_id = $${params.length}` + } + const result = await query( + ` + SELECT * + FROM platform_after_sales + WHERE provider = $1 + AND local_order_id = $2 + AND state = 'open' + ${itemFilter} + ORDER BY last_event_at DESC NULLS LAST, id DESC + LIMIT 1 + `, + params, + ) + return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null +} + +export async function findPlatformAfterSaleByExternalId(input: { + provider: string + externalAfterSaleId: string +}): Promise { + const result = await query( + ` + SELECT * + FROM platform_after_sales + WHERE provider = $1 AND external_after_sale_id = $2 + LIMIT 1 + `, + [input.provider, input.externalAfterSaleId], + ) + return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null +} + +export async function bindPlatformAfterSalesToLocalOrder(input: { + provider: string + platformOrderId: string + localOrderId: number + localOrderItemId?: number | null +}): Promise { + const result = await query( + ` + UPDATE platform_after_sales + SET local_order_id = $3, + local_order_item_id = COALESCE(local_order_item_id, $4), + updated_at = $5 + WHERE provider = $1 + AND platform_order_id = $2 + AND local_order_id IS NULL + `, + [ + input.provider, + input.platformOrderId, + input.localOrderId, + normalizeOptionalId(input.localOrderItemId), + nowIso(), + ], + ) + return result.rowCount || 0 +} + +export async function markPlatformAfterSaleState(input: { + provider: string + externalAfterSaleId: string + state: PlatformAfterSaleState + eventAt?: string | null +}): Promise { + await query( + ` + UPDATE platform_after_sales + SET state = CASE + WHEN state = 'success' THEN state + WHEN state IN ('agreed', 'closed', 'refused') AND $3 = 'open' THEN state + ELSE $3 + END, + last_event_at = $4, + updated_at = $4 + WHERE provider = $1 AND external_after_sale_id = $2 + `, + [input.provider, input.externalAfterSaleId, input.state, input.eventAt || nowIso()], + ) +} + +function mapPlatformAfterSale(row: PlatformAfterSaleRow): PlatformAfterSale { + return { + id: Number(row.id), + provider: row.provider, + platformOrderId: row.platform_order_id, + subOrderId: row.sub_order_id || '', + externalAfterSaleId: row.external_after_sale_id, + localOrderId: row.local_order_id ? Number(row.local_order_id) : null, + localOrderItemId: row.local_order_item_id ? Number(row.local_order_item_id) : null, + amountFen: Number(row.amount_fen || 0), + afterSaleStatus: row.after_sale_status, + afterSaleType: row.after_sale_type, + refundStatus: row.refund_status, + platformUpdateTime: row.platform_update_time || '', + state: row.state, + raw: + row.raw_json && typeof row.raw_json === 'object' && !Array.isArray(row.raw_json) + ? (row.raw_json as JsonObject) + : {}, + appliedAt: row.applied_at, + lastEventAt: row.last_event_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function normalizeText(value: unknown, maxLength: number) { + return String(value ?? '') + .trim() + .slice(0, maxLength) +} + +function normalizeOptionalId(value: unknown): number | null { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function normalizeAmountFen(value: unknown): number { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 +} + +function normalizeOptionalInteger(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : null +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index bc6695ca..a0d0b6ea 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -71,9 +71,11 @@ import { fetchAdminWorkerProductNameSuffix, fetchAdminWorkerUsers, fetchAdminWorkOrders, + executeAdminCustomerRefund, markAdminWorkOrderProblem, markAdminWorkOrderShareProblem, pinAdminWorkOrder, + previewAdminCustomerRefund, publishAdminWorkOrder, reduceAdminWorkOrderShareQuantity, reopenAdminCancelledWorkOrder, @@ -86,6 +88,7 @@ import { updateAdminWorkOrderNote, updateAdminWorkOrderSharing, } from '@/services/admin' +import type { CustomerRefundPreview } from '@/services/admin/worker-platform' import { checkWorkOrderConsignmentMatches, fulfillWorkOrderWithConsignment, @@ -203,6 +206,11 @@ type CancelSharingPartFormValues = { reason?: string } +type CustomerRefundFormValues = { + reason: string + note?: string +} + /** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null { const consignment = asRecord((row.material as Record | undefined)?.consignment) @@ -304,6 +312,9 @@ export default function WorkOrdersPanel() { const canCancelOrder = sessionPermissions ? sessionPermissions.includes('worker_order.cancel') : hasAdminPermission('worker_order.cancel') + const canCustomerRefund = sessionPermissions + ? sessionPermissions.includes('worker_order.customer_refund') + : hasAdminPermission('worker_order.customer_refund') const canCancelShare = sessionPermissions ? sessionPermissions.includes('worker_order.cancel_share') : hasAdminPermission('worker_order.cancel_share') @@ -379,6 +390,12 @@ export default function WorkOrdersPanel() { const [noteOrder, setNoteOrder] = useState(null) const [deductOrder, setDeductOrder] = useState(null) const [cancelOrder, setCancelOrder] = useState(null) + const [customerRefundOrder, setCustomerRefundOrder] = useState(null) + const [customerRefundPreview, setCustomerRefundPreview] = useState( + null, + ) + const [customerRefundLoading, setCustomerRefundLoading] = useState(false) + const [customerRefundSubmitting, setCustomerRefundSubmitting] = useState(false) const [problemForm] = Form.useForm() const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() @@ -393,6 +410,7 @@ export default function WorkOrdersPanel() { const [cancelSharingForm] = Form.useForm() const [cancelSharingPartForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() + const [customerRefundForm] = Form.useForm() const [editForm] = Form.useForm<{ productName?: string platformOrderId?: string @@ -692,6 +710,46 @@ export default function WorkOrdersPanel() { cancelForm.setFieldsValue({ action: 'return_to_hall', reason: '' }) } + async function openCustomerRefundModal(row: WorkOrder) { + setCustomerRefundOrder(row) + setCustomerRefundPreview(null) + customerRefundForm.setFieldsValue({ reason: '客户退款', note: '' }) + setCustomerRefundLoading(true) + try { + const response = await previewAdminCustomerRefund(row.workOrderId) + setCustomerRefundPreview(response.data) + } catch (error) { + setCustomerRefundOrder(null) + message.error(error instanceof Error ? error.message : '客户退款预检失败') + } finally { + setCustomerRefundLoading(false) + } + } + + async function submitCustomerRefund(values: CustomerRefundFormValues) { + if (!customerRefundOrder || !customerRefundPreview?.canExecute) return + setCustomerRefundSubmitting(true) + try { + const response = await executeAdminCustomerRefund(customerRefundOrder.workOrderId, values) + message.success( + response.data.idempotent + ? '该客户退款已处理,无需重复操作' + : `客户退款已完成,退款 ${formatMoney(customerRefundPreview.remainingFen)}`, + ) + setCustomerRefundOrder(null) + setCustomerRefundPreview(null) + customerRefundForm.resetFields() + await refreshAll() + void queryClient.invalidateQueries({ queryKey: ['admin-redeem-certificates'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-kuaishou-industry-vouchers'] }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '客户退款失败') + } finally { + setCustomerRefundSubmitting(false) + } + } + function applyMaterialPaste(text: string) { if (!materialOrder) return const recognized = parseWorkerMaterialText(text) @@ -1990,6 +2048,27 @@ export default function WorkOrdersPanel() { 手动匹配寄售 ) : null} + {canCustomerRefund && + [ + 'pending_material', + 'unassigned', + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', + ].includes(String(row.rawStatus || row.status || '')) ? ( + + ) : null} {row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? (