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/migrate.ts b/apps/backend/src/db/migrate.ts index 0b5b23f2..8e58c2e8 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -24,10 +24,23 @@ const MIGRATIONS_DIR = path.join(CURRENT_DIR, 'migrations') const MIGRATIONS_TABLE = 'schema_migrations' const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/ const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i +/** + * 合并 main 后寄卖迁移从 129-131 顺延为 135-137(main 已占用 129-134)。 + * 已跑过旧编号的环境要清掉旧记录,让新编号按正常顺序重跑(迁移本身幂等)。 + */ +const RENAMED_MIGRATIONS: Array<{ legacyName: string; currentName: string }> = [ + { legacyName: '129_consignment_matching', currentName: '135_consignment_matching' }, + { legacyName: '130_consignment_dynamic_specs', currentName: '136_consignment_dynamic_specs' }, + { + legacyName: '131_consignment_surnan_default_profile', + currentName: '137_consignment_surnan_default_profile', + }, +] export async function runDatabaseMigrations() { assertMigrationFilesOrdered() await ensureNodePgMigrateMetadataCompatibility() + await dropRenamedMigrationRecords() console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`) @@ -180,6 +193,31 @@ async function ensureNodePgMigrateMetadataCompatibility() { `) } +/** + * 合并 main 后寄卖迁移编号顺延:清掉旧编号的已执行记录。 + * 只在新文件存在、旧文件已不存在、且新编号尚未记录时执行, + * 之后由 runner 按新编号正常重跑(寄卖迁移均为幂等脚本)。 + */ +async function dropRenamedMigrationRecords() { + for (const { legacyName, currentName } of RENAMED_MIGRATIONS) { + if (fs.existsSync(path.join(MIGRATIONS_DIR, `${legacyName}.sql`))) continue + if (!fs.existsSync(path.join(MIGRATIONS_DIR, `${currentName}.sql`))) continue + const result = await query( + ` + DELETE FROM ${MIGRATIONS_TABLE} + WHERE name = $1 + AND NOT EXISTS ( + SELECT 1 FROM ${MIGRATIONS_TABLE} WHERE name = $2 + ) + `, + [legacyName, currentName], + ) + if ((result.rowCount ?? 0) > 0) { + console.info(`[db:migrate] 迁移编号已顺延: ${legacyName} -> ${currentName}`) + } + } +} + async function syncLegacyMigrationColumns() { await query(` UPDATE ${MIGRATIONS_TABLE} 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..b1851b38 --- /dev/null +++ b/apps/backend/src/db/migrations/129_fulfillment_retry_semantics.sql @@ -0,0 +1,129 @@ +-- 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); + +-- 人工 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() +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..7332a001 --- /dev/null +++ b/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql @@ -0,0 +1,50 @@ +-- 延迟核销任务。 +-- +-- 业务事件只负责投递任务,真正的外部平台核销由定时任务稍后执行; +-- 任务持久化到数据库,服务重启也不会丢失延迟核销。 + +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 +); + +-- 同一平台、同一个核销目标只保留一个活跃或已成功任务。 +-- 失败任务允许后续业务事件重新投递;成功任务阻止重复投递。 +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 + '延迟核销任务:业务事件负责投递,定时任务负责执行'; +COMMENT ON COLUMN voucher_consume_jobs.target_key IS + '稳定去重目标:优先订单项,没有订单项时使用任务或订单'; diff --git a/apps/backend/src/db/migrations/131_pending_channel_selection.sql b/apps/backend/src/db/migrations/131_pending_channel_selection.sql new file mode 100644 index 00000000..2dc3354c --- /dev/null +++ b/apps/backend/src/db/migrations/131_pending_channel_selection.sql @@ -0,0 +1,18 @@ +-- 允许待履约订单项保留已经选择的履约渠道。 +-- +-- pending + 渠道:渠道已确定,但载体、商品或成本配置尚未完成; +-- assigned:真实履约载体已经创建完成。 + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel_status; + +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_fulfillment_channel_status + CHECK ( + fulfillment_status = 'pending' + OR (fulfillment_status = 'assigned' AND fulfillment_channel IS NOT NULL) + OR fulfillment_status = 'archived' + ); + +COMMENT ON CONSTRAINT chk_order_items_fulfillment_channel_status ON order_items IS + 'pending 允许保留已选择渠道并等待配置完成;assigned 必须存在履约渠道'; diff --git a/apps/backend/src/db/migrations/132_on_account_and_manual_channels.sql b/apps/backend/src/db/migrations/132_on_account_and_manual_channels.sql new file mode 100644 index 00000000..3a413e35 --- /dev/null +++ b/apps/backend/src/db/migrations/132_on_account_and_manual_channels.sql @@ -0,0 +1,93 @@ +-- 132_on_account_and_manual_channels.sql +-- +-- 新增“统一结算”履约渠道,并支持一个内部 SKU 配置多个人工履约渠道。 +-- 客服确认履约时选择本次实际渠道;统一结算只生成虚拟账单,不扣积分或备用金。 + +-- 1. 订单项履约渠道增加 on_account。 +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel; + +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_fulfillment_channel + CHECK ( + fulfillment_channel IS NULL + OR fulfillment_channel IN ( + 'work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account' + ) + ); + +-- 2. 内部 SKU 渠道配置增加 on_account。 +ALTER TABLE internal_sku_channels + DROP CONSTRAINT IF EXISTS internal_sku_channels_channel_check; + +ALTER TABLE internal_sku_channels + ADD CONSTRAINT internal_sku_channels_channel_check + CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account')); + +-- 3. 自动核销任务允许记录 on_account 渠道。 +ALTER TABLE voucher_consume_jobs + DROP CONSTRAINT IF EXISTS voucher_consume_jobs_channel_check; + +ALTER TABLE voucher_consume_jobs + ADD CONSTRAINT voucher_consume_jobs_channel_check + CHECK (channel IN ('affiliate_dash', 'work_order', 'manual', 'third_party_voucher', 'on_account')); + +-- 4. 人工 SKU 配置支持多个渠道明细,旧单渠道字段继续保留用于兼容。 +ALTER TABLE manual_fulfillment_configs + ADD COLUMN IF NOT EXISTS channels_json JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE manual_fulfillment_configs + DROP CONSTRAINT IF EXISTS chk_manual_fulfillment_configs_platform; + +ALTER TABLE manual_fulfillment_configs + ADD CONSTRAINT chk_manual_fulfillment_configs_platform + CHECK (platform_key IN ('nanhang', 'lewan', 'rock', 'fund_cash', 'on_account')); + +UPDATE manual_fulfillment_configs +SET channels_json = jsonb_build_array( + jsonb_build_object( + 'platformKey', platform_key, + 'creditQuantity', credit_quantity, + 'costMode', cost_mode, + 'enabled', enabled + ) +) +WHERE channels_json = '[]'::jsonb; + +COMMENT ON COLUMN manual_fulfillment_configs.channels_json IS + '人工履约渠道明细:platformKey / creditQuantity / costMode / enabled;旧 platform_key 字段保留兼容'; + +-- 5. 统一结算虚拟账单。后续统一对账以该表为待办真源。 +CREATE TABLE IF NOT EXISTS on_account_bills ( + id BIGSERIAL PRIMARY KEY, + bill_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, + sku_key TEXT NOT NULL DEFAULT '', + canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL, + platform_order_id TEXT NOT NULL DEFAULT '', + amount_fen BIGINT NOT NULL CHECK (amount_fen >= 0), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'reconciled', 'void')), + payee_name TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + metadata_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(), + reconciled_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_on_account_bills_order_item + ON on_account_bills(order_item_id); + +CREATE INDEX IF NOT EXISTS idx_on_account_bills_status_created + ON on_account_bills(status, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_on_account_bills_order + ON on_account_bills(order_id, order_item_id); + +COMMENT ON TABLE on_account_bills IS + '统一结算虚拟账单:确认履约时生成,不扣客服积分或备用金,后续统一对账'; +COMMENT ON COLUMN on_account_bills.status IS + 'pending 待对账 / reconciled 已对账 / void 已作废'; diff --git a/apps/backend/src/db/migrations/133_manual_credit_quantity_decimal.sql b/apps/backend/src/db/migrations/133_manual_credit_quantity_decimal.sql new file mode 100644 index 00000000..da9dfd45 --- /dev/null +++ b/apps/backend/src/db/migrations/133_manual_credit_quantity_decimal.sql @@ -0,0 +1,21 @@ +-- 133_manual_credit_quantity_decimal.sql +-- +-- 人工履约的现金金额支持最多 2 位小数;积分渠道仍只接受整数。 +-- 成本金额仍按整数分保存,避免把金额精度扩散到财务快照和总账。 + +ALTER TABLE manual_fulfillment_configs + ALTER COLUMN credit_quantity TYPE NUMERIC(18, 2) + USING ROUND(credit_quantity::numeric, 2); + +COMMENT ON COLUMN manual_fulfillment_configs.credit_quantity IS + '每单消耗量;积分渠道必须为整数,备用金现金/统一结算为元且最多 2 位小数;0 表示成本不固定'; + +ALTER TABLE manual_fulfillment_configs + DROP CONSTRAINT IF EXISTS chk_manual_fulfillment_configs_integer_credit; + +ALTER TABLE manual_fulfillment_configs + ADD CONSTRAINT chk_manual_fulfillment_configs_integer_credit + CHECK ( + platform_key IN ('fund_cash', 'on_account') + OR credit_quantity = TRUNC(credit_quantity) + ); 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/db/migrations/129_consignment_matching.sql b/apps/backend/src/db/migrations/135_consignment_matching.sql similarity index 99% rename from apps/backend/src/db/migrations/129_consignment_matching.sql rename to apps/backend/src/db/migrations/135_consignment_matching.sql index 560b92df..570cddd0 100644 --- a/apps/backend/src/db/migrations/129_consignment_matching.sql +++ b/apps/backend/src/db/migrations/135_consignment_matching.sql @@ -1,4 +1,4 @@ --- 129_consignment_matching.sql +-- 135_consignment_matching.sql -- 寄卖配置化 + 库存数量/售价 + 内部匹配/自动匹配 + 拼单库存分配。 -- 全部语句幂等,可安全重跑;不改动历史迁移。 diff --git a/apps/backend/src/db/migrations/130_consignment_dynamic_specs.sql b/apps/backend/src/db/migrations/136_consignment_dynamic_specs.sql similarity index 99% rename from apps/backend/src/db/migrations/130_consignment_dynamic_specs.sql rename to apps/backend/src/db/migrations/136_consignment_dynamic_specs.sql index 4c735814..74eae118 100644 --- a/apps/backend/src/db/migrations/130_consignment_dynamic_specs.sql +++ b/apps/backend/src/db/migrations/136_consignment_dynamic_specs.sql @@ -1,4 +1,4 @@ --- 130_consignment_dynamic_specs.sql +-- 136_consignment_dynamic_specs.sql -- 寄卖动态规格:提报/库存增加 spec_values_json、spec_key、spec_text 与改价上限。 -- 全部语句幂等,可安全重跑;不改动历史迁移。 diff --git a/apps/backend/src/db/migrations/131_consignment_surnan_default_profile.sql b/apps/backend/src/db/migrations/137_consignment_surnan_default_profile.sql similarity index 99% rename from apps/backend/src/db/migrations/131_consignment_surnan_default_profile.sql rename to apps/backend/src/db/migrations/137_consignment_surnan_default_profile.sql index bc7091ce..43364fdd 100644 --- a/apps/backend/src/db/migrations/131_consignment_surnan_default_profile.sql +++ b/apps/backend/src/db/migrations/137_consignment_surnan_default_profile.sql @@ -1,4 +1,4 @@ --- 131_consignment_surnan_default_profile.sql +-- 137_consignment_surnan_default_profile.sql -- 苏尔南手枪默认寄卖配置:五个规格字段(与旧发布页一致),直接发布 v1。 -- 幂等:该分类已有寄卖配置时不做任何事,避免覆盖运营已保存/已发布的配置。 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..55f0f25f 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -8,15 +8,16 @@ import { Router } from 'express' import { - assignChannelByInternalSku, assignChannelBySku, assignChannelToOrderItems, + assignPendingChannelGroup, deletePendingChannelGroup, deleteChannelRule, deleteChannelRules, 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'), @@ -68,17 +150,18 @@ router.post( createJsonHandler( (req) => deletePendingChannelGroup({ + groupKey: String(bodyOf(req).groupKey || ''), internalSku: String(bodyOf(req).internalSku || ''), adminUserId: req.adminSession?.userId ?? null, }), { - successMessage: '待分类记录已删除', - errorMessage: '删除待分类记录失败', + successMessage: '待分类记录已忽略归档', + errorMessage: '归档待分类记录失败', scope: '[admin/fulfillment/pending/delete]', audit: (req, data) => ({ - action: 'fulfillment.delete_pending_group', - targetType: 'internal_sku', - targetId: String(bodyOf(req).internalSku || ''), + action: 'fulfillment.archive_pending_group', + targetType: 'pending_group', + targetId: String(bodyOf(req).groupKey || bodyOf(req).internalSku || ''), data: data as Record, }), }, @@ -110,6 +193,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'), @@ -203,13 +308,14 @@ router.post( const adminUserId = req.adminSession?.userId ?? null const note = String(body.note || '') - if (body.internalSku) { - return assignChannelByInternalSku({ - internalSku: String(body.internalSku), + if (body.groupKey || body.internalSku) { + return assignPendingChannelGroup({ + groupKey: String(body.groupKey || ''), + internalSku: String(body.internalSku || ''), + mappedInternalSku: String(body.mappedInternalSku || ''), channel, adminUserId, note, - createRule: body.createRule !== false, }) } @@ -237,8 +343,14 @@ router.post( scope: '[admin/fulfillment/assign]', audit: (req, data) => ({ action: 'fulfillment.assign_channel', - targetType: 'order_item', - targetId: String(bodyOf(req).skuCode || bodyOf(req).orderItemId || ''), + targetType: bodyOf(req).groupKey ? 'pending_group' : 'order_item', + targetId: String( + bodyOf(req).groupKey || + bodyOf(req).internalSku || + bodyOf(req).skuCode || + bodyOf(req).orderItemId || + '', + ), data: { payload: bodyOf(req), result: data as Record }, }), }, 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-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index b2b48839..049c1b9d 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -144,6 +144,7 @@ const ORDER_ITEM_FULFILLMENT_CHANNELS = new Set([ 'third_party_voucher', 'legacy_platform', 'manual', + 'on_account', 'pending', 'archived', ]) 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/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 40b9558d..cadd4e45 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -152,6 +152,7 @@ const ADMIN_ORDER_FULFILLMENT_CHANNELS = new Set([ 'affiliate_dash', 'third_party_voucher', 'manual', + 'on_account', 'legacy_platform', 'mixed', 'pending', diff --git a/apps/backend/src/services/cost-center/cost-center-constants.ts b/apps/backend/src/services/cost-center/cost-center-constants.ts index de0aa318..e0114409 100644 --- a/apps/backend/src/services/cost-center/cost-center-constants.ts +++ b/apps/backend/src/services/cost-center/cost-center-constants.ts @@ -64,6 +64,7 @@ export type FulfillmentChannel = | 'affiliate_dash' | 'third_party_voucher' | 'manual' + | 'on_account' | 'pending' | 'archived' | 'unclassified' @@ -73,6 +74,7 @@ export const FULFILLMENT_CHANNELS = new Set([ 'affiliate_dash', 'third_party_voucher', 'manual', + 'on_account', 'pending', 'archived', 'unclassified', diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 7e5abc56..fc34145c 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -70,7 +70,7 @@ export function costModelAppliesTo(costModel: string, channel: string) { if (costModel === 'affiliate_purchase') return channel === 'affiliate_dash' if (costModel === 'work_order_reward') return channel === 'work_order' if (costModel === 'third_party_voucher_cost') return channel === 'third_party_voucher' - if (costModel === 'manual_actual') return channel === 'manual' + if (costModel === 'manual_actual') return channel === 'manual' || channel === 'on_account' return false } @@ -88,7 +88,7 @@ export function costModelScopeSql(channelExpression: string): string { OR (ci.cost_model = 'third_party_voucher_cost' AND ${channelExpression} = 'third_party_voucher') OR (ci.cost_model = 'manual_actual' - AND ${channelExpression} = 'manual') + AND ${channelExpression} IN ('manual', 'on_account')) )` } diff --git a/apps/backend/src/services/cost-center/cost-resolution-service.ts b/apps/backend/src/services/cost-center/cost-resolution-service.ts index 67bc7fca..a560000b 100644 --- a/apps/backend/src/services/cost-center/cost-resolution-service.ts +++ b/apps/backend/src/services/cost-center/cost-resolution-service.ts @@ -37,6 +37,7 @@ import type { MissingCostItem, OrderItemCostContext, } from './cost-center-shared.js' +import { calculateManualCostFen, parseManualChannelQuantity } from './manual-credit-quantity.js' export async function previewAdminCost(payload: JsonObject = {}) { const context = await resolveCostContext(payload) @@ -138,14 +139,14 @@ async function resolveManualCostHint(orderItemId: number | null) { ) const row = result.rows[0] if (!row) return null - const creditQuantity = Number(row.credit_quantity || 0) + const creditQuantity = parseManualChannelQuantity(row.credit_quantity, row.platform_key) ?? 0 const unitPriceFen = Number(row.unit_price_fen || 1) return { platformKey: row.platform_key, creditQuantity, unitPriceFen, costMode: row.cost_mode === 'fixed' ? ('fixed' as const) : ('variable' as const), - costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null, + costFen: creditQuantity > 0 ? calculateManualCostFen(creditQuantity, unitPriceFen) : null, } } 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..b1ff75a7 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-service.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-service.ts @@ -319,11 +319,11 @@ 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 ( - 'affiliate_dash', 'third_party_voucher', 'work_order', 'manual' + 'affiliate_dash', 'third_party_voucher', 'work_order', 'manual', 'on_account' ) ` diff --git a/apps/backend/src/services/cost-center/manual-credit-quantity.test.ts b/apps/backend/src/services/cost-center/manual-credit-quantity.test.ts new file mode 100644 index 00000000..3881e706 --- /dev/null +++ b/apps/backend/src/services/cost-center/manual-credit-quantity.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + calculateManualCostFen, + normalizeManualCreditQuantity, + parseManualChannelQuantity, + parseManualCreditQuantity, +} from './manual-credit-quantity.js' + +test('人工履约现金金额支持两位小数', () => { + assert.equal(normalizeManualCreditQuantity('12.345'), 12.35) + assert.equal(parseManualCreditQuantity('12.34'), 12.34) + assert.equal(parseManualCreditQuantity(-1), null) +}) + +test('积分渠道拒绝小数,现金渠道允许小数', () => { + assert.equal(parseManualChannelQuantity('2.5', 'nanhang'), null) + assert.equal(parseManualChannelQuantity('2', 'nanhang'), 2) + assert.equal(parseManualChannelQuantity('2.5', 'fund_cash'), 2.5) + assert.equal(parseManualChannelQuantity('2.5', 'on_account'), 2.5) +}) + +test('人工履约现金成本按分四舍五入', () => { + assert.equal(calculateManualCostFen(12.34, 100), 1234) + assert.equal(calculateManualCostFen(0.015, 100), 2) +}) + +test('负数和超出数据库精度的数量返回非法', () => { + assert.equal(parseManualCreditQuantity('abc'), null) + assert.equal(parseManualCreditQuantity(-0.01), null) + assert.equal(parseManualCreditQuantity('1000000000000000'), null) +}) diff --git a/apps/backend/src/services/cost-center/manual-credit-quantity.ts b/apps/backend/src/services/cost-center/manual-credit-quantity.ts new file mode 100644 index 00000000..0c12d305 --- /dev/null +++ b/apps/backend/src/services/cost-center/manual-credit-quantity.ts @@ -0,0 +1,41 @@ +const QUANTITY_SCALE = 100 +const MAX_QUANTITY = 99_999_999_999.99 +const CASH_CHANNELS = new Set(['fund_cash', 'on_account']) + +/** + * 人工履约的现金金额统一保留 2 位小数。 + * 积分渠道仍只接受整数,由配置入口按平台单独校验。 + */ +export function normalizeManualCreditQuantity(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) return 0 + const normalized = Math.round((parsed + Number.EPSILON) * QUANTITY_SCALE) / QUANTITY_SCALE + return Math.min(MAX_QUANTITY, normalized) +} + +/** 解析接口入参;非法值返回 null,由调用方给出明确的业务错误。 */ +export function parseManualCreditQuantity(value: unknown): number | null { + const parsed = parseManualSignedCreditQuantity(value) + return parsed !== null && parsed >= 0 ? parsed : null +} + +/** 积分渠道只接受整数;备用金现金和统一结算接受两位小数。 */ +export function parseManualChannelQuantity(value: unknown, platformKey: string): number | null { + const parsed = parseManualCreditQuantity(value) + if (parsed === null) return null + if (!CASH_CHANNELS.has(platformKey) && !Number.isInteger(parsed)) return null + return parsed +} + +function parseManualSignedCreditQuantity(value: unknown): number | null { + const parsed = Number(value) + if (!Number.isFinite(parsed) || Math.abs(parsed) > MAX_QUANTITY) return null + return Math.round((parsed + Number.EPSILON) * QUANTITY_SCALE) / QUANTITY_SCALE +} + +/** 成本金额始终落库为整数分;不足 1 分时按四舍五入处理。 */ +export function calculateManualCostFen(creditQuantity: unknown, unitPriceFen: unknown): number { + const quantity = normalizeManualCreditQuantity(creditQuantity) + const unitPrice = Math.max(0, Math.trunc(Number(unitPriceFen) || 0)) + return Math.round(quantity * unitPrice) +} diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 99ba48ec..48acc6c8 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -8,6 +8,11 @@ import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-se import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js' import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' import { refreshFactsForDates } from '../finance/refresh-facts.js' +import { + calculateManualCostFen, + normalizeManualCreditQuantity, + parseManualChannelQuantity, +} from './manual-credit-quantity.js' /** * 人工履约 SKU 配置服务。 @@ -15,7 +20,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js' * 口径(2026-10-24 确认): * * 配置主键优先取内部 SKU(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn), * 缺失时按 sku_code → sku_name 兜底;同名/同码算同一个商品,跨渠道共用一份配置; - * * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金)+ 消耗积分 + 成本模式; + * * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金/统一结算)+ 消耗量 + 成本模式; * * 换算率固定:1 积分 = 1 分钱; * * 消耗积分 0 = 成本不固定,由客服在确认履约时手填实际成本; * * 「重新匹配」= 把配置回填到历史人工订单项,幂等可反复执行。 @@ -23,7 +28,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js' * 没有匹配引擎、没有打分:这就是一张带统计的 SKU 配置清单。 */ -export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash'] as const +export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number] /** @@ -42,6 +47,7 @@ const PLATFORM_UNIT_PRICE_FEN: Record = { lewan: 1, rock: 100, fund_cash: 100, + on_account: 100, } const PLATFORM_LABELS: Record = { @@ -49,6 +55,7 @@ const PLATFORM_LABELS: Record = { lewan: '乐玩后台', rock: 'ROCK 后台', fund_cash: '备用金现金', + on_account: '统一结算', } /** 读取平台档案里配置的换算率;档案不存在时用默认表。 */ @@ -86,10 +93,18 @@ type ManualSkuRow = { scan_to_date: string | null config_id: number | null config_platform_key: string | null - config_credit_quantity: number | null + config_credit_quantity: number | string | null config_cost_mode: string | null config_canonical_product_id: number | null config_enabled: boolean | null + config_channels_json: unknown +} + +type ManualChannelConfig = { + platformKey: ManualPlatformKey + creditQuantity: number + costMode: 'fixed' | 'variable' + enabled: boolean } /** @@ -238,7 +253,10 @@ export async function listManualSkus(input: Record = {}) { } if (platformKey) { params.push(platformKey) - conditions.push(`cfg.platform_key = $${params.length}`) + conditions.push(`( + cfg.platform_key = $${params.length} + OR cfg.channels_json @> jsonb_build_array(jsonb_build_object('platformKey', $${params.length})) + )`) } if (provider) { params.push(provider) @@ -275,7 +293,8 @@ export async function listManualSkus(input: Record = {}) { cfg.credit_quantity AS config_credit_quantity, cfg.cost_mode AS config_cost_mode, cfg.canonical_product_id AS config_canonical_product_id, - cfg.enabled AS config_enabled + cfg.enabled AS config_enabled, + cfg.channels_json AS config_channels_json FROM manual_sku_registry r LEFT JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) @@ -340,8 +359,10 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { errorCode: 'manual_config_sku_key_required', }) } - const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key) - const creditQuantity = normalizeCreditQuantity(payload.creditQuantity ?? payload.credit_quantity) + const channels = normalizeManualChannels(payload) + const primaryChannel = channels[0]! + const platformKey = primaryChannel.platformKey + const creditQuantity = primaryChannel.creditQuantity const canonicalProductId = normalizeOptionalId( payload.canonicalProductId ?? payload.canonical_product_id, ) @@ -349,7 +370,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { const note = optionalText(payload.note, 300) // 消耗积分为 0 = 成本不固定,客服确认履约时手填。 - const costMode = creditQuantity > 0 ? 'fixed' : 'variable' + const costMode = primaryChannel.costMode const unitPrices = await loadPlatformUnitPriceFen() const now = nowIso() @@ -411,7 +432,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { `UPDATE manual_fulfillment_configs SET sku_key = $2, canonical_product_id = $3, platform_key = $4, credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8, - updated_at = $9 + channels_json = $9::jsonb, updated_at = $10 WHERE id = $1 RETURNING *`, [ @@ -423,16 +444,27 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { costMode, enabled, note, + JSON.stringify(channels), now, ], ) : await client.query( `INSERT INTO manual_fulfillment_configs ( sku_key, canonical_product_id, platform_key, credit_quantity, - cost_mode, enabled, note, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + cost_mode, enabled, note, channels_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $9) RETURNING *`, - [skuKey, resolvedProductId, platformKey, creditQuantity, costMode, enabled, note, now], + [ + skuKey, + resolvedProductId, + platformKey, + creditQuantity, + costMode, + enabled, + note, + JSON.stringify(channels), + now, + ], ) return requiredRow(result.rows[0], '保存人工履约配置失败') }) @@ -537,8 +569,8 @@ export async function deleteManualSkuClassification( skuKey: string, ): Promise { const key = canonicalizeInternalSku(skuKey) - if (!key || !containsCjk(key)) { - throw createHttpError('必须使用包含中文的内部 SKU', { + if (!key) { + throw createHttpError('内部 SKU 不能为空', { statusCode: 400, errorCode: 'manual_config_sku_key_required', }) @@ -755,7 +787,7 @@ export async function rematchManualFulfillments(input: Record = } /** - * 为已配置的人工 SKU 生成/刷新成本版本(按 1 积分 = 1 分钱换算)。 + * 为已配置的人工 SKU 生成/刷新固定成本版本(按各平台积分换算率折算)。 * * 成本版本是「成本金额」的唯一真源;积分消耗量记在配置里, * 两者按固定换算率保持一致。 @@ -776,8 +808,8 @@ async function attachCostVersionsForManualConfigs() { ) SELECT $1, 'product', '', '', cfg.canonical_product_id, NULL, - (cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint), 0, - '1970-01-01T00:00:00+08:00', 'manual', 'active', + ROUND(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint, 0)::bigint, + 0, '1970-01-01T00:00:00+08:00', 'manual', 'active', '人工履约配置:积分按平台换算率折算', NOW(), NOW() FROM manual_fulfillment_configs cfg @@ -817,10 +849,11 @@ export async function getManualFulfillmentConfig(skuKey: string) { sku_key: string canonical_product_id: number | null platform_key: string - credit_quantity: number + credit_quantity: number | string cost_mode: string enabled: boolean note: string + channels_json: unknown created_at: string updated_at: string }>( @@ -842,16 +875,86 @@ export async function listManualPlatformOptions() { items: MANUAL_PLATFORM_KEYS.map((key) => ({ key, label: PLATFORM_LABELS[key], - isCredit: key !== 'fund_cash', + isCredit: key !== 'fund_cash' && key !== 'on_account', creditUnitPriceFen: unitPrices[key], })), } } +function normalizeStoredChannels(input: { + channels_json: unknown + platform_key: string + credit_quantity: number | string + cost_mode: string + enabled: boolean +}): ManualChannelConfig[] { + const raw = Array.isArray(input.channels_json) ? input.channels_json : [] + const parsed: ManualChannelConfig[] = [] + const seen = new Set() + for (const item of raw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + const row = item as Record + const key = String(row.platformKey || row.platform_key || '').trim() as ManualPlatformKey + if (!MANUAL_PLATFORM_KEYS.includes(key) || seen.has(key)) continue + seen.add(key) + const creditQuantity = + parseManualChannelQuantity(row.creditQuantity ?? row.credit_quantity ?? 0, key) ?? 0 + parsed.push({ + platformKey: key, + creditQuantity, + costMode: creditQuantity > 0 ? 'fixed' : 'variable', + enabled: row.enabled !== false, + }) + } + if (parsed.length > 0) return parsed + + const key = String(input.platform_key || '').trim() as ManualPlatformKey + if (!MANUAL_PLATFORM_KEYS.includes(key)) return [] + const creditQuantity = parseManualChannelQuantity(input.credit_quantity, key) ?? 0 + return [ + { + platformKey: key, + creditQuantity, + costMode: creditQuantity > 0 ? 'fixed' : 'variable', + enabled: input.enabled !== false, + }, + ] +} + +function mapConfiguredChannels( + channels: ManualChannelConfig[], + unitPrices: Record, +) { + return channels.map((channel) => { + const creditUnitPriceFen = unitPrices[channel.platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN + return { + platformKey: channel.platformKey, + platformLabel: PLATFORM_LABELS[channel.platformKey] || channel.platformKey, + creditQuantity: channel.creditQuantity, + costMode: channel.costMode, + costFen: calculateManualCostFen(channel.creditQuantity, creditUnitPriceFen), + creditUnitPriceFen, + enabled: channel.enabled, + isFundCash: channel.platformKey === 'fund_cash', + isOnAccount: channel.platformKey === 'on_account', + } + }) +} + function mapManualSku(row: ManualSkuRow, unitPrices: Record) { - const creditQuantity = Number(row.config_credit_quantity || 0) + const creditQuantity = normalizeManualCreditQuantity(row.config_credit_quantity) const platformKey = (row.config_platform_key || '') as ManualPlatformKey const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN + const channels = + row.config_id === null + ? [] + : normalizeStoredChannels({ + channels_json: row.config_channels_json, + platform_key: row.config_platform_key || '', + credit_quantity: row.config_credit_quantity || 0, + cost_mode: row.config_cost_mode || 'variable', + enabled: row.config_enabled !== false, + }) return { id: Number(row.id), skuKey: row.sku_key, @@ -873,12 +976,13 @@ function mapManualSku(row: ManualSkuRow, unitPrices: Record, ) { - const creditQuantity = Number(row.credit_quantity || 0) + const creditQuantity = normalizeManualCreditQuantity(row.credit_quantity) const platformKey = row.platform_key as ManualPlatformKey const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN + const channels = normalizeStoredChannels(row) return { id: Number(row.id), skuKey: row.sku_key, @@ -910,9 +1016,10 @@ function mapManualConfig( platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key, creditQuantity, costMode: row.cost_mode, - costFen: creditQuantity * unitPriceFen, + costFen: calculateManualCostFen(creditQuantity, unitPriceFen), creditUnitPriceFen: unitPriceFen, enabled: row.enabled, + channels: mapConfiguredChannels(channels, unitPrices), note: row.note || '', createdAt: row.created_at, updatedAt: row.updated_at, @@ -930,14 +1037,67 @@ function normalizePlatformKey(value: unknown): ManualPlatformKey { return text as ManualPlatformKey } -function normalizeCreditQuantity(value: unknown) { - const parsed = Number(value) - if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) { - throw createHttpError('消耗积分必须是非负整数', { - statusCode: 400, - errorCode: 'manual_config_credit_invalid', +function normalizeManualChannels(payload: JsonObject): ManualChannelConfig[] { + const rawChannels = Array.isArray(payload.channels) ? payload.channels : [] + const source = + rawChannels.length > 0 + ? rawChannels + : [ + { + platformKey: payload.platformKey ?? payload.platform_key, + creditQuantity: payload.creditQuantity ?? payload.credit_quantity, + enabled: payload.enabled, + }, + ] + const seen = new Set() + const channels: ManualChannelConfig[] = [] + + for (const raw of source) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue + const item = raw as JsonObject + const platformKey = normalizePlatformKey(item.platformKey ?? item.platform_key) + if (seen.has(platformKey)) continue + seen.add(platformKey) + const creditQuantity = normalizeCreditQuantity( + item.creditQuantity ?? item.credit_quantity ?? 0, + platformKey, + ) + channels.push({ + platformKey, + creditQuantity, + costMode: creditQuantity > 0 ? 'fixed' : 'variable', + enabled: item.enabled === false ? false : true, }) } + + if (channels.length === 0) { + throw createHttpError('请至少配置一个履约渠道', { + statusCode: 400, + errorCode: 'manual_config_channels_required', + }) + } + if (channels.length > 10) { + throw createHttpError('单个 SKU 最多配置 10 个履约渠道', { + statusCode: 400, + errorCode: 'manual_config_channels_too_many', + }) + } + return channels +} + +function normalizeCreditQuantity(value: unknown, platformKey: ManualPlatformKey) { + const parsed = parseManualChannelQuantity(value, platformKey) + if (parsed === null) { + throw createHttpError( + platformKey === 'fund_cash' || platformKey === 'on_account' + ? '金额必须是大于等于 0 且最多 2 位小数的数字' + : '积分渠道的消耗积分必须是非负整数', + { + statusCode: 400, + errorCode: 'manual_config_credit_invalid', + }, + ) + } return parsed } diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index 92956dce..acf6257b 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -822,7 +822,7 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {} const modes = channel ? [channel] - : ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual'] + : ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account'] // 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。 const aggregates: Array<{ groupTotal: number; itemTotal: number }> = [] for (const mode of modes) { @@ -874,7 +874,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) { // 按履约方式拆开统计:命中索引,单模式约 90ms;「全部」时顺序跑四种模式。 const modes = channel ? [channel] - : ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual'] + : ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account'] const totals: Array<{ groupTotal: number; itemTotal: number }> = [] for (const mode of modes) { const totalParams = [...params, mode] 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/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/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index fb2a4973..85952ad6 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -21,6 +21,17 @@ test('人工成本:固定成本按配置积分 × 平台换算率', () => { ) }) +test('人工成本:备用金和统一结算金额支持两位小数', () => { + assert.deepEqual( + resolveManualFulfillmentCost({ + creditQuantity: 12.34, + unitPriceFen: 100, + providedCostFen: undefined, + }), + { costFen: 1234, costMode: 'fixed' }, + ) +}) + test('人工成本:成本不固定必须手填正数金额', () => { assert.deepEqual( resolveManualFulfillmentCost({ @@ -107,6 +118,18 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务 configSource.includes('manual_sku_key'), '人工 SKU 匹配必须统一走 order_items.manual_sku_key 生成列', ) + assert.ok( + configSource.includes('amount_fen, rate_bps') && + configSource.includes("0, '1970-01-01T00:00:00+08:00', 'manual', 'active'"), + '人工履约固定成本版本必须同时写入 amount_fen 和 rate_bps,避免保存成功但后处理报错', + ) + const deleteStart = configSource.indexOf('export async function deleteManualSkuClassification') + const deleteEnd = configSource.indexOf('export async function rematchManualFulfillments') + const deleteBody = configSource.slice(deleteStart, deleteEnd) + assert.ok( + !deleteBody.includes('containsCjk(key)'), + '移除错误人工分类不能要求 SKU 含中文,否则纯数字等脏数据永远删不掉', + ) assert.ok( migrationSource.includes("spec_json->>'skuNick'") && migrationSource.includes("spec_json->>'outSkuSn'") && diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index 25c996a8..59fc8b4e 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -16,6 +16,12 @@ 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' +import { + calculateManualCostFen, + normalizeManualCreditQuantity, + parseManualChannelQuantity, +} from '../cost-center/manual-credit-quantity.js' /** * 人工履约待办与「确认履约」。 @@ -36,6 +42,92 @@ const PLATFORM_LABELS: Record = { lewan: '乐玩后台', rock: 'ROCK 后台', fund_cash: '备用金现金', + on_account: '统一结算', +} + +const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const +type ManualExecutionPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number] + +type ManualConfiguredChannel = { + platformKey: ManualExecutionPlatformKey + platformLabel: string + creditQuantity: number + creditUnitPriceFen: number + costFen: number | null + costMode: 'fixed' | 'variable' + isFundCash: boolean + isOnAccount: boolean + enabled: boolean +} + +async function loadManualPlatformUnitPrices(): Promise> { + const result = await query<{ profile_key: string; config_json: Record }>( + `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`, + ) + const prices: Record = { + nanhang: 1, + lewan: 1, + rock: 100, + fund_cash: 100, + on_account: 100, + } + for (const row of result.rows) { + const key = row.profile_key.replace(/^manual_/, '') as ManualExecutionPlatformKey + const price = Number(row.config_json?.rechargeUnitPriceFen) + if (key in prices && Number.isInteger(price) && price > 0) { + prices[key] = price + } + } + return prices +} + +function resolveManualConfiguredChannels(input: { + channelsJson: unknown + legacyPlatformKey: unknown + legacyCreditQuantity: unknown + legacyCostMode: unknown + legacyEnabled: unknown + unitPrices: Record +}): ManualConfiguredChannel[] { + const raw = Array.isArray(input.channelsJson) ? input.channelsJson : [] + const source = + raw.length > 0 + ? raw + : [ + { + platformKey: input.legacyPlatformKey, + creditQuantity: input.legacyCreditQuantity, + costMode: input.legacyCostMode, + enabled: input.legacyEnabled, + }, + ] + const seen = new Set() + const channels: ManualConfiguredChannel[] = [] + + for (const item of source) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + const row = item as Record + const platformKey = String( + row.platformKey ?? row.platform_key ?? '', + ).trim() as ManualExecutionPlatformKey + if (!MANUAL_PLATFORM_KEYS.includes(platformKey) || seen.has(platformKey)) continue + if (row.enabled === false) continue + seen.add(platformKey) + const creditQuantity = parseManualChannelQuantity(row.creditQuantity, platformKey) ?? 0 + const unitPriceFen = input.unitPrices[platformKey] + channels.push({ + platformKey, + platformLabel: PLATFORM_LABELS[platformKey] || platformKey, + creditQuantity, + creditUnitPriceFen: unitPriceFen, + costFen: creditQuantity > 0 ? calculateManualCostFen(creditQuantity, unitPriceFen) : null, + costMode: creditQuantity > 0 ? 'fixed' : 'variable', + isFundCash: platformKey === 'fund_cash', + isOnAccount: platformKey === 'on_account', + enabled: true, + }) + } + return channels } type PendingRow = { @@ -54,6 +146,7 @@ type PendingRow = { credit_quantity: string cost_mode: string credit_unit_price_fen: string + config_channels_json: unknown fulfilled_at: string | null } @@ -66,6 +159,7 @@ type ConfirmContextRow = { shop_id: string sku_code: string sku_name: string + internal_sku: string revenue_fen: string basis_at: string canonical_product_id: number | null @@ -75,6 +169,7 @@ type ConfirmContextRow = { credit_quantity: string cost_mode: string credit_unit_price_fen: string + config_channels_json: unknown work_order_id: number | null work_order_no: string | null work_product_rule_id: number | null @@ -90,6 +185,7 @@ export async function listManualFulfillmentPending( input: Record = {}, session: AdminSession, ) { + const unitPrices = await loadManualPlatformUnitPrices() const page = normalizePositiveInteger(input.page, 1, '页码') const pageSize = Math.min( 100, @@ -172,6 +268,7 @@ export async function listManualFulfillmentPending( cfg.platform_key, cfg.credit_quantity, cfg.cost_mode, + cfg.channels_json AS config_channels_json, COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen, fulfilled.fulfilled_at FROM order_items oi @@ -186,7 +283,7 @@ export async function listManualFulfillmentPending( ) return { - items: result.rows.map(mapPending), + items: result.rows.map((row) => mapPending(row, unitPrices)), pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) }, } } @@ -203,6 +300,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm '请选择订单项', ) const now = nowIso() + const platformUnitPrices = await loadManualPlatformUnitPrices() // 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。 // 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。 @@ -222,14 +320,16 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm o.shop_id, oi.sku_code, oi.sku_name, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, COALESCE(o.paid_at, o.created_at) AS basis_at, oi.canonical_product_id, COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel, oi.fulfillment_config_id AS config_id, COALESCE(cfg.platform_key, '') AS platform_key, - COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity, + COALESCE(cfg.credit_quantity, 0) AS credit_quantity, COALESCE(cfg.cost_mode, 'variable') AS cost_mode, + cfg.channels_json AS config_channels_json, COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen, wo.id AS work_order_id, wo.work_order_no, @@ -283,13 +383,30 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm errorCode: 'manual_fulfillment_config_missing', }) } - const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash' - if (platformKey !== 'fund_cash' && !PLATFORM_CREDIT_KEYS.includes(platformKey)) { - throw createHttpError('该商品的人工履约平台配置无效,请重新配置', { + const configuredChannels = resolveManualConfiguredChannels({ + channelsJson: row.config_channels_json, + legacyPlatformKey: row.platform_key, + legacyCreditQuantity: row.credit_quantity, + legacyCostMode: row.cost_mode, + legacyEnabled: true, + unitPrices: platformUnitPrices, + }) + if (configuredChannels.length === 0) { + throw createHttpError('该商品没有可用的人工履约渠道,请重新配置', { statusCode: 409, errorCode: 'manual_fulfillment_config_invalid', }) } + const requestedPlatformKey = String(payload.platformKey ?? payload.platform_key ?? '').trim() + const selectedChannel = + configuredChannels.find((channel) => channel.platformKey === requestedPlatformKey) || + (configuredChannels.length === 1 ? configuredChannels[0] : undefined) + if (!selectedChannel) { + throw createHttpError('请选择本次实际执行的履约渠道', { + statusCode: 400, + errorCode: 'manual_fulfillment_platform_required', + }) + } // 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。 const existing = await client.query<{ id: number }>( @@ -305,20 +422,26 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm }) } - const creditQuantity = Number(row.credit_quantity || 0) - const unitPriceFen = Number(row.credit_unit_price_fen || 1) - if (platformKey !== 'fund_cash' && creditQuantity <= 0) { + const platformKey = selectedChannel.platformKey + const creditQuantity = selectedChannel.creditQuantity + const unitPriceFen = selectedChannel.creditUnitPriceFen + if (platformKey !== 'fund_cash' && platformKey !== 'on_account' && creditQuantity <= 0) { throw createHttpError('该人工 SKU 的积分配置无效,请先在成本中心重新配置', { statusCode: 409, errorCode: 'manual_fulfillment_config_invalid', }) } - const costPlan = resolveManualFulfillmentCost({ - creditQuantity, - unitPriceFen, - providedCostFen: payload.costFen ?? payload.cost_fen ?? payload.amountFen, - }) + const providedCostFen = payload.costFen ?? payload.cost_fen ?? payload.amountFen + const costPlan = + platformKey === 'on_account' && + (providedCostFen === undefined || providedCostFen === null || providedCostFen === '') + ? ({ costFen: 0, costMode: 'variable' } as const) + : resolveManualFulfillmentCost({ + creditQuantity, + unitPriceFen, + providedCostFen, + }) const costFen = costPlan.costFen const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) const note = optionalText(payload.note, 300) @@ -341,9 +464,12 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm let fundExpenseId: number | null = null let creditEntryId: number | null = null let balanceAfter: number | null = null - let subjectSource: 'manual_expense' | 'platform_credit' + let onAccountBillId: number | null = null + let subjectSource: 'manual_expense' | 'platform_credit' | 'on_account' - if (platformKey === 'fund_cash') { + if (platformKey === 'on_account') { + subjectSource = 'on_account' + } else if (platformKey === 'fund_cash') { // 备用金现金:复用唯一写入口,但把 client 传进去,和快照共用同一个事务。 const fundAccountId = await resolveFundAccountId(payload, session, client) const { writeFundExpense } = await import('../admin/admin-fund-service.js') @@ -456,11 +582,49 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm }) } + if (platformKey === 'on_account') { + const billNo = `OAB${Date.now()}${orderItemId}` + const bill = await client.query<{ id: number }>( + ` + INSERT INTO on_account_bills ( + bill_no, order_id, order_item_id, sku_key, canonical_product_id, + platform_order_id, amount_fen, status, payee_name, note, metadata_json, + created_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10::jsonb, $11, $12, $12 + ) + RETURNING id + `, + [ + billNo, + row.order_id_resolved, + orderItemId, + String(row.internal_sku || row.sku_name || '').trim(), + row.canonical_product_id, + row.platform_order_id, + costFen, + payeeName, + note, + JSON.stringify({ + platformKey, + configId: Number(row.config_id), + creditQuantity, + costMode: costPlan.costMode, + orderItemId, + }), + session.userId, + now, + ], + ) + onAccountBillId = Number(bill.rows[0]?.id || 0) || null + } + // 人工确认即渠道成立:写新三列,避免订单一直停在 pending。 + const finalChannel = platformKey === 'on_account' ? 'on_account' : 'manual' await client.query( ` UPDATE order_items - SET fulfillment_channel = 'manual', + SET fulfillment_channel = $3, fulfillment_status = 'assigned', fulfillment_source = 'manual_confirm', fulfillment_decided_at = $2, @@ -468,7 +632,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm WHERE id = $1 AND fulfillment_status <> 'archived' `, - [orderItemId, now], + [orderItemId, now, finalChannel], ) // 人工缺成本异常立即收口,不等下一轮定时任务。 @@ -507,9 +671,11 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm fundExpenseId, creditEntryId, balanceAfter, + onAccountBillId, } }) + await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined) // 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。 await refreshFactsForOrderItem(orderItemId) return result @@ -752,10 +918,13 @@ export function resolveManualFulfillmentCost(input: { unitPriceFen: number providedCostFen?: unknown }): { costFen: number; costMode: 'fixed' | 'variable' } { - const creditQuantity = Math.max(0, Math.trunc(Number(input.creditQuantity) || 0)) + const creditQuantity = normalizeManualCreditQuantity(input.creditQuantity) if (creditQuantity > 0) { const unitPriceFen = Math.max(0, Math.trunc(Number(input.unitPriceFen) || 0)) - return { costFen: creditQuantity * unitPriceFen, costMode: 'fixed' } + return { + costFen: calculateManualCostFen(creditQuantity, unitPriceFen), + costMode: 'fixed', + } } if ( input.providedCostFen === undefined || @@ -777,10 +946,16 @@ export function resolveManualFulfillmentCost(input: { return { costFen, costMode: 'variable' } } -function mapPending(row: PendingRow) { - const creditQuantity = Number(row.credit_quantity || 0) - const unitPriceFen = Number(row.credit_unit_price_fen || 1) - const isFundCash = row.platform_key === 'fund_cash' +function mapPending(row: PendingRow, unitPrices: Record) { + const channels = resolveManualConfiguredChannels({ + channelsJson: row.config_channels_json, + legacyPlatformKey: row.platform_key, + legacyCreditQuantity: row.credit_quantity, + legacyCostMode: row.cost_mode, + legacyEnabled: true, + unitPrices, + }) + const primary = channels[0] return { orderItemId: Number(row.order_item_id), orderId: Number(row.order_id), @@ -793,14 +968,16 @@ function mapPending(row: PendingRow) { revenueFen: Number(row.revenue_fen || 0), paidAt: row.paid_at, configId: Number(row.config_id), - platformKey: row.platform_key, - platformLabel: PLATFORM_LABELS[row.platform_key] || row.platform_key, - isFundCash, - creditQuantity, - creditUnitPriceFen: unitPriceFen, - costMode: row.cost_mode || 'variable', + platformKey: primary?.platformKey || '', + platformLabel: primary?.platformLabel || '', + isFundCash: primary?.isFundCash === true, + isOnAccount: primary?.isOnAccount === true, + creditQuantity: primary?.creditQuantity || 0, + creditUnitPriceFen: primary?.creditUnitPriceFen || 1, + costMode: primary?.costMode || 'variable', /** 固定成本的金额;成本不固定时为 null,客服要手填。 */ - costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null, + costFen: primary?.costFen ?? null, + channels, fulfilled: Boolean(row.fulfilled_at), fulfilledAt: row.fulfilled_at || null, } 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/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-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index ec3958a6..5b013571 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, @@ -127,10 +182,26 @@ export async function ensureCarrierForOrderItem(input: { return { ready: true, created: false, reason: 'carrier_not_required', message: '' } } + if (channel === 'on_account') { + return { ready: true, created: false, reason: 'carrier_not_required', message: '' } + } + if (channel === 'work_order') { 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 +250,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 +270,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 +316,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..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 @@ -49,11 +50,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 +77,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 +129,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 +142,8 @@ async function markPendingWithoutCarrier(input: { input.itemId, JSON.stringify({ internalSku: input.internalSku, + carrierIssue, + carrierMessage: input.attempts[0]?.message || '', attempts: input.attempts, }), ], @@ -176,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 } /** @@ -191,6 +210,9 @@ export async function reconcilePendingCarriers( orderItemIds?: number[] includeAssignedMissingCarrier?: boolean allowFallback?: boolean + afterId?: number + includeLocked?: boolean + retryInactiveCarrier?: boolean } = {}, ): Promise<{ scanned: number @@ -199,11 +221,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 +298,8 @@ export async function reconcilePendingCarriers( order, item, channel: attempt.channel, + retryInactiveCarrier: input.retryInactiveCarrier === true, + allowInactiveTaskRetry: true, }) if (!carrier.ready) { failures.push({ @@ -364,5 +391,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..4e08ab3c 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' @@ -30,8 +31,10 @@ 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' +import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js' /** * 管道 A:渠道判定。 @@ -116,12 +119,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, }, } } @@ -199,7 +213,7 @@ export function resolveChannelDecision(item: ChannelCandidateItem): ChannelDecis pendingReason: '', } } - if (plan.primary.channel === 'third_party_voucher') { + if (plan.primary.channel === 'third_party_voucher' || plan.primary.channel === 'on_account') { return { channel: plan.primary.channel, source: 'auto_mapping', @@ -234,7 +248,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 +277,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 +320,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 +386,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)], @@ -365,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 @@ -380,16 +419,30 @@ export async function assignPendingOrderItemChannels( AS input(id, channel, source) WHERE oi.id = input.id AND oi.fulfillment_status = 'pending' - AND oi.fulfillment_locked = FALSE + AND (oi.fulfillment_locked = FALSE OR $4::boolean) + RETURNING oi.id `, [ assignments.map((row) => row.id), assignments.map((row) => row.channel), assignments.map((row) => row.source), + options.includeLocked === true, ], ) - 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), + }) + }, + ) } } @@ -404,11 +457,18 @@ export async function assignOrderItemsAfterCarrierReady(input: { source: FulfillmentSource lock: boolean note?: string | undefined -}): Promise<{ updated: number; carrierFailed: number }> { + canonicalProductId?: number | null +}): Promise<{ + updated: number + carrierFailed: number + carrierFailures: Array<{ orderItemId: number; reason: string; message: string }> +}> { const ids = [ ...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)), ] - if (ids.length === 0) return { updated: 0, carrierFailed: 0 } + if (ids.length === 0) { + return { updated: 0, carrierFailed: 0, carrierFailures: [] } + } const items = ( await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) @@ -421,6 +481,7 @@ export async function assignOrderItemsAfterCarrierReady(input: { ) const assignedIds: number[] = [] let carrierFailed = 0 + const carrierFailures: Array<{ orderItemId: number; reason: string; message: string }> = [] for (const item of items) { if (item.fulfillment_status === 'archived') continue @@ -445,7 +506,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, @@ -457,6 +524,11 @@ export async function assignOrderItemsAfterCarrierReady(input: { if (!carrier.ready) { carrierFailed += 1 + carrierFailures.push({ + orderItemId: Number(item.id), + reason: carrier.reason, + message: carrier.message, + }) if (item.fulfillment_status === 'assigned') { // 运营切换渠道失败时恢复原渠道,宁可保留旧状态,也不制造渠道与载体不一致。 await query( @@ -484,12 +556,13 @@ export async function assignOrderItemsAfterCarrierReady(input: { } else { await query( `UPDATE order_items - SET fulfillment_channel = NULL, + SET fulfillment_channel = $3, fulfillment_status = 'pending', - fulfillment_source = '', + fulfillment_source = 'operator_assigned', fulfillment_locked = TRUE, fulfillment_pending_reason = 'no_carrier', fulfillment_pending_detail = $2::jsonb, + canonical_product_id = COALESCE($4, canonical_product_id), updated_at = NOW() WHERE id = $1 AND fulfillment_status <> 'archived'`, [ @@ -498,7 +571,10 @@ export async function assignOrderItemsAfterCarrierReady(input: { channel: input.channel, reason: carrier.reason, message: carrier.message, + carrierIssue: carrier.reason, }), + input.channel, + input.canonicalProductId || null, ], ) } @@ -511,17 +587,31 @@ export async function assignOrderItemsAfterCarrierReady(input: { fulfillment_status = 'assigned', fulfillment_source = $3, fulfillment_locked = $4, + canonical_product_id = COALESCE($6, canonical_product_id), fulfillment_reason = COALESCE(NULLIF($5, ''), fulfillment_reason), fulfillment_pending_reason = NULL, fulfillment_pending_detail = NULL, updated_at = NOW() WHERE id = $1 AND fulfillment_status <> 'archived'`, - [item.id, input.channel, input.source, input.lock, String(input.note || '').slice(0, 200)], + [ + item.id, + input.channel, + input.source, + input.lock, + String(input.note || '').slice(0, 200), + input.canonicalProductId || null, + ], ) if ((updated.rowCount || 0) > 0) assignedIds.push(Number(item.id)) } 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 = [ @@ -546,7 +636,11 @@ export async function assignOrderItemsAfterCarrierReady(input: { [ids, input.channel, input.source, input.lock], ) await syncAssignedChannelSnapshots(ids, input.channel, input.source) - return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed } + return { + updated: Number(final.rows[0]?.count || assignedIds.length), + carrierFailed, + carrierFailures, + } } async function syncAssignedChannelSnapshots( @@ -581,7 +675,9 @@ async function syncAssignedChannelSnapshots( reason: channel === 'work_order' ? '内部 SKU 渠道配置指定接单平台' - : '内部 SKU 渠道配置指定第三方卡券', + : channel === 'on_account' + ? '内部 SKU 渠道配置指定统一结算' + : '内部 SKU 渠道配置指定第三方卡券', matchedRule: null, candidates: [], skipped: [], @@ -898,13 +994,18 @@ export async function unlockChannel(orderItemIds: number[]): Promise<{ updated: } export type PendingGroupRow = { - internal_sku: string + group_key: string + scope_type: 'internal_sku' | 'source_sku' + internal_sku: string | null sku_name: string providers: string[] shop_ids: string[] 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 @@ -912,7 +1013,7 @@ export type PendingGroupRow = { } type PendingShopNameRow = { - internal_sku: string + group_key: string provider: string shop_id: string shop_name: string @@ -921,6 +1022,20 @@ type PendingShopNameRow = { type PendingGroupDbRow = Omit +const PENDING_GROUP_KEY_SQL = ` + CASE + WHEN COALESCE(BTRIM(${INTERNAL_SKU_SQL}), '') <> '' + THEN 'sku:' || encode(convert_to(${INTERNAL_SKU_SQL}, 'UTF8'), 'hex') + ELSE 'source:' || encode( + convert_to( + CONCAT_WS(CHR(31), o.provider, o.shop_id, oi.sku_code), + 'UTF8' + ), + 'hex' + ) + END +` + /** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */ export async function listPendingChannelGroups(input: { page?: number @@ -942,13 +1057,16 @@ export async function listPendingChannelGroups(input: { const total = await query<{ count: number }>( ` - SELECT COUNT(*)::int AS count FROM ( - SELECT 1 + WITH pending_items AS ( + SELECT + ${INTERNAL_SKU_SQL} AS internal_sku, + ${PENDING_GROUP_KEY_SQL} AS group_key FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE ${where} - GROUP BY ${INTERNAL_SKU_SQL} - ) grouped + ) + SELECT COUNT(DISTINCT group_key)::int AS count + FROM pending_items `, params, ) @@ -956,68 +1074,116 @@ export async function listPendingChannelGroups(input: { params.push(pageSize, (page - 1) * pageSize) const rows = await query( ` + WITH pending_items AS ( + SELECT + oi.id, + oi.order_id, + oi.sku_code, + oi.sku_name, + oi.fulfillment_locked, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + oi.revenue_amount_fen, + oi.created_at, + o.provider, + o.shop_id, + ${INTERNAL_SKU_SQL} AS internal_sku, + ${PENDING_GROUP_KEY_SQL} AS group_key + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE ${where} + ) SELECT - ${INTERNAL_SKU_SQL} AS internal_sku, - MIN(oi.sku_name) AS sku_name, - array_agg(DISTINCT o.provider) AS providers, - 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, + pi.group_key, + CASE WHEN pi.internal_sku <> '' THEN 'internal_sku' ELSE 'source_sku' END AS scope_type, + pi.internal_sku, + MIN(pi.sku_name) AS sku_name, + array_agg(DISTINCT pi.provider) AS providers, + array_agg(DISTINCT pi.shop_id) AS shop_ids, + array_agg(DISTINCT pi.sku_code) AS sku_codes, + array_remove(array_agg(DISTINCT pi.fulfillment_pending_reason), NULL) AS pending_reasons, + array_remove( + array_agg( + DISTINCT COALESCE( + pi.fulfillment_pending_detail->>'carrierIssue', + pi.fulfillment_pending_detail->>'reason' + ) + ), + NULL + ) AS pending_subreasons, + COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels, + COUNT(*) FILTER (WHERE pi.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 - WHERE ${where} - GROUP BY ${INTERNAL_SKU_SQL} + COALESCE(SUM(pi.revenue_amount_fen), 0)::bigint AS revenue_fen, + MIN(pi.created_at) AS first_seen_at, + MAX(pi.created_at) AS last_seen_at + FROM pending_items pi + 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) = + pi.internal_sku + ) cfg ON TRUE + GROUP BY pi.group_key, pi.internal_sku, cfg.channels ORDER BY item_count DESC, internal_sku LIMIT $${params.length - 1} OFFSET $${params.length} `, params, ) - const internalSkus = rows.rows.map((row) => row.internal_sku) + const groupKeys = rows.rows.map((row) => row.group_key) const shopsBySku = new Map>() - if (internalSkus.length > 0) { + if (groupKeys.length > 0) { const shopRows = await query( ` + WITH pending_items AS ( + SELECT + oi.id, + o.provider, + o.shop_id, + o.shop_name, + oi.sku_code, + ${INTERNAL_SKU_SQL} AS internal_sku, + ${PENDING_GROUP_KEY_SQL} AS group_key + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'pending' + ) SELECT - ${INTERNAL_SKU_SQL} AS internal_sku, - o.provider, - o.shop_id, - COALESCE(NULLIF(BTRIM(o.shop_name), ''), o.shop_id) AS shop_name, + group_key, + provider, + shop_id, + COALESCE(NULLIF(BTRIM(shop_name), ''), shop_id) AS shop_name, COUNT(*)::int AS usage_count - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'pending' - AND ${INTERNAL_SKU_SQL} = ANY($1::text[]) - AND BTRIM(o.shop_id) <> '' - GROUP BY ${INTERNAL_SKU_SQL}, o.provider, o.shop_id, o.shop_name + FROM pending_items + WHERE group_key = ANY($1::text[]) + AND BTRIM(shop_id) <> '' + GROUP BY group_key, provider, shop_id, shop_name ORDER BY - internal_sku, + group_key, provider, shop_id, CASE - WHEN BTRIM(o.shop_name) = '' OR BTRIM(o.shop_name) = o.shop_id THEN 3 - WHEN BTRIM(o.shop_name) = '91卡券' OR BTRIM(o.shop_name) LIKE '快手店铺-%' THEN 2 - WHEN BTRIM(o.shop_name) ~ '[一-龥]' THEN 0 + WHEN BTRIM(shop_name) = '' OR BTRIM(shop_name) = shop_id THEN 3 + WHEN BTRIM(shop_name) = '91卡券' OR BTRIM(shop_name) LIKE '快手店铺-%' THEN 2 + WHEN BTRIM(shop_name) ~ '[一-龥]' THEN 0 ELSE 1 END, usage_count DESC, shop_name `, - [internalSkus], + [groupKeys], ) const seen = new Set() for (const row of shopRows.rows) { - const shopKey = `${row.internal_sku}\u0000${row.shop_id}` + const shopKey = `${row.group_key}\u0000${row.shop_id}` if (seen.has(shopKey)) continue seen.add(shopKey) - const shops = shopsBySku.get(row.internal_sku) || [] + const shops = shopsBySku.get(row.group_key) || [] shops.push({ id: row.shop_id, name: row.shop_name || row.shop_id }) - shopsBySku.set(row.internal_sku, shops) + shopsBySku.set(row.group_key, shops) } } @@ -1025,7 +1191,7 @@ export async function listPendingChannelGroups(input: { items: rows.rows.map((row) => ({ ...row, shops: - shopsBySku.get(row.internal_sku) || + shopsBySku.get(row.group_key) || row.shop_ids.map((shopId) => ({ id: shopId, name: shopId })), })), total: total.rows[0]?.count || 0, @@ -1034,6 +1200,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 @@ -1043,23 +1305,67 @@ export type DeletePendingChannelGroupResult = { skippedClosedDays: number } +type PendingGroupScope = + | { type: 'internal_sku'; internalSku: string } + | { type: 'source_sku'; provider: string; shopId: string; skuCode: string } + +function resolvePendingGroupScopeInput(input: { + groupKey?: string + internalSku?: string +}): PendingGroupScope | null { + const groupKey = String(input.groupKey || '').trim() + if (groupKey) { + const match = /^(sku|source):([0-9a-f]+)$/i.exec(groupKey) + if (!match) return null + const scopeType = String(match[1] || '').toLowerCase() + const encoded = String(match[2] || '') + if (!encoded || encoded.length % 2 !== 0) return null + const decoded = Buffer.from(encoded, 'hex').toString('utf8') + if (scopeType === 'sku') { + const internalSku = canonicalizeInternalSku(decoded) + return internalSku ? { type: 'internal_sku', internalSku } : null + } + const parts = decoded.split(String.fromCharCode(31)) + if (parts.length !== 3 || !parts[0]) return null + return { + type: 'source_sku', + provider: parts[0], + shopId: parts[1] || '', + skuCode: parts[2] || '', + } + } + + const internalSku = canonicalizeInternalSku(input.internalSku) + return internalSku ? { type: 'internal_sku', internalSku } : null +} + /** * 删除待分类 SKU:只归档订单项并清理其成本/异常,不删除原始订单。 - * 已关账月份拒绝操作,避免已固化财务口径与底层数据不一致。 + * 已关账月份只跳过事实重算,不再阻止历史待办归档。 */ export async function deletePendingChannelGroup(input: { - internalSku: string + groupKey?: string + internalSku?: string adminUserId: number | null }): Promise { - const internalSku = canonicalizeInternalSku(input.internalSku) - if (!internalSku || !containsCjk(internalSku)) { - throw createHttpError('必须使用包含中文的内部 SKU', { + const scope = resolvePendingGroupScopeInput(input) + if (!scope) { + throw createHttpError('缺少待分类队列分组标识', { statusCode: 400, - errorCode: 'internal_sku_required', + errorCode: 'pending_group_key_required', }) } const result = await withTransaction(async (client) => { + const itemParams: unknown[] = [] + const itemWhere = ["oi.fulfillment_status = 'pending'"] + if (scope.type === 'internal_sku') { + itemParams.push(scope.internalSku) + itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`) + } else { + itemParams.push(scope.provider, scope.shopId, scope.skuCode) + itemWhere.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`) + } const items = await client.query<{ id: number; order_id: number; basis_date: string }>( ` SELECT @@ -1069,12 +1375,11 @@ export async function deletePendingChannelGroup(input: { AS basis_date FROM order_items oi JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'pending' - AND ${INTERNAL_SKU_SQL} = $1 + WHERE ${itemWhere.join(' AND ')} ORDER BY oi.id FOR UPDATE OF oi `, - [internalSku], + itemParams, ) const itemIds = items.rows.map((row) => Number(row.id)) if (itemIds.length === 0) { @@ -1087,29 +1392,6 @@ export async function deletePendingChannelGroup(input: { } } - const periodKeys = [ - ...new Set(items.rows.map((row) => String(row.basis_date || '').slice(0, 7)).filter(Boolean)), - ] - const closedPeriods = await client.query<{ period_key: string }>( - ` - SELECT period_key - FROM finance_periods - WHERE status = 'closed' - AND period_key = ANY($1::text[]) - ORDER BY period_key - `, - [periodKeys], - ) - if (closedPeriods.rows.length > 0) { - throw createHttpError( - `包含已关账期间(${closedPeriods.rows.map((row) => row.period_key).join('、')}),不能删除`, - { - statusCode: 409, - errorCode: 'finance_period_closed', - }, - ) - } - const snapshots = await client.query( `DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, [itemIds], @@ -1186,6 +1468,71 @@ export async function deletePendingChannelGroup(input: { } } +export async function assignPendingChannelGroup(input: { + groupKey?: string + internalSku?: string + mappedInternalSku?: string + channel: string + adminUserId: number | null + note?: string | undefined +}): Promise<{ + updated: number + ruleCreated: boolean + carrierFailed: number + channelSaved: boolean + draftProductId: number | null +}> { + const scope = resolvePendingGroupScopeInput(input) + if (!scope) { + throw createHttpError('缺少待分类队列分组标识', { + statusCode: 400, + errorCode: 'pending_group_key_required', + }) + } + if (scope.type !== 'internal_sku') { + const mappedInternalSku = canonicalizeInternalSku(input.mappedInternalSku) + if (!mappedInternalSku || !containsCjk(mappedInternalSku)) { + throw createHttpError('该来源 SKU 缺少内部 SKU,请先填写包含中文的内部 SKU', { + statusCode: 409, + errorCode: 'internal_sku_mapping_required', + }) + } + const updated = await query( + ` + UPDATE order_items oi + SET spec_json = COALESCE(oi.spec_json, '{}'::jsonb) + || jsonb_build_object('skuNick', $4::text), + updated_at = NOW() + FROM orders o + WHERE o.id = oi.order_id + AND o.provider = $1 + AND o.shop_id = $2 + AND oi.sku_code = $3 + AND oi.fulfillment_status = 'pending' + `, + [scope.provider, scope.shopId, scope.skuCode, mappedInternalSku], + ) + if ((updated.rowCount || 0) === 0) { + throw createHttpError('待分类来源 SKU 已发生变化,请刷新后重试', { + statusCode: 409, + errorCode: 'pending_group_stale', + }) + } + return assignChannelByInternalSku({ + internalSku: mappedInternalSku, + channel: input.channel, + adminUserId: input.adminUserId, + note: input.note, + }) + } + return assignChannelByInternalSku({ + internalSku: scope.internalSku, + channel: input.channel, + adminUserId: input.adminUserId, + note: input.note, + }) +} + /** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */ export async function assignChannelByInternalSku(input: { internalSku: string @@ -1193,7 +1540,14 @@ export async function assignChannelByInternalSku(input: { adminUserId: number | null note?: string | undefined createRule?: boolean | undefined -}): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> { +}): Promise<{ + updated: number + ruleCreated: boolean + carrierFailed: number + channelSaved: boolean + draftProductId: number | null + carrierFailures: Array<{ orderItemId: number; reason: string; message: string }> +}> { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) } @@ -1226,6 +1580,16 @@ export async function assignChannelByInternalSku(input: { [internalSku, input.channel], ) + const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel).catch( + (error) => { + logWarn('[channel-assign]', '自动创建渠道草稿商品失败', { + internalSku, + channel: input.channel, + error: error instanceof Error ? error.message : String(error), + }) + return null + }, + ) const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel) await setInternalSkuPrimaryChannel({ internalSku, @@ -1249,6 +1613,7 @@ export async function assignChannelByInternalSku(input: { source: 'operator_assigned', lock: true, note: input.note, + canonicalProductId: draftProductId, }) await query( `UPDATE order_items @@ -1257,7 +1622,12 @@ export async function assignChannelByInternalSku(input: { [ids, input.adminUserId], ) - return { ...applied, ruleCreated } + return { + ...applied, + ruleCreated, + channelSaved: true, + draftProductId, + } } /** 按平台 SKU 定位内部 SKU 后全局指定渠道。 */ @@ -1415,6 +1785,72 @@ export async function listChannelRulesPage( } } +async function ensureDraftProductForChannel( + internalSku: string, + channel: FulfillmentChannel, +): Promise { + const key = canonicalizeInternalSku(internalSku) + if (!key) return null + const definition = { + affiliate_dash: { prefix: 'aff:', category: '自动发货', unit: '件' }, + work_order: { prefix: 'tpl:', category: '接单', unit: '件' }, + manual: { prefix: 'man:', category: '人工', unit: '件' }, + on_account: { prefix: 'man:', category: '统一结算', unit: '件' }, + third_party_voucher: { prefix: 'tpv:', category: '卡券', unit: '张' }, + }[channel] + const canonicalSku = `${definition.prefix}${key}` + if (canonicalSku.length > 64) return null + + const now = nowIso() + const result = await query<{ id: number; status: string }>( + ` + INSERT INTO product_catalog ( + canonical_sku, + canonical_name, + category, + unit_name, + status, + attributes_json, + created_at, + updated_at + ) VALUES ( + $1, $2, $3, $4, 'active', + $5::jsonb, $6, $6 + ) + ON CONFLICT (canonical_sku) DO UPDATE + SET updated_at = product_catalog.updated_at + RETURNING id, status + `, + [ + canonicalSku, + key.slice(0, 120), + definition.category, + definition.unit, + JSON.stringify({ + source: channel, + auto_created: true, + draft: true, + pending_cost_config: true, + }), + now, + ], + ) + const product = result.rows[0] + const productId = Number(product?.id || 0) + if (!productId || product?.status !== 'active') return null + + await query( + ` + INSERT INTO product_channel_capabilities ( + product_id, channel, priority, is_fallback, enabled, source + ) VALUES ($1, $2, 10, FALSE, TRUE, 'auto') + ON CONFLICT (product_id, channel) DO NOTHING + `, + [productId, channel], + ) + return productId +} + async function resolveInternalSkuCarrierConfig( internalSku: string, channel: string, @@ -1443,7 +1879,13 @@ async function resolveInternalSkuCarrierConfig( } const prefixes = - channel === 'affiliate_dash' ? ['aff:'] : channel === 'work_order' ? ['tpl:', 'wo:'] : ['tpv:'] + channel === 'affiliate_dash' + ? ['aff:'] + : channel === 'work_order' + ? ['tpl:', 'wo:'] + : channel === 'on_account' + ? ['man:'] + : ['tpv:'] const product = await query<{ id: number; canonical_sku: string }>( `SELECT id, canonical_sku FROM product_catalog @@ -1481,7 +1923,9 @@ async function resolveInternalSkuCarrierConfig( ? { affiliateSku: carrierKey } : channel === 'work_order' ? { workRuleKey: carrierKey } - : { voucherSku: carrierKey }, + : channel === 'on_account' + ? { settlementSku: carrierKey } + : { voucherSku: carrierKey }, } } diff --git a/apps/backend/src/services/fulfillment/channel-types.ts b/apps/backend/src/services/fulfillment/channel-types.ts index 205dd6fd..a0d5c1cf 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 = [ @@ -12,6 +12,7 @@ export const FULFILLMENT_CHANNELS = [ 'affiliate_dash', 'third_party_voucher', 'manual', + 'on_account', ] as const export type FulfillmentChannel = (typeof FULFILLMENT_CHANNELS)[number] @@ -30,6 +31,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', @@ -47,6 +52,7 @@ export const CHANNEL_LABELS: Record = { affiliate_dash: '自动发货', third_party_voucher: '第三方卡券', manual: '人工客服', + on_account: '统一结算', } export const MATCH_KEY_TYPES = ['sku_code', 'internal_sku', 'sku_name'] as const @@ -60,13 +66,17 @@ export type RuleSource = (typeof RULE_SOURCES)[number] /** 渠道是否需要履约载体(卡券渠道不需要)。 */ export function channelNeedsCarrier(channel: FulfillmentChannel | null | undefined): boolean { if (!channel) return false - return channel !== 'third_party_voucher' + return channel !== 'third_party_voucher' && channel !== 'on_account' } /** 渠道是否参与成本核算。 */ export function channelHasCostModel(channel: FulfillmentChannel | null | undefined): boolean { return ( - channel === 'affiliate_dash' || channel === 'work_order' || channel === 'third_party_voucher' + channel === 'affiliate_dash' || + channel === 'work_order' || + channel === 'third_party_voucher' || + channel === 'manual' || + channel === 'on_account' ) } 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/intake-channel-decision-service.ts b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts index 4d5c8ef6..ee92a616 100644 --- a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts +++ b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts @@ -200,7 +200,9 @@ export function applyIntakeChannelPlan( reason: channel === 'work_order' ? '内部 SKU 渠道配置指定接单平台' - : '内部 SKU 渠道配置指定第三方卡券', + : channel === 'on_account' + ? '内部 SKU 渠道配置指定统一结算' + : '内部 SKU 渠道配置指定第三方卡券', configured: false, }) } 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/internal-sku-resolver.ts b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts index a7b5ac4c..f5ac2f43 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-resolver.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts @@ -57,6 +57,7 @@ export const INTERNAL_SKU_CHANNEL_ORDER: readonly FulfillmentChannel[] = [ 'work_order', 'third_party_voucher', 'manual', + 'on_account', ] export function compareFulfillmentChannel(a: FulfillmentChannel, b: FulfillmentChannel): number { 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..63a9706e --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.test.ts @@ -0,0 +1,165 @@ +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, + }) + assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, { + enabled: true, + mode: 'after_order_received', + delaySeconds: 5, + }) +}) + +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('收到订单后事件可匹配四个渠道,第三方卡券默认延迟 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, + 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) +}) + +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 new file mode 100644 index 00000000..591c0562 --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts @@ -0,0 +1,252 @@ +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', + 'on_account', +] as const +export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number] + +export const VOUCHER_CONSUME_EVENTS = [ + 'order_received', + '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_order_received', + '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 = { + version: number + enabled: boolean + platforms: Record +} + +export function createDefaultVoucherConsumePolicy(): VoucherConsumePolicy { + return { + version: 2, + 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 : {} + 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, + ), + }, + } +} + +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: true, + mode: 'after_order_received', + delaySeconds: 5, + }, + on_account: { + enabled: false, + mode: 'manual', + delaySeconds: 0, + }, + }, + } +} + +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), + channels: { + affiliate_dash: normalizeChannelPolicy( + rawChannels.affiliate_dash, + fallback.channels.affiliate_dash, + ['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', + 'after_work_order_published_or_assigned', + ]), + manual: normalizeChannelPolicy(rawChannels.manual, fallback.channels.manual, [ + 'manual', + 'after_order_received', + 'after_task_created', + ]), + third_party_voucher: normalizeChannelPolicy( + legacyThirdPartyDisabled ? {} : rawChannels.third_party_voucher, + fallback.channels.third_party_voucher, + ['manual', 'after_order_received'], + ), + on_account: normalizeChannelPolicy(rawChannels.on_account, fallback.channels.on_account, [ + '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, + 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_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' + 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..ea852d31 --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -0,0 +1,932 @@ +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 +} + +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 + +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), + }) +} + +/** + * 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 { + 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 { + return resolveVoucherProviderFromFields(order.provider, order.platform) +} + +function resolveVoucherProviderFromFields( + orderProvider: unknown, + orderPlatform: unknown, +): VoucherConsumeProvider | null { + if (isDouyinMashidaProvider(orderProvider)) { + return 'douyin_mashida' + } + 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) + ) { + return 'kuaishou_industry' + } + 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' || + channel === 'on_account' + ) { + return channel + } + 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' + } + + return normalizeVoucherChannel(orderItem?.fulfillment_channel) +} + +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/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/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/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/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 8cbf592b..6a238436 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 @@ -153,6 +153,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/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index 247682b8..81eabed6 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -87,9 +87,9 @@ test('降价提示:未到时长不提示,到点后按比例给出建议售 assert.equal(suggestion?.dropHours, 24) }) -test('迁移 129 必须包含寄卖配置、库存数量/售价与分配表', async () => { +test('迁移 135 必须包含寄卖配置、库存数量/售价与分配表', async () => { const source = await readFile( - new URL('../../db/migrations/129_consignment_matching.sql', import.meta.url), + new URL('../../db/migrations/135_consignment_matching.sql', import.meta.url), 'utf8', ) for (const fragment of [ @@ -101,6 +101,6 @@ test('迁移 129 必须包含寄卖配置、库存数量/售价与分配表', as 'internal_match_deadline_at', 'consignment_inventory_id', ]) { - assert.ok(source.includes(fragment), `迁移 129 缺少 ${fragment}`) + assert.ok(source.includes(fragment), `迁移 135 缺少 ${fragment}`) } }) diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts index c036d249..8e6c011b 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts @@ -34,7 +34,7 @@ function option(label: string, value: string) { return { label, value, enabled: true } } -/** 苏尔南手枪种子配置的五个字段(与迁移 131 一致)。 */ +/** 苏尔南手枪种子配置的五个字段(与迁移 137 一致)。 */ function surnanSpecs(): ConsignmentSpecField[] { return [ { @@ -423,9 +423,9 @@ test('动态规格:旧列仍由兼容 key 同步', () => { }) }) -test('迁移 130 必须包含 JSON、匹配 key、文本与改价上限', async () => { +test('迁移 136 必须包含 JSON、匹配 key、文本与改价上限', async () => { const sql = await readFile( - new URL('../../db/migrations/130_consignment_dynamic_specs.sql', import.meta.url), + new URL('../../db/migrations/136_consignment_dynamic_specs.sql', import.meta.url), 'utf8', ) for (const fragment of [ @@ -435,14 +435,14 @@ test('迁移 130 必须包含 JSON、匹配 key、文本与改价上限', async 'price_cap_fen', 'idx_consignment_inventories_spec_key', ]) { - assert.ok(sql.includes(fragment), `迁移 130 缺少 ${fragment}`) + assert.ok(sql.includes(fragment), `迁移 136 缺少 ${fragment}`) } assert.ok(parseConsignmentSpecValues({ system: '苹果', diamondLevel: 3 }).system === '苹果') }) -test('迁移 131 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => { +test('迁移 137 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => { const sql = await readFile( - new URL('../../db/migrations/131_consignment_surnan_default_profile.sql', import.meta.url), + new URL('../../db/migrations/137_consignment_surnan_default_profile.sql', import.meta.url), 'utf8', ) for (const fragment of [ @@ -452,6 +452,6 @@ test('迁移 131 必须为苏尔南手枪写入已发布的五个单选字段配 "'published'", '"key": "brillianceCount"', ]) { - assert.ok(sql.includes(fragment), `迁移 131 缺少 ${fragment}`) + assert.ok(sql.includes(fragment), `迁移 137 缺少 ${fragment}`) } }) 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 f34f09b1..ae4fbd21 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 @@ -564,6 +564,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/backend/src/types/admin/read-models.ts b/apps/backend/src/types/admin/read-models.ts index 08034c37..5403f823 100644 --- a/apps/backend/src/types/admin/read-models.ts +++ b/apps/backend/src/types/admin/read-models.ts @@ -13,6 +13,7 @@ export type AdminOrderItemFulfillmentMode = | 'affiliate_dash' | 'legacy_platform' | 'manual' + | 'on_account' | 'pending' export type AdminOrderFulfillmentMode = AdminOrderItemFulfillmentMode | 'mixed' diff --git a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx index 412bf305..cf3413a5 100644 --- a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx +++ b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx @@ -32,7 +32,7 @@ import { fetchAdminManualFulfillmentPending, fetchAdminPlatformCreditOwners, } from '@/services/admin' -import type { ManualFulfillmentPendingItem } from '@/types/admin' +import type { ManualConfiguredChannel, ManualFulfillmentPendingItem } from '@/types/admin' import { getAdminRole, getAdminUserId } from '@/utils/admin-auth' type ConfirmFormValues = { @@ -67,6 +67,7 @@ export function ManualFulfillmentConfirmModal({ const queryClient = useQueryClient() const [form] = Form.useForm() const [selectedId, setSelectedId] = useState(null) + const [selectedPlatformKey, setSelectedPlatformKey] = useState(null) const [fundAccountId, setFundAccountId] = useState(null) const [creditOwnerAdminUserId, setCreditOwnerAdminUserId] = useState(null) const role = getAdminRole() @@ -91,30 +92,51 @@ export function ManualFulfillmentConfirmModal({ items.find((item) => item.orderItemId === initialOrderItemId) || items[0] || null + const configuredChannels = selected?.channels || [] + const selectedChannel: ManualConfiguredChannel | null = + configuredChannels.find((channel) => channel.platformKey === selectedPlatformKey) || + configuredChannels[0] || + null const fundAccountsQuery = useQuery({ queryKey: ['admin-manual-confirm-fund-accounts'], queryFn: () => fetchAdminFundAccounts({ page: 1, pageSize: 100 }), - enabled: open && privileged && Boolean(selected?.isFundCash), + enabled: open && privileged && selectedChannel?.isFundCash === true, retry: false, }) const creditOwnersQuery = useQuery({ queryKey: ['admin-manual-confirm-credit-owners'], queryFn: () => fetchAdminPlatformCreditOwners(), - enabled: open && privileged && Boolean(selected) && !selected?.isFundCash, + enabled: + open && + privileged && + Boolean(selectedChannel) && + selectedChannel?.isFundCash !== true && + selectedChannel?.isOnAccount !== true, retry: false, }) useEffect(() => { if (!open) return setSelectedId(initialOrderItemId || null) + setSelectedPlatformKey(null) form.resetFields() }, [open, initialOrderItemId, form]) + useEffect(() => { + if (!open || !selected) return + setSelectedPlatformKey((current) => { + if (current && selected.channels.some((channel) => channel.platformKey === current)) { + return current + } + return selected.channels[0]?.platformKey || selected.platformKey || null + }) + }, [open, selected?.orderItemId, selected?.channels, selected?.platformKey]) + // 代录时默认选本人账户;客服没有代录权限,不展示选择。 useEffect(() => { if (!open || !privileged) return - if (selected?.isFundCash) { + if (selectedChannel?.isFundCash) { const accounts = (fundAccountsQuery.data?.data.items || []).filter( (item) => item.accountType === 'agent' && item.status === 'active', ) @@ -122,6 +144,11 @@ export function ManualFulfillmentConfirmModal({ setFundAccountId(own?.id ?? null) return } + if (selectedChannel?.isOnAccount) { + setCreditOwnerAdminUserId(null) + setFundAccountId(null) + return + } const owners = creditOwnersQuery.data?.data.items || [] const own = owners.find((item) => item.id === currentUserId) setCreditOwnerAdminUserId(own?.id ?? null) @@ -129,7 +156,8 @@ export function ManualFulfillmentConfirmModal({ open, privileged, selected?.orderItemId, - selected?.isFundCash, + selectedChannel?.isFundCash, + selectedChannel?.isOnAccount, currentUserId, fundAccountsQuery.data, creditOwnersQuery.data, @@ -139,18 +167,25 @@ export function ManualFulfillmentConfirmModal({ mutationFn: (values: ConfirmFormValues) => confirmAdminManualFulfillment({ orderItemId: selected?.orderItemId || 0, + platformKey: selectedChannel?.platformKey || selected?.platformKey || '', costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100), payeeName: values.payeeName || '', note: values.note || '', - ...(privileged && selected?.isFundCash && fundAccountId ? { fundAccountId } : {}), - ...(privileged && selected && !selected.isFundCash && creditOwnerAdminUserId + ...(privileged && selectedChannel?.isFundCash && fundAccountId ? { fundAccountId } : {}), + ...(privileged && + selectedChannel && + !selectedChannel.isFundCash && + !selectedChannel.isOnAccount && + creditOwnerAdminUserId ? { creditOwnerAdminUserId } : {}), }), onSuccess: (response) => { const result = response.data message.success( - `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`, + result.onAccountBillId + ? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}` + : `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`, ) void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] }) void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] }) @@ -167,7 +202,8 @@ export function ManualFulfillmentConfirmModal({ }, }) - const showCostInput = selected?.costFen === null + const showCostInput = selectedChannel?.costFen === null + const costRequired = showCostInput && selectedChannel?.isOnAccount !== true return ( , disabled: !selected || + !selectedChannel || (privileged && - ((selected.isFundCash && !fundAccountId) || - (!selected.isFundCash && !creditOwnerAdminUserId))), + ((selectedChannel.isFundCash && !fundAccountId) || + (!selectedChannel.isFundCash && + !selectedChannel.isOnAccount && + !creditOwnerAdminUserId))), }} cancelText="取消" confirmLoading={confirmMutation.isPending} @@ -227,12 +266,30 @@ export function ManualFulfillmentConfirmModal({
- 履约渠道: - - {selected?.platformLabel || '-'} - - {selected && !selected.isFundCash ? ( - 消耗 {selected.creditQuantity} 积分 + 本次实际履约渠道: + {selected && configuredChannels.length > 1 ? ( +
- ) : ( + ) : selectedChannel.isOnAccount ? null : (
代录到客服积分账户: + setInternalSkuDrafts((current) => ({ + ...current, + [rowKey(row)]: event.target.value, + })) + } + /> + )} + {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 +313,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,13 +366,15 @@ function PendingPanel() { }, { title: '指定渠道', - width: 360, + width: 440, render: (_: unknown, row: PendingChannelGroup) => ( ({ - 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/OrderServicePanel.tsx b/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx index 977ef382..e81395e8 100644 --- a/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx +++ b/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx @@ -2,8 +2,10 @@ import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, + DownOutlined, PlusOutlined, ReloadOutlined, + RightOutlined, SaveOutlined, SearchOutlined, } from '@ant-design/icons' @@ -41,6 +43,7 @@ import { retryAdminOrderServiceAutoPublishJob, saveAdminOrderServiceProfile, type AdminOrderServiceAutoPublishJob, + type AdminOrderServiceCategory, type AdminOrderServiceProfile, type OrderServiceField, type OrderServiceMaterialConfirmation, @@ -81,6 +84,11 @@ type OrderServiceFormValues = { statusSteps: OrderServiceStatusSteps } +type OrderServiceCategoryNode = { + category: AdminOrderServiceCategory + children: AdminOrderServiceCategory[] +} + const DEFAULT_SCREENSHOTS: ScreenshotForm = { enabled: true, required: true, @@ -245,6 +253,7 @@ export default function OrderServicePanel() { const [form] = Form.useForm() const [selectedCategoryId, setSelectedCategoryId] = useState(null) const [categoryKeyword, setCategoryKeyword] = useState('') + const [expandedCategoryIds, setExpandedCategoryIds] = useState>(() => new Set()) const [fields, setFields] = useState([]) const [guideImage, setGuideImage] = useState | null>(null) const [saving, setSaving] = useState(false) @@ -268,22 +277,56 @@ export default function OrderServicePanel() { [profiles], ) const normalizedKeyword = categoryKeyword.trim().toLowerCase() - const filteredCategories = useMemo( - () => - categories.filter((item) => { - if (!normalizedKeyword) return true - return ( - item.name.toLowerCase().includes(normalizedKeyword) || - item.categoryKey.toLowerCase().includes(normalizedKeyword) - ) - }), - [categories, normalizedKeyword], + const categoryTree = useMemo(() => { + const childrenByParent = new Map() + const roots: AdminOrderServiceCategory[] = [] + for (const category of categories) { + if (category.parentId) { + const children = childrenByParent.get(category.parentId) || [] + children.push(category) + childrenByParent.set(category.parentId, children) + } else { + roots.push(category) + } + } + const matches = (category: AdminOrderServiceCategory) => + !normalizedKeyword || + category.name.toLowerCase().includes(normalizedKeyword) || + category.categoryKey.toLowerCase().includes(normalizedKeyword) + + return roots.flatMap((category) => { + const children = childrenByParent.get(category.categoryId) || [] + if (!normalizedKeyword) return [{ category, children }] + if (matches(category)) return [{ category, children }] + const matchedChildren = children.filter(matches) + return matchedChildren.length > 0 ? [{ category, children: matchedChildren }] : [] + }) + }, [categories, normalizedKeyword]) + const filteredCategoryCount = categoryTree.reduce( + (total, node) => total + 1 + node.children.length, + 0, ) - const configuredCount = categories.filter((item) => - profileByCategoryId.has(item.categoryId), - ).length + const configuredCount = new Set( + profiles.filter((item) => item.status !== 'archived').map((item) => item.categoryId), + ).size const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null - const profile = (selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null + const ownProfile = + (selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null + const parentCategory = selectedCategory?.parentId + ? categories.find((item) => item.categoryId === selectedCategory.parentId) || null + : null + const parentProfileCandidate = parentCategory + ? profileByCategoryId.get(parentCategory.categoryId) || null + : null + const parentProfile = + parentProfileCandidate?.currentVersionNo && parentProfileCandidate.status !== 'archived' + ? parentProfileCandidate + : null + const profile = ownProfile || parentProfile + const inheritsParentProfile = !ownProfile && Boolean(parentProfile) + const runtimeInheritsParent = + Boolean(parentProfile) && + (!ownProfile || ownProfile.status === 'archived' || !ownProfile.currentVersionNo) const guideFiles = useMemo(() => { const image = resolveGuideImage(guideImage) return image ? [image] : [] @@ -311,7 +354,10 @@ export default function OrderServicePanel() { function applyProfile(nextProfile: AdminOrderServiceProfile | null) { form.setFieldsValue({ - name: nextProfile?.name || selectedCategory?.name || '', + name: + inheritsParentProfile && selectedCategory + ? selectedCategory.name + : nextProfile?.name || selectedCategory?.name || '', title: nextProfile?.title || '', description: nextProfile?.description || '', guideImageDescription: nextProfile?.guideImageDescription || '', @@ -436,7 +482,11 @@ export default function OrderServicePanel() { successText: values.successText, postSubmitText: values.postSubmitText, }) - message.success('草稿已保存;点击“发布新版本”后才会影响新工单') + message.success( + inheritsParentProfile + ? '已创建二级分类独立草稿;发布后优先使用该配置' + : '草稿已保存;点击“发布新版本”后才会影响新工单', + ) await refresh() } catch (error) { message.error(error instanceof Error ? error.message : '保存失败') @@ -446,13 +496,13 @@ export default function OrderServicePanel() { } async function handlePublish() { - if (!profile) { - message.warning('请先保存配置草稿') + if (!ownProfile) { + message.warning('当前分类继承父级配置,请先保存二级分类独立草稿') return } setPublishing(true) try { - const response = await publishAdminOrderServiceProfile(profile.id) + const response = await publishAdminOrderServiceProfile(ownProfile.id) const backfilled = Number(response.data.backfilledPendingOrders || 0) message.success( backfilled > 0 @@ -468,10 +518,10 @@ export default function OrderServicePanel() { } async function handleArchive() { - if (!profile) return + if (!ownProfile) return setPublishing(true) try { - await archiveAdminOrderServiceProfile(profile.id) + await archiveAdminOrderServiceProfile(ownProfile.id) message.success('配置已归档;已有工单快照不受影响') await refresh() } catch (error) { @@ -571,7 +621,7 @@ export default function OrderServicePanel() { 接单分类 - {filteredCategories.length}/{categories.length} + {filteredCategoryCount}/{categories.length}
- {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..54e242dd --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx @@ -0,0 +1,387 @@ +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: '人工客服', + on_account: '统一结算', + third_party_voucher: '第三方卡券', +} + +const MODE_OPTIONS: Record< + VoucherConsumeChannel, + 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: '仅指派后' }, + { value: 'after_task_created', label: '履约任务创建后' }, + { value: 'manual', label: '人工核销' }, + ], + manual: [ + { value: 'after_order_received', label: '收到订单后' }, + { value: 'after_task_created', label: '人工任务创建后' }, + { value: 'manual', label: '人工核销' }, + ], + third_party_voucher: [ + { value: 'after_order_received', label: '收到订单后' }, + { value: 'manual', label: '人工核销' }, + ], + on_account: [ + { value: 'after_order_received', label: '收到订单后' }, + { 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) => ( + + + + + + +
+ ) : null} +
+ - 指派后订单直接进入该打手的「我的订单」,不再出现在抢单大厅;有关联电子凭证时会先自动核销,再按订单规则冻结打手押金。核销后取消指派不会恢复电子凭证。 + 指派后订单直接进入该打手的「我的订单」,不再出现在抢单大厅;有关联电子凭证时会按核销策略安排延迟核销,并按订单规则冻结打手押金。