fix(fulfillment): 收口渠道商品与统一结算边界
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
-- 144_fulfillment_shadow_configuration_sync.sql
|
||||
--
|
||||
-- 统一履约视图会读取人工配置和 internal_sku_channels 的派生状态。
|
||||
-- 这两张配置表变化时也必须刷新影子模型,否则影子表会保留旧载体状态和旧异常。
|
||||
|
||||
CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_manual_config()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
old_key TEXT;
|
||||
new_key TEXT;
|
||||
BEGIN
|
||||
IF TG_OP <> 'INSERT' THEN
|
||||
old_key := canonicalize_internal_sku(OLD.sku_key);
|
||||
END IF;
|
||||
IF TG_OP <> 'DELETE' THEN
|
||||
new_key := canonicalize_internal_sku(NEW.sku_key);
|
||||
END IF;
|
||||
|
||||
PERFORM sync_fulfillment_shadow(item.id)
|
||||
FROM order_items item
|
||||
WHERE item.fulfillment_status IN ('pending', 'assigned')
|
||||
AND canonicalize_internal_sku(
|
||||
resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code)
|
||||
) IN (old_key, new_key);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_manual_fulfillment_config_shadow ON manual_fulfillment_configs;
|
||||
CREATE TRIGGER trg_manual_fulfillment_config_shadow
|
||||
AFTER INSERT OR UPDATE OR DELETE ON manual_fulfillment_configs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_fulfillment_shadow_for_manual_config();
|
||||
|
||||
CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_internal_channel()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
old_key TEXT;
|
||||
new_key TEXT;
|
||||
BEGIN
|
||||
IF TG_OP <> 'INSERT' THEN
|
||||
old_key := canonicalize_internal_sku(OLD.internal_sku);
|
||||
END IF;
|
||||
IF TG_OP <> 'DELETE' THEN
|
||||
new_key := canonicalize_internal_sku(NEW.internal_sku);
|
||||
END IF;
|
||||
|
||||
PERFORM sync_fulfillment_shadow(item.id)
|
||||
FROM order_items item
|
||||
WHERE item.fulfillment_status IN ('pending', 'assigned')
|
||||
AND canonicalize_internal_sku(
|
||||
resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code)
|
||||
) IN (old_key, new_key);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow ON internal_sku_channels;
|
||||
CREATE TRIGGER trg_internal_sku_channel_shadow
|
||||
AFTER INSERT OR UPDATE OR DELETE ON internal_sku_channels
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_fulfillment_shadow_for_internal_channel();
|
||||
|
||||
CREATE OR REPLACE FUNCTION assert_manual_config_product_source()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.canonical_product_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM product_catalog product
|
||||
WHERE product.id = NEW.canonical_product_id
|
||||
AND product.status = 'active'
|
||||
AND product.canonical_sku LIKE 'man:%'
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION
|
||||
'manual fulfillment config product % must be an active man: product',
|
||||
NEW.canonical_product_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_manual_config_product_source ON manual_fulfillment_configs;
|
||||
CREATE TRIGGER trg_manual_config_product_source
|
||||
BEFORE INSERT OR UPDATE OF canonical_product_id ON manual_fulfillment_configs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION assert_manual_config_product_source();
|
||||
|
||||
-- 修正迁移 141 后由配置变化造成的存量陈旧影子记录。
|
||||
SELECT sync_fulfillment_shadow(item.id)
|
||||
FROM order_items item
|
||||
WHERE item.fulfillment_status IN ('pending', 'assigned');
|
||||
|
||||
COMMENT ON FUNCTION sync_fulfillment_shadow_for_manual_config() IS
|
||||
'人工配置变更后重算同内部 SKU 的履约影子';
|
||||
COMMENT ON FUNCTION sync_fulfillment_shadow_for_internal_channel() IS
|
||||
'统一渠道规则变更后重算同内部 SKU 的履约影子';
|
||||
COMMENT ON FUNCTION assert_manual_config_product_source() IS
|
||||
'人工履约配置只能关联有效的 man: 标准商品';
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 145_fulfillment_current_attempt_latest.sql
|
||||
--
|
||||
-- 统一读模型按 task.id DESC 取当前尝试。为保持与 is_current 指针一致,
|
||||
-- 从现在开始禁止把较旧任务重新标记为 current;新尝试仍由业务先退休旧指针。
|
||||
|
||||
CREATE OR REPLACE FUNCTION assert_fulfillment_current_is_latest()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.is_current = TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM fulfillment_tasks newer
|
||||
WHERE newer.order_item_id = NEW.order_item_id
|
||||
AND newer.unit_index = NEW.unit_index
|
||||
AND newer.id > COALESCE(NEW.id, 0)
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION
|
||||
'fulfillment current attempt must be the latest attempt for order_item % unit %',
|
||||
NEW.order_item_id,
|
||||
NEW.unit_index
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_fulfillment_current_is_latest ON fulfillment_tasks;
|
||||
CREATE TRIGGER trg_fulfillment_current_is_latest
|
||||
BEFORE INSERT OR UPDATE OF is_current, order_item_id, unit_index
|
||||
ON fulfillment_tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION assert_fulfillment_current_is_latest();
|
||||
|
||||
COMMENT ON FUNCTION assert_fulfillment_current_is_latest() IS
|
||||
'当前履约尝试必须是同订单项单元中 id 最大的尝试,保证统一视图 id DESC 与 is_current 一致';
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
confirmManualFulfillment,
|
||||
listManualFulfillmentPending,
|
||||
} from '../../services/finance/manual-fulfillment-service.js'
|
||||
import {
|
||||
listOnAccountBills,
|
||||
reconcileOnAccountBill,
|
||||
} from '../../services/finance/on-account-bill-service.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
requireAdminPermission,
|
||||
@@ -152,4 +156,44 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/on-account-bills',
|
||||
requireAdminRoles(['admin', 'operator', 'support', 'finance']),
|
||||
createJsonHandler((req) => listOnAccountBills(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取统一结算账单失败',
|
||||
scope: '[admin/finance/on-account-bills]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/on-account-bills/:billId/reconcile',
|
||||
requireAdminPermission('finance.reconcile'),
|
||||
createJsonHandler(
|
||||
(req) => reconcileOnAccountBill(req.params.billId, bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '统一结算账单已对账',
|
||||
errorMessage: '统一结算账单对账失败',
|
||||
scope: '[admin/finance/on-account-bills/:billId/reconcile]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
bill?: { id?: number; billNo?: string }
|
||||
amountFen?: number
|
||||
snapshotId?: number
|
||||
}
|
||||
return {
|
||||
action: 'on_account_bill_reconciled',
|
||||
targetType: 'on_account_bill',
|
||||
targetId: String(result.bill?.id || req.params.billId || ''),
|
||||
data: {
|
||||
billNo: result.bill?.billNo || '',
|
||||
amountFen: result.amountFen || 0,
|
||||
costSnapshotId: result.snapshotId || null,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
@@ -19,6 +21,7 @@ export const COST_EXCEPTION_REASONS = [
|
||||
'cost_missing',
|
||||
'manual_config_missing',
|
||||
'manual_cost_missing',
|
||||
'on_account_cost_pending',
|
||||
'carrier_missing',
|
||||
] as const
|
||||
|
||||
@@ -29,14 +32,16 @@ export type UpsertExceptionInput = {
|
||||
scopeKey: string
|
||||
orderItemId: number
|
||||
detail?: Record<string, unknown>
|
||||
client?: Pick<PoolClient, 'query'>
|
||||
}
|
||||
|
||||
/** 幂等上浮一条聚合问题:已存在则累加明细并刷新 last_seen。 */
|
||||
export async function upsertAggregatedException(input: UpsertExceptionInput): Promise<void> {
|
||||
const now = nowIso()
|
||||
const detail = JSON.stringify(input.detail || {})
|
||||
const run = input.client ? input.client.query.bind(input.client) : query
|
||||
|
||||
const result = await query<{ id: number }>(
|
||||
const result = await run<{ id: number }>(
|
||||
`
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, reason, status, detail_json,
|
||||
@@ -55,7 +60,7 @@ export async function upsertAggregatedException(input: UpsertExceptionInput): Pr
|
||||
const exceptionId = result.rows[0]?.id
|
||||
if (!exceptionId) return
|
||||
|
||||
await query(
|
||||
await run(
|
||||
`
|
||||
INSERT INTO cost_exception_items (exception_id, order_item_id, created_at)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -64,7 +69,7 @@ export async function upsertAggregatedException(input: UpsertExceptionInput): Pr
|
||||
[exceptionId, input.orderItemId, now],
|
||||
)
|
||||
|
||||
await query(
|
||||
await run(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET item_count = (
|
||||
|
||||
@@ -468,6 +468,26 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
const existingId = Number(existing.rows[0]?.id || 0)
|
||||
let resolvedProductId = canonicalProductId
|
||||
|
||||
if (resolvedProductId) {
|
||||
const product = await client.query<{ canonical_sku: string; status: string }>(
|
||||
`SELECT canonical_sku, status
|
||||
FROM product_catalog
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[resolvedProductId],
|
||||
)
|
||||
if (
|
||||
!product.rows[0] ||
|
||||
product.rows[0].status !== 'active' ||
|
||||
!String(product.rows[0].canonical_sku || '').startsWith('man:')
|
||||
) {
|
||||
throw createHttpError('人工履约配置只能关联有效的 man: 标准商品', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_product_source_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 人工配置本身就是该 SKU 的标准商品声明。没有显式关联时自动创建/复用
|
||||
// man:<内部 SKU>,避免运营再去「标准商品」手工建一次。
|
||||
if (!resolvedProductId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
@@ -63,9 +64,11 @@ function item(patch: Record<string, unknown> = {}) {
|
||||
sku_name: '任意',
|
||||
normalized_sku_name: null as string | null,
|
||||
normalized_sku_series: null as string | null,
|
||||
fulfillment_channel: 'work_order' as string | null,
|
||||
fulfillment_channel: null as string | null,
|
||||
canonical_product_id: null as number | null,
|
||||
work_rule_key: null as string | null,
|
||||
manual_config_product_id: null as number | null,
|
||||
manual_config_product_sku: null as string | null,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
@@ -123,6 +126,7 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => {
|
||||
fulfillment_channel: 'manual',
|
||||
sku_code: 'unknown',
|
||||
manual_config_product_id: 44,
|
||||
manual_config_product_sku: 'man:配置商品',
|
||||
}),
|
||||
index,
|
||||
),
|
||||
@@ -157,11 +161,52 @@ test('商品归一:第三方卡券缺少 tpv 商品时不能串到同名人工
|
||||
)
|
||||
})
|
||||
|
||||
test('商品归一:其他已确定渠道也不能借用同名其他来源商品', () => {
|
||||
for (const fulfillmentChannel of ['manual', 'on_account', 'affiliate_dash', 'work_order']) {
|
||||
assert.equal(
|
||||
resolveProductId(
|
||||
item({
|
||||
fulfillment_channel: fulfillmentChannel,
|
||||
sku_code: 'unknown',
|
||||
internal_sku: '套装-测试',
|
||||
}),
|
||||
index,
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('商品归一:人工配置也只能挂载 man: 商品', () => {
|
||||
assert.equal(
|
||||
resolveProductId(
|
||||
item({
|
||||
fulfillment_channel: 'manual',
|
||||
manual_config_product_id: 99,
|
||||
manual_config_product_sku: 'aff:错误商品',
|
||||
}),
|
||||
index,
|
||||
),
|
||||
null,
|
||||
)
|
||||
assert.equal(
|
||||
resolveProductId(
|
||||
item({
|
||||
fulfillment_channel: 'on_account',
|
||||
manual_config_product_id: 99,
|
||||
manual_config_product_sku: 'man:正确商品',
|
||||
}),
|
||||
index,
|
||||
),
|
||||
99,
|
||||
)
|
||||
})
|
||||
|
||||
test('商品归一:中文内部 SKU 命中标准商品,不依赖平台 SKU', () => {
|
||||
assert.equal(
|
||||
resolveProductId(
|
||||
item({
|
||||
fulfillment_channel: 'affiliate_dash',
|
||||
fulfillment_channel: null,
|
||||
sku_code: 'unknown',
|
||||
internal_sku: '套装-测试',
|
||||
}),
|
||||
@@ -191,3 +236,14 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => {
|
||||
assert.equal(voucherCanonicalSku({ manual_sku_key: '', sku_name: '', sku_code: 'x' }), null)
|
||||
assert.equal(voucherCanonicalSku({}), null)
|
||||
})
|
||||
|
||||
test('商品归一扫描会纠正所有渠道的错误来源商品', async () => {
|
||||
const source = await readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8')
|
||||
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'aff:%'"))
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'man:%'"))
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpl:%'"))
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpv:%'"))
|
||||
assert.ok(source.includes('canonicalProductId: null'))
|
||||
assert.ok(source.includes('onlyWhenCanonicalProductDifferent: true'))
|
||||
})
|
||||
|
||||
@@ -3,7 +3,10 @@ import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exc
|
||||
import { readWatermarkItemId } from '../watermark-store.js'
|
||||
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
|
||||
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
|
||||
import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js'
|
||||
import {
|
||||
attachFulfillmentConfigurations,
|
||||
transitionFulfillmentState,
|
||||
} from '../fulfillment/fulfillment-state-service.js'
|
||||
|
||||
/**
|
||||
* 管道 B:商品归一。
|
||||
@@ -51,8 +54,10 @@ type ItemRow = {
|
||||
normalized_sku_name: string | null
|
||||
normalized_sku_series: string | null
|
||||
fulfillment_channel: string | null
|
||||
canonical_product_id?: number | null
|
||||
work_rule_key: string | null
|
||||
manual_config_product_id: number | null
|
||||
manual_config_product_sku?: string | null
|
||||
}
|
||||
|
||||
function key(...parts: string[]): string {
|
||||
@@ -145,6 +150,17 @@ export function voucherCanonicalSku(item: {
|
||||
return internal ? `tpv:${internal}` : null
|
||||
}
|
||||
|
||||
async function clearMismatchedProduct(item: ItemRow) {
|
||||
if (!item.fulfillment_channel || !item.canonical_product_id) return
|
||||
await transitionFulfillmentState({
|
||||
orderItemId: Number(item.id),
|
||||
expected: { status: 'assigned' },
|
||||
patch: { canonicalProductId: null },
|
||||
eventType: 'product_source_cleared',
|
||||
reason: `渠道 ${item.fulfillment_channel} 与当前标准商品来源不一致`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 三方卡券没有预先建库时,按内部 SKU 自动建 `tpv:` 商品并补渠道规则。
|
||||
* 建完后该商品会出现在「商品与成本中心 → 第三方卡券」,运营直接设采购价。
|
||||
@@ -225,20 +241,23 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
|
||||
const seriesKey = String(item.normalized_sku_series || '').trim()
|
||||
const internalSku = canonicalizeInternalSku(item.internal_sku || item.manual_sku_key)
|
||||
|
||||
// 人工渠道:配置里已经指定商品,直接用,不需要再建映射。
|
||||
if (item.fulfillment_channel === 'manual' && item.manual_config_product_id) {
|
||||
return Number(item.manual_config_product_id)
|
||||
// 已确定渠道必须命中本渠道商品,不能借用同名的其他来源商品。
|
||||
if (item.fulfillment_channel === 'manual' || item.fulfillment_channel === 'on_account') {
|
||||
return item.manual_config_product_id &&
|
||||
String(item.manual_config_product_sku || '').startsWith('man:')
|
||||
? Number(item.manual_config_product_id)
|
||||
: null
|
||||
}
|
||||
|
||||
// 接单平台:模板即商品来源(tpl:<rule_key>),无需渠道映射。
|
||||
if (item.fulfillment_channel === 'work_order' && item.work_rule_key) {
|
||||
if (item.fulfillment_channel === 'work_order') {
|
||||
if (!item.work_rule_key) return null
|
||||
const templateProduct = index.templateRules.get(String(item.work_rule_key).trim())
|
||||
if (templateProduct) return templateProduct
|
||||
return templateProduct || null
|
||||
}
|
||||
|
||||
if (item.fulfillment_channel === 'affiliate_dash' && skuCode) {
|
||||
const aff = index.affSkus.get(skuCode)
|
||||
if (aff) return aff
|
||||
if (item.fulfillment_channel === 'affiliate_dash') {
|
||||
return skuCode ? index.affSkus.get(skuCode) || null : null
|
||||
}
|
||||
|
||||
// 第三方卡券:与自动发货同构,商品编码 tpv:<sku>。
|
||||
@@ -325,13 +344,16 @@ export async function normalizeOrderItemProducts(
|
||||
oi.normalized_sku_name,
|
||||
oi.normalized_sku_series,
|
||||
oi.fulfillment_channel,
|
||||
oi.canonical_product_id,
|
||||
wpr.rule_key AS work_rule_key,
|
||||
cfg.canonical_product_id AS manual_config_product_id
|
||||
cfg.canonical_product_id AS manual_config_product_id,
|
||||
cfg_product.canonical_sku AS manual_config_product_sku
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN product_catalog cfg_product ON cfg_product.id = cfg.canonical_product_id
|
||||
LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (
|
||||
@@ -343,6 +365,30 @@ export async function normalizeOrderItemProducts(
|
||||
OR current_product.canonical_sku NOT LIKE 'tpv:%'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'affiliate_dash'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'aff:%'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel IN ('manual', 'on_account')
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'man:%'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'work_order'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR (
|
||||
current_product.canonical_sku NOT LIKE 'tpl:%'
|
||||
AND current_product.canonical_sku NOT LIKE 'wo:%'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
AND oi.id > $1
|
||||
ORDER BY oi.id
|
||||
@@ -369,6 +415,7 @@ export async function normalizeOrderItemProducts(
|
||||
}
|
||||
if (!productId) {
|
||||
result.unmatched += 1
|
||||
await clearMismatchedProduct(item)
|
||||
await upsertAggregatedException({
|
||||
reason: 'product_mapping_missing',
|
||||
scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`,
|
||||
@@ -487,13 +534,16 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
|
||||
oi.normalized_sku_name,
|
||||
oi.normalized_sku_series,
|
||||
oi.fulfillment_channel,
|
||||
oi.canonical_product_id,
|
||||
wpr.rule_key AS work_rule_key,
|
||||
cfg.canonical_product_id AS manual_config_product_id
|
||||
cfg.canonical_product_id AS manual_config_product_id,
|
||||
cfg_product.canonical_sku AS manual_config_product_sku
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN product_catalog cfg_product ON cfg_product.id = cfg.canonical_product_id
|
||||
LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (
|
||||
@@ -505,6 +555,30 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
|
||||
OR current_product.canonical_sku NOT LIKE 'tpv:%'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'affiliate_dash'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'aff:%'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel IN ('manual', 'on_account')
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'man:%'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'work_order'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR (
|
||||
current_product.canonical_sku NOT LIKE 'tpl:%'
|
||||
AND current_product.canonical_sku NOT LIKE 'wo:%'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
AND oi.id = ANY($1::bigint[])
|
||||
`,
|
||||
@@ -519,6 +593,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
|
||||
}
|
||||
if (!productId) {
|
||||
unmatched += 1
|
||||
await clearMismatchedProduct(item)
|
||||
await upsertAggregatedException({
|
||||
reason: 'product_mapping_missing',
|
||||
scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`,
|
||||
|
||||
@@ -165,6 +165,15 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
|
||||
)
|
||||
})
|
||||
|
||||
test('统一结算金额未知时只生成待对账账单,不写 0 元实际成本', async () => {
|
||||
const source = await readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8')
|
||||
|
||||
assert.ok(source.includes("platformKey === 'on_account' && costFen <= 0"))
|
||||
assert.ok(source.includes('if (!onAccountCostPending)'))
|
||||
assert.ok(source.includes("reason: 'on_account_cost_pending'"))
|
||||
assert.ok(source.includes('costPending: onAccountCostPending'))
|
||||
})
|
||||
|
||||
test('人工成本缺失由管道按配置与快照生成并收口', async () => {
|
||||
const [pipelineSource, manualSource] = await Promise.all([
|
||||
readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
attachFulfillmentConfigurationsWithExecutor,
|
||||
transitionFulfillmentStateWithExecutor,
|
||||
} from '../fulfillment/fulfillment-state-service.js'
|
||||
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
|
||||
import {
|
||||
calculateManualCostFen,
|
||||
normalizeManualCreditQuantity,
|
||||
@@ -411,6 +412,20 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
errorCode: 'manual_fulfillment_platform_required',
|
||||
})
|
||||
}
|
||||
if (selectedChannel.platformKey === 'on_account') {
|
||||
const existingBill = await client.query<{ id: number }>(
|
||||
`SELECT id FROM on_account_bills
|
||||
WHERE order_item_id = $1 AND status IN ('pending', 'reconciled')
|
||||
LIMIT 1`,
|
||||
[orderItemId],
|
||||
)
|
||||
if (existingBill.rows[0]) {
|
||||
throw createHttpError('该订单项已生成统一结算账单,不能重复操作', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_on_account_already_exists',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。
|
||||
const existing = await client.query<{ id: number }>(
|
||||
@@ -447,6 +462,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
providedCostFen,
|
||||
})
|
||||
const costFen = costPlan.costFen
|
||||
const onAccountCostPending = platformKey === 'on_account' && costFen <= 0
|
||||
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
|
||||
const note = optionalText(payload.note, 300)
|
||||
const fulfillmentReference = buildManualFulfillmentReference({
|
||||
@@ -455,16 +471,8 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
orderItemId,
|
||||
})
|
||||
|
||||
// 先把成本项/版本解析完再扣钱:版本缺失不能发生在扣款之后。
|
||||
const costItemId = await resolveManualCostItemIdWithClient(client)
|
||||
const versionId = await resolveCostVersionIdWithClient(client, costItemId, row, now)
|
||||
if (!versionId) {
|
||||
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_cost_version_missing',
|
||||
})
|
||||
}
|
||||
|
||||
// 扣款前先确定最终成本模式。统一结算未填金额时只生成待对账账单,
|
||||
// 不写 0 元 actual 成本快照,避免利润被虚增。
|
||||
let fundExpenseId: number | null = null
|
||||
let creditEntryId: number | null = null
|
||||
let balanceAfter: number | null = null
|
||||
@@ -522,11 +530,22 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
subjectSource = 'platform_credit'
|
||||
}
|
||||
|
||||
// subject_id 固定为 order_item_id:不再与 manual_expenses.id /
|
||||
// platform_credit_entries.id 混用,避免两张表序列撞车。
|
||||
// subject_type 仍为 manual_case,资金流水只放在 metadata 里。
|
||||
const snapshot = await client.query<{ id: number }>(
|
||||
`
|
||||
let snapshotId: number | null = null
|
||||
if (!onAccountCostPending) {
|
||||
const costItemId = await resolveManualCostItemIdWithClient(client)
|
||||
const versionId = await resolveCostVersionIdWithClient(client, costItemId, row, now)
|
||||
if (!versionId) {
|
||||
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_cost_version_missing',
|
||||
})
|
||||
}
|
||||
|
||||
// subject_id 固定为 order_item_id:不再与 manual_expenses.id /
|
||||
// platform_credit_entries.id 混用,资金流水只放在 metadata 里。
|
||||
// subject_type 仍为 manual_case。
|
||||
const snapshot = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
cost_item_id, cost_version_id, canonical_product_id,
|
||||
@@ -540,50 +559,51 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
orderItemId,
|
||||
row.order_id_resolved,
|
||||
row.work_order_id,
|
||||
costItemId,
|
||||
versionId,
|
||||
row.canonical_product_id,
|
||||
costFen,
|
||||
JSON.stringify({
|
||||
source: 'manual_fulfillment_confirm',
|
||||
subjectSource,
|
||||
configId: Number(row.config_id),
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditOwnerAdminUserId:
|
||||
platformKey === 'fund_cash'
|
||||
? null
|
||||
: Number(payload.creditOwnerAdminUserId || 0) || null,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costMode: costPlan.costMode,
|
||||
`,
|
||||
[
|
||||
orderItemId,
|
||||
row.order_id_resolved,
|
||||
row.work_order_id,
|
||||
costItemId,
|
||||
versionId,
|
||||
row.canonical_product_id,
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
payeeName,
|
||||
note,
|
||||
fulfilledBy: {
|
||||
userId: Number(session.userId || 0),
|
||||
displayName: String(session.displayName || ''),
|
||||
role: String(session.role || ''),
|
||||
},
|
||||
}),
|
||||
now,
|
||||
],
|
||||
)
|
||||
const snapshotId = snapshot.rows[0]?.id ?? null
|
||||
if (!snapshotId) {
|
||||
// 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。
|
||||
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_snapshot_conflict',
|
||||
})
|
||||
JSON.stringify({
|
||||
source: 'manual_fulfillment_confirm',
|
||||
subjectSource,
|
||||
configId: Number(row.config_id),
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditOwnerAdminUserId:
|
||||
platformKey === 'fund_cash'
|
||||
? null
|
||||
: Number(payload.creditOwnerAdminUserId || 0) || null,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costMode: costPlan.costMode,
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
payeeName,
|
||||
note,
|
||||
fulfilledBy: {
|
||||
userId: Number(session.userId || 0),
|
||||
displayName: String(session.displayName || ''),
|
||||
role: String(session.role || ''),
|
||||
},
|
||||
}),
|
||||
now,
|
||||
],
|
||||
)
|
||||
snapshotId = snapshot.rows[0]?.id ?? null
|
||||
if (!snapshotId) {
|
||||
// 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。
|
||||
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_snapshot_conflict',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (platformKey === 'on_account') {
|
||||
@@ -641,30 +661,46 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
reason: platformKey,
|
||||
})
|
||||
|
||||
// 人工缺成本异常立即收口,不等下一轮定时任务。
|
||||
await client.query(
|
||||
`UPDATE cost_exceptions
|
||||
SET status = 'resolved', resolved_at = $2
|
||||
WHERE subject_type = 'order_item'
|
||||
AND subject_id = $1
|
||||
AND reason = 'manual_cost_missing'
|
||||
AND status = 'open'`,
|
||||
[orderItemId, now],
|
||||
)
|
||||
if (onAccountCostPending) {
|
||||
await upsertAggregatedException({
|
||||
reason: 'on_account_cost_pending',
|
||||
scopeKey: `on_account_cost_pending|-|-|${row.internal_sku}|on_account`,
|
||||
orderItemId,
|
||||
detail: {
|
||||
billId: onAccountBillId,
|
||||
internalSku: row.internal_sku,
|
||||
platformOrderId: row.platform_order_id,
|
||||
},
|
||||
client,
|
||||
})
|
||||
} else {
|
||||
// 人工缺成本异常立即收口,不等下一轮定时任务。
|
||||
await client.query(
|
||||
`UPDATE cost_exceptions
|
||||
SET status = 'resolved', resolved_at = $2
|
||||
WHERE subject_type = 'order_item'
|
||||
AND subject_id = $1
|
||||
AND reason = 'manual_cost_missing'
|
||||
AND status = 'open'`,
|
||||
[orderItemId, now],
|
||||
)
|
||||
}
|
||||
|
||||
// v2 聚合异常:删除本条明细,没人再引用的异常头自动关闭。
|
||||
await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId])
|
||||
await client.query(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1
|
||||
WHERE ce.status <> 'resolved'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id
|
||||
)
|
||||
`,
|
||||
[now],
|
||||
)
|
||||
if (!onAccountCostPending) {
|
||||
// v2 聚合异常:成本已经落账后,删除本条明细并自动关闭空异常头。
|
||||
await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId])
|
||||
await client.query(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1
|
||||
WHERE ce.status <> 'resolved'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id
|
||||
)
|
||||
`,
|
||||
[now],
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
orderItemId,
|
||||
@@ -678,6 +714,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
onAccountBillId,
|
||||
costPending: onAccountCostPending,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -693,7 +730,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
* 事实任务默认只回看最近几天,确认一笔较早的订单时不会自动覆盖,
|
||||
* 所以这里按该订单自己的业务日期精确重算。
|
||||
*/
|
||||
async function refreshFactsForOrderItem(orderItemId: number): Promise<void> {
|
||||
export async function refreshFactsForOrderItem(orderItemId: number): Promise<void> {
|
||||
try {
|
||||
const basis = await query<{ stat_date: string | null }>(
|
||||
`
|
||||
@@ -841,7 +878,7 @@ async function matchManualFulfillmentConfigWithClient(client: PoolClient, orderI
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveManualCostItemIdWithClient(client: PoolClient) {
|
||||
export async function resolveManualCostItemIdWithClient(client: PoolClient) {
|
||||
const result = await client.query<{ id: number }>(
|
||||
`SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`,
|
||||
)
|
||||
@@ -855,7 +892,7 @@ async function resolveManualCostItemIdWithClient(client: PoolClient) {
|
||||
return id
|
||||
}
|
||||
|
||||
async function resolveCostVersionIdWithClient(
|
||||
export async function resolveCostVersionIdWithClient(
|
||||
client: PoolClient,
|
||||
costItemId: number,
|
||||
row: {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
test('统一结算对账用实际金额替代成本快照并重算事实', async () => {
|
||||
const source = await readFile(new URL('./on-account-bill-service.ts', import.meta.url), 'utf8')
|
||||
|
||||
assert.ok(source.includes('withTransaction('))
|
||||
assert.ok(source.includes("status = 'reconciled'"))
|
||||
assert.ok(source.includes('SET superseded_at = $2'))
|
||||
assert.ok(source.includes("source: 'on_account_reconcile'"))
|
||||
assert.ok(source.includes("'on_account_cost_pending'"))
|
||||
assert.ok(source.includes('refreshFactsForOrderItem'))
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import type { AdminSession } from '../admin/admin-auth-service.js'
|
||||
import { resolveExceptionsForItemsByReason } from '../cost-center/cost-exception-service.js'
|
||||
import {
|
||||
refreshFactsForOrderItem,
|
||||
resolveCostVersionIdWithClient,
|
||||
resolveManualCostItemIdWithClient,
|
||||
} from './manual-fulfillment-service.js'
|
||||
|
||||
type OnAccountBillRow = {
|
||||
id: number
|
||||
bill_no: string
|
||||
order_id: number
|
||||
order_item_id: number
|
||||
sku_key: string
|
||||
canonical_product_id: number | null
|
||||
platform_order_id: string
|
||||
amount_fen: string | number
|
||||
status: 'pending' | 'reconciled' | 'void'
|
||||
payee_name: string
|
||||
note: string
|
||||
metadata_json: Record<string, unknown>
|
||||
created_by_admin_user_id: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
reconciled_at: string | null
|
||||
provider?: string
|
||||
shop_id?: string
|
||||
sku_name?: string
|
||||
revenue_amount_fen?: string | number
|
||||
}
|
||||
|
||||
function mapBill(row: OnAccountBillRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
billNo: row.bill_no,
|
||||
orderId: Number(row.order_id),
|
||||
orderItemId: Number(row.order_item_id),
|
||||
skuKey: row.sku_key,
|
||||
skuName: row.sku_name || row.sku_key,
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
platformOrderId: row.platform_order_id,
|
||||
provider: row.provider || '',
|
||||
shopId: row.shop_id || '',
|
||||
revenueFen: Number(row.revenue_amount_fen || 0),
|
||||
amountFen: Number(row.amount_fen || 0),
|
||||
status: row.status,
|
||||
payeeName: row.payee_name || '',
|
||||
note: row.note || '',
|
||||
metadata: row.metadata_json || {},
|
||||
createdByAdminUserId: row.created_by_admin_user_id
|
||||
? Number(row.created_by_admin_user_id)
|
||||
: null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
reconciledAt: row.reconciled_at,
|
||||
}
|
||||
}
|
||||
|
||||
const BILL_SELECT = `
|
||||
SELECT
|
||||
bill.*,
|
||||
order_row.provider,
|
||||
order_row.shop_id,
|
||||
item.sku_name,
|
||||
item.revenue_amount_fen
|
||||
FROM on_account_bills bill
|
||||
JOIN orders order_row ON order_row.id = bill.order_id
|
||||
JOIN order_items item ON item.id = bill.order_item_id
|
||||
`
|
||||
|
||||
export async function listOnAccountBills(input: JsonObject = {}) {
|
||||
const page = Math.max(1, Math.trunc(Number(input.page) || 1))
|
||||
const pageSize = Math.min(100, Math.max(1, Math.trunc(Number(input.pageSize) || 20)))
|
||||
const keyword = String(input.keyword || '').trim()
|
||||
const status = String(input.status || '').trim()
|
||||
const where: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
where.push(`(
|
||||
bill.bill_no ILIKE $${params.length}
|
||||
OR bill.platform_order_id ILIKE $${params.length}
|
||||
OR bill.sku_key ILIKE $${params.length}
|
||||
OR item.sku_name ILIKE $${params.length}
|
||||
)`)
|
||||
}
|
||||
if (['pending', 'reconciled', 'void'].includes(status)) {
|
||||
params.push(status)
|
||||
where.push(`bill.status = $${params.length}`)
|
||||
}
|
||||
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
||||
const total = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM on_account_bills bill
|
||||
JOIN order_items item ON item.id = bill.order_item_id
|
||||
${whereSql}`,
|
||||
params,
|
||||
)
|
||||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||||
const result = await query<OnAccountBillRow>(
|
||||
`${BILL_SELECT}
|
||||
${whereSql}
|
||||
ORDER BY
|
||||
CASE bill.status WHEN 'pending' THEN 0 WHEN 'reconciled' THEN 1 ELSE 2 END,
|
||||
bill.created_at DESC,
|
||||
bill.id DESC
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows.map(mapBill),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: Number(total.rows[0]?.total || 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileOnAccountBill(
|
||||
billIdInput: unknown,
|
||||
payload: JsonObject,
|
||||
session: AdminSession,
|
||||
) {
|
||||
const billId = Number(billIdInput)
|
||||
if (!Number.isInteger(billId) || billId <= 0) {
|
||||
throw createHttpError('统一结算账单 ID 无效', { statusCode: 400 })
|
||||
}
|
||||
const amountFen = Number(payload.amountFen ?? payload.amount_fen)
|
||||
if (!Number.isFinite(amountFen) || amountFen <= 0 || !Number.isInteger(amountFen)) {
|
||||
throw createHttpError('实际结算金额必须是大于 0 的整数分', {
|
||||
statusCode: 400,
|
||||
errorCode: 'on_account_amount_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const result = await withTransaction(async (client) => {
|
||||
const billResult = await client.query<OnAccountBillRow>(
|
||||
`${BILL_SELECT}
|
||||
WHERE bill.id = $1
|
||||
LIMIT 1
|
||||
FOR UPDATE OF bill, item`,
|
||||
[billId],
|
||||
)
|
||||
const bill = billResult.rows[0]
|
||||
if (!bill) {
|
||||
throw createHttpError('统一结算账单不存在', { statusCode: 404 })
|
||||
}
|
||||
if (bill.status !== 'pending') {
|
||||
throw createHttpError('只有待对账账单可以确认结算', {
|
||||
statusCode: 409,
|
||||
errorCode: 'on_account_bill_not_pending',
|
||||
})
|
||||
}
|
||||
|
||||
const orderResult = await client.query<{
|
||||
provider: string
|
||||
shop_id: string
|
||||
paid_at: string | null
|
||||
created_at: string
|
||||
}>(
|
||||
`SELECT provider, shop_id, paid_at, created_at
|
||||
FROM orders WHERE id = $1`,
|
||||
[bill.order_id],
|
||||
)
|
||||
const order = orderResult.rows[0]
|
||||
if (!order) {
|
||||
throw createHttpError('统一结算账单关联订单不存在', { statusCode: 409 })
|
||||
}
|
||||
const basisAt = order.paid_at || order.created_at
|
||||
const period = await client.query<{ status: string }>(
|
||||
`SELECT status FROM finance_periods
|
||||
WHERE period_key = to_char($1::timestamptz AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM')
|
||||
LIMIT 1`,
|
||||
[basisAt],
|
||||
)
|
||||
if (period.rows[0]?.status === 'closed') {
|
||||
throw createHttpError('该订单所属期间已关账,请先反关账后再对账', {
|
||||
statusCode: 409,
|
||||
errorCode: 'on_account_period_closed',
|
||||
})
|
||||
}
|
||||
|
||||
const workOrder = await client.query<{ product_rule_id: number | null }>(
|
||||
`SELECT product_rule_id FROM work_orders
|
||||
WHERE order_item_id = $1 AND status <> 'cancelled'
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[bill.order_item_id],
|
||||
)
|
||||
const costItemId = await resolveManualCostItemIdWithClient(client)
|
||||
const versionId = await resolveCostVersionIdWithClient(
|
||||
client,
|
||||
costItemId,
|
||||
{
|
||||
order_id_resolved: Number(bill.order_id),
|
||||
provider: order.provider,
|
||||
shop_id: order.shop_id,
|
||||
canonical_product_id: bill.canonical_product_id,
|
||||
work_product_rule_id: workOrder.rows[0]?.product_rule_id || null,
|
||||
credit_unit_price_fen: '1',
|
||||
},
|
||||
basisAt,
|
||||
)
|
||||
if (!versionId) {
|
||||
throw createHttpError('人工履约成本版本不可用,无法完成对账', {
|
||||
statusCode: 409,
|
||||
errorCode: 'on_account_cost_version_missing',
|
||||
})
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE cost_snapshots
|
||||
SET superseded_at = $2
|
||||
WHERE order_item_id = $1
|
||||
AND subject_type = 'manual_case'
|
||||
AND superseded_at IS NULL
|
||||
AND reversed_at IS NULL`,
|
||||
[bill.order_item_id, now],
|
||||
)
|
||||
const snapshot = await client.query<{ id: number }>(
|
||||
`INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
cost_item_id, cost_version_id, canonical_product_id,
|
||||
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
|
||||
metadata_json, cost_basis_at, resolved_at, created_at
|
||||
) VALUES (
|
||||
'manual_case', $1, $2, $1, NULL,
|
||||
$3, $4, $5,
|
||||
1, $6, $6, 'actual', 'manual_input',
|
||||
$7::jsonb, $8, $9, $9
|
||||
)
|
||||
RETURNING id`,
|
||||
[
|
||||
bill.order_item_id,
|
||||
bill.order_id,
|
||||
costItemId,
|
||||
versionId,
|
||||
bill.canonical_product_id,
|
||||
amountFen,
|
||||
JSON.stringify({
|
||||
source: 'on_account_reconcile',
|
||||
billId: bill.id,
|
||||
billNo: bill.bill_no,
|
||||
platformKey: 'on_account',
|
||||
previousAmountFen: Number(bill.amount_fen || 0),
|
||||
reconciledBy: {
|
||||
userId: Number(session.userId || 0),
|
||||
displayName: String(session.displayName || ''),
|
||||
},
|
||||
}),
|
||||
basisAt,
|
||||
now,
|
||||
],
|
||||
)
|
||||
const snapshotId = Number(snapshot.rows[0]?.id || 0)
|
||||
if (!snapshotId) {
|
||||
throw createHttpError('统一结算成本快照保存失败', { statusCode: 500 })
|
||||
}
|
||||
|
||||
const updated = await client.query<OnAccountBillRow>(
|
||||
`UPDATE on_account_bills
|
||||
SET amount_fen = $1,
|
||||
status = 'reconciled',
|
||||
payee_name = $2,
|
||||
note = $3,
|
||||
metadata_json = metadata_json || $4::jsonb,
|
||||
reconciled_at = $5,
|
||||
updated_at = $5
|
||||
WHERE id = $6
|
||||
RETURNING *`,
|
||||
[
|
||||
amountFen,
|
||||
String(payload.payeeName ?? payload.payee_name ?? bill.payee_name ?? '').slice(0, 120),
|
||||
String(payload.note ?? bill.note ?? '').slice(0, 300),
|
||||
JSON.stringify({
|
||||
reconciledByAdminUserId: Number(session.userId || 0) || null,
|
||||
costSnapshotId: snapshotId,
|
||||
}),
|
||||
now,
|
||||
bill.id,
|
||||
],
|
||||
)
|
||||
return {
|
||||
bill: mapBill({ ...bill, ...updated.rows[0]!, amount_fen: amountFen }),
|
||||
orderItemId: Number(bill.order_item_id),
|
||||
snapshotId,
|
||||
amountFen,
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
resolveExceptionsForItemsByReason([result.orderItemId], 'on_account_cost_pending'),
|
||||
resolveExceptionsForItemsByReason([result.orderItemId], 'manual_cost_missing'),
|
||||
resolveExceptionsForItemsByReason([result.orderItemId], 'cost_missing'),
|
||||
]).catch(() => undefined)
|
||||
await refreshFactsForOrderItem(result.orderItemId).catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
@@ -434,7 +434,10 @@ export async function assignPendingOrderItemChannels(
|
||||
.filter((id) => id > 0)
|
||||
.slice(0, 2000)
|
||||
result.assigned += assignedItemIds.length
|
||||
result.assignedItemIds = assignedItemIds
|
||||
result.assignedItemIds = [...new Set([...result.assignedItemIds, ...assignedItemIds])].slice(
|
||||
0,
|
||||
2000,
|
||||
)
|
||||
await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch(
|
||||
(error) => {
|
||||
logWarn('[channel-assign]', '订单收到后安排自动核销失败', {
|
||||
|
||||
@@ -16,6 +16,10 @@ const currentAttemptMigration = readFileSync(
|
||||
new URL('../../db/migrations/138_fulfillment_current_attempt.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const currentLatestMigration = readFileSync(
|
||||
new URL('../../db/migrations/145_fulfillment_current_attempt_latest.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('自动发货历史非活跃任务默认阻止后台自动重开', () => {
|
||||
assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes'))
|
||||
@@ -55,3 +59,9 @@ test('创建新履约尝试时自动退休旧当前尝试', () => {
|
||||
assert.ok(taskRepo.includes('$26, TRUE'))
|
||||
assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE'))
|
||||
})
|
||||
|
||||
test('当前尝试必须是同单元最新任务,统一视图不能读到已退休尝试', () => {
|
||||
assert.ok(currentLatestMigration.includes('assert_fulfillment_current_is_latest'))
|
||||
assert.ok(currentLatestMigration.includes('newer.id > COALESCE(NEW.id, 0)'))
|
||||
assert.ok(currentLatestMigration.includes("ERRCODE = '23514'"))
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ const auditScript = readFileSync(
|
||||
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configSyncMigration = readFileSync(
|
||||
new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('正式履约影子模型包含履约、尝试和异常三张表', () => {
|
||||
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments'))
|
||||
@@ -27,6 +31,18 @@ test('影子模型从现有真源回填并持续双写', () => {
|
||||
assert.ok(migration.includes('assign_fulfillment_event_shadow'))
|
||||
})
|
||||
|
||||
test('人工配置和统一渠道规则变化会刷新履约影子', () => {
|
||||
assert.ok(configSyncMigration.includes('trg_manual_fulfillment_config_shadow'))
|
||||
assert.ok(configSyncMigration.includes('trg_internal_sku_channel_shadow'))
|
||||
assert.ok(configSyncMigration.includes('sync_fulfillment_shadow(item.id)'))
|
||||
})
|
||||
|
||||
test('人工履约配置不能关联其他来源商品', () => {
|
||||
assert.ok(configSyncMigration.includes('trg_manual_config_product_source'))
|
||||
assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'"))
|
||||
assert.ok(configSyncMigration.includes("ERRCODE = '23514'"))
|
||||
})
|
||||
|
||||
test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => {
|
||||
assert.ok(migration.includes('v_fulfillment_item_state_raw'))
|
||||
assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'"))
|
||||
|
||||
@@ -43,6 +43,10 @@ test('待分类查询使用统一读模型并返回异常数量', () => {
|
||||
assert.ok(channelAssign.includes('issueCategoryInput'))
|
||||
})
|
||||
|
||||
test('批量判渠保留所有批次的即时成本同步订单项', () => {
|
||||
assert.ok(channelAssign.includes('...new Set([...result.assignedItemIds, ...assignedItemIds])'))
|
||||
})
|
||||
|
||||
test('重试必须按可重试 issueCode 限定,不能按整个 SKU 处理售后和复核项', () => {
|
||||
assert.ok(channelAssign.includes('RETRYABLE_CARRIER_ISSUES'))
|
||||
assert.ok(channelAssign.includes('issueCodes?: string[]'))
|
||||
|
||||
@@ -183,9 +183,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
result.onAccountBillId
|
||||
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
|
||||
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
result.costPending
|
||||
? '履约已确认,已生成待对账的统一结算账单;实际成本待对账后入账'
|
||||
: result.onAccountBillId
|
||||
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
|
||||
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
|
||||
@@ -60,6 +60,7 @@ const REASON_LABELS: Record<string, string> = {
|
||||
cost_missing: '成本缺失',
|
||||
manual_config_missing: '人工 SKU 未配置',
|
||||
manual_cost_missing: '人工成本未记录',
|
||||
on_account_cost_pending: '统一结算待对账',
|
||||
carrier_missing: '缺少履约载体',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PlusOutlined, ReloadOutlined, RollbackOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -34,18 +34,22 @@ import {
|
||||
createAdminFundExpense,
|
||||
createAdminFundReturn,
|
||||
fetchAdminFundMyAccount,
|
||||
fetchAdminOnAccountBills,
|
||||
fetchAdminPlatformCreditAccounts,
|
||||
fetchAdminPlatformCreditEntries,
|
||||
reconcileAdminOnAccountBill,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFundEntry,
|
||||
AdminFundExpense,
|
||||
AdminFundExpenseCategory,
|
||||
AdminFundPayeeType,
|
||||
OnAccountBill,
|
||||
PlatformCreditEntry,
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
@@ -79,14 +83,23 @@ type ReturnFormValues = {
|
||||
note?: string
|
||||
}
|
||||
|
||||
type OnAccountReconcileFormValues = {
|
||||
amountYuan: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export default function FundSupportView() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [expenseOpen, setExpenseOpen] = useState(false)
|
||||
const [returnOpen, setReturnOpen] = useState(false)
|
||||
const [reconcileBill, setReconcileBill] = useState<OnAccountBill | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [expenseForm] = Form.useForm<ExpenseFormValues>()
|
||||
const [returnForm] = Form.useForm<ReturnFormValues>()
|
||||
const [reconcileForm] = Form.useForm<OnAccountReconcileFormValues>()
|
||||
const canReconcileOnAccount = hasAdminPermission('finance.reconcile')
|
||||
|
||||
const myQuery = useQuery({
|
||||
queryKey: ['admin-fund-my'],
|
||||
@@ -110,9 +123,35 @@ export default function FundSupportView() {
|
||||
refetchOnMount: 'always',
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const onAccountBillsQuery = useQuery({
|
||||
queryKey: ['admin-on-account-bills', 'pending'],
|
||||
queryFn: () => fetchAdminOnAccountBills({ status: 'pending', pageSize: 100 }),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const creditAccounts = creditAccountsQuery.data?.data.items || []
|
||||
const creditEntries = creditEntriesQuery.data?.data.items || []
|
||||
const creditPlatforms = creditAccountsQuery.data?.data.platforms || []
|
||||
const onAccountBills = onAccountBillsQuery.data?.data.items || []
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: (values: OnAccountReconcileFormValues) =>
|
||||
reconcileAdminOnAccountBill(reconcileBill!.id, {
|
||||
amountFen: toFen(values.amountYuan),
|
||||
payeeName: values.payeeName || '',
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success('统一结算账单已对账,实际成本已入账')
|
||||
setReconcileBill(null)
|
||||
reconcileForm.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '统一结算对账失败'),
|
||||
})
|
||||
|
||||
/** 客服自己的三个积分钱包,按平台顺序排好。 */
|
||||
const myCreditWallets = creditPlatforms.map((platform) => {
|
||||
@@ -132,6 +171,7 @@ export default function FundSupportView() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
|
||||
}
|
||||
|
||||
function openExpense() {
|
||||
@@ -155,6 +195,15 @@ export default function FundSupportView() {
|
||||
setReturnOpen(true)
|
||||
}
|
||||
|
||||
function openReconcileBill(bill: OnAccountBill) {
|
||||
setReconcileBill(bill)
|
||||
reconcileForm.setFieldsValue({
|
||||
amountYuan: bill.amountFen > 0 ? bill.amountFen / 100 : undefined,
|
||||
payeeName: bill.payeeName || '',
|
||||
note: bill.note || '',
|
||||
})
|
||||
}
|
||||
|
||||
async function submitExpense(values: ExpenseFormValues) {
|
||||
if (!account) return
|
||||
setSubmitting(true)
|
||||
@@ -384,6 +433,56 @@ export default function FundSupportView() {
|
||||
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
|
||||
]
|
||||
|
||||
const onAccountBillColumns: TableColumnsType<OnAccountBill> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||
{
|
||||
title: '订单 / SKU',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text copyable={{ text: row.platformOrderId }}>
|
||||
{row.platformOrderId || `#${row.orderId}`}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.skuName || row.skuKey}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单收入',
|
||||
dataIndex: 'revenueFen',
|
||||
width: 120,
|
||||
render: (value: number) => formatFundAmount(value),
|
||||
},
|
||||
{
|
||||
title: '待结算金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 130,
|
||||
render: (value: number) => (value > 0 ? formatFundAmount(value) : '待填写'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 110,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!canReconcileOnAccount}
|
||||
onClick={() => openReconcileBill(row)}
|
||||
>
|
||||
对账
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
@@ -475,6 +574,38 @@ export default function FundSupportView() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={`统一结算待对账(${onAccountBillsQuery.data?.data.pagination.total || 0})`}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={onAccountBillsQuery.isFetching}
|
||||
onClick={() => void onAccountBillsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="统一结算金额未确认前不会生成 actual 成本快照"
|
||||
description="完成对账后,系统按实际金额生成成本快照并重算销售利润。"
|
||||
/>
|
||||
<PercentTable<OnAccountBill>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={onAccountBillColumns}
|
||||
dataSource={onAccountBills}
|
||||
loading={onAccountBillsQuery.isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 980 }}
|
||||
locale={{ emptyText: '没有待对账的统一结算账单' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{account ? (
|
||||
<Card>
|
||||
<Descriptions column={2} size="small">
|
||||
@@ -699,6 +830,43 @@ export default function FundSupportView() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`统一结算对账${reconcileBill ? ` · ${reconcileBill.billNo}` : ''}`}
|
||||
open={Boolean(reconcileBill)}
|
||||
onCancel={() => setReconcileBill(null)}
|
||||
onOk={() => reconcileForm.submit()}
|
||||
confirmLoading={reconcileMutation.isPending}
|
||||
okText="确认对账"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="确认后将按本次金额生成 actual 成本快照"
|
||||
description="订单收入不会改变;如果此前已有固定成本快照,会用本次实际结算金额替代。"
|
||||
/>
|
||||
<Form<OnAccountReconcileFormValues>
|
||||
form={reconcileForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => reconcileMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="实际结算金额(元)"
|
||||
rules={[{ required: true, message: '请输入实际结算金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="payeeName" label="实际收款方">
|
||||
<Input maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="对账备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
ManualSkuListResult,
|
||||
ManualSkuOverview,
|
||||
ManualSkuScanResult,
|
||||
OnAccountBill,
|
||||
OnAccountBillListResult,
|
||||
ProductCatalogItem,
|
||||
ProductCatalogListResult,
|
||||
TemplateProductBootstrapPreview,
|
||||
@@ -555,3 +557,26 @@ export function deleteAdminVoucherProduct(productId: number) {
|
||||
}
|
||||
}>(`/api/v1/admin/cost-center/voucher-products/${productId}`)
|
||||
}
|
||||
|
||||
export function fetchAdminOnAccountBills(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<OnAccountBillListResult>('/api/v1/admin/finance/on-account-bills', params)
|
||||
}
|
||||
|
||||
export function reconcileAdminOnAccountBill(
|
||||
billId: number,
|
||||
payload: { amountFen: number; payeeName?: string; note?: string },
|
||||
) {
|
||||
return apiPost<{
|
||||
bill: OnAccountBill
|
||||
orderItemId: number
|
||||
snapshotId: number
|
||||
amountFen: number
|
||||
}>(`/api/v1/admin/finance/on-account-bills/${billId}/reconcile`, payload)
|
||||
}
|
||||
|
||||
@@ -564,7 +564,7 @@ export interface ManualFulfillmentPendingListResult {
|
||||
export interface ManualFulfillmentConfirmResult {
|
||||
orderItemId: number
|
||||
orderId: number
|
||||
costSnapshotId: number
|
||||
costSnapshotId: number | null
|
||||
platformKey: string
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
@@ -573,4 +573,33 @@ export interface ManualFulfillmentConfirmResult {
|
||||
creditEntryId: number | null
|
||||
balanceAfter: number | null
|
||||
onAccountBillId: number | null
|
||||
costPending: boolean
|
||||
}
|
||||
|
||||
export interface OnAccountBill {
|
||||
id: number
|
||||
billNo: string
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
skuKey: string
|
||||
skuName: string
|
||||
canonicalProductId: number | null
|
||||
platformOrderId: string
|
||||
provider: string
|
||||
shopId: string
|
||||
revenueFen: number
|
||||
amountFen: number
|
||||
status: 'pending' | 'reconciled' | 'void'
|
||||
payeeName: string
|
||||
note: string
|
||||
metadata: Record<string, unknown>
|
||||
createdByAdminUserId: number | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
reconciledAt: string | null
|
||||
}
|
||||
|
||||
export interface OnAccountBillListResult {
|
||||
items: OnAccountBill[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
@@ -11,3 +11,21 @@ test('客服我的钱包展示统一结算虚拟账单入口', async () => {
|
||||
assert.ok(source.includes('生成虚拟账单,不扣积分或备用金'))
|
||||
assert.ok(source.includes('support-wallet-grid'))
|
||||
})
|
||||
|
||||
test('客服钱包提供统一结算待对账列表和实际金额确认', async () => {
|
||||
const [source, service, confirmModal] = await Promise.all([
|
||||
readFile(new URL('../src/pages/admin/fund/FundSupportView.tsx', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'),
|
||||
readFile(
|
||||
new URL('../src/components/admin/ManualFulfillmentConfirmModal.tsx', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
])
|
||||
|
||||
assert.ok(source.includes('统一结算待对账'))
|
||||
assert.ok(source.includes('实际结算金额(元)'))
|
||||
assert.ok(source.includes('确认对账'))
|
||||
assert.ok(confirmModal.includes('costPending'))
|
||||
assert.ok(service.includes('/api/v1/admin/finance/on-account-bills'))
|
||||
assert.ok(service.includes('/reconcile'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user