Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b170d3f45 | ||
|
|
dc78dc92d1 | ||
|
|
c7d650b482 |
@@ -0,0 +1,22 @@
|
|||||||
|
-- 109_after_sales_withdraw_deduction.sql
|
||||||
|
--
|
||||||
|
-- 售后追缴要能把「提现冻结中」的钱一并扣掉。
|
||||||
|
--
|
||||||
|
-- 背景:追缴原本只扣「待解冻押金 + 可用余额」,打手在验收后立刻申请提现,
|
||||||
|
-- 钱就从可用余额挪进 frozen_withdraw_amount,追缴只能扣到零头,剩下挂欠款;
|
||||||
|
-- 而提现驳回后钱回到可用余额,也不会再抵扣。生产 case AS4f0566f0a192 即为此:
|
||||||
|
-- 追缴 75.15,实扣 0.35,1 分 27 秒后提现被驳回,75.00 停留在可用余额。
|
||||||
|
--
|
||||||
|
-- 现在的口径:追缴按 待解冻押金 → 提现冻结 → 可用余额 → 欠款 依次扣,
|
||||||
|
-- 动到提现冻结时自动把对应提现申请置为 rejected(备注写明是售后追缴驳回)。
|
||||||
|
|
||||||
|
ALTER TABLE worker_after_sales_cases
|
||||||
|
ADD COLUMN IF NOT EXISTS withdraw_deduction_amount BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN worker_after_sales_cases.withdraw_deduction_amount IS
|
||||||
|
'本单从「提现冻结」中扣走的金额(同时会驳回对应提现申请)';
|
||||||
|
|
||||||
|
-- 追缴时需要按打手捞 pending 提现申请并加行锁,补一个部分索引。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_finance_requests_worker_pending_withdraw
|
||||||
|
ON worker_finance_requests (worker_id, created_at, id)
|
||||||
|
WHERE request_type = 'withdraw' AND status = 'pending';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { resolveAfterSalesRecoveryAllocation } from './after-sales-recovery.js'
|
||||||
|
|
||||||
|
test('追缴顺序:待解冻押金 → 提现冻结 → 可用余额 → 欠款', () => {
|
||||||
|
const allocation = resolveAfterSalesRecoveryAllocation({
|
||||||
|
recoveryAmount: 1000,
|
||||||
|
pendingDepositAmount: 200,
|
||||||
|
frozenWithdrawAmount: 300,
|
||||||
|
availableAmount: 100,
|
||||||
|
})
|
||||||
|
assert.deepEqual(allocation, {
|
||||||
|
pendingDepositDeductionAmount: 200,
|
||||||
|
withdrawDeductionAmount: 300,
|
||||||
|
availableDeductionAmount: 100,
|
||||||
|
debtAmount: 400,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('提现冻结足够时不用动可用余额,也不产生欠款', () => {
|
||||||
|
const allocation = resolveAfterSalesRecoveryAllocation({
|
||||||
|
recoveryAmount: 7515,
|
||||||
|
pendingDepositAmount: 0,
|
||||||
|
frozenWithdrawAmount: 7500,
|
||||||
|
availableAmount: 35,
|
||||||
|
})
|
||||||
|
assert.deepEqual(allocation, {
|
||||||
|
pendingDepositDeductionAmount: 0,
|
||||||
|
withdrawDeductionAmount: 7500,
|
||||||
|
availableDeductionAmount: 15,
|
||||||
|
debtAmount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('提现冻结多余的部分不会被动用', () => {
|
||||||
|
const allocation = resolveAfterSalesRecoveryAllocation({
|
||||||
|
recoveryAmount: 500,
|
||||||
|
pendingDepositAmount: 0,
|
||||||
|
frozenWithdrawAmount: 7500,
|
||||||
|
availableAmount: 0,
|
||||||
|
})
|
||||||
|
assert.deepEqual(allocation, {
|
||||||
|
pendingDepositDeductionAmount: 0,
|
||||||
|
withdrawDeductionAmount: 500,
|
||||||
|
availableDeductionAmount: 0,
|
||||||
|
debtAmount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('各项都为 0 时全额转欠款', () => {
|
||||||
|
const allocation = resolveAfterSalesRecoveryAllocation({
|
||||||
|
recoveryAmount: 7480,
|
||||||
|
pendingDepositAmount: 0,
|
||||||
|
frozenWithdrawAmount: 0,
|
||||||
|
availableAmount: 0,
|
||||||
|
})
|
||||||
|
assert.deepEqual(allocation, {
|
||||||
|
pendingDepositDeductionAmount: 0,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
|
availableDeductionAmount: 0,
|
||||||
|
debtAmount: 7480,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('负数与空值按 0 处理', () => {
|
||||||
|
const allocation = resolveAfterSalesRecoveryAllocation({
|
||||||
|
recoveryAmount: -100,
|
||||||
|
pendingDepositAmount: -1,
|
||||||
|
frozenWithdrawAmount: -1,
|
||||||
|
availableAmount: -1,
|
||||||
|
})
|
||||||
|
assert.deepEqual(allocation, {
|
||||||
|
pendingDepositDeductionAmount: 0,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
|
availableDeductionAmount: 0,
|
||||||
|
debtAmount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
export type AfterSalesRecoveryAllocation = {
|
export type AfterSalesRecoveryAllocation = {
|
||||||
pendingDepositDeductionAmount: number
|
pendingDepositDeductionAmount: number
|
||||||
|
withdrawDeductionAmount: number
|
||||||
availableDeductionAmount: number
|
availableDeductionAmount: number
|
||||||
debtAmount: number
|
debtAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追缴分配顺序:待解冻押金 → 提现冻结 → 可用余额 → 欠款。
|
||||||
|
*
|
||||||
|
* 提现冻结排在可用余额之前,是因为它的钱本来就是从可用余额转过去的:
|
||||||
|
* 打手在验收后立刻提现,追缴时如果只看可用余额,就只能扣到零头,
|
||||||
|
* 剩下的挂成欠款;把提现冻结一并算进来,才能当场收齐。
|
||||||
|
*/
|
||||||
export function resolveAfterSalesRecoveryAllocation(input: {
|
export function resolveAfterSalesRecoveryAllocation(input: {
|
||||||
recoveryAmount: number
|
recoveryAmount: number
|
||||||
pendingDepositAmount: number
|
pendingDepositAmount: number
|
||||||
|
frozenWithdrawAmount: number
|
||||||
availableAmount: number
|
availableAmount: number
|
||||||
}): AfterSalesRecoveryAllocation {
|
}): AfterSalesRecoveryAllocation {
|
||||||
let remaining = Math.max(0, Number(input.recoveryAmount) || 0)
|
let remaining = Math.max(0, Number(input.recoveryAmount) || 0)
|
||||||
@@ -15,6 +24,11 @@ export function resolveAfterSalesRecoveryAllocation(input: {
|
|||||||
Math.max(0, Number(input.pendingDepositAmount) || 0),
|
Math.max(0, Number(input.pendingDepositAmount) || 0),
|
||||||
)
|
)
|
||||||
remaining -= pendingDepositDeductionAmount
|
remaining -= pendingDepositDeductionAmount
|
||||||
|
const withdrawDeductionAmount = Math.min(
|
||||||
|
remaining,
|
||||||
|
Math.max(0, Number(input.frozenWithdrawAmount) || 0),
|
||||||
|
)
|
||||||
|
remaining -= withdrawDeductionAmount
|
||||||
const availableDeductionAmount = Math.min(
|
const availableDeductionAmount = Math.min(
|
||||||
remaining,
|
remaining,
|
||||||
Math.max(0, Number(input.availableAmount) || 0),
|
Math.max(0, Number(input.availableAmount) || 0),
|
||||||
@@ -22,6 +36,7 @@ export function resolveAfterSalesRecoveryAllocation(input: {
|
|||||||
remaining -= availableDeductionAmount
|
remaining -= availableDeductionAmount
|
||||||
return {
|
return {
|
||||||
pendingDepositDeductionAmount,
|
pendingDepositDeductionAmount,
|
||||||
|
withdrawDeductionAmount,
|
||||||
availableDeductionAmount,
|
availableDeductionAmount,
|
||||||
debtAmount: remaining,
|
debtAmount: remaining,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
WorkerAfterSalesCaseRow,
|
WorkerAfterSalesCaseRow,
|
||||||
WorkerAfterSalesDebtRow,
|
WorkerAfterSalesDebtRow,
|
||||||
WorkerDepositUnfreezeRow,
|
WorkerDepositUnfreezeRow,
|
||||||
|
WorkerFinanceRequestRow,
|
||||||
WorkerWalletRow,
|
WorkerWalletRow,
|
||||||
WorkOrderRow,
|
WorkOrderRow,
|
||||||
WorkOrderShareRow,
|
WorkOrderShareRow,
|
||||||
@@ -314,6 +315,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pendingDepositDeductionAmount = 0
|
let pendingDepositDeductionAmount = 0
|
||||||
|
let withdrawDeductionAmount = 0
|
||||||
let availableDeductionAmount = 0
|
let availableDeductionAmount = 0
|
||||||
let debtAmount = 0
|
let debtAmount = 0
|
||||||
let nextStatus = 'closed'
|
let nextStatus = 'closed'
|
||||||
@@ -327,6 +329,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
now: input.now,
|
now: input.now,
|
||||||
})
|
})
|
||||||
pendingDepositDeductionAmount = recovery.pendingDepositDeductionAmount
|
pendingDepositDeductionAmount = recovery.pendingDepositDeductionAmount
|
||||||
|
withdrawDeductionAmount = recovery.withdrawDeductionAmount
|
||||||
availableDeductionAmount = recovery.availableDeductionAmount
|
availableDeductionAmount = recovery.availableDeductionAmount
|
||||||
debtAmount = recovery.debtAmount
|
debtAmount = recovery.debtAmount
|
||||||
nextStatus = debtAmount > 0 ? 'recovery_pending' : 'closed'
|
nextStatus = debtAmount > 0 ? 'recovery_pending' : 'closed'
|
||||||
@@ -341,12 +344,13 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
resolution_note = $3,
|
resolution_note = $3,
|
||||||
recovery_amount = $4,
|
recovery_amount = $4,
|
||||||
pending_deposit_deduction_amount = $5,
|
pending_deposit_deduction_amount = $5,
|
||||||
available_deduction_amount = $6,
|
withdraw_deduction_amount = $6,
|
||||||
debt_amount = $7,
|
available_deduction_amount = $7,
|
||||||
resolved_by = $8,
|
debt_amount = $8,
|
||||||
resolved_at = $9,
|
resolved_by = $9,
|
||||||
updated_at = $9
|
resolved_at = $10,
|
||||||
WHERE id = $10
|
updated_at = $10
|
||||||
|
WHERE id = $11
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -355,6 +359,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
input.resolutionNote,
|
input.resolutionNote,
|
||||||
input.action === 'upheld' ? input.recoveryAmount : 0,
|
input.action === 'upheld' ? input.recoveryAmount : 0,
|
||||||
pendingDepositDeductionAmount,
|
pendingDepositDeductionAmount,
|
||||||
|
withdrawDeductionAmount,
|
||||||
availableDeductionAmount,
|
availableDeductionAmount,
|
||||||
debtAmount,
|
debtAmount,
|
||||||
input.resolvedBy,
|
input.resolvedBy,
|
||||||
@@ -372,6 +377,7 @@ export async function resolveAfterSalesCase(input: {
|
|||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
recoveryAmount: input.action === 'upheld' ? input.recoveryAmount : 0,
|
recoveryAmount: input.action === 'upheld' ? input.recoveryAmount : 0,
|
||||||
pendingDepositDeductionAmount,
|
pendingDepositDeductionAmount,
|
||||||
|
withdrawDeductionAmount,
|
||||||
availableDeductionAmount,
|
availableDeductionAmount,
|
||||||
debtAmount,
|
debtAmount,
|
||||||
resolutionNote: input.resolutionNote,
|
resolutionNote: input.resolutionNote,
|
||||||
@@ -538,8 +544,13 @@ export async function offsetWorkerAfterSalesDebtsWithClient(
|
|||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE worker_after_sales_cases
|
UPDATE worker_after_sales_cases
|
||||||
SET debt_amount = $1,
|
SET debt_amount = $1::bigint,
|
||||||
status = CASE WHEN $1 = 0 AND status = 'recovery_pending' THEN 'closed' ELSE status END,
|
-- 必须显式 ::bigint:与下面和 0(integer)比较时,Postgres 会报
|
||||||
|
-- inconsistent types deduced for parameter,导致整笔抵扣失败。
|
||||||
|
status = CASE
|
||||||
|
WHEN $1::bigint = 0 AND status = 'recovery_pending' THEN 'closed'
|
||||||
|
ELSE status
|
||||||
|
END,
|
||||||
updated_at = $2::timestamptz
|
updated_at = $2::timestamptz
|
||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
`,
|
`,
|
||||||
@@ -728,6 +739,7 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
},
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
pendingDepositDeductionAmount: number
|
pendingDepositDeductionAmount: number
|
||||||
|
withdrawDeductionAmount: number
|
||||||
availableDeductionAmount: number
|
availableDeductionAmount: number
|
||||||
debtAmount: number
|
debtAmount: number
|
||||||
}> {
|
}> {
|
||||||
@@ -740,6 +752,20 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
`,
|
`,
|
||||||
[input.workOrderId, input.workerId],
|
[input.workOrderId, input.workerId],
|
||||||
)
|
)
|
||||||
|
// 提现冻结中的钱同属「已被扣住」的资金:追缴时直接把待审核提现驳回并扣走,
|
||||||
|
// 不能等它变成可用余额(打手往往在验收后立刻发起提现,等不到驳回)。
|
||||||
|
const pendingWithdrawResult = await client.query<WorkerFinanceRequestRow>(
|
||||||
|
`
|
||||||
|
SELECT * FROM worker_finance_requests
|
||||||
|
WHERE worker_id = $1
|
||||||
|
AND request_type = 'withdraw'
|
||||||
|
AND status = 'pending'
|
||||||
|
AND frozen_amount > 0
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[input.workerId],
|
||||||
|
)
|
||||||
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||||
let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId)
|
let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
const allocation = resolveAfterSalesRecoveryAllocation({
|
const allocation = resolveAfterSalesRecoveryAllocation({
|
||||||
@@ -748,6 +774,10 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
(total, row) => total + Math.max(0, Number(row.amount) || 0),
|
(total, row) => total + Math.max(0, Number(row.amount) || 0),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
|
frozenWithdrawAmount: pendingWithdrawResult.rows.reduce(
|
||||||
|
(total, row) => total + Math.max(0, Number(row.frozen_amount) || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
availableAmount: Number(wallet?.available_amount || 0),
|
availableAmount: Number(wallet?.available_amount || 0),
|
||||||
})
|
})
|
||||||
let pendingRemaining = allocation.pendingDepositDeductionAmount
|
let pendingRemaining = allocation.pendingDepositDeductionAmount
|
||||||
@@ -793,6 +823,88 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
pendingRemaining -= deduction
|
pendingRemaining -= deduction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提现冻结:整笔驳回申请,扣走需要的部分,剩余额度退回可用余额(随后仍会被下面的
|
||||||
|
// 「扣可用余额」按需扣掉)。frozen_amount 保留原值作为历史申请记录。
|
||||||
|
let withdrawRemaining = allocation.withdrawDeductionAmount
|
||||||
|
for (const row of pendingWithdrawResult.rows) {
|
||||||
|
if (withdrawRemaining <= 0) break
|
||||||
|
const frozen = Math.max(0, Number(row.frozen_amount) || 0)
|
||||||
|
if (frozen <= 0) continue
|
||||||
|
const deduction = Math.min(frozen, withdrawRemaining)
|
||||||
|
const released = frozen - deduction
|
||||||
|
wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
|
const nextAvailable = Number(wallet?.available_amount || 0) + released
|
||||||
|
const nextFrozenWithdraw = Math.max(0, Number(wallet?.frozen_withdraw_amount || 0) - frozen)
|
||||||
|
await client.query(
|
||||||
|
`UPDATE worker_wallets
|
||||||
|
SET available_amount = $1, frozen_withdraw_amount = $2, updated_at = $3
|
||||||
|
WHERE worker_id = $4`,
|
||||||
|
[nextAvailable, nextFrozenWithdraw, input.now, input.workerId],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`UPDATE worker_finance_requests
|
||||||
|
SET status = 'rejected',
|
||||||
|
reviewed_note = $1,
|
||||||
|
reviewed_at = $2,
|
||||||
|
updated_at = $2,
|
||||||
|
payload_json = payload_json || $3::jsonb
|
||||||
|
WHERE id = $4`,
|
||||||
|
[
|
||||||
|
'售后追缴自动驳回提现',
|
||||||
|
input.now,
|
||||||
|
JSON.stringify({
|
||||||
|
review: {
|
||||||
|
at: input.now,
|
||||||
|
by: 'system:after_sales',
|
||||||
|
note: '售后追缴自动驳回提现',
|
||||||
|
status: 'rejected',
|
||||||
|
source: 'after_sales_recovery',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
row.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if (deduction > 0) {
|
||||||
|
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, 'after_sales_withdraw_deduction', $2, $3, $4, $5, '售后问题单扣减提现冻结资金', $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.workerId,
|
||||||
|
-deduction,
|
||||||
|
nextAvailable,
|
||||||
|
nextFrozenWithdraw,
|
||||||
|
input.workOrderId,
|
||||||
|
JSON.stringify({ caseId: input.caseId, note: input.note, requestId: Number(row.id) }),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (released > 0) {
|
||||||
|
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, 'withdraw_release', $2, $3, $4, $5, '售后追缴驳回提现,扣除后余额退回', $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.workerId,
|
||||||
|
released,
|
||||||
|
nextAvailable,
|
||||||
|
nextFrozenWithdraw,
|
||||||
|
input.workOrderId,
|
||||||
|
JSON.stringify({ caseId: input.caseId, requestId: Number(row.id) }),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
withdrawRemaining -= deduction
|
||||||
|
}
|
||||||
|
|
||||||
wallet = await getWorkerWalletWithClient(client, input.workerId)
|
wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
const availableDeductionAmount = allocation.availableDeductionAmount
|
const availableDeductionAmount = allocation.availableDeductionAmount
|
||||||
if (availableDeductionAmount > 0) {
|
if (availableDeductionAmount > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追缴要能把「提现冻结中」的钱一并扣掉。
|
||||||
|
*
|
||||||
|
* 事故背景:case AS4f0566f0a192 追缴 75.15 时余额只剩 0.35,其余 75.00 在提现冻结里,
|
||||||
|
* 追缴只扣到 0.35;1 分 27 秒后提现被驳回,钱回到可用余额却不再抵扣。
|
||||||
|
*/
|
||||||
|
test('追缴 SQL 必须锁定并驳回 pending 提现申请', async () => {
|
||||||
|
const source = await readFile(new URL('./after-sales-repo.ts', import.meta.url), 'utf8')
|
||||||
|
const fn = source
|
||||||
|
.split('async function recoverAfterSalesAmountWithClient')[1]
|
||||||
|
?.split('async function createAfterSalesCaseEventWithClient')[0]
|
||||||
|
assert.ok(fn, '没找到 recoverAfterSalesAmountWithClient')
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fn.includes("request_type = 'withdraw'") && fn.includes("status = 'pending'"),
|
||||||
|
'追缴前必须捞出待审核提现申请',
|
||||||
|
)
|
||||||
|
assert.ok(fn.includes('FOR UPDATE'), '待审核提现申请必须加行锁')
|
||||||
|
assert.ok(fn.includes("SET status = 'rejected'"), '追缴动到提现冻结时必须驳回该提现申请')
|
||||||
|
assert.ok(
|
||||||
|
fn.includes('after_sales_withdraw_deduction'),
|
||||||
|
'扣提现冻结的钱要落独立流水类型,便于对账',
|
||||||
|
)
|
||||||
|
assert.ok(fn.includes('withdraw_release'), '扣不完的部分要退回可用余额并落 withdraw_release 流水')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('售后单要记录从提现冻结里扣走的金额', async () => {
|
||||||
|
const migration = await readFile(
|
||||||
|
new URL('../../db/migrations/109_after_sales_withdraw_deduction.sql', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
assert.ok(migration.includes('withdraw_deduction_amount'))
|
||||||
|
|
||||||
|
const source = await readFile(new URL('./after-sales-repo.ts', import.meta.url), 'utf8')
|
||||||
|
assert.ok(source.includes('withdraw_deduction_amount = $6'))
|
||||||
|
})
|
||||||
@@ -12,6 +12,9 @@ import test from 'node:test'
|
|||||||
const TIMESTAMP_ASSIGNMENT =
|
const TIMESTAMP_ASSIGNMENT =
|
||||||
/(?:^|\s)(updated_at|created_at|accepted_at|deadline_at|submitted_at|friend_added_at|gift_available_at|consumed_at|revoked_at|archived_at|assigned_at|published_at|pinned_at)\s*=\s*\$(\d+)(?!\s*::)/g
|
/(?:^|\s)(updated_at|created_at|accepted_at|deadline_at|submitted_at|friend_added_at|gift_available_at|consumed_at|revoked_at|archived_at|assigned_at|published_at|pinned_at)\s*=\s*\$(\d+)(?!\s*::)/g
|
||||||
const TEXT_CAST = /\$(\d+)::text/g
|
const TEXT_CAST = /\$(\d+)::text/g
|
||||||
|
const NUMERIC_UNCAST_ASSIGNMENT =
|
||||||
|
/(?:^|[\s,(])([a-z_]*(?:amount|quantity|count))\s*=\s*\$(\d+)(?!\s*::)/g
|
||||||
|
const NUMERIC_LITERAL_COMPARE = /\$(\d+)(?!\s*::)\s*(?:<=|>=|<>|!=|=|<|>)\s*-?\d+/g
|
||||||
|
|
||||||
test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async () => {
|
test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async () => {
|
||||||
const offenders: string[] = []
|
const offenders: string[] = []
|
||||||
@@ -34,6 +37,42 @@ test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async
|
|||||||
assert.deepEqual(offenders, [])
|
assert.deepEqual(offenders, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('仓库 SQL 不许让同一个数字占位符既赋值给金额列又和数字常量比较', async () => {
|
||||||
|
// 真实事故:`SET debt_amount = $1, status = CASE WHEN $1 = 0 ...`
|
||||||
|
// Postgres 从 $1=0 推断成 integer、从 debt_amount 推断成 bigint,
|
||||||
|
// 直接报 `inconsistent types deduced for parameter $1 (integer versus bigint)`,
|
||||||
|
// 导致手动偿还、验收报酬抵扣、充值入账抵扣全部 500。
|
||||||
|
const offenders: string[] = []
|
||||||
|
for await (const file of glob('src/**/*.ts', { cwd: new URL('../../../', import.meta.url) })) {
|
||||||
|
if (file.includes('.test.')) continue
|
||||||
|
const source = await readFile(new URL(`../../../${file}`, import.meta.url), 'utf8')
|
||||||
|
for (const chunk of source.split('`')) {
|
||||||
|
if (!chunk.includes('UPDATE') && !chunk.includes('INSERT')) continue
|
||||||
|
const numericAssignments = new Set(
|
||||||
|
[...chunk.matchAll(NUMERIC_UNCAST_ASSIGNMENT)].map((match) => match[2]),
|
||||||
|
)
|
||||||
|
if (numericAssignments.size === 0) continue
|
||||||
|
for (const match of chunk.matchAll(NUMERIC_LITERAL_COMPARE)) {
|
||||||
|
const param = match[1]
|
||||||
|
if (param && numericAssignments.has(param)) {
|
||||||
|
offenders.push(
|
||||||
|
`${file}: $${param} 既赋值金额列又与数字常量比较(需 ::bigint 等显式转换)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(offenders, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('售后欠款回写 SQL 显式把 debt_amount 参数转成 bigint', async () => {
|
||||||
|
const source = await readFile(new URL('./after-sales-repo.ts', import.meta.url), 'utf8')
|
||||||
|
const statement = source.split('`').find((chunk) => chunk.includes('debt_amount = $1'))
|
||||||
|
assert.ok(statement, '没找到回写 debt_amount 的 SQL')
|
||||||
|
assert.ok(statement.includes('debt_amount = $1::bigint'))
|
||||||
|
assert.ok(statement.includes('$1::bigint = 0'))
|
||||||
|
})
|
||||||
|
|
||||||
test('摘寄售标记的 SQL 显式把时间戳参数转回 timestamptz', async () => {
|
test('摘寄售标记的 SQL 显式把时间戳参数转回 timestamptz', async () => {
|
||||||
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
||||||
const detachStatement = source
|
const detachStatement = source
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export type WorkerAfterSalesCaseRow = {
|
|||||||
resolution_note: string
|
resolution_note: string
|
||||||
recovery_amount: number
|
recovery_amount: number
|
||||||
pending_deposit_deduction_amount: number
|
pending_deposit_deduction_amount: number
|
||||||
|
withdraw_deduction_amount: number
|
||||||
available_deduction_amount: number
|
available_deduction_amount: number
|
||||||
debt_amount: number
|
debt_amount: number
|
||||||
refunded_amount?: number
|
refunded_amount?: number
|
||||||
|
|||||||
@@ -547,6 +547,15 @@ export async function reviewWorkerFinanceRequest(input: {
|
|||||||
input.now,
|
input.now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
// 驳回退回来的钱同样属于「入账」,和其他入账路径保持一致:先抵扣未结清售后欠款,
|
||||||
|
// 否则会出现「追缴时钱在提现冻结里扣不到、驳回后钱回到余额却不再抵扣」的漏收。
|
||||||
|
await repayWorkerAfterSalesDebtsFromAvailableWithClient(client, {
|
||||||
|
workerId: Number(current.worker_id),
|
||||||
|
now: input.now,
|
||||||
|
note: '提现驳回退回自动偿还售后欠款',
|
||||||
|
actorType: 'system',
|
||||||
|
actorId: 'withdraw_release',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ router.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 「店铺」筛选下拉:固定全量候选,不随列表筛选变化(前端 Select 自带搜索过滤)。
|
// 「店铺」筛选下拉:固定全量候选,不随列表筛选变化(前端 Select 自带搜索过滤)。
|
||||||
|
// 服务端有 5 分钟缓存,这里再给浏览器 30 秒:后台切换页签时不必每次都回源。
|
||||||
router.get(
|
router.get(
|
||||||
'/worker-platform/orders/shop-options',
|
'/worker-platform/orders/shop-options',
|
||||||
requireAdminPermission('worker_order.view'),
|
requireAdminPermission('worker_order.view'),
|
||||||
|
setHttpCache(30),
|
||||||
createJsonHandler(() => listAdminWorkOrderShopNames(), {
|
createJsonHandler(() => listAdminWorkOrderShopNames(), {
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取店铺筛选选项失败',
|
errorMessage: '读取店铺筛选选项失败',
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const FINANCE_EVENTS_CTE = `
|
|||||||
FROM worker_wallet_ledgers wl
|
FROM worker_wallet_ledgers wl
|
||||||
WHERE wl.ledger_type IN (
|
WHERE wl.ledger_type IN (
|
||||||
'reward_settlement', 'sharing_reward', 'after_sales_deposit_deduction',
|
'reward_settlement', 'sharing_reward', 'after_sales_deposit_deduction',
|
||||||
'after_sales_balance_deduction', 'after_sales_debt_offset'
|
'after_sales_withdraw_deduction', 'after_sales_balance_deduction', 'after_sales_debt_offset'
|
||||||
)
|
)
|
||||||
AND wl.amount <> 0
|
AND wl.amount <> 0
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,20 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
|||||||
AND wpr.rule_key = substring(p.canonical_sku from 5)
|
AND wpr.rule_key = substring(p.canonical_sku from 5)
|
||||||
ORDER BY v.effective_from DESC, v.id DESC
|
ORDER BY v.effective_from DESC, v.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
),
|
||||||
|
-- 还没有成本版本的接单模板商品(例如刚建、还没跑「同步模板发单金额」),
|
||||||
|
-- 直接展示模板同步过来的发单金额,避免显示「未配置」让改价入口看起来不可用。
|
||||||
|
(
|
||||||
|
SELECT CASE
|
||||||
|
WHEN wpr2.unit_price_fen > 0 AND wpr2.reward_amount = 0
|
||||||
|
THEN wpr2.unit_price_fen
|
||||||
|
ELSE wpr2.reward_amount
|
||||||
|
END
|
||||||
|
FROM work_product_rules wpr2
|
||||||
|
WHERE p.canonical_sku LIKE 'tpl:%'
|
||||||
|
AND wpr2.rule_key = substring(p.canonical_sku from 5)
|
||||||
|
AND (wpr2.reward_amount > 0 OR wpr2.unit_price_fen > 0)
|
||||||
|
LIMIT 1
|
||||||
)
|
)
|
||||||
) AS active_cost_amount_fen,
|
) AS active_cost_amount_fen,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
|
|||||||
@@ -24,21 +24,26 @@ export async function previewAdminTemplateProductBootstrap(payload: JsonObject =
|
|||||||
const range = resolveBootstrapDateRange(payload)
|
const range = resolveBootstrapDateRange(payload)
|
||||||
const data = await loadTemplateProductBootstrapData(range)
|
const data = await loadTemplateProductBootstrapData(range)
|
||||||
const plan = buildTemplateMappingPlan(data.evidenceRows)
|
const plan = buildTemplateMappingPlan(data.evidenceRows)
|
||||||
const templateRuleKeys = new Set(data.templates.map((row) => row.rule_key))
|
const enabledRuleKeys = new Set(data.enabledTemplates.map((row) => row.rule_key))
|
||||||
const candidates = plan.candidates.filter(
|
const candidates = plan.candidates.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
templateRuleKeys.has(row.ruleKey) &&
|
enabledRuleKeys.has(row.ruleKey) &&
|
||||||
!data.existingMappingKeys.has(
|
!data.existingMappingKeys.has(
|
||||||
mappingKey(row.provider, row.shopId, row.skuCode, row.matchType, row.skuNameKey),
|
mappingKey(row.provider, row.shopId, row.skuCode, row.matchType, row.skuNameKey),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const usedRuleIds = new Set(data.evidenceRows.map((row) => Number(row.product_rule_id)))
|
|
||||||
const candidatesByMatchType = countMappingCandidates(candidates)
|
const candidatesByMatchType = countMappingCandidates(candidates)
|
||||||
|
const templatesWithoutWorkOrders =
|
||||||
|
data.enabledTemplates.length - data.templatesWithEvidence.length
|
||||||
return {
|
return {
|
||||||
dateFrom: range.fromDate,
|
dateFrom: range.fromDate,
|
||||||
dateTo: range.toDate,
|
dateTo: range.toDate,
|
||||||
templateCount: data.templates.length,
|
totalTemplateCount: data.enabledTemplates.length + data.disabledTemplates.length,
|
||||||
productExistingCount: data.templates.length - data.productsToCreate.length,
|
templateCount: data.enabledTemplates.length,
|
||||||
|
disabledTemplateCount: data.disabledTemplates.length,
|
||||||
|
evidenceTemplateCount: data.templatesWithEvidence.length,
|
||||||
|
templatesWithoutWorkOrders,
|
||||||
|
productExistingCount: data.enabledTemplates.length - data.productsToCreate.length,
|
||||||
productCreateCount: data.productsToCreate.length,
|
productCreateCount: data.productsToCreate.length,
|
||||||
mappingCandidateCount: plan.candidates.length,
|
mappingCandidateCount: plan.candidates.length,
|
||||||
mappingExistingCount: plan.candidates.length - candidates.length,
|
mappingExistingCount: plan.candidates.length - candidates.length,
|
||||||
@@ -50,11 +55,10 @@ export async function previewAdminTemplateProductBootstrap(payload: JsonObject =
|
|||||||
mappingConflictItemCount: plan.unresolvedItemCount,
|
mappingConflictItemCount: plan.unresolvedItemCount,
|
||||||
multiRuleKeyCount: plan.codeConflictKeyCount,
|
multiRuleKeyCount: plan.codeConflictKeyCount,
|
||||||
seriesFallbackConflictCount: plan.seriesFallbackConflictCount,
|
seriesFallbackConflictCount: plan.seriesFallbackConflictCount,
|
||||||
templatesWithoutWorkOrders: data.templates.filter((row) => !usedRuleIds.has(Number(row.id)))
|
|
||||||
.length,
|
|
||||||
samples: candidates.slice(0, 30).map((row) => ({
|
samples: candidates.slice(0, 30).map((row) => ({
|
||||||
ruleKey: row.ruleKey,
|
ruleKey: row.ruleKey,
|
||||||
productName: data.templates.find((item) => item.rule_key === row.ruleKey)?.product_name || '',
|
productName:
|
||||||
|
data.enabledTemplates.find((item) => item.rule_key === row.ruleKey)?.product_name || '',
|
||||||
provider: row.provider,
|
provider: row.provider,
|
||||||
shopId: row.shopId,
|
shopId: row.shopId,
|
||||||
skuCode: row.skuCode,
|
skuCode: row.skuCode,
|
||||||
@@ -79,13 +83,19 @@ export async function applyAdminTemplateProductBootstrap(
|
|||||||
_session?: AdminSession,
|
_session?: AdminSession,
|
||||||
) {
|
) {
|
||||||
const repair = payload.repair !== false
|
const repair = payload.repair !== false
|
||||||
|
const includeDisabledTemplates =
|
||||||
|
payload.includeDisabledTemplates === true || payload.includeDisabled === true
|
||||||
const range = resolveBootstrapDateRange(payload)
|
const range = resolveBootstrapDateRange(payload)
|
||||||
const resetResult = repair
|
const resetResult = repair
|
||||||
? await resetImportedTemplateProductBootstrap(range)
|
? await resetImportedTemplateProductBootstrap(range)
|
||||||
: { deletedMappingCount: 0, resetOrderItemCount: 0 }
|
: { deletedMappingCount: 0, resetOrderItemCount: 0 }
|
||||||
const data = await loadTemplateProductBootstrapData(range)
|
const data = await loadTemplateProductBootstrapData(range)
|
||||||
if (data.templates.length === 0) {
|
// 商品是价格/成本的载体,必须全量同步:新模板还没产生工单也要能定价。
|
||||||
throw createHttpError('没有可导入的接单模板', {
|
const productTemplates = includeDisabledTemplates
|
||||||
|
? [...data.enabledTemplates, ...data.disabledTemplates]
|
||||||
|
: data.enabledTemplates
|
||||||
|
if (productTemplates.length === 0) {
|
||||||
|
throw createHttpError('没有可同步的接单模板', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
errorCode: 'cost_template_bootstrap_empty',
|
errorCode: 'cost_template_bootstrap_empty',
|
||||||
})
|
})
|
||||||
@@ -94,6 +104,7 @@ export async function applyAdminTemplateProductBootstrap(
|
|||||||
const plan = buildTemplateMappingPlan(data.evidenceRows)
|
const plan = buildTemplateMappingPlan(data.evidenceRows)
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
let createdProductCount = 0
|
let createdProductCount = 0
|
||||||
|
let updatedProductCount = 0
|
||||||
let reusedProductCount = 0
|
let reusedProductCount = 0
|
||||||
let createdMappingCount = 0
|
let createdMappingCount = 0
|
||||||
let skippedMappingCount = 0
|
let skippedMappingCount = 0
|
||||||
@@ -101,30 +112,9 @@ export async function applyAdminTemplateProductBootstrap(
|
|||||||
|
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
const productIdByRuleKey = new Map<string, number>()
|
const productIdByRuleKey = new Map<string, number>()
|
||||||
for (const template of data.templates) {
|
for (const template of productTemplates) {
|
||||||
const existing = data.productByRuleKey.get(template.rule_key)
|
|
||||||
if (existing) {
|
|
||||||
productIdByRuleKey.set(template.rule_key, existing)
|
|
||||||
reusedProductCount += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalSku = buildTemplateProductSku(template.rule_key)
|
const canonicalSku = buildTemplateProductSku(template.rule_key)
|
||||||
const inserted = await client.query<{ id: number }>(
|
const attributes = {
|
||||||
`
|
|
||||||
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 NOTHING
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
canonicalSku,
|
|
||||||
template.product_name,
|
|
||||||
template.category,
|
|
||||||
inferTemplateUnitName(template.product_name),
|
|
||||||
JSON.stringify({
|
|
||||||
source: 'work_product_rule',
|
source: 'work_product_rule',
|
||||||
ruleId: Number(template.id),
|
ruleId: Number(template.id),
|
||||||
ruleKey: template.rule_key,
|
ruleKey: template.rule_key,
|
||||||
@@ -135,7 +125,55 @@ export async function applyAdminTemplateProductBootstrap(
|
|||||||
sellerIds: template.seller_ids_json,
|
sellerIds: template.seller_ids_json,
|
||||||
unitPriceFen: Number(template.unit_price_fen || 0),
|
unitPriceFen: Number(template.unit_price_fen || 0),
|
||||||
rewardAmountFen: Number(template.reward_amount || 0),
|
rewardAmountFen: Number(template.reward_amount || 0),
|
||||||
}),
|
templateEnabled: template.enabled,
|
||||||
|
}
|
||||||
|
const existing = data.productByRuleKey.get(template.rule_key)
|
||||||
|
if (existing) {
|
||||||
|
// 模板改名/改价后刷新接单模板自己的商品;其他来源的同名商品只复用,不改写。
|
||||||
|
const refreshed = await client.query(
|
||||||
|
`
|
||||||
|
UPDATE product_catalog
|
||||||
|
SET canonical_name = $1,
|
||||||
|
category = $2,
|
||||||
|
unit_name = $3,
|
||||||
|
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $4::jsonb,
|
||||||
|
updated_at = $5
|
||||||
|
WHERE id = $6 AND canonical_sku LIKE 'tpl:%'
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
template.product_name,
|
||||||
|
template.category,
|
||||||
|
inferTemplateUnitName(template.product_name),
|
||||||
|
JSON.stringify(attributes),
|
||||||
|
now,
|
||||||
|
existing,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if (refreshed.rowCount) {
|
||||||
|
updatedProductCount += 1
|
||||||
|
} else {
|
||||||
|
reusedProductCount += 1
|
||||||
|
}
|
||||||
|
productIdByRuleKey.set(template.rule_key, existing)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO product_catalog (
|
||||||
|
canonical_sku, canonical_name, category, unit_name, status,
|
||||||
|
attributes_json, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
|
||||||
|
ON CONFLICT (canonical_sku) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
canonicalSku,
|
||||||
|
template.product_name,
|
||||||
|
template.category,
|
||||||
|
inferTemplateUnitName(template.product_name),
|
||||||
|
template.enabled ? 'active' : 'archived',
|
||||||
|
JSON.stringify(attributes),
|
||||||
now,
|
now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -208,8 +246,12 @@ export async function applyAdminTemplateProductBootstrap(
|
|||||||
return {
|
return {
|
||||||
dateFrom: range.fromDate,
|
dateFrom: range.fromDate,
|
||||||
dateTo: range.toDate,
|
dateTo: range.toDate,
|
||||||
templateCount: data.templates.length,
|
templateCount: productTemplates.length,
|
||||||
|
enabledTemplateCount: data.enabledTemplates.length,
|
||||||
|
disabledTemplateCount: includeDisabledTemplates ? data.disabledTemplates.length : 0,
|
||||||
|
evidenceTemplateCount: data.templatesWithEvidence.length,
|
||||||
createdProductCount,
|
createdProductCount,
|
||||||
|
updatedProductCount,
|
||||||
reusedProductCount,
|
reusedProductCount,
|
||||||
createdMappingCount,
|
createdMappingCount,
|
||||||
skippedMappingCount,
|
skippedMappingCount,
|
||||||
@@ -234,6 +276,7 @@ export async function applyAdminTemplateProductBootstrap(
|
|||||||
|
|
||||||
type TemplateBootstrapTemplateRow = {
|
type TemplateBootstrapTemplateRow = {
|
||||||
id: number
|
id: number
|
||||||
|
enabled: boolean
|
||||||
rule_key: string
|
rule_key: string
|
||||||
product_name: string
|
product_name: string
|
||||||
category: string
|
category: string
|
||||||
@@ -647,6 +690,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
|||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
|
r.enabled,
|
||||||
r.rule_key,
|
r.rule_key,
|
||||||
r.product_name,
|
r.product_name,
|
||||||
COALESCE(c.name, '') AS category,
|
COALESCE(c.name, '') AS category,
|
||||||
@@ -666,7 +710,6 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
|||||||
ORDER BY wm.id
|
ORDER BY wm.id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) m ON TRUE
|
) m ON TRUE
|
||||||
WHERE r.enabled = TRUE
|
|
||||||
ORDER BY r.id
|
ORDER BY r.id
|
||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
@@ -714,8 +757,11 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
|||||||
])
|
])
|
||||||
|
|
||||||
const usedRuleIds = new Set(evidenceResult.rows.map((row) => Number(row.product_rule_id)))
|
const usedRuleIds = new Set(evidenceResult.rows.map((row) => Number(row.product_rule_id)))
|
||||||
// 只初始化时间范围内真正有工单证据的模板,避免把很早以前、已不再使用的模板商品重新带出来。
|
// 商品是「成本/价格」的载体,必须全量同步:新模板还没产生工单时也要能定价。
|
||||||
const templates = templatesResult.rows.filter((row) => usedRuleIds.has(Number(row.id)))
|
// 渠道映射仍然只按工单证据建(没有订单就没有平台 SKU 可映射)。
|
||||||
|
const enabledTemplates = templatesResult.rows.filter((row) => row.enabled)
|
||||||
|
const disabledTemplates = templatesResult.rows.filter((row) => !row.enabled)
|
||||||
|
const templatesWithEvidence = enabledTemplates.filter((row) => usedRuleIds.has(Number(row.id)))
|
||||||
const productByCanonicalSku = new Map(
|
const productByCanonicalSku = new Map(
|
||||||
productsResult.rows.map((row) => [row.canonical_sku, Number(row.id)]),
|
productsResult.rows.map((row) => [row.canonical_sku, Number(row.id)]),
|
||||||
)
|
)
|
||||||
@@ -728,7 +774,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
|||||||
}
|
}
|
||||||
const productByRuleKey = new Map<string, number>()
|
const productByRuleKey = new Map<string, number>()
|
||||||
const productsToCreate: TemplateBootstrapTemplateRow[] = []
|
const productsToCreate: TemplateBootstrapTemplateRow[] = []
|
||||||
for (const template of templates) {
|
for (const template of enabledTemplates) {
|
||||||
const existing =
|
const existing =
|
||||||
productByCanonicalSku.get(buildTemplateProductSku(template.rule_key)) ||
|
productByCanonicalSku.get(buildTemplateProductSku(template.rule_key)) ||
|
||||||
productByCanonicalName.get(normalizeProductNameKey(template.product_name))
|
productByCanonicalName.get(normalizeProductNameKey(template.product_name))
|
||||||
@@ -740,7 +786,9 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates,
|
enabledTemplates,
|
||||||
|
disabledTemplates,
|
||||||
|
templatesWithEvidence,
|
||||||
evidenceRows: evidenceResult.rows,
|
evidenceRows: evidenceResult.rows,
|
||||||
productsToCreate,
|
productsToCreate,
|
||||||
productByRuleKey,
|
productByRuleKey,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
|
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
|
||||||
import { getWorkerHallConfig } from './worker-hall-config-service.js'
|
import { getWorkerHallConfig } from './worker-hall-config-service.js'
|
||||||
|
import { invalidateWorkOrderShopOptionsCache } from './work-order-shop-options-cache.js'
|
||||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||||
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
|
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
|
||||||
|
|
||||||
@@ -182,6 +183,8 @@ export async function deleteAdminWorkOrder(workOrderId: number | string) {
|
|||||||
errorCode: 'work_order_delete_failed',
|
errorCode: 'work_order_delete_failed',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 删掉某店铺的最后一张工单后,它不该继续出现在「店铺」筛选候选里。
|
||||||
|
invalidateWorkOrderShopOptionsCache()
|
||||||
publishWorkOrderRealtimeChange({
|
publishWorkOrderRealtimeChange({
|
||||||
workOrderId: Number(workOrder.id),
|
workOrderId: Number(workOrder.id),
|
||||||
hallChanged: workOrder.status === WORK_ORDER_STATUS.OPEN,
|
hallChanged: workOrder.status === WORK_ORDER_STATUS.OPEN,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
listConsignmentInventoriesByIds,
|
listConsignmentInventoriesByIds,
|
||||||
listWorkOrderEventsByOrderId,
|
listWorkOrderEventsByOrderId,
|
||||||
listWorkOrderSharesByOrderIds,
|
listWorkOrderSharesByOrderIds,
|
||||||
listWorkOrderShopNames,
|
|
||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
sumPendingUnfreezeByOrderIds,
|
sumPendingUnfreezeByOrderIds,
|
||||||
type ConsignmentInventoryRow,
|
type ConsignmentInventoryRow,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
normalizeOptionalId,
|
normalizeOptionalId,
|
||||||
normalizeStatuses,
|
normalizeStatuses,
|
||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
|
import { listCachedWorkOrderShopNames } from './work-order-shop-options-cache.js'
|
||||||
import { WorkerListCache } from './worker-list-cache.js'
|
import { WorkerListCache } from './worker-list-cache.js'
|
||||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||||
|
|
||||||
@@ -194,7 +194,13 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
|||||||
*
|
*
|
||||||
* 固定全量去重(不随状态/日期等筛选变化),下拉里带搜索框由前端过滤即可;
|
* 固定全量去重(不随状态/日期等筛选变化),下拉里带搜索框由前端过滤即可;
|
||||||
* 数据口径见 repository 的 listWorkOrderShopNames(orders.shop_name,精确匹配)。
|
* 数据口径见 repository 的 listWorkOrderShopNames(orders.shop_name,精确匹配)。
|
||||||
|
*
|
||||||
|
* 结果走 work-order-shop-options-cache:这条 SQL 每次要并行全表扫 orders 并与
|
||||||
|
* work_orders 做半连接(生产实测 1.6 秒 / 200MB 磁盘读),而候选只有十几个、
|
||||||
|
* 极少变化,绝不能每次打开页面都重算。
|
||||||
*/
|
*/
|
||||||
export async function listAdminWorkOrderShopNames(): Promise<{ items: string[] }> {
|
export async function listAdminWorkOrderShopNames(): Promise<{ items: string[] }> {
|
||||||
return { items: await listWorkOrderShopNames() }
|
const items = await listCachedWorkOrderShopNames()
|
||||||
|
// 返回副本:缓存里的是共享数组,调用方(含序列化前的映射)不该改到它。
|
||||||
|
return { items: [...items] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
resolveWorkOrderSharingEnabled,
|
resolveWorkOrderSharingEnabled,
|
||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
|
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
|
||||||
|
import { registerWorkOrderShopName } from './work-order-shop-options-cache.js'
|
||||||
import { decideOrderItemsFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
|
import { decideOrderItemsFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
|
||||||
|
|
||||||
export async function syncWorkerOrdersForSourceOrder(
|
export async function syncWorkerOrdersForSourceOrder(
|
||||||
@@ -271,6 +272,8 @@ export async function syncWorkerOrdersForSourceOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
created.push(workOrder)
|
created.push(workOrder)
|
||||||
|
// 店铺第一次产生工单时要能出现在后台「店铺」筛选里;老店铺出单不会动缓存。
|
||||||
|
registerWorkOrderShopName(order.shop_name)
|
||||||
decisions.push(
|
decisions.push(
|
||||||
buildWorkerConsumerDecision(item, {
|
buildWorkerConsumerDecision(item, {
|
||||||
result: 'created',
|
result: 'created',
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { listWorkOrderShopNames } from '../../repositories/worker-platform/index.js'
|
||||||
|
import { WorkerListCache } from './worker-list-cache.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台工单「店铺」筛选下拉的缓存归属模块。
|
||||||
|
*
|
||||||
|
* 为什么要缓存:这条 SQL 从 orders(约 43 万行)出发做并行全表扫描,再与 work_orders
|
||||||
|
* 做哈希半连接,生产实测单次 1.6 秒、读 22~28 万块(≈200MB 磁盘),而结果只有十几个
|
||||||
|
* 店铺名,且只在「某个店铺第一次产生工单」时才会变。后台工单页每次挂载、每次改单后的
|
||||||
|
* refreshAll 都会重新全表扫一遍,几条并发就能吃掉 4 核里的一到两个核,连接池随即见底
|
||||||
|
* (2026-10-30 18:57 的生产事故就是这么放大的)。
|
||||||
|
*
|
||||||
|
* 为什么单独一个文件:缓存实例要同时被「读」(admin-work-order-query-service)和
|
||||||
|
* 「写」(建单/删单路径主动失效)引用,抽成只依赖 WorkerListCache 的中立模块,
|
||||||
|
* 服务层之间不会形成循环依赖。
|
||||||
|
*
|
||||||
|
* 失效策略是「精确」而不是「无脑作废」:高峰期每 10~20 秒就建一张新工单,
|
||||||
|
* 如果建单就整体失效,等于没缓存。所以记住当前候选集合,只有真的出现新店铺名才作废。
|
||||||
|
* TTL 取 5 分钟只是兜底,正常情况下靠 registerWorkOrderShopName 即时对齐。
|
||||||
|
*/
|
||||||
|
const WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS = 5 * 60_000
|
||||||
|
const workOrderShopOptionsCache = new WorkerListCache(WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS, 1)
|
||||||
|
|
||||||
|
/** 全量固定口径只有一份结果,键用常量即可;getOrSet 同时合并并发请求。 */
|
||||||
|
const WORK_ORDER_SHOP_OPTIONS_CACHE_KEY = 'worker-platform:order-shop-options'
|
||||||
|
|
||||||
|
/** 当前候选集合的快照,用来判断「这次建单是否引入了新店铺」。 */
|
||||||
|
let knownShopNames: Set<string> | null = null
|
||||||
|
|
||||||
|
export function listCachedWorkOrderShopNames(): Promise<string[]> {
|
||||||
|
return workOrderShopOptionsCache.getOrSet(WORK_ORDER_SHOP_OPTIONS_CACHE_KEY, async () => {
|
||||||
|
const items = await listWorkOrderShopNames()
|
||||||
|
knownShopNames = new Set(items)
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建单后登记店铺名,返回是否因此作废了缓存。
|
||||||
|
*
|
||||||
|
* 只有「候选里从没出现过的店铺」才需要重算:老店铺出单不影响下拉内容。
|
||||||
|
* 缓存尚未建立时直接忽略(下一次读取自然会带上新店铺)。
|
||||||
|
*/
|
||||||
|
export function registerWorkOrderShopName(shopName: string): boolean {
|
||||||
|
const name = String(shopName || '').trim()
|
||||||
|
if (!name || !knownShopNames || knownShopNames.has(name)) return false
|
||||||
|
// 先记账再作废:并发的同店铺建单不会重复触发全表扫描。
|
||||||
|
knownShopNames.add(name)
|
||||||
|
workOrderShopOptionsCache.clear()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删单可能让某个店铺彻底不再挂着工单,此时直接作废(删单是低频操作)。
|
||||||
|
* 返回逐出的条目数,便于测试与观测。
|
||||||
|
*/
|
||||||
|
export function invalidateWorkOrderShopOptionsCache(): number {
|
||||||
|
knownShopNames = null
|
||||||
|
return workOrderShopOptionsCache.clear()
|
||||||
|
}
|
||||||
@@ -209,6 +209,7 @@ export function mapWorkerAfterSalesCase(afterSalesCase: {
|
|||||||
resolution_note: string
|
resolution_note: string
|
||||||
recovery_amount: number
|
recovery_amount: number
|
||||||
pending_deposit_deduction_amount: number
|
pending_deposit_deduction_amount: number
|
||||||
|
withdraw_deduction_amount: number
|
||||||
available_deduction_amount: number
|
available_deduction_amount: number
|
||||||
debt_amount: number
|
debt_amount: number
|
||||||
refunded_amount?: number
|
refunded_amount?: number
|
||||||
@@ -259,6 +260,7 @@ export function mapWorkerAfterSalesCase(afterSalesCase: {
|
|||||||
resolutionNote: String(afterSalesCase.resolution_note || ''),
|
resolutionNote: String(afterSalesCase.resolution_note || ''),
|
||||||
recoveryAmount: Number(afterSalesCase.recovery_amount || 0),
|
recoveryAmount: Number(afterSalesCase.recovery_amount || 0),
|
||||||
pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0),
|
pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0),
|
||||||
|
withdrawDeductionAmount: Number(afterSalesCase.withdraw_deduction_amount || 0),
|
||||||
availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0),
|
availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0),
|
||||||
debtAmount: Number(afterSalesCase.debt_amount || 0),
|
debtAmount: Number(afterSalesCase.debt_amount || 0),
|
||||||
refundedAmount: Number(afterSalesCase.refunded_amount || 0),
|
refundedAmount: Number(afterSalesCase.refunded_amount || 0),
|
||||||
|
|||||||
@@ -24,3 +24,19 @@ test('WorkerListCache coalesces concurrent reads and expires entries', async ()
|
|||||||
assert.deepEqual(await cache.getOrSet('same', factory), { calls: 2 })
|
assert.deepEqual(await cache.getOrSet('same', factory), { calls: 2 })
|
||||||
assert.equal(calls, 2)
|
assert.equal(calls, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('WorkerListCache.clear 逐出全部条目(含在途 Promise),并返回逐出数量', async () => {
|
||||||
|
const cache = new WorkerListCache(60_000)
|
||||||
|
let calls = 0
|
||||||
|
const factory = async () => {
|
||||||
|
calls += 1
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(await cache.getOrSet('a', factory), 1)
|
||||||
|
assert.equal(await cache.getOrSet('b', factory), 2)
|
||||||
|
assert.equal(cache.clear(), 2)
|
||||||
|
// 清空后同一个键要重新计算,而不是继续命中旧值
|
||||||
|
assert.equal(await cache.getOrSet('a', factory), 3)
|
||||||
|
assert.equal(cache.clear(), 1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -45,4 +45,11 @@ export class WorkerListCache {
|
|||||||
}
|
}
|
||||||
return evicted
|
return evicted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 逐出全部条目(只有一份结果的配置类缓存用),返回逐出数量。 */
|
||||||
|
clear(): number {
|
||||||
|
const evicted = this.entries.size
|
||||||
|
this.entries.clear()
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function normalizeWalletLedgerType(value: unknown) {
|
|||||||
'reward_settlement',
|
'reward_settlement',
|
||||||
'sharing_reward',
|
'sharing_reward',
|
||||||
'after_sales_deposit_deduction',
|
'after_sales_deposit_deduction',
|
||||||
|
'after_sales_withdraw_deduction',
|
||||||
'after_sales_balance_deduction',
|
'after_sales_balance_deduction',
|
||||||
'after_sales_debt_offset',
|
'after_sales_debt_offset',
|
||||||
'after_sales_refund',
|
'after_sales_refund',
|
||||||
|
|||||||
@@ -623,10 +623,12 @@ test('售后追缴优先扣减待解冻押金', () => {
|
|||||||
resolveAfterSalesRecoveryAllocation({
|
resolveAfterSalesRecoveryAllocation({
|
||||||
recoveryAmount: 10_000,
|
recoveryAmount: 10_000,
|
||||||
pendingDepositAmount: 12_000,
|
pendingDepositAmount: 12_000,
|
||||||
|
frozenWithdrawAmount: 0,
|
||||||
availableAmount: 8_000,
|
availableAmount: 8_000,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
pendingDepositDeductionAmount: 10_000,
|
pendingDepositDeductionAmount: 10_000,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
availableDeductionAmount: 0,
|
availableDeductionAmount: 0,
|
||||||
debtAmount: 0,
|
debtAmount: 0,
|
||||||
},
|
},
|
||||||
@@ -638,10 +640,12 @@ test('售后追缴在押金不足时继续扣减可用余额', () => {
|
|||||||
resolveAfterSalesRecoveryAllocation({
|
resolveAfterSalesRecoveryAllocation({
|
||||||
recoveryAmount: 10_000,
|
recoveryAmount: 10_000,
|
||||||
pendingDepositAmount: 3_000,
|
pendingDepositAmount: 3_000,
|
||||||
|
frozenWithdrawAmount: 0,
|
||||||
availableAmount: 8_000,
|
availableAmount: 8_000,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
pendingDepositDeductionAmount: 3_000,
|
pendingDepositDeductionAmount: 3_000,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
availableDeductionAmount: 7_000,
|
availableDeductionAmount: 7_000,
|
||||||
debtAmount: 0,
|
debtAmount: 0,
|
||||||
},
|
},
|
||||||
@@ -653,16 +657,35 @@ test('售后追缴在余额不足时形成欠款', () => {
|
|||||||
resolveAfterSalesRecoveryAllocation({
|
resolveAfterSalesRecoveryAllocation({
|
||||||
recoveryAmount: 10_000,
|
recoveryAmount: 10_000,
|
||||||
pendingDepositAmount: 3_000,
|
pendingDepositAmount: 3_000,
|
||||||
|
frozenWithdrawAmount: 0,
|
||||||
availableAmount: 2_000,
|
availableAmount: 2_000,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
pendingDepositDeductionAmount: 3_000,
|
pendingDepositDeductionAmount: 3_000,
|
||||||
|
withdrawDeductionAmount: 0,
|
||||||
availableDeductionAmount: 2_000,
|
availableDeductionAmount: 2_000,
|
||||||
debtAmount: 5_000,
|
debtAmount: 5_000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('售后追缴先扣待解冻押金、再扣提现冻结、最后扣可用余额', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveAfterSalesRecoveryAllocation({
|
||||||
|
recoveryAmount: 10_000,
|
||||||
|
pendingDepositAmount: 3_000,
|
||||||
|
frozenWithdrawAmount: 5_000,
|
||||||
|
availableAmount: 8_000,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
pendingDepositDeductionAmount: 3_000,
|
||||||
|
withdrawDeductionAmount: 5_000,
|
||||||
|
availableDeductionAmount: 2_000,
|
||||||
|
debtAmount: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function buildOrderItemRow(): OrderItemRow {
|
function buildOrderItemRow(): OrderItemRow {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -2890,6 +2913,83 @@ test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('后台「店铺」筛选下拉必须缓存 + 写路径失效(单次全表扫约 200MB)', async () => {
|
||||||
|
const { readFile } = await import('node:fs/promises')
|
||||||
|
|
||||||
|
// 背景:listWorkOrderShopNames 从 orders(43 万行)做并行全表扫描,再与 work_orders
|
||||||
|
// 做哈希半连接。生产实测单次 1.6 秒、读 22~28 万块(≈200MB),结果却只有十几个店铺名。
|
||||||
|
// 2026-10-30 18:57 的事故里,后台工单页每次挂载、每次改单后的 refreshAll 都重算一遍,
|
||||||
|
// 几条并发就把 4 核打满并拖垮连接池,所以这里必须有缓存 + 主动失效。
|
||||||
|
const cacheModule = await readFile(
|
||||||
|
new URL('./work-order-shop-options-cache.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
assert.ok(cacheModule.includes('new WorkerListCache('), '缺少店铺下拉缓存实例')
|
||||||
|
assert.ok(
|
||||||
|
cacheModule.includes('WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS = 5 * 60_000'),
|
||||||
|
'TTL 必须拉长到 5 分钟:这份候选只在「新店铺第一次出单」时变化',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
cacheModule.includes('workOrderShopOptionsCache.getOrSet('),
|
||||||
|
'必须走 getOrSet:多人同时刷新要合并成一次全表扫描',
|
||||||
|
)
|
||||||
|
assert.ok(cacheModule.includes('clear()'), '缺少主动失效入口,只能等 TTL 自然过期')
|
||||||
|
assert.ok(
|
||||||
|
cacheModule.includes('registerWorkOrderShopName('),
|
||||||
|
'缺少建单登记入口:新店铺必须立刻可选中',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
cacheModule.includes('knownShopNames.has(name)'),
|
||||||
|
'建单必须只对「新店铺」作废缓存:高峰期每 10~20 秒一单,无条件作废等于没缓存',
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = await readFile(
|
||||||
|
new URL('./admin-work-order-query-service.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
service.includes('listCachedWorkOrderShopNames()'),
|
||||||
|
'店铺下拉没走缓存,仍会每次全表扫 orders',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
service.includes('items: [...items]'),
|
||||||
|
'缓存里的数组必须复制后返回,否则调用方改到共享数组会污染缓存',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 浏览器侧也挡住:后台切页签时不必每次都回源(服务端缓存 5 分钟,这里只加 30 秒)
|
||||||
|
const route = await readFile(
|
||||||
|
new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const routeBody = route.slice(
|
||||||
|
route.indexOf("'/worker-platform/orders/shop-options'"),
|
||||||
|
route.indexOf("'/worker-platform/orders/mock/custom-shops'"),
|
||||||
|
)
|
||||||
|
assert.ok(routeBody.includes('setHttpCache('), '店铺下拉接口缺少 Cache-Control')
|
||||||
|
|
||||||
|
// 写路径精确失效:建单只在「新店铺」时重算,删单可能让某个店铺的候选消失
|
||||||
|
const syncSrc = await readFile(
|
||||||
|
new URL('./sync-worker-orders-service.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
syncSrc.includes('registerWorkOrderShopName(order.shop_name)'),
|
||||||
|
'建单路径缺少店铺登记:新店铺要等 5 分钟才出现在下拉里',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!syncSrc.includes('invalidateWorkOrderShopOptionsCache()'),
|
||||||
|
'建单不能整体作废缓存:老店铺出单同样会触发,等于没缓存',
|
||||||
|
)
|
||||||
|
const manageSrc = await readFile(
|
||||||
|
new URL('./admin-work-order-management-service.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
manageSrc.includes('invalidateWorkOrderShopOptionsCache()'),
|
||||||
|
'删单路径缺少失效:删掉某店铺最后一张工单后它仍留在下拉里',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('残留字段 work_orders.task_id 不得再被当成关联键 JOIN(迁移 091)', async () => {
|
test('残留字段 work_orders.task_id 不得再被当成关联键 JOIN(迁移 091)', async () => {
|
||||||
const { readFile } = await import('node:fs/promises')
|
const { readFile } = await import('node:fs/promises')
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
import type { TableColumnsType } from 'antd'
|
import type { TableColumnsType } from 'antd'
|
||||||
import PercentTable from '@/components/common/PercentTable'
|
import PercentTable from '@/components/common/PercentTable'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
|
|
||||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||||
@@ -65,6 +65,33 @@ import {
|
|||||||
type ProductFormValues,
|
type ProductFormValues,
|
||||||
} from './shared'
|
} from './shared'
|
||||||
|
|
||||||
|
/** 全量同步选项:停用模板默认不建商品,勾选后建成「归档」商品。 */
|
||||||
|
type BootstrapSyncOptions = { includeDisabledTemplates: boolean }
|
||||||
|
|
||||||
|
function DisabledTemplateSyncOption({
|
||||||
|
initialValue,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
initialValue: boolean
|
||||||
|
onChange: (value: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [checked, setChecked] = useState(initialValue)
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => {
|
||||||
|
setChecked(event.target.checked)
|
||||||
|
onChange(event.target.checked)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>同时为停用模板建商品(归档状态,不参与成本统计)</strong>
|
||||||
|
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||||
|
默认只同步启用模板;停用模板一般是历史遗留,建出来的商品会以「归档」出现在商品列表里。
|
||||||
|
</div>
|
||||||
|
</Checkbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ProductCatalogPanel() {
|
export function ProductCatalogPanel() {
|
||||||
const { message, modal } = App.useApp()
|
const { message, modal } = App.useApp()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -82,6 +109,7 @@ export function ProductCatalogPanel() {
|
|||||||
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
|
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
|
||||||
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
|
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
|
||||||
const [bootstrapApplying, setBootstrapApplying] = useState(false)
|
const [bootstrapApplying, setBootstrapApplying] = useState(false)
|
||||||
|
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
|
||||||
const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false)
|
const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false)
|
||||||
const [normalizingManualProducts, setNormalizingManualProducts] = useState(false)
|
const [normalizingManualProducts, setNormalizingManualProducts] = useState(false)
|
||||||
const [syncingAffiliate, setSyncingAffiliate] = useState(false)
|
const [syncingAffiliate, setSyncingAffiliate] = useState(false)
|
||||||
@@ -152,36 +180,51 @@ export function ProductCatalogPanel() {
|
|||||||
try {
|
try {
|
||||||
const response = await previewAdminTemplateProductBootstrap(backfillPayload())
|
const response = await previewAdminTemplateProductBootstrap(backfillPayload())
|
||||||
const data = response.data
|
const data = response.data
|
||||||
|
bootstrapSyncOptionsRef.current.includeDisabledTemplates = false
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: '从接单模板初始化商品与映射',
|
title: '从接单模板全量同步商品与映射',
|
||||||
width: 720,
|
width: 720,
|
||||||
content: (
|
content: (
|
||||||
<Space direction="vertical" size={8}>
|
<Space direction="vertical" size={8}>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message={`统计范围:${data.dateFrom} ~ ${data.dateTo}(默认最近 30 天)`}
|
message={`统计范围:${data.dateFrom} ~ ${data.dateTo}(商品全量同步,不依赖订单;映射证据按该范围)`}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
启用接单模板 <strong>{data.templateCount}</strong> 个:将新建{' '}
|
接单模板共 <strong>{data.totalTemplateCount}</strong> 个(启用{' '}
|
||||||
<strong>{data.productCreateCount}</strong> 个标准商品,复用{' '}
|
<strong>{data.templateCount}</strong> / 停用{' '}
|
||||||
<strong>{data.productExistingCount}</strong> 个已有商品。
|
<strong>{data.disabledTemplateCount}</strong>)。
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
根据所选时间范围内工单命中的模板,将建立 <strong>{data.mappingCreateCount}</strong>{' '}
|
启用模板全部建标准商品:将新建 <strong>{data.productCreateCount}</strong> 个、复用{' '}
|
||||||
条渠道映射:编码精确 <strong>{data.mappingCreateByMatchType.sku_code}</strong>{' '}
|
<strong>{data.productExistingCount}</strong>{' '}
|
||||||
条、编码 + 名称系列 <strong>{data.mappingCreateByMatchType.sku_code_series}</strong>{' '}
|
个(已有模板商品会同步刷新名称、分类、发单金额)。
|
||||||
条、 编码 + 精确名称 <strong>{data.mappingCreateByMatchType.sku_code_name}</strong>{' '}
|
</span>
|
||||||
条、名称系列兜底 <strong>{data.mappingCreateByMatchType.sku_name_series}</strong> 条。
|
<span>
|
||||||
|
其中 <strong>{data.evidenceTemplateCount}</strong> 个模板在范围内有工单证据,将建立{' '}
|
||||||
|
<strong>{data.mappingCreateCount}</strong> 条渠道映射:编码精确{' '}
|
||||||
|
<strong>{data.mappingCreateByMatchType.sku_code}</strong> 条、编码 + 名称系列{' '}
|
||||||
|
<strong>{data.mappingCreateByMatchType.sku_code_series}</strong> 条、 编码 + 精确名称{' '}
|
||||||
|
<strong>{data.mappingCreateByMatchType.sku_code_name}</strong> 条、名称系列兜底{' '}
|
||||||
|
<strong>{data.mappingCreateByMatchType.sku_name_series}</strong> 条。
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{data.templatesWithoutWorkOrders}</strong> 个启用模板在范围内没有工单证据:
|
||||||
|
只建商品(可在「商品与成本中心」直接改价),不建渠道映射。
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
预计可归一 <strong>{data.orderItemCoverCount}</strong> / {data.evidenceItemCount}{' '}
|
预计可归一 <strong>{data.orderItemCoverCount}</strong> / {data.evidenceItemCount}{' '}
|
||||||
个范围内有工单证据的订单项。
|
个范围内有工单证据的订单项。
|
||||||
</span>
|
</span>
|
||||||
<span>
|
{data.disabledTemplateCount > 0 ? (
|
||||||
<strong>{data.templatesWithoutWorkOrders}</strong>{' '}
|
<DisabledTemplateSyncOption
|
||||||
个启用模板在范围内没有工单证据,本次不会创建。
|
initialValue={false}
|
||||||
</span>
|
onChange={(value) => {
|
||||||
|
bootstrapSyncOptionsRef.current = { includeDisabledTemplates: value }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{data.mappingConflictCount > 0 ? (
|
{data.mappingConflictCount > 0 ? (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -215,12 +258,15 @@ export function ProductCatalogPanel() {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
okText: '开始初始化并修复旧导入',
|
okText: '开始全量同步',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setBootstrapApplying(true)
|
setBootstrapApplying(true)
|
||||||
try {
|
try {
|
||||||
const result = await applyAdminTemplateProductBootstrap(backfillPayload())
|
const result = await applyAdminTemplateProductBootstrap({
|
||||||
|
...backfillPayload(),
|
||||||
|
includeDisabledTemplates: bootstrapSyncOptionsRef.current.includeDisabledTemplates,
|
||||||
|
})
|
||||||
const applied = result.data
|
const applied = result.data
|
||||||
const rebuildText =
|
const rebuildText =
|
||||||
applied.deletedImportedMappingCount > 0
|
applied.deletedImportedMappingCount > 0
|
||||||
@@ -230,8 +276,12 @@ export function ProductCatalogPanel() {
|
|||||||
applied.mappingConflictCount > 0
|
applied.mappingConflictCount > 0
|
||||||
? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理`
|
? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理`
|
||||||
: ''
|
: ''
|
||||||
|
const disabledText =
|
||||||
|
applied.disabledTemplateCount > 0
|
||||||
|
? `(含停用模板 ${applied.disabledTemplateCount} 个,已建为归档商品)`
|
||||||
|
: ''
|
||||||
message.success(
|
message.success(
|
||||||
`已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
|
`已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
|
||||||
)
|
)
|
||||||
refreshAll()
|
refreshAll()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -411,7 +461,7 @@ export function ProductCatalogPanel() {
|
|||||||
item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined
|
item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined
|
||||||
? (item.activeCostAmountFen / 100).toFixed(2)
|
? (item.activeCostAmountFen / 100).toFixed(2)
|
||||||
: undefined,
|
: undefined,
|
||||||
effectiveScope: 'retroactive',
|
effectiveScope: 'immediate',
|
||||||
changeReason: '在商品与成本中心统一调整价格',
|
changeReason: '在商品与成本中心统一调整价格',
|
||||||
syncOpenOrders: true,
|
syncOpenOrders: true,
|
||||||
syncSnapshots: true,
|
syncSnapshots: true,
|
||||||
@@ -1065,16 +1115,16 @@ export function ProductCatalogPanel() {
|
|||||||
rules={[{ required: true, message: '请选择生效时间范围' }]}
|
rules={[{ required: true, message: '请选择生效时间范围' }]}
|
||||||
>
|
>
|
||||||
<Radio.Group style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<Radio.Group style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<Radio value="retroactive">
|
<Radio value="immediate">
|
||||||
<strong>追溯至历史订单(推荐)</strong>
|
<strong>自当前时间立即生效(推荐,仅对后续订单生效)</strong>
|
||||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||||
自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常
|
保留旧版本历史,自新的价格版本创建时刻起计算后续履约结算
|
||||||
</div>
|
</div>
|
||||||
</Radio>
|
</Radio>
|
||||||
<Radio value="immediate">
|
<Radio value="retroactive">
|
||||||
<strong>自当前时间立即生效(仅对后续订单生效)</strong>
|
<strong>追溯至历史订单</strong>
|
||||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||||
保留旧版本历史,自新版本创建时刻起计算后续履约结算
|
自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常
|
||||||
</div>
|
</div>
|
||||||
</Radio>
|
</Radio>
|
||||||
<Radio value="custom">
|
<Radio value="custom">
|
||||||
|
|||||||
@@ -371,6 +371,9 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
|||||||
{item.resolutionAction === 'upheld' ? (
|
{item.resolutionAction === 'upheld' ? (
|
||||||
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
|
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
|
||||||
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
|
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
|
||||||
|
{Number(item.withdrawDeductionAmount || 0) > 0
|
||||||
|
? `(含提现冻结 ${formatMoney(item.withdrawDeductionAmount)},提现已驳回)`
|
||||||
|
: ''}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
{item.refundedAmount > 0 ? (
|
{item.refundedAmount > 0 ? (
|
||||||
@@ -518,7 +521,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
|||||||
label="追缴金额"
|
label="追缴金额"
|
||||||
name="recoveryAmount"
|
name="recoveryAmount"
|
||||||
rules={[{ required: true, message: '请输入追缴金额' }]}
|
rules={[{ required: true, message: '请输入追缴金额' }]}
|
||||||
extra="系统依次扣减该责任打手的本单待解冻金额、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
|
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
|
||||||
>
|
>
|
||||||
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
|
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const LEDGER_TYPE_LABELS: Record<string, string> = {
|
|||||||
reward_settlement: '结算报酬',
|
reward_settlement: '结算报酬',
|
||||||
sharing_reward: '拼单报酬',
|
sharing_reward: '拼单报酬',
|
||||||
after_sales_deposit_deduction: '售后扣减押金',
|
after_sales_deposit_deduction: '售后扣减押金',
|
||||||
|
after_sales_withdraw_deduction: '售后扣减提现',
|
||||||
after_sales_balance_deduction: '售后扣减余额',
|
after_sales_balance_deduction: '售后扣减余额',
|
||||||
after_sales_debt_offset: '抵扣售后欠款',
|
after_sales_debt_offset: '抵扣售后欠款',
|
||||||
after_sales_refund: '售后追缴退还',
|
after_sales_refund: '售后追缴退还',
|
||||||
@@ -65,6 +66,7 @@ function resolveLedgerTagColor(ledgerType: string) {
|
|||||||
[
|
[
|
||||||
'deposit_deduction',
|
'deposit_deduction',
|
||||||
'after_sales_deposit_deduction',
|
'after_sales_deposit_deduction',
|
||||||
|
'after_sales_withdraw_deduction',
|
||||||
'after_sales_balance_deduction',
|
'after_sales_balance_deduction',
|
||||||
].includes(ledgerType)
|
].includes(ledgerType)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ function WorkerAfterSalesItem({
|
|||||||
const hasRecoveryDetail =
|
const hasRecoveryDetail =
|
||||||
Number(item.recoveryAmount || 0) > 0 ||
|
Number(item.recoveryAmount || 0) > 0 ||
|
||||||
Number(item.pendingDepositDeductionAmount || 0) > 0 ||
|
Number(item.pendingDepositDeductionAmount || 0) > 0 ||
|
||||||
|
Number(item.withdrawDeductionAmount || 0) > 0 ||
|
||||||
Number(item.availableDeductionAmount || 0) > 0 ||
|
Number(item.availableDeductionAmount || 0) > 0 ||
|
||||||
Number(item.debtAmount || 0) > 0 ||
|
Number(item.debtAmount || 0) > 0 ||
|
||||||
Number(item.refundedAmount || 0) > 0
|
Number(item.refundedAmount || 0) > 0
|
||||||
@@ -720,6 +721,15 @@ function WorkerAfterSalesItem({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{Number(item.withdrawDeductionAmount || 0) > 0 && (
|
||||||
|
<span className="financial-chip is-available">
|
||||||
|
<span className="chip-name">提现冻结抵扣</span>
|
||||||
|
<span className="chip-amount">
|
||||||
|
{formatMoney(item.withdrawDeductionAmount)}
|
||||||
|
</span>
|
||||||
|
<span className="chip-hint">(提现申请已驳回)</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{Number(item.availableDeductionAmount || 0) > 0 && (
|
{Number(item.availableDeductionAmount || 0) > 0 && (
|
||||||
<span className="financial-chip is-available">
|
<span className="financial-chip is-available">
|
||||||
<span className="chip-name">余额抵扣</span>
|
<span className="chip-name">余额抵扣</span>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const LEDGER_TYPE_OPTIONS = [
|
|||||||
{ value: 'reward_settlement', label: '结算报酬' },
|
{ value: 'reward_settlement', label: '结算报酬' },
|
||||||
{ value: 'sharing_reward', label: '拼单报酬' },
|
{ value: 'sharing_reward', label: '拼单报酬' },
|
||||||
{ value: 'after_sales_deposit_deduction', label: '售后扣减押金' },
|
{ value: 'after_sales_deposit_deduction', label: '售后扣减押金' },
|
||||||
|
{ value: 'after_sales_withdraw_deduction', label: '售后扣减提现' },
|
||||||
{ value: 'after_sales_balance_deduction', label: '售后扣减余额' },
|
{ value: 'after_sales_balance_deduction', label: '售后扣减余额' },
|
||||||
{ value: 'after_sales_debt_offset', label: '抵扣售后欠款' },
|
{ value: 'after_sales_debt_offset', label: '抵扣售后欠款' },
|
||||||
{ value: 'after_sales_refund', label: '售后追缴退还' },
|
{ value: 'after_sales_refund', label: '售后追缴退还' },
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function formatLedgerType(ledgerType: string) {
|
|||||||
reward_settlement: '结算报酬',
|
reward_settlement: '结算报酬',
|
||||||
sharing_reward: '拼单报酬',
|
sharing_reward: '拼单报酬',
|
||||||
after_sales_deposit_deduction: '售后扣减押金',
|
after_sales_deposit_deduction: '售后扣减押金',
|
||||||
|
after_sales_withdraw_deduction: '售后扣减提现',
|
||||||
after_sales_balance_deduction: '售后扣减余额',
|
after_sales_balance_deduction: '售后扣减余额',
|
||||||
after_sales_debt_offset: '抵扣售后欠款',
|
after_sales_debt_offset: '抵扣售后欠款',
|
||||||
after_sales_refund: '售后追缴退还',
|
after_sales_refund: '售后追缴退还',
|
||||||
@@ -82,6 +83,7 @@ export function resolveLedgerTagColor(ledgerType: string) {
|
|||||||
if (ledgerType === 'reward_settlement') return 'cyan'
|
if (ledgerType === 'reward_settlement') return 'cyan'
|
||||||
if (ledgerType === 'sharing_reward') return 'cyan'
|
if (ledgerType === 'sharing_reward') return 'cyan'
|
||||||
if (ledgerType === 'after_sales_deposit_deduction') return 'red'
|
if (ledgerType === 'after_sales_deposit_deduction') return 'red'
|
||||||
|
if (ledgerType === 'after_sales_withdraw_deduction') return 'red'
|
||||||
if (ledgerType === 'after_sales_balance_deduction') return 'red'
|
if (ledgerType === 'after_sales_balance_deduction') return 'red'
|
||||||
if (ledgerType === 'after_sales_debt_offset') return 'orange'
|
if (ledgerType === 'after_sales_debt_offset') return 'orange'
|
||||||
if (ledgerType === 'after_sales_refund') return 'green'
|
if (ledgerType === 'after_sales_refund') return 'green'
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function previewAdminTemplateProductBootstrap(payload?: {
|
|||||||
export function applyAdminTemplateProductBootstrap(payload?: {
|
export function applyAdminTemplateProductBootstrap(payload?: {
|
||||||
dateFrom?: string
|
dateFrom?: string
|
||||||
dateTo?: string
|
dateTo?: string
|
||||||
|
includeDisabledTemplates?: boolean
|
||||||
}) {
|
}) {
|
||||||
return apiPost<TemplateProductBootstrapResult>(
|
return apiPost<TemplateProductBootstrapResult>(
|
||||||
'/api/v1/admin/cost-center/template-bootstrap/apply',
|
'/api/v1/admin/cost-center/template-bootstrap/apply',
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ export interface CostCenterOptions {
|
|||||||
export interface TemplateProductBootstrapPreview {
|
export interface TemplateProductBootstrapPreview {
|
||||||
dateFrom: string
|
dateFrom: string
|
||||||
dateTo: string
|
dateTo: string
|
||||||
|
totalTemplateCount: number
|
||||||
templateCount: number
|
templateCount: number
|
||||||
|
disabledTemplateCount: number
|
||||||
|
evidenceTemplateCount: number
|
||||||
productExistingCount: number
|
productExistingCount: number
|
||||||
productCreateCount: number
|
productCreateCount: number
|
||||||
mappingCandidateCount: number
|
mappingCandidateCount: number
|
||||||
@@ -215,7 +218,11 @@ export interface TemplateProductBootstrapPreview {
|
|||||||
|
|
||||||
export interface TemplateProductBootstrapResult {
|
export interface TemplateProductBootstrapResult {
|
||||||
templateCount: number
|
templateCount: number
|
||||||
|
enabledTemplateCount: number
|
||||||
|
disabledTemplateCount: number
|
||||||
|
evidenceTemplateCount: number
|
||||||
createdProductCount: number
|
createdProductCount: number
|
||||||
|
updatedProductCount: number
|
||||||
reusedProductCount: number
|
reusedProductCount: number
|
||||||
createdMappingCount: number
|
createdMappingCount: number
|
||||||
skippedMappingCount: number
|
skippedMappingCount: number
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export type WorkerAfterSalesCase = {
|
|||||||
resolutionNote: string
|
resolutionNote: string
|
||||||
recoveryAmount: number
|
recoveryAmount: number
|
||||||
pendingDepositDeductionAmount: number
|
pendingDepositDeductionAmount: number
|
||||||
|
withdrawDeductionAmount: number
|
||||||
availableDeductionAmount: number
|
availableDeductionAmount: number
|
||||||
debtAmount: number
|
debtAmount: number
|
||||||
refundedAmount: number
|
refundedAmount: number
|
||||||
|
|||||||
+12
-9
@@ -152,19 +152,22 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列
|
|||||||
|
|
||||||
### 2.4 接单模板批量初始化
|
### 2.4 接单模板批量初始化
|
||||||
|
|
||||||
662 个接单模板不允许逐个手工建商品。后台「从接单模板初始化」按以下规则批量生成:
|
接单模板不允许逐个手工建商品。后台「从接单模板全量同步商品与映射」按以下规则批量生成:
|
||||||
|
|
||||||
1. **只处理时间范围内有工单证据的启用模板**,内部编码 `tpl:<rule_key>`;默认最近 30 天,可在页面日期筛选里调整。范围外、暂无工单的模板不再先建商品,避免很早以前的模板 SKU 重新污染目录。
|
1. **商品全量同步,不依赖订单**:所有启用模板都建/刷新标准商品,内部编码 `tpl:<rule_key>`;模板改名、改分类、改发单金额都会刷新到商品上。新模板还没有工单也能在「商品与成本中心」直接定价。停用模板默认不建商品,勾选后可一并建成「归档」商品。
|
||||||
2. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。
|
2. **渠道映射仍只按工单证据建**:默认最近 30 天,可在页面日期筛选里调整;范围内没有工单的模板只建商品、不建映射——没有订单就没有平台 SKU 可映射。
|
||||||
3. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。
|
3. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。
|
||||||
4. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。
|
4. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。
|
||||||
5. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。
|
5. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。
|
||||||
6. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。
|
6. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。
|
||||||
7. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。
|
7. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。
|
||||||
8. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单不删除。
|
8. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。
|
||||||
|
9. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单、已建商品都不删除。
|
||||||
|
|
||||||
本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。
|
本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。
|
||||||
|
|
||||||
|
全量同步改版后(2026-10-29,本地库预览 09-30 ~ 10-29):模板 710 个全部启用、范围内有工单证据 388 个;商品新建 704 个 / 复用 6 个,候选映射 3,198 条。之前被跳过的 322 个无单模板,现在也会有商品(可定价),只是暂时没有渠道映射。
|
||||||
|
|
||||||
### 2.5 自动发货商品
|
### 2.5 自动发货商品
|
||||||
|
|
||||||
自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:<skuCode>`,**不写 `channel_product_mappings`**。渠道映射只代表"平台 SKU 能归一到标准商品",不代表自动发货。
|
自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:<skuCode>`,**不写 `channel_product_mappings`**。渠道映射只代表"平台 SKU 能归一到标准商品",不代表自动发货。
|
||||||
|
|||||||
Reference in New Issue
Block a user