feat(work-order): 发布并核销支持抖音券(按来源分流,抖音走 DYC 券核销)
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { RedeemCertificateRow } from '../../repositories/redeem-certificate-repo.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
|
||||
import { verifyDouyinMashidaCertificateForWorkOrderPublish } from './publish-douyin-voucher-service.js'
|
||||
import type { PublishDouyinVoucherServiceDeps } from './publish-douyin-voucher-service.js'
|
||||
|
||||
function buildWorkOrder(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
|
||||
return {
|
||||
id: 1,
|
||||
work_order_no: 'WK001',
|
||||
order_id: 10,
|
||||
order_item_id: 20,
|
||||
task_id: null,
|
||||
platform_order_id: 'DOUYIN-PARENT-ORDER',
|
||||
product_name: '测试商品',
|
||||
category_id: null,
|
||||
status: 'unassigned',
|
||||
reward_amount: 100,
|
||||
required_deposit_amount: 0,
|
||||
deposit_threshold_amount: 20000,
|
||||
assigned_worker_id: null,
|
||||
last_assigned_worker_id: null,
|
||||
material_json: '{}',
|
||||
requirement_json: '{}',
|
||||
acceptance_json: '{}',
|
||||
draft_acceptance_json: '{}',
|
||||
problem_note: '',
|
||||
sharing_enabled: false,
|
||||
sharing_total_quantity: 1,
|
||||
sharing_unit_reward: 0,
|
||||
published_at: null,
|
||||
hall_queued_at: null,
|
||||
pinned_at: null,
|
||||
assigned_at: null,
|
||||
submitted_at: null,
|
||||
accepted_at: null,
|
||||
deadline_at: null,
|
||||
timeout_minutes: 0,
|
||||
timeout_policy: 'reopen',
|
||||
created_at: '2026-10-14T00:00:00.000Z',
|
||||
updated_at: '2026-10-14T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCertificate(overrides: Partial<RedeemCertificateRow> = {}): RedeemCertificateRow {
|
||||
return {
|
||||
id: 1,
|
||||
provider: 'douyin_mashida',
|
||||
cert_no: 'DYCABC',
|
||||
order_id: 'DOUYIN-PARENT-ORDER',
|
||||
parent_order_id: 'DOUYIN-PARENT-ORDER',
|
||||
local_order_id: 10,
|
||||
order_item_id: 20,
|
||||
cert_link: 'https://ks.khhao.com/h5/DOUYIN-PARENT-ORDER',
|
||||
status: 'synced',
|
||||
sync_status: 'success',
|
||||
sync_error: '',
|
||||
platform_cert_no: 'PLATFORM-CERT',
|
||||
platform_cert_id: 'PC1',
|
||||
platform_status: 2,
|
||||
platform_synced_at: '2026-10-14T00:00:00.000Z',
|
||||
grant_time: '2026-10-14 00:00:00',
|
||||
synced_at: '2026-10-14T00:00:00.000Z',
|
||||
verified_at: null,
|
||||
raw_payload_json: {},
|
||||
created_at: '2026-10-14T00:00:00.000Z',
|
||||
updated_at: '2026-10-14T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type TestState = {
|
||||
verifyCertNos: string[]
|
||||
updatedMaterialJson: string[]
|
||||
eventTypes: string[]
|
||||
}
|
||||
|
||||
function buildDeps(
|
||||
certificate: RedeemCertificateRow | null,
|
||||
overrides: Partial<PublishDouyinVoucherServiceDeps> = {},
|
||||
): PublishDouyinVoucherServiceDeps & TestState {
|
||||
const state: TestState = {
|
||||
verifyCertNos: [],
|
||||
updatedMaterialJson: [],
|
||||
eventTypes: [],
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
findCertificateByOrder: async () => certificate,
|
||||
verifyCertificate: async ({ certNo }) => {
|
||||
state.verifyCertNos.push(certNo)
|
||||
return {
|
||||
...(certificate || buildCertificate()),
|
||||
cert_no: certNo,
|
||||
status: 'verified',
|
||||
verified_at: '2026-10-15T00:00:00.000Z',
|
||||
}
|
||||
},
|
||||
now: () => '2026-10-15T00:00:00.000Z',
|
||||
updateWorkOrder: async (_id, patch) => {
|
||||
state.updatedMaterialJson.push(String(patch.material_json || ''))
|
||||
return { status: 'open' }
|
||||
},
|
||||
createWorkOrderEvent: async (input) => {
|
||||
state.eventTypes.push(input.eventType)
|
||||
return { id: 1 }
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('抖音券核销成功后回填工单 material_json 并写工单事件', async () => {
|
||||
const deps = buildDeps(buildCertificate())
|
||||
|
||||
const result = await verifyDouyinMashidaCertificateForWorkOrderPublish(
|
||||
buildWorkOrder(),
|
||||
'work_order_publish',
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.consumedCount, 1)
|
||||
assert.equal(result.alreadyConsumedCount, 0)
|
||||
assert.deepEqual(result.voucherCodes, ['DYCABC'])
|
||||
// 委托给唯一真源 verifyCertificate(平台侧券码优先级由它处理)
|
||||
assert.deepEqual(deps.verifyCertNos, ['DYCABC'])
|
||||
|
||||
assert.equal(deps.updatedMaterialJson.length, 1)
|
||||
const material = JSON.parse(deps.updatedMaterialJson[0] || '{}')
|
||||
assert.equal(material.douyinMashidaVoucher.length, 1)
|
||||
assert.equal(material.douyinMashidaVoucher[0].certNo, 'DYCABC')
|
||||
assert.equal(material.douyinMashidaVoucher[0].platformCertNo, 'PLATFORM-CERT')
|
||||
assert.equal(material.douyinMashidaVoucher[0].status, 'verified')
|
||||
assert.deepEqual(deps.eventTypes, ['publish_voucher_consumed'])
|
||||
})
|
||||
|
||||
test('回填时保留工单 material_json 上已有的其它字段', async () => {
|
||||
const deps = buildDeps(buildCertificate())
|
||||
const workOrder = buildWorkOrder({
|
||||
material_json: JSON.stringify({ note: '运营备注', kuaishouIndustryVoucher: [{ a: 1 }] }),
|
||||
})
|
||||
|
||||
await verifyDouyinMashidaCertificateForWorkOrderPublish(workOrder, 'work_order_publish', deps)
|
||||
|
||||
const material = JSON.parse(deps.updatedMaterialJson[0] || '{}')
|
||||
assert.equal(material.note, '运营备注')
|
||||
assert.deepEqual(material.kuaishouIndustryVoucher, [{ a: 1 }])
|
||||
assert.equal(material.douyinMashidaVoucher[0].certNo, 'DYCABC')
|
||||
})
|
||||
|
||||
test('抖音券尚未生成时发布失败并给出明确指引(不静默放过)', async () => {
|
||||
const deps = buildDeps(null)
|
||||
|
||||
await assert.rejects(
|
||||
verifyDouyinMashidaCertificateForWorkOrderPublish(buildWorkOrder(), 'work_order_publish', deps),
|
||||
/抖音券码尚未生成/,
|
||||
)
|
||||
assert.deepEqual(deps.verifyCertNos, [])
|
||||
})
|
||||
|
||||
test('核销真源报错(未同步/已作废/平台拒绝)时返回失败结果且不回填工单', async () => {
|
||||
const deps = buildDeps(buildCertificate(), {
|
||||
verifyCertificate: async () => {
|
||||
throw new Error('该券码尚未同步成功,暂不能核销')
|
||||
},
|
||||
})
|
||||
|
||||
const result = await verifyDouyinMashidaCertificateForWorkOrderPublish(
|
||||
buildWorkOrder(),
|
||||
'work_order_publish',
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.failedCount, 1)
|
||||
assert.match(result.errorMessage, /核销失败.*尚未同步成功/)
|
||||
assert.deepEqual(deps.updatedMaterialJson, [])
|
||||
assert.deepEqual(deps.eventTypes, [])
|
||||
})
|
||||
|
||||
test('已核销的券按幂等成功反馈(不重复打平台,由真源保证)', async () => {
|
||||
const deps = buildDeps(
|
||||
buildCertificate({ status: 'verified', verified_at: '2026-10-14T01:00:00.000Z' }),
|
||||
)
|
||||
|
||||
const result = await verifyDouyinMashidaCertificateForWorkOrderPublish(
|
||||
buildWorkOrder(),
|
||||
'work_order_publish',
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.consumedCount, 0)
|
||||
assert.equal(result.alreadyConsumedCount, 1)
|
||||
})
|
||||
|
||||
test('指派路径复用同一套抖音券核销口径', async () => {
|
||||
const deps = buildDeps(buildCertificate())
|
||||
|
||||
const result = await verifyDouyinMashidaCertificateForWorkOrderPublish(
|
||||
buildWorkOrder(),
|
||||
'work_order_assign',
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.consumedCount, 1)
|
||||
})
|
||||
|
||||
test('工单没有平台订单号时跳过(不算失败)', async () => {
|
||||
const deps = buildDeps(buildCertificate())
|
||||
|
||||
const result = await verifyDouyinMashidaCertificateForWorkOrderPublish(
|
||||
buildWorkOrder({ platform_order_id: '' }),
|
||||
'work_order_publish',
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.voucherCount, 0)
|
||||
assert.deepEqual(deps.verifyCertNos, [])
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
findRedeemCertificateByOrder,
|
||||
type RedeemCertificateRow,
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import {
|
||||
createWorkOrderEvent,
|
||||
updateWorkOrder,
|
||||
type WorkOrderRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
buildDouyinMashidaWorkOrderVoucher,
|
||||
DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY,
|
||||
mergeDouyinMashidaVoucherIntoMaterial,
|
||||
verifyCertificate,
|
||||
} from '../redeem/certificate-service.js'
|
||||
import type { WorkOrderVoucherConsumeResult } from './publish-voucher-service.js'
|
||||
|
||||
type PublishDouyinVoucherServiceDeps = {
|
||||
findCertificateByOrder: typeof findRedeemCertificateByOrder
|
||||
/** 核销上报的唯一真源:落库 + 幂等 + 失败自愈(见 redeem/certificate-service.ts) */
|
||||
verifyCertificate: typeof verifyCertificate
|
||||
now: typeof nowIso
|
||||
updateWorkOrder: typeof updateWorkOrder
|
||||
createWorkOrderEvent: typeof createWorkOrderEvent
|
||||
}
|
||||
|
||||
const defaultDeps: PublishDouyinVoucherServiceDeps = {
|
||||
findCertificateByOrder: findRedeemCertificateByOrder,
|
||||
verifyCertificate,
|
||||
now: nowIso,
|
||||
updateWorkOrder,
|
||||
createWorkOrderEvent,
|
||||
}
|
||||
|
||||
const DOUYIN_MASHIDA_PROVIDER = 'douyin_mashida'
|
||||
|
||||
/**
|
||||
* 工单「发布 / 指派」顺带核销抖音码速达券(`redeem_certificates`,provider=douyin_mashida)。
|
||||
*
|
||||
* 为什么必须单独一条分支:抖音券与快手行业电子凭证是**两张表两套状态机**——
|
||||
* 快手走 `consumeKuaishouIndustryVoucher`(快手 OpenAPI),抖音走 `coupons.verifyV3`
|
||||
* (我们自发的 DYC 券码)。在 `publish-voucher-service` 合并之前,抖音工单发布时
|
||||
* 只会去查 `kuaishou_industry_vouchers`(抖音订单那里永远是空),等于**从未核销过抖音券**。
|
||||
*
|
||||
* 核销本身**不重复实现**:直接调 `verifyCertificate`(与买家兑换页、领取页自动核销、
|
||||
* 后台手动核销同一个入口),因此天然获得:
|
||||
* - 幂等:本地已 `verified` 直接返回,不重复打平台;
|
||||
* - 状态守卫:`invalid`(退款作废)与 `sync_status≠success`(平台侧没有这张券)都拒绝核销;
|
||||
* - 平台侧券码优先:`syncV2` 可能改写我方券码,直接用我方券码会拿到 `10017`;
|
||||
* - 失败自愈:先对账(平台其实已核销 → 按平台状态标记),再用平台券码重试一次。
|
||||
*
|
||||
* 核销成功后把券快照回填工单 `material_json.douyinMashidaVoucher`(对齐快手券挂任务的做法),
|
||||
* 让打手在工单上能看到「交付的是哪张券」——这一步是 best-effort,失败只留痕。
|
||||
*/
|
||||
export async function verifyDouyinMashidaCertificateForWorkOrderPublish(
|
||||
workOrder: WorkOrderRow,
|
||||
source: 'work_order_publish' | 'work_order_assign',
|
||||
deps: PublishDouyinVoucherServiceDeps = defaultDeps,
|
||||
): Promise<WorkOrderVoucherConsumeResult> {
|
||||
const receiptOrderId = String(workOrder.platform_order_id || '').trim()
|
||||
if (!receiptOrderId) {
|
||||
return buildDouyinConsumeResult([])
|
||||
}
|
||||
|
||||
const certificate = await deps.findCertificateByOrder({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
orderId: receiptOrderId,
|
||||
})
|
||||
if (!certificate) {
|
||||
// 抖音「任何订单都发券」是业务口径:查不到券说明发券环节没走完,必须显式报错让人跟进,
|
||||
// 不能静默放过(否则平台侧券永远不核销,买家端却以为已完成)。
|
||||
throw createHttpError('该订单的抖音券码尚未生成,请先在「抖音券核销」里重发券码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_publish_douyin_certificate_missing',
|
||||
context: { workOrderId: workOrder.id, receiptOrderId, source },
|
||||
})
|
||||
}
|
||||
|
||||
const alreadyConsumed = certificate.status === 'verified'
|
||||
let verified: RedeemCertificateRow
|
||||
|
||||
try {
|
||||
verified = await deps.verifyCertificate({ certNo: certificate.cert_no })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '抖音券核销失败'
|
||||
logIntegration(
|
||||
'[work-order/publish-douyin-voucher]',
|
||||
'发布顺带核销抖音券失败',
|
||||
{
|
||||
workOrderId: workOrder.id,
|
||||
source,
|
||||
certNo: certificate.cert_no,
|
||||
localOrderId: certificate.local_order_id,
|
||||
error: message,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
voucherCount: 1,
|
||||
consumedCount: 0,
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 1,
|
||||
voucherCodes: [certificate.cert_no],
|
||||
errorMessage: `抖音券 ${certificate.cert_no} 核销失败:${message}`,
|
||||
}
|
||||
}
|
||||
|
||||
await attachVoucherToWorkOrder(workOrder, verified, deps)
|
||||
|
||||
logIntegration('[work-order/publish-douyin-voucher]', '发布顺带核销抖音券成功', {
|
||||
workOrderId: workOrder.id,
|
||||
source,
|
||||
certNo: verified.cert_no,
|
||||
platformCertNo: verified.platform_cert_no,
|
||||
alreadyConsumed,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
voucherCount: 1,
|
||||
consumedCount: alreadyConsumed ? 0 : 1,
|
||||
alreadyConsumedCount: alreadyConsumed ? 1 : 0,
|
||||
failedCount: 0,
|
||||
voucherCodes: [verified.cert_no],
|
||||
errorMessage: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 核销成功后把券快照并回工单 material_json(best-effort:失败只留痕,不影响发布结果)。 */
|
||||
async function attachVoucherToWorkOrder(
|
||||
workOrder: WorkOrderRow,
|
||||
certificate: RedeemCertificateRow,
|
||||
deps: PublishDouyinVoucherServiceDeps,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const voucher = buildDouyinMashidaWorkOrderVoucher(certificate)
|
||||
if (!voucher.certNo) {
|
||||
return
|
||||
}
|
||||
|
||||
const material =
|
||||
typeof workOrder.material_json === 'string'
|
||||
? safeParseJsonObject(workOrder.material_json)
|
||||
: workOrder.material_json || {}
|
||||
const merged = mergeDouyinMashidaVoucherIntoMaterial(material, voucher)
|
||||
if (!merged.changed) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = deps.now()
|
||||
const updated = await deps.updateWorkOrder(workOrder.id, {
|
||||
material_json: JSON.stringify(merged.material),
|
||||
updated_at: now,
|
||||
})
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
|
||||
await deps.createWorkOrderEvent({
|
||||
workOrderId: Number(workOrder.id),
|
||||
actorType: 'system',
|
||||
actorId: DOUYIN_MASHIDA_PROVIDER,
|
||||
eventType: 'publish_voucher_consumed',
|
||||
fromStatus: String(workOrder.status || ''),
|
||||
toStatus: String(updated.status || workOrder.status || ''),
|
||||
payloadJson: JSON.stringify({
|
||||
voucherKey: DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY,
|
||||
certNo: voucher.certNo,
|
||||
platformCertNo: voucher.platformCertNo,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
} catch (error) {
|
||||
logIntegration(
|
||||
'[work-order/publish-douyin-voucher]',
|
||||
'抖音券快照回填工单失败(核销已成功,不影响发布)',
|
||||
{
|
||||
workOrderId: workOrder.id,
|
||||
certNo: certificate.cert_no,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function safeParseJsonObject(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDouyinConsumeResult(certNos: string[]): WorkOrderVoucherConsumeResult {
|
||||
return {
|
||||
ok: true,
|
||||
voucherCount: certNos.length,
|
||||
consumedCount: 0,
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 0,
|
||||
voucherCodes: certNos,
|
||||
errorMessage: '',
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository
|
||||
import {
|
||||
consumeIndustryVouchersBeforeWorkOrderAssign,
|
||||
consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||
isDouyinMashidaWorkOrder,
|
||||
resolveWorkOrderVoucherTargets,
|
||||
setDouyinVoucherDepsForTest,
|
||||
setOrderProviderResolverForTest,
|
||||
} from './publish-voucher-service.js'
|
||||
|
||||
function buildWorkOrder(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
|
||||
@@ -289,3 +292,90 @@ test('指派前电子凭证核销失败时返回失败结果', async () => {
|
||||
assert.equal(result.failedCount, 1)
|
||||
assert.equal(result.errorMessage, '指派前核销失败')
|
||||
})
|
||||
|
||||
test('抖音订单的工单走抖音券核销,不查快手凭证', async () => {
|
||||
setOrderProviderResolverForTest(async () => 'douyin_mashida')
|
||||
// 抖音券服务注入假依赖:返回「券尚未生成」错误,验证分流且快手仓储未被触达
|
||||
setDouyinVoucherDepsForTest({
|
||||
findCertificateByOrder: async () => {
|
||||
throw new Error('抖音券仓储不应在分流测试中被触达')
|
||||
},
|
||||
verifyCertificate: async () => {
|
||||
throw new Error('抖音核销真源不应在分流测试中被触达')
|
||||
},
|
||||
now: async () => {
|
||||
throw new Error('now 不应被触达')
|
||||
},
|
||||
updateWorkOrder: async () => {
|
||||
throw new Error('updateWorkOrder 不应被触达')
|
||||
},
|
||||
createWorkOrderEvent: async () => {
|
||||
throw new Error('createWorkOrderEvent 不应被触达')
|
||||
},
|
||||
} as never)
|
||||
try {
|
||||
let kuaishouListed = false
|
||||
// 与生产一致:publishAdminWorkOrder 外层 try/catch 会把抖音券服务的异常转成失败结果,
|
||||
// 这里直接断言「抖音服务抛错、快手仓储全程未被查询」
|
||||
await assert.rejects(
|
||||
consumeIndustryVouchersBeforeWorkOrderPublish(
|
||||
buildWorkOrder({ platform_order_id: 'DOU123' }),
|
||||
{
|
||||
listVouchersByOid: async () => {
|
||||
kuaishouListed = true
|
||||
return []
|
||||
},
|
||||
listTasksByOrderId: async () => [],
|
||||
consumeVoucher: async (voucher) => ({ ok: true, voucher, callbackSuccess: true }),
|
||||
attachVoucherToTask: async (task) => task,
|
||||
},
|
||||
),
|
||||
/抖音券仓储/,
|
||||
)
|
||||
assert.equal(kuaishouListed, false)
|
||||
} finally {
|
||||
setDouyinVoucherDepsForTest(undefined)
|
||||
setOrderProviderResolverForTest(null)
|
||||
}
|
||||
})
|
||||
|
||||
test('非抖音订单的工单仍走快手电子凭证核销', async () => {
|
||||
setOrderProviderResolverForTest(async () => '91kaquan')
|
||||
try {
|
||||
let kuaishouListed = false
|
||||
const result = await consumeIndustryVouchersBeforeWorkOrderPublish(buildWorkOrder(), {
|
||||
listVouchersByOid: async () => {
|
||||
kuaishouListed = true
|
||||
return []
|
||||
},
|
||||
listTasksByOrderId: async () => [],
|
||||
consumeVoucher: async (voucher) => ({ ok: true, voucher, callbackSuccess: true }),
|
||||
attachVoucherToTask: async (task) => task,
|
||||
})
|
||||
|
||||
assert.equal(kuaishouListed, true)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.voucherCount, 0)
|
||||
} finally {
|
||||
setOrderProviderResolverForTest(null)
|
||||
}
|
||||
})
|
||||
|
||||
test('isDouyinMashidaWorkOrder 按 provider 判定', async () => {
|
||||
setOrderProviderResolverForTest(async () => 'douyin_mashida')
|
||||
try {
|
||||
assert.equal(await isDouyinMashidaWorkOrder(buildWorkOrder()), true)
|
||||
} finally {
|
||||
setOrderProviderResolverForTest(null)
|
||||
}
|
||||
|
||||
setOrderProviderResolverForTest(async () => '91kaquan')
|
||||
try {
|
||||
assert.equal(await isDouyinMashidaWorkOrder(buildWorkOrder()), false)
|
||||
} finally {
|
||||
setOrderProviderResolverForTest(null)
|
||||
}
|
||||
|
||||
// 没有关联本地订单的工单不可能判定为抖音
|
||||
assert.equal(await isDouyinMashidaWorkOrder(buildWorkOrder({ order_id: null })), false)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js'
|
||||
import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js'
|
||||
import { isDouyinMashidaProvider } from '../platforms/douyin-mashida/order-mapper.js'
|
||||
import { verifyDouyinMashidaCertificateForWorkOrderPublish } from './publish-douyin-voucher-service.js'
|
||||
|
||||
export type WorkOrderVoucherConsumeResult = {
|
||||
ok: boolean
|
||||
@@ -48,6 +51,16 @@ async function consumeIndustryVouchersBeforeWorkOrderAction(
|
||||
source: 'work_order_publish' | 'work_order_assign',
|
||||
deps: WorkOrderVoucherServiceDeps,
|
||||
): Promise<WorkOrderVoucherConsumeResult> {
|
||||
// 来源决定核销对象:抖音码速达工单核销我们自发的 DYC 券(redeem_certificates),
|
||||
// 其余(91/快手)核销快手行业电子凭证(kuaishou_industry_vouchers)——两套状态机不可混用。
|
||||
if (await isDouyinMashidaWorkOrder(workOrder)) {
|
||||
return verifyDouyinMashidaCertificateForWorkOrderPublish(
|
||||
workOrder,
|
||||
source,
|
||||
douyinVoucherDepsForTest,
|
||||
)
|
||||
}
|
||||
|
||||
const oid = String(workOrder.platform_order_id || '').trim()
|
||||
if (!oid) {
|
||||
return buildConsumeResult([])
|
||||
@@ -192,3 +205,49 @@ function normalizeVoucherStatus(value: unknown) {
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 抖音券服务的依赖注入口:生产恒为 undefined(用其 defaultDeps 直连仓储/网关),
|
||||
* 仅分流单测注入假实现以免触库/触网。
|
||||
*/
|
||||
let douyinVoucherDepsForTest: Parameters<
|
||||
typeof verifyDouyinMashidaCertificateForWorkOrderPublish
|
||||
>[2]
|
||||
|
||||
/** 仅测试用:注入/清除抖音券服务的依赖。 */
|
||||
export function setDouyinVoucherDepsForTest(
|
||||
deps: Parameters<typeof verifyDouyinMashidaCertificateForWorkOrderPublish>[2],
|
||||
) {
|
||||
douyinVoucherDepsForTest = deps
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单是否来自抖音码速达订单(按订单 provider 判定;查询失败按非抖音处理,走快手路径)。
|
||||
* provider 查询抽成可注入的 resolver,仅为单测免触库。
|
||||
*/
|
||||
type OrderProviderResolver = (orderId: number) => Promise<string>
|
||||
|
||||
let resolveOrderProvider: OrderProviderResolver = async (orderId) => {
|
||||
const order = await getOrderById(orderId).catch(() => null)
|
||||
return String(order?.provider || '')
|
||||
}
|
||||
|
||||
/** 仅测试用:替换订单 provider 查询实现。 */
|
||||
export function setOrderProviderResolverForTest(resolver: OrderProviderResolver | null) {
|
||||
resolveOrderProvider =
|
||||
resolver ||
|
||||
(async (orderId) => {
|
||||
const order = await getOrderById(orderId).catch(() => null)
|
||||
return String(order?.provider || '')
|
||||
})
|
||||
}
|
||||
|
||||
export async function isDouyinMashidaWorkOrder(workOrder: WorkOrderRow): Promise<boolean> {
|
||||
const orderId = Number(workOrder.order_id || 0)
|
||||
if (!Number.isFinite(orderId) || orderId <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const provider = await resolveOrderProvider(Math.trunc(orderId))
|
||||
return isDouyinMashidaProvider(provider)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user