add:新增同一个工单同一人可以多次拼单
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
-- 后台「接单工单 → 店铺」筛选:按订单归属店铺精确匹配(可多选)。
|
||||||
|
-- 过滤器写法是 BTRIM(o.shop_name) = ANY($n::text[]),普通 btree 无法命中表达式,
|
||||||
|
-- 所以补一个与查询同口径的表达式索引;部分索引省掉空店铺的无效条目。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed
|
||||||
|
ON orders (BTRIM(shop_name))
|
||||||
|
WHERE BTRIM(shop_name) <> '';
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_orders_shop_name_trimmed IS
|
||||||
|
'支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选';
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- 104_work_order_share_multi_participation.sql
|
||||||
|
-- 目标:同一打手可对同一拼单工单多次参与,每次参与一条 work_order_shares;
|
||||||
|
-- 每份在参与时固化截止时间与超时策略;超时/验收/撤单按份额独立。
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- 1. 只加列 + 回填 + 删「一人一条」唯一键 + 建索引,不拆分历史行、不改历史资金。
|
||||||
|
-- 2. 历史合并份额(quantity > 1)保持原样,结算公式 quantity × unit_reward 完全兼容。
|
||||||
|
-- 3. request_id 为客户端幂等键,唯一索引只约束非空值。
|
||||||
|
|
||||||
|
ALTER TABLE work_order_shares
|
||||||
|
ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN work_order_shares.deadline_at IS '本份额截止时间:参与时 = created_at + 母单当时 timeout_minutes;0 分钟为 NULL';
|
||||||
|
COMMENT ON COLUMN work_order_shares.timeout_minutes IS '参与时固化的时限(分钟),母单后续改配置不影响';
|
||||||
|
COMMENT ON COLUMN work_order_shares.timeout_policy IS '参与时固化的超时策略 reopen/cancel_release/cancel_deduct';
|
||||||
|
COMMENT ON COLUMN work_order_shares.cancelled_reason IS '取消来源:timeout/worker_request/admin/problem/settlement';
|
||||||
|
COMMENT ON COLUMN work_order_shares.request_id IS '客户端幂等键(一次点击一个),重复请求返回原份额';
|
||||||
|
|
||||||
|
-- 回填历史份额:按当时母单配置一次性固化
|
||||||
|
UPDATE work_order_shares s
|
||||||
|
SET timeout_minutes = COALESCE(w.timeout_minutes, 0),
|
||||||
|
timeout_policy = COALESCE(NULLIF(w.timeout_policy, ''), 'reopen'),
|
||||||
|
deadline_at = CASE
|
||||||
|
WHEN COALESCE(w.timeout_minutes, 0) > 0
|
||||||
|
THEN s.created_at + (w.timeout_minutes || ' minutes')::interval
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
FROM work_orders w
|
||||||
|
WHERE w.id = s.work_order_id
|
||||||
|
AND s.timeout_minutes = 0;
|
||||||
|
|
||||||
|
-- 删除「同一工单同一打手只能一条」约束(否则第二次参与报 23505)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'work_order_shares_work_order_id_worker_id_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE work_order_shares
|
||||||
|
DROP CONSTRAINT work_order_shares_work_order_id_worker_id_key;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 到期扫描:只覆盖 joined 份额,走部分索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_due
|
||||||
|
ON work_order_shares (deadline_at)
|
||||||
|
WHERE status = 'joined';
|
||||||
|
|
||||||
|
-- 打手在某工单的份额列表(我的订单展开、押金累计求和)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker_order
|
||||||
|
ON work_order_shares (worker_id, work_order_id, created_at DESC);
|
||||||
|
|
||||||
|
-- 母单份额集合推导(recompute 母单状态、剩余份数)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_order_status
|
||||||
|
ON work_order_shares (work_order_id, status, created_at ASC);
|
||||||
|
|
||||||
|
-- 幂等键:同一打手同一个 request_id 只能产生一条份额
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request
|
||||||
|
ON work_order_shares (worker_id, request_id)
|
||||||
|
WHERE request_id <> '';
|
||||||
@@ -66,8 +66,25 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as
|
|||||||
test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => {
|
test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => {
|
||||||
const raw = await readRepoSource()
|
const raw = await readRepoSource()
|
||||||
const source = stripComments(raw)
|
const source = stripComments(raw)
|
||||||
|
|
||||||
|
// 我的订单概览里按「工单 + 份额状态」聚合金额,必须限定到当前打手;
|
||||||
|
// 它不是整表聚合,先单独摘出来校验,避免被下面的全表聚合守卫误判。
|
||||||
|
const shareStatusStart = source.indexOf('share_status_rows AS (')
|
||||||
|
const shareStatusEnd = source.indexOf('order_status_rows AS (')
|
||||||
|
const workerShareAggregation =
|
||||||
|
shareStatusStart >= 0 && shareStatusEnd > shareStatusStart
|
||||||
|
? source.slice(shareStatusStart, shareStatusEnd)
|
||||||
|
: ''
|
||||||
|
assert.ok(workerShareAggregation, '缺少我的订单概览的份额状态聚合')
|
||||||
|
|
||||||
|
const sourceWithoutWorkerShareAggregation =
|
||||||
|
shareStatusStart >= 0 && shareStatusEnd > shareStatusStart
|
||||||
|
? source.slice(0, shareStatusStart) + source.slice(shareStatusEnd)
|
||||||
|
: source
|
||||||
const globalAggregations = [
|
const globalAggregations = [
|
||||||
...source.matchAll(/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g),
|
...sourceWithoutWorkerShareAggregation.matchAll(
|
||||||
|
/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -78,15 +95,28 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onlyRemaining = globalAggregations[0]!
|
const onlyRemaining = globalAggregations[0]!
|
||||||
const around = source.slice(Math.max(0, onlyRemaining.index - 900), onlyRemaining.index + 200)
|
const around = sourceWithoutWorkerShareAggregation.slice(
|
||||||
|
Math.max(0, (onlyRemaining.index ?? 0) - 900),
|
||||||
|
(onlyRemaining.index ?? 0) + 200,
|
||||||
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
around.includes('operational_share_summary'),
|
around.includes('operational_share_summary'),
|
||||||
'保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之',
|
'保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之',
|
||||||
)
|
)
|
||||||
|
|
||||||
// 注释在 stripComments 里被去掉了,所以断言理由时用原文
|
// 注释在 stripComments 里被去掉了,所以断言理由时用原文。
|
||||||
const rawIndex = raw.indexOf('GROUP BY wos.work_order_id')
|
// 注意:raw 里前面还有 share_status_rows 的工单内聚合,必须找 operational 那一处。
|
||||||
const rawAround = raw.slice(Math.max(0, rawIndex - 1200), rawIndex + 200)
|
const rawMatches = [...raw.matchAll(/GROUP BY\s+wos\.work_order_id/g)]
|
||||||
|
const operationalRaw = rawMatches.find((match) =>
|
||||||
|
raw
|
||||||
|
.slice(Math.max(0, (match.index ?? 0) - 1200), (match.index ?? 0) + 200)
|
||||||
|
.includes('operational_share_summary'),
|
||||||
|
)
|
||||||
|
assert.ok(operationalRaw, '找不到 operational_share_summary 的整表聚合')
|
||||||
|
const rawAround = raw.slice(
|
||||||
|
Math.max(0, (operationalRaw.index ?? 0) - 1200),
|
||||||
|
(operationalRaw.index ?? 0) + 200,
|
||||||
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
rawAround.includes('无打手范围时一次聚合全部拼单'),
|
rawAround.includes('无打手范围时一次聚合全部拼单'),
|
||||||
'保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删',
|
'保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删',
|
||||||
@@ -105,18 +135,67 @@ test('我的订单的份额关联按工单取一条,避免多份额把列表
|
|||||||
|
|
||||||
test('我的订单概览按工单计数,金额仍按份额求和', async () => {
|
test('我的订单概览按工单计数,金额仍按份额求和', async () => {
|
||||||
const source = stripComments(await readRepoSource())
|
const source = stripComments(await readRepoSource())
|
||||||
const start = source.indexOf('worker_base AS (')
|
const start = source.indexOf('order_flags AS (')
|
||||||
assert.ok(start >= 0, '缺少我的订单概览的 worker_base CTE')
|
assert.ok(start >= 0, '缺少我的订单概览的 order_flags CTE')
|
||||||
|
|
||||||
const block = source.slice(start, start + 2200)
|
const block = source.slice(start, start + 4200)
|
||||||
assert.match(block, /LEFT JOIN LATERAL \(/, '概览的份额关联没有按工单取一条')
|
assert.match(block, /share_status_rows AS \(/, '概览没有按份额状态拆分金额')
|
||||||
assert.match(block, /share_reward_total/, '概览没有汇总多份份额的奖励金额')
|
assert.match(block, /order_status_rows AS \(/, '概览没有聚合成工单状态行')
|
||||||
|
assert.match(block, /SUM\(wos\.share_reward\)/, '概览没有汇总份额奖励')
|
||||||
|
assert.match(
|
||||||
|
block,
|
||||||
|
/GROUP BY wos\.work_order_id, wos\.status/,
|
||||||
|
'份额金额没有按「工单 + 份额状态」聚合',
|
||||||
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!block.includes('LEFT JOIN work_order_shares wos'),
|
!block.includes('LEFT JOIN work_order_shares wos'),
|
||||||
'概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单',
|
'概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('多份额混合状态:任一状态命中即可出现在对应页签', async () => {
|
||||||
|
const source = stripComments(await readRepoSource())
|
||||||
|
|
||||||
|
assert.ok(source.includes("ws_filter.status = 'submitted'"), '待验收筛选没有检查任一份额')
|
||||||
|
assert.ok(source.includes("'pending_acceptance' = ANY"), '待验收状态没有参与任一份额匹配')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 概览与列表必须是同一把尺子:列表里「待审核取消申请/未处理反馈」只把进行中的份额
|
||||||
|
* 归入问题单,已提交待验收的份额照旧留在待验收。
|
||||||
|
* 概览旧写法把这类工单的份额整体排除、再由第二分支记成问题单,于是出现
|
||||||
|
* 「问题单卡片有数字、点进去列表是空的」「待验收列表有单、卡片不计数」。
|
||||||
|
*/
|
||||||
|
test('概览与列表同口径:待处理申请只把进行中份额归问题单', async () => {
|
||||||
|
const source = stripComments(await readRepoSource())
|
||||||
|
|
||||||
|
const shareStart = source.indexOf('share_status_rows AS (')
|
||||||
|
assert.ok(shareStart >= 0, '缺少 share_status_rows CTE')
|
||||||
|
const shareBlock = source.slice(shareStart, shareStart + 1600)
|
||||||
|
assert.match(
|
||||||
|
shareBlock,
|
||||||
|
/WHEN wos\.status = 'joined' AND f\.has_pending_handling THEN 'problem'/,
|
||||||
|
'待处理申请只应把进行中的份额归问题单',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!shareBlock.includes('AND NOT f.has_pending_handling'),
|
||||||
|
'概览不能整体排除有待处理申请的份额,否则已提交待验收会从页签里消失',
|
||||||
|
)
|
||||||
|
|
||||||
|
const orderStart = source.indexOf('order_status_rows AS (', shareStart)
|
||||||
|
assert.ok(orderStart > shareStart, '缺少 order_status_rows CTE')
|
||||||
|
const orderBlock = source.slice(orderStart, orderStart + 1600)
|
||||||
|
assert.ok(
|
||||||
|
!/OR f\.has_pending_handling\b/.test(orderBlock),
|
||||||
|
'第二分支不能无条件把有待处理申请的工单塞进问题单(会产生重复/错页签计数)',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
orderBlock,
|
||||||
|
/WHEN f\.has_pending_handling AND f\.wo_status = 'in_progress'/,
|
||||||
|
'无份额的指派单也要按「进行中才归问题单」判定',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('超时统计按工单去重,不按份额/事件行数累加', async () => {
|
test('超时统计按工单去重,不按份额/事件行数累加', async () => {
|
||||||
const source = stripComments(await readRepoSource())
|
const source = stripComments(await readRepoSource())
|
||||||
|
|
||||||
|
|||||||
@@ -223,25 +223,29 @@ export function workerOrderBaseStatusExpression(workerShareAlias = 'worker_share
|
|||||||
* 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单,
|
* 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单,
|
||||||
* 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。
|
* 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。
|
||||||
*/
|
*/
|
||||||
export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') {
|
function workerPendingHandlingExistsExpression(workerId: number): string {
|
||||||
const baseExpression = workerOrderBaseStatusExpression(workerShareAlias)
|
return `(
|
||||||
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
|
|
||||||
if (normalizedWorkerId <= 0) return baseExpression
|
|
||||||
const pendingHandlingExists = `(
|
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM worker_order_cancel_requests wcr
|
FROM worker_order_cancel_requests wcr
|
||||||
WHERE wcr.work_order_id = wo.id
|
WHERE wcr.work_order_id = wo.id
|
||||||
AND wcr.worker_id = ${normalizedWorkerId}
|
AND wcr.worker_id = ${workerId}
|
||||||
AND wcr.status = 'pending'
|
AND wcr.status = 'pending'
|
||||||
) OR EXISTS (
|
) OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM worker_order_feedbacks wf
|
FROM worker_order_feedbacks wf
|
||||||
WHERE wf.work_order_id = wo.id
|
WHERE wf.work_order_id = wo.id
|
||||||
AND wf.worker_id = ${normalizedWorkerId}
|
AND wf.worker_id = ${workerId}
|
||||||
AND wf.status = 'pending'
|
AND wf.status = 'pending'
|
||||||
)
|
)
|
||||||
)`
|
)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') {
|
||||||
|
const baseExpression = workerOrderBaseStatusExpression(workerShareAlias)
|
||||||
|
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
|
||||||
|
if (normalizedWorkerId <= 0) return baseExpression
|
||||||
|
const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId)
|
||||||
const isTimeoutForMe = `(
|
const isTimeoutForMe = `(
|
||||||
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
|
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
|
||||||
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted')
|
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted')
|
||||||
@@ -638,12 +642,12 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
|||||||
WHERE timeout_ev.event_type LIKE 'timeout_%'
|
WHERE timeout_ev.event_type LIKE 'timeout_%'
|
||||||
AND timeout_ev.payload_json->>'workerId' = $1::text
|
AND timeout_ev.payload_json->>'workerId' = $1::text
|
||||||
),
|
),
|
||||||
worker_base AS (
|
order_flags AS (
|
||||||
SELECT
|
SELECT
|
||||||
wo.id,
|
wo.id,
|
||||||
wo.status AS wo_status,
|
wo.status AS wo_status,
|
||||||
wos.status AS wos_status,
|
wo.reward_amount,
|
||||||
wos.status IS NOT NULL AS is_worker_share,
|
wo.acceptance_json,
|
||||||
wo.assigned_worker_id = $1 AS is_assigned_to_me,
|
wo.assigned_worker_id = $1 AS is_assigned_to_me,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM work_order_events we
|
SELECT 1 FROM work_order_events we
|
||||||
@@ -651,88 +655,92 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
|||||||
AND we.event_type LIKE 'timeout_%'
|
AND we.event_type LIKE 'timeout_%'
|
||||||
AND we.payload_json->>'workerId' = $1::text
|
AND we.payload_json->>'workerId' = $1::text
|
||||||
) AS has_timeout_for_me,
|
) AS has_timeout_for_me,
|
||||||
CASE
|
(
|
||||||
WHEN wos.status IS NOT NULL THEN wos.share_reward_total
|
EXISTS (
|
||||||
WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount
|
SELECT 1 FROM worker_order_cancel_requests wcr
|
||||||
ELSE 0
|
WHERE wcr.work_order_id = wo.id
|
||||||
END AS worker_settlement_amount,
|
AND wcr.worker_id = $1
|
||||||
wo.acceptance_json
|
AND wcr.status = 'pending'
|
||||||
|
) OR EXISTS (
|
||||||
|
SELECT 1 FROM worker_order_feedbacks wf
|
||||||
|
WHERE wf.work_order_id = wo.id
|
||||||
|
AND wf.worker_id = $1
|
||||||
|
AND wf.status = 'pending'
|
||||||
|
)
|
||||||
|
) AS has_pending_handling,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(wos.share_reward)
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id
|
||||||
|
AND wos.worker_id = $1
|
||||||
|
AND wos.status != 'cancelled'
|
||||||
|
), 0)::int AS share_reward_total,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id
|
||||||
|
AND wos.worker_id = $1
|
||||||
|
AND wos.status != 'cancelled'
|
||||||
|
) AS is_worker_share
|
||||||
FROM work_orders wo
|
FROM work_orders wo
|
||||||
INNER JOIN visible_orders vo ON vo.work_order_id = wo.id
|
INNER JOIN visible_orders vo ON vo.work_order_id = wo.id
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT
|
|
||||||
(
|
|
||||||
SELECT w2.status
|
|
||||||
FROM work_order_shares w2
|
|
||||||
WHERE w2.work_order_id = wo.id
|
|
||||||
AND w2.worker_id = $1
|
|
||||||
AND w2.status != 'cancelled'
|
|
||||||
ORDER BY
|
|
||||||
CASE w2.status
|
|
||||||
WHEN 'joined' THEN 0
|
|
||||||
WHEN 'submitted' THEN 1
|
|
||||||
WHEN 'accepted' THEN 2
|
|
||||||
ELSE 3
|
|
||||||
END,
|
|
||||||
w2.created_at DESC,
|
|
||||||
w2.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) AS status,
|
|
||||||
COALESCE(SUM(wos.share_reward), 0)::int AS share_reward_total
|
|
||||||
FROM work_order_shares wos
|
|
||||||
WHERE wos.work_order_id = wo.id
|
|
||||||
AND wos.worker_id = $1
|
|
||||||
AND wos.status != 'cancelled'
|
|
||||||
) wos ON true
|
|
||||||
),
|
),
|
||||||
worker_orders AS (
|
share_status_rows AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
wos.work_order_id,
|
||||||
CASE
|
CASE
|
||||||
WHEN NOT is_assigned_to_me AND (NOT is_worker_share OR wos_status = 'cancelled' OR wos_status IS NULL) AND has_timeout_for_me
|
-- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单;
|
||||||
THEN 'open'
|
-- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的,
|
||||||
WHEN (
|
-- 这里若整体排除会出现「问题单有数字、点进去空列表」)。
|
||||||
CASE
|
WHEN wos.status = 'joined' AND f.has_pending_handling THEN 'problem'
|
||||||
WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem')
|
WHEN wos.status = 'joined' THEN 'in_progress'
|
||||||
THEN CASE wos_status
|
WHEN wos.status = 'submitted' THEN 'pending_acceptance'
|
||||||
WHEN 'joined' THEN 'in_progress'
|
WHEN wos.status = 'accepted' THEN 'accepted'
|
||||||
WHEN 'submitted' THEN 'pending_acceptance'
|
|
||||||
WHEN 'accepted' THEN 'accepted'
|
|
||||||
WHEN 'cancelled' THEN 'cancelled'
|
|
||||||
ELSE wo_status
|
|
||||||
END
|
|
||||||
ELSE wo_status
|
|
||||||
END
|
|
||||||
) = 'in_progress' AND (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM worker_order_cancel_requests wcr
|
|
||||||
WHERE wcr.work_order_id = worker_base.id
|
|
||||||
AND wcr.worker_id = $1
|
|
||||||
AND wcr.status = 'pending'
|
|
||||||
) OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM worker_order_feedbacks wf
|
|
||||||
WHERE wf.work_order_id = worker_base.id
|
|
||||||
AND wf.worker_id = $1
|
|
||||||
AND wf.status = 'pending'
|
|
||||||
)
|
|
||||||
) THEN 'problem'
|
|
||||||
ELSE CASE
|
|
||||||
WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem')
|
|
||||||
THEN CASE wos_status
|
|
||||||
WHEN 'joined' THEN 'in_progress'
|
|
||||||
WHEN 'submitted' THEN 'pending_acceptance'
|
|
||||||
WHEN 'accepted' THEN 'accepted'
|
|
||||||
WHEN 'cancelled' THEN 'cancelled'
|
|
||||||
ELSE wo_status
|
|
||||||
END
|
|
||||||
ELSE wo_status
|
|
||||||
END
|
|
||||||
END AS status,
|
END AS status,
|
||||||
|
SUM(wos.share_reward)::int AS amount,
|
||||||
|
(ARRAY_AGG(wos.acceptance_json ORDER BY wos.id DESC))[1] AS acceptance_json
|
||||||
|
FROM work_order_shares wos
|
||||||
|
INNER JOIN order_flags f ON f.id = wos.work_order_id
|
||||||
|
WHERE wos.worker_id = $1
|
||||||
|
AND wos.status IN ('joined', 'submitted', 'accepted')
|
||||||
|
AND f.wo_status NOT IN ('accepted', 'cancelled', 'problem')
|
||||||
|
GROUP BY wos.work_order_id, wos.status, f.has_pending_handling
|
||||||
|
),
|
||||||
|
order_status_rows AS (
|
||||||
|
SELECT
|
||||||
|
work_order_id,
|
||||||
|
status,
|
||||||
|
amount,
|
||||||
acceptance_json,
|
acceptance_json,
|
||||||
is_worker_share,
|
TRUE AS is_worker_share
|
||||||
worker_settlement_amount
|
FROM share_status_rows
|
||||||
FROM worker_base
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
f.id AS work_order_id,
|
||||||
|
CASE
|
||||||
|
WHEN f.has_timeout_for_me
|
||||||
|
AND NOT f.is_assigned_to_me
|
||||||
|
AND NOT f.is_worker_share
|
||||||
|
THEN 'open'
|
||||||
|
WHEN f.wo_status IN ('accepted', 'cancelled', 'problem')
|
||||||
|
THEN f.wo_status
|
||||||
|
WHEN f.has_pending_handling AND f.wo_status = 'in_progress'
|
||||||
|
THEN 'problem'
|
||||||
|
ELSE f.wo_status
|
||||||
|
END AS status,
|
||||||
|
CASE
|
||||||
|
WHEN f.is_worker_share THEN f.share_reward_total
|
||||||
|
WHEN f.is_assigned_to_me THEN f.reward_amount
|
||||||
|
ELSE 0
|
||||||
|
END AS amount,
|
||||||
|
f.acceptance_json,
|
||||||
|
f.is_worker_share
|
||||||
|
FROM order_flags f
|
||||||
|
WHERE
|
||||||
|
f.wo_status IN ('accepted', 'cancelled', 'problem')
|
||||||
|
OR (f.has_timeout_for_me AND NOT f.is_assigned_to_me AND NOT f.is_worker_share)
|
||||||
|
OR NOT f.is_worker_share
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
status,
|
status,
|
||||||
@@ -742,7 +750,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
|||||||
CASE
|
CASE
|
||||||
WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted')
|
WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted')
|
||||||
OR (status = 'open' AND is_worker_share)
|
OR (status = 'open' AND is_worker_share)
|
||||||
THEN worker_settlement_amount
|
THEN amount
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
),
|
),
|
||||||
@@ -755,7 +763,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
|||||||
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||||
)::int AS vip_evidence_pending_count,
|
)::int AS vip_evidence_pending_count,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
SUM(worker_settlement_amount) FILTER (
|
SUM(amount) FILTER (
|
||||||
WHERE status = 'accepted'
|
WHERE status = 'accepted'
|
||||||
AND acceptance_json->>'reviewMode' = 'vip_auto'
|
AND acceptance_json->>'reviewMode' = 'vip_auto'
|
||||||
AND acceptance_json->>'evidenceStatus' = 'pending'
|
AND acceptance_json->>'evidenceStatus' = 'pending'
|
||||||
@@ -763,8 +771,41 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
|||||||
),
|
),
|
||||||
0
|
0
|
||||||
)::int AS vip_evidence_pending_settlement_amount
|
)::int AS vip_evidence_pending_settlement_amount
|
||||||
FROM worker_orders
|
FROM order_status_rows
|
||||||
GROUP BY status
|
GROUP BY status
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'all' AS status,
|
||||||
|
COUNT(DISTINCT work_order_id)::int AS order_count,
|
||||||
|
COALESCE(
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted')
|
||||||
|
OR (status = 'open' AND is_worker_share)
|
||||||
|
THEN amount
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)::int AS settlement_amount,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE status = 'accepted'
|
||||||
|
AND acceptance_json->>'reviewMode' = 'vip_auto'
|
||||||
|
AND acceptance_json->>'evidenceStatus' = 'pending'
|
||||||
|
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||||
|
)::int AS vip_evidence_pending_count,
|
||||||
|
COALESCE(
|
||||||
|
SUM(amount) FILTER (
|
||||||
|
WHERE status = 'accepted'
|
||||||
|
AND acceptance_json->>'reviewMode' = 'vip_auto'
|
||||||
|
AND acceptance_json->>'evidenceStatus' = 'pending'
|
||||||
|
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)::int AS vip_evidence_pending_settlement_amount
|
||||||
|
FROM order_status_rows
|
||||||
`,
|
`,
|
||||||
[normalizedWorkerId],
|
[normalizedWorkerId],
|
||||||
)
|
)
|
||||||
@@ -1277,9 +1318,35 @@ export function buildWorkOrderWhere({
|
|||||||
params.push(normalizedStatuses)
|
params.push(normalizedStatuses)
|
||||||
const statusesPlaceholder = `$${params.length}`
|
const statusesPlaceholder = `$${params.length}`
|
||||||
if (workerSharingId) {
|
if (workerSharingId) {
|
||||||
filters.push(
|
const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`
|
||||||
`${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`,
|
// 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。
|
||||||
)
|
// 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。
|
||||||
|
const anyShareStatus = `(
|
||||||
|
wo.status NOT IN ('accepted', 'cancelled', 'problem')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM work_order_shares ws_filter
|
||||||
|
WHERE ws_filter.work_order_id = wo.id
|
||||||
|
AND ws_filter.worker_id = ${workerSharingId}
|
||||||
|
AND ws_filter.status IN ('joined', 'submitted', 'accepted')
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
ws_filter.status = 'joined'
|
||||||
|
AND 'in_progress' = ANY(${statusesPlaceholder}::text[])
|
||||||
|
AND NOT ${workerPendingHandlingExistsExpression(workerSharingId)}
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
ws_filter.status = 'submitted'
|
||||||
|
AND 'pending_acceptance' = ANY(${statusesPlaceholder}::text[])
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
ws_filter.status = 'accepted'
|
||||||
|
AND 'accepted' = ANY(${statusesPlaceholder}::text[])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`
|
||||||
|
filters.push(`(${representativeStatus} OR ${anyShareStatus})`)
|
||||||
} else {
|
} else {
|
||||||
filters.push(
|
filters.push(
|
||||||
`${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`,
|
`${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归:同一打手在同一拼单工单里有多份份额时,活跃接单数只应计 1 单。
|
||||||
|
*
|
||||||
|
* 之前 countWorkerActiveOrders 仍是「指派单数 + 拼单份额行数」直接相加,
|
||||||
|
* 打手资料页、抢普通单和后台指派会把同工单的多份拼单算成多单,提前撞接单上限。
|
||||||
|
* 事务内版本已经去重,这里把「事务内外共用同一段计数 SQL」钉死,防止再次分裂。
|
||||||
|
*/
|
||||||
|
async function readRepoSource() {
|
||||||
|
return readFile(new URL('./work-order-query-repo.ts', import.meta.url), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉 SQL 行注释与 TS 行注释,避免注释里的旧写法被误判成代码。 */
|
||||||
|
function stripComments(source: string) {
|
||||||
|
return source
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/--.*$/, '').replace(/\/\/.*$/, ''))
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('活跃接单数按工单去重,同一工单多份拼单只占 1 个名额', async () => {
|
||||||
|
const source = stripComments(await readRepoSource())
|
||||||
|
const start = source.indexOf('const COUNT_WORKER_ACTIVE_ORDERS_SQL')
|
||||||
|
assert.ok(start >= 0, '缺少共享的活跃单计数 SQL 常量')
|
||||||
|
|
||||||
|
const sql = source.slice(start, start + 800)
|
||||||
|
assert.match(sql, /UNION/, '活跃单计数必须用 UNION 按工单去重')
|
||||||
|
assert.match(sql, /work_order_shares/, '活跃单计数必须包含拼单份额')
|
||||||
|
assert.match(sql, /swo\.status = 'open'/, '只统计母单仍在大厅的拼单份额')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('事务内外共用同一段活跃单计数 SQL,避免口径分裂', async () => {
|
||||||
|
const source = stripComments(await readRepoSource())
|
||||||
|
|
||||||
|
for (const fn of ['countWorkerActiveOrders', 'countWorkerActiveOrdersWithClient']) {
|
||||||
|
const start = source.indexOf(`export async function ${fn}`)
|
||||||
|
assert.ok(start >= 0, `缺少函数 ${fn}`)
|
||||||
|
|
||||||
|
const block = source.slice(start, start + 700)
|
||||||
|
assert.ok(
|
||||||
|
block.includes('COUNT_WORKER_ACTIVE_ORDERS_SQL'),
|
||||||
|
`${fn} 没有复用共享的活跃单计数 SQL`,
|
||||||
|
)
|
||||||
|
assert.ok(!block.includes(') + ('), `${fn} 仍在把「指派单数 + 拼单份额行数」直接相加`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,9 +3,13 @@ import type { Response } from 'express'
|
|||||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getLatestMaterialEventAtByOrderIds,
|
||||||
|
getPendingCancelRequestOrderIds,
|
||||||
|
getPendingFeedbackOrderIds,
|
||||||
getWorkerFinanceRequestById,
|
getWorkerFinanceRequestById,
|
||||||
getWorkerUserById,
|
getWorkerUserById,
|
||||||
getWorkOrderById,
|
getWorkOrderById,
|
||||||
|
listWorkerTimeoutEventsByOrderIds,
|
||||||
listWorkerWorkOrderNotes,
|
listWorkerWorkOrderNotes,
|
||||||
listWorkerWorkOrderViews,
|
listWorkerWorkOrderViews,
|
||||||
listWorkOrderShares,
|
listWorkOrderShares,
|
||||||
@@ -23,10 +27,13 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js'
|
|||||||
import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js'
|
import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js'
|
||||||
import {
|
import {
|
||||||
isWorkOrderSharingFilled,
|
isWorkOrderSharingFilled,
|
||||||
|
isWorkerOrderVipEvidencePending,
|
||||||
mapFinanceRequest,
|
mapFinanceRequest,
|
||||||
mapWorkOrderAdmin,
|
mapWorkOrderAdmin,
|
||||||
mapWorkOrderForWorker,
|
mapWorkOrderForWorker,
|
||||||
mapWorkerUser,
|
mapWorkerUser,
|
||||||
|
pickWorkerRepresentativeShare,
|
||||||
|
resolveWorkerOrderStatusTabs,
|
||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
} from '../worker-platform/mappers.js'
|
} from '../worker-platform/mappers.js'
|
||||||
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js'
|
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js'
|
||||||
@@ -336,33 +343,121 @@ async function publishWorkOrderSnapshots(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 资料更新时间只与工单有关,提到循环外只查一次(参与人数多时省掉 N 次查询)。
|
||||||
|
const materialEventAt =
|
||||||
|
(await getLatestMaterialEventAtByOrderIds([workOrderId])).get(workOrderId) || ''
|
||||||
|
|
||||||
for (const workerId of new Set(
|
for (const workerId of new Set(
|
||||||
(input.workerIds || []).map((item) => Number(item)).filter(Boolean),
|
(input.workerIds || []).map((item) => Number(item)).filter(Boolean),
|
||||||
)) {
|
)) {
|
||||||
const worker = await getWorkerUserById(workerId)
|
const worker = await getWorkerUserById(workerId)
|
||||||
if (!worker) continue
|
if (!worker) continue
|
||||||
const [notes, views] = await Promise.all([
|
|
||||||
listWorkerWorkOrderNotes(workerId, [workOrderId]),
|
// 同一打手在同一工单可能有多份份额(多次拼单),必须按「全部份额」还原这一行:
|
||||||
listWorkerWorkOrderViews(workerId, [workOrderId]),
|
// 旧实现用 shares.find(...) 只取最早一条,多份额时会把可见性、个人状态、金额都算错,
|
||||||
])
|
// 还会让实时补丁把行从错误的页签删掉(打手端偶发「页签里没有数据」)。
|
||||||
const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || ''
|
|
||||||
const viewedAt =
|
|
||||||
views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null
|
|
||||||
const share = shares.find((item) => Number(item.worker_id) === workerId)
|
|
||||||
const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId
|
const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId
|
||||||
const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled')
|
const workerShares = shares.filter((item) => Number(item.worker_id) === workerId)
|
||||||
|
const activeWorkerShares = workerShares.filter((item) => item.status !== 'cancelled')
|
||||||
|
const representativeShare = pickWorkerRepresentativeShare(
|
||||||
|
activeWorkerShares.length > 0 ? activeWorkerShares : workerShares,
|
||||||
|
)
|
||||||
|
const cancelledShare = workerShares
|
||||||
|
.filter((item) => item.status === 'cancelled')
|
||||||
|
.sort((left, right) => Number(right.id) - Number(left.id))[0]
|
||||||
|
const isOwnerCurrently = isAssignedWorker || activeWorkerShares.length > 0
|
||||||
|
const workerOrderVisible = isOwnerCurrently
|
||||||
|
|
||||||
|
// 只有「已不持有该单」时才需要判断超时归属,避免给正常在办的单多打一次查询。
|
||||||
|
const needsTimeoutCheck = !isOwnerCurrently
|
||||||
|
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, timeoutEventsByOrderId] =
|
||||||
|
await Promise.all([
|
||||||
|
listWorkerWorkOrderNotes(workerId, [workOrderId]),
|
||||||
|
listWorkerWorkOrderViews(workerId, [workOrderId]),
|
||||||
|
getPendingCancelRequestOrderIds(workerId, [workOrderId]),
|
||||||
|
getPendingFeedbackOrderIds(workerId, [workOrderId]),
|
||||||
|
needsTimeoutCheck
|
||||||
|
? listWorkerTimeoutEventsByOrderIds(workerId, [workOrderId])
|
||||||
|
: Promise.resolve(new Map<number, { createdAt: string }>()),
|
||||||
|
])
|
||||||
|
const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || ''
|
||||||
|
const view = views.find((item) => Number(item.work_order_id) === workOrderId)
|
||||||
|
const viewedAt = view?.viewed_at || null
|
||||||
|
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
|
||||||
|
const isTimedOutForMe =
|
||||||
|
!isOwnerCurrently && Boolean(timeoutEvent || (cancelledShare && workOrder.sharing_enabled))
|
||||||
|
|
||||||
|
const timeoutNotice = isTimedOutForMe
|
||||||
|
? {
|
||||||
|
isTimedOut: true,
|
||||||
|
timedOutAt:
|
||||||
|
timeoutEvent?.createdAt ||
|
||||||
|
(workOrder.deadline_at ? String(workOrder.deadline_at) : null) ||
|
||||||
|
workOrder.updated_at,
|
||||||
|
warningMessage:
|
||||||
|
(workOrder.assigned_worker_id != null &&
|
||||||
|
Number(workOrder.assigned_worker_id) !== workerId) ||
|
||||||
|
['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes(
|
||||||
|
workOrder.status,
|
||||||
|
)
|
||||||
|
? '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。'
|
||||||
|
: '该订单已超时退回抢单大厅,您已失去接单资格,请勿在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。',
|
||||||
|
isTakenByOther:
|
||||||
|
(workOrder.assigned_worker_id != null &&
|
||||||
|
Number(workOrder.assigned_worker_id) !== workerId) ||
|
||||||
|
['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes(
|
||||||
|
workOrder.status,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const hasPendingHandling =
|
||||||
|
pendingCancelOrderIds.has(workOrderId) || pendingFeedbackOrderIds.has(workOrderId)
|
||||||
|
const materialSeenAt = view?.material_seen_at || ''
|
||||||
|
const vipEvidencePending = isWorkerOrderVipEvidencePending(workOrder)
|
||||||
|
|
||||||
publishRealtimeEvent({
|
publishRealtimeEvent({
|
||||||
type: 'work_order.changed',
|
type: 'work_order.changed',
|
||||||
entityId: workOrderId,
|
entityId: workOrderId,
|
||||||
scopes: ['worker_my_orders'],
|
scopes: ['worker_my_orders'],
|
||||||
operation: workerOrderVisible ? 'upsert' : 'remove',
|
operation: workerOrderVisible ? 'upsert' : 'remove',
|
||||||
data: mapWorkOrderForWorker(
|
data: {
|
||||||
workOrder,
|
...mapWorkOrderForWorker(
|
||||||
resolveWorkerPermissions(worker),
|
workOrder,
|
||||||
share,
|
resolveWorkerPermissions(worker),
|
||||||
note,
|
representativeShare,
|
||||||
viewedAt,
|
note,
|
||||||
),
|
viewedAt,
|
||||||
|
{
|
||||||
|
isTimedOutForMe,
|
||||||
|
...(timeoutNotice ? { timeoutNotice } : {}),
|
||||||
|
// 与列表一致:只带未取消份额,按 id DESC(参与先后倒序)
|
||||||
|
myShares: [...activeWorkerShares].sort(
|
||||||
|
(left, right) => Number(right.id) - Number(left.id),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/**
|
||||||
|
* 这张单在该打手「我的订单」里属于哪些状态页签。
|
||||||
|
* 多份额工单可同时属于代练中/待验收,单个 status 字段表达不了,前端据此判归属。
|
||||||
|
*/
|
||||||
|
workerStatuses: resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: workOrder.status,
|
||||||
|
isAssignedWorker,
|
||||||
|
shares: workerShares,
|
||||||
|
hasPendingHandling,
|
||||||
|
hasTimeoutForMe: Boolean(timeoutEvent),
|
||||||
|
vipEvidencePending,
|
||||||
|
}),
|
||||||
|
problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||||
|
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
||||||
|
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
||||||
|
materialUpdated:
|
||||||
|
!isTimedOutForMe &&
|
||||||
|
Boolean(materialEventAt) &&
|
||||||
|
(!materialSeenAt ||
|
||||||
|
new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime()),
|
||||||
|
},
|
||||||
workerIds: [workerId],
|
workerIds: [workerId],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export {
|
|||||||
} from './worker-finance-mappers.js'
|
} from './worker-finance-mappers.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
isWorkerOrderVipEvidencePending,
|
||||||
mapCollectLookupOrder,
|
mapCollectLookupOrder,
|
||||||
mapWorkCategory,
|
mapWorkCategory,
|
||||||
mapWorkOrderAdmin,
|
mapWorkOrderAdmin,
|
||||||
@@ -91,6 +92,8 @@ export {
|
|||||||
mapWorkOrderPublic,
|
mapWorkOrderPublic,
|
||||||
mapWorkOrderShare,
|
mapWorkOrderShare,
|
||||||
mapWorkOrderSharingWorkers,
|
mapWorkOrderSharingWorkers,
|
||||||
|
pickWorkerRepresentativeShare,
|
||||||
|
resolveWorkerOrderStatusTabs,
|
||||||
resolveWorkOrderOperationalStatus,
|
resolveWorkOrderOperationalStatus,
|
||||||
resolveWorkerShareProductName,
|
resolveWorkerShareProductName,
|
||||||
resolveWorkerShareStatus,
|
resolveWorkerShareStatus,
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js'
|
||||||
|
import {
|
||||||
|
isWorkerOrderVipEvidencePending,
|
||||||
|
pickWorkerRepresentativeShare,
|
||||||
|
resolveWorkerOrderStatusTabs,
|
||||||
|
summarizeWorkerShares,
|
||||||
|
} from './work-order-display-mappers.js'
|
||||||
|
|
||||||
|
function share(overrides: Record<string, unknown>): WorkOrderShareRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
work_order_id: 1,
|
||||||
|
worker_id: 1,
|
||||||
|
quantity: 1,
|
||||||
|
unit_reward: 10,
|
||||||
|
share_reward: 10,
|
||||||
|
share_deposit: 0,
|
||||||
|
status: 'joined',
|
||||||
|
deadline_at: null,
|
||||||
|
timeout_minutes: 0,
|
||||||
|
created_at: '2026-10-23T10:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
} as unknown as WorkOrderShareRow
|
||||||
|
}
|
||||||
|
|
||||||
|
test('同一工单多份份额:数量/奖励/押金按份额求和,已取消份额不计入金额', () => {
|
||||||
|
const summary = summarizeWorkerShares([
|
||||||
|
share({
|
||||||
|
id: 1,
|
||||||
|
quantity: 2,
|
||||||
|
share_reward: 20,
|
||||||
|
share_deposit: 5,
|
||||||
|
status: 'joined',
|
||||||
|
created_at: '2026-10-23T10:00:00.000Z',
|
||||||
|
deadline_at: '2026-10-23T12:00:00.000Z',
|
||||||
|
}),
|
||||||
|
share({
|
||||||
|
id: 2,
|
||||||
|
quantity: 3,
|
||||||
|
share_reward: 30,
|
||||||
|
share_deposit: 5,
|
||||||
|
status: 'joined',
|
||||||
|
created_at: '2026-10-23T10:10:00.000Z',
|
||||||
|
deadline_at: '2026-10-23T11:00:00.000Z',
|
||||||
|
}),
|
||||||
|
share({
|
||||||
|
id: 3,
|
||||||
|
quantity: 9,
|
||||||
|
share_reward: 90,
|
||||||
|
share_deposit: 99,
|
||||||
|
status: 'cancelled',
|
||||||
|
created_at: '2026-10-23T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(summary.quantity, 5)
|
||||||
|
assert.equal(summary.rewardAmount, 50)
|
||||||
|
assert.equal(summary.depositAmount, 10)
|
||||||
|
// 有有效份额时,接单时间取第一份有效份额,不取更早的已取消记录。
|
||||||
|
assert.equal(summary.firstJoinedAt, '2026-10-23T10:00:00.000Z')
|
||||||
|
// 父行倒计时取未完成份额中最早的截止时间。
|
||||||
|
assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T11:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('历史份额没有 deadline_at 时,用份额自己的 timeout_minutes 兜底', () => {
|
||||||
|
const summary = summarizeWorkerShares([
|
||||||
|
share({
|
||||||
|
id: 1,
|
||||||
|
quantity: 2,
|
||||||
|
share_reward: 20,
|
||||||
|
status: 'joined',
|
||||||
|
created_at: '2026-10-23T10:00:00.000Z',
|
||||||
|
deadline_at: null,
|
||||||
|
timeout_minutes: 30,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T10:30:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('只剩已取消份额时,金额归零但保留最早参与时间', () => {
|
||||||
|
const summary = summarizeWorkerShares([
|
||||||
|
share({
|
||||||
|
id: 1,
|
||||||
|
quantity: 2,
|
||||||
|
share_reward: 20,
|
||||||
|
status: 'cancelled',
|
||||||
|
created_at: '2026-10-23T09:00:00.000Z',
|
||||||
|
}),
|
||||||
|
share({
|
||||||
|
id: 2,
|
||||||
|
quantity: 3,
|
||||||
|
share_reward: 30,
|
||||||
|
status: 'cancelled',
|
||||||
|
created_at: '2026-10-23T09:30:00.000Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(summary.quantity, 0)
|
||||||
|
assert.equal(summary.rewardAmount, 0)
|
||||||
|
assert.equal(summary.depositAmount, 0)
|
||||||
|
assert.equal(summary.firstJoinedAt, '2026-10-23T09:00:00.000Z')
|
||||||
|
assert.equal(summary.earliestActiveDeadlineAt, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('代表份额与列表 LATERAL 同序:joined > submitted > accepted > cancelled,再按创建时间/id 倒序', () => {
|
||||||
|
const shares = [
|
||||||
|
share({ id: 1, status: 'cancelled', created_at: '2026-10-23T09:00:00.000Z' }),
|
||||||
|
share({ id: 2, status: 'submitted', created_at: '2026-10-23T09:30:00.000Z' }),
|
||||||
|
share({ id: 3, status: 'joined', created_at: '2026-10-23T10:00:00.000Z' }),
|
||||||
|
]
|
||||||
|
assert.equal(pickWorkerRepresentativeShare(shares)?.id, 3)
|
||||||
|
|
||||||
|
// 同状态取更晚创建的一份(旧实现取的是「最早一条」,多份额时必然挑错)
|
||||||
|
const sameStatus = [
|
||||||
|
share({ id: 10, status: 'submitted', created_at: '2026-10-23T09:00:00.000Z' }),
|
||||||
|
share({ id: 11, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }),
|
||||||
|
share({ id: 12, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }),
|
||||||
|
]
|
||||||
|
assert.equal(pickWorkerRepresentativeShare(sameStatus)?.id, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('多份额工单(一份进行中 + 一份待验收)必须同时属于两个页签', () => {
|
||||||
|
const tabs = resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'in_progress',
|
||||||
|
isAssignedWorker: false,
|
||||||
|
shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })],
|
||||||
|
})
|
||||||
|
assert.deepEqual(tabs.sort(), ['in_progress', 'pending_acceptance'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('有待审核取消申请/未处理反馈时,进行中份额归问题单,但待验收份额仍留在待验收', () => {
|
||||||
|
const tabs = resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'in_progress',
|
||||||
|
isAssignedWorker: false,
|
||||||
|
shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })],
|
||||||
|
hasPendingHandling: true,
|
||||||
|
})
|
||||||
|
assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('母单进入终态后只按母单状态归类(与 SQL 关闭 anyShare 分支一致)', () => {
|
||||||
|
const tabs = resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'problem',
|
||||||
|
isAssignedWorker: true,
|
||||||
|
shares: [share({ id: 1, status: 'submitted' })],
|
||||||
|
})
|
||||||
|
assert.deepEqual(tabs, ['problem'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('份额全部取消时只归已取消,不得混进待验收', () => {
|
||||||
|
const tabs = resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'pending_acceptance',
|
||||||
|
isAssignedWorker: true,
|
||||||
|
shares: [share({ id: 1, status: 'cancelled' })],
|
||||||
|
})
|
||||||
|
assert.deepEqual(tabs, ['cancelled'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有份额的指派单按母单状态归类;超时退回后归已超时', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'pending_acceptance',
|
||||||
|
isAssignedWorker: true,
|
||||||
|
shares: [],
|
||||||
|
}),
|
||||||
|
['pending_acceptance'],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'pending_acceptance',
|
||||||
|
isAssignedWorker: false,
|
||||||
|
shares: [share({ id: 1, status: 'cancelled' })],
|
||||||
|
hasTimeoutForMe: true,
|
||||||
|
}),
|
||||||
|
['open'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到期才归入该页签', () => {
|
||||||
|
const now = Date.parse('2026-10-23T10:00:00.000Z')
|
||||||
|
const base = {
|
||||||
|
status: 'accepted',
|
||||||
|
acceptance_json: {
|
||||||
|
reviewMode: 'vip_auto',
|
||||||
|
evidenceStatus: 'pending',
|
||||||
|
evidenceDueAt: '2026-10-24T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.equal(isWorkerOrderVipEvidencePending(base, now), true)
|
||||||
|
assert.equal(
|
||||||
|
isWorkerOrderVipEvidencePending(
|
||||||
|
{ ...base, acceptance_json: { ...base.acceptance_json, evidenceStatus: 'submitted' } },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
isWorkerOrderVipEvidencePending(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
acceptance_json: { ...base.acceptance_json, evidenceDueAt: '2026-10-22T10:00:00.000Z' },
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'accepted',
|
||||||
|
isAssignedWorker: true,
|
||||||
|
shares: [share({ id: 1, status: 'accepted' })],
|
||||||
|
vipEvidencePending: true,
|
||||||
|
}).sort(),
|
||||||
|
['accepted', 'vip_evidence_pending'],
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -597,6 +597,147 @@ export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: u
|
|||||||
return workOrderStatus
|
return workOrderStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打手端「我的订单」状态页签键,与 buildWorkOrderWhere 的打手状态筛选同口径。 */
|
||||||
|
export type WorkerOrderStatusTab =
|
||||||
|
| 'in_progress'
|
||||||
|
| 'pending_acceptance'
|
||||||
|
| 'problem'
|
||||||
|
| 'open'
|
||||||
|
| 'accepted'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'vip_evidence_pending'
|
||||||
|
|
||||||
|
/** 母单进入终态后,列表只按母单状态归类(任一命中分支会被 wo.status NOT IN (...) 关掉)。 */
|
||||||
|
const TERMINAL_WORK_ORDER_STATUSES = new Set(['accepted', 'cancelled', 'problem'])
|
||||||
|
|
||||||
|
/** 代表份额优先级,与 SQL LATERAL 的 CASE 顺序一致:joined > submitted > accepted > cancelled。 */
|
||||||
|
const WORKER_SHARE_STATUS_PRIORITY: Record<string, number> = {
|
||||||
|
joined: 0,
|
||||||
|
submitted: 1,
|
||||||
|
accepted: 2,
|
||||||
|
cancelled: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerShareLike = {
|
||||||
|
status?: unknown
|
||||||
|
created_at?: unknown
|
||||||
|
id?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一打手在一个工单里的「代表份额」。
|
||||||
|
*
|
||||||
|
* 实时广播过去用 `shares.find(worker_id === ...)`(= 最早一条),同一打手多次拼单时会挑到
|
||||||
|
* 已取消/进行中的那份,进而把快照算成「不在可见范围」或错误的个人状态,导致打手端某个
|
||||||
|
* 状态页签的行被实时补丁删掉(表现为「待验收有时候没有数据」)。
|
||||||
|
* 这里与 `work-order-query-repo` 的 LATERAL 排序完全一致:
|
||||||
|
* status 优先级 ASC, created_at DESC, id DESC
|
||||||
|
*/
|
||||||
|
export function pickWorkerRepresentativeShare<T extends WorkerShareLike>(
|
||||||
|
shares: readonly T[],
|
||||||
|
): T | null {
|
||||||
|
let representative: T | null = null
|
||||||
|
for (const share of shares) {
|
||||||
|
if (!representative) {
|
||||||
|
representative = share
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const priority = WORKER_SHARE_STATUS_PRIORITY[String(share.status || '')] ?? 4
|
||||||
|
const representativePriority =
|
||||||
|
WORKER_SHARE_STATUS_PRIORITY[String(representative.status || '')] ?? 4
|
||||||
|
if (priority !== representativePriority) {
|
||||||
|
if (priority < representativePriority) representative = share
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const createdMs = Date.parse(String(share.created_at || ''))
|
||||||
|
const representativeCreatedMs = Date.parse(String(representative.created_at || ''))
|
||||||
|
if (Number.isFinite(createdMs) && Number.isFinite(representativeCreatedMs)) {
|
||||||
|
if (createdMs !== representativeCreatedMs) {
|
||||||
|
if (createdMs > representativeCreatedMs) representative = share
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number(share.id || 0) > Number(representative.id || 0)) representative = share
|
||||||
|
}
|
||||||
|
return representative
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个工单在该打手「我的订单」里会出现在哪些状态页签。
|
||||||
|
*
|
||||||
|
* 为什么需要它:多份额工单可以同时属于「代练中」和「待验收」(一份 joined + 一份 submitted),
|
||||||
|
* 但实时快照只能带一个 `status` 字段。前端旧逻辑只要「快照状态 ≠ 当前页签」就把行从缓存删掉,
|
||||||
|
* 于是同一张单在其中一个页签凭空消失。广播里带上完整页签集合后,前端按集合判归属即可。
|
||||||
|
*
|
||||||
|
* 口径逐条对齐 `buildWorkOrderWhere`(打手状态筛选):
|
||||||
|
* - 母单 accepted/cancelled/problem:只看代表状态(母单状态);
|
||||||
|
* - 其余:代表状态 + 「任一有效份额命中」(joined 需无待处理申请,submitted/accepted 直接命中);
|
||||||
|
* - 有该打手的超时事件且他已不持有订单:代表状态记 open。
|
||||||
|
*/
|
||||||
|
export function resolveWorkerOrderStatusTabs(input: {
|
||||||
|
workOrderStatus: string
|
||||||
|
isAssignedWorker: boolean
|
||||||
|
/** 该打手在该工单的全部份额(含已取消),用于还原代表份额。 */
|
||||||
|
shares?: readonly WorkerShareLike[]
|
||||||
|
/** 存在待审核取消申请或未处理反馈(进行中的份额会归入问题单)。 */
|
||||||
|
hasPendingHandling?: boolean
|
||||||
|
/** 该打手在这张单上有超时事件。 */
|
||||||
|
hasTimeoutForMe?: boolean
|
||||||
|
/** 命中「VIP 免审核待补图」筛选(列表里它是独立筛选,不参与 status 参数)。 */
|
||||||
|
vipEvidencePending?: boolean
|
||||||
|
}): WorkerOrderStatusTab[] {
|
||||||
|
const tabs = new Set<WorkerOrderStatusTab>()
|
||||||
|
const workOrderStatus = String(input.workOrderStatus || '')
|
||||||
|
if (input.vipEvidencePending) tabs.add('vip_evidence_pending')
|
||||||
|
|
||||||
|
if (TERMINAL_WORK_ORDER_STATUSES.has(workOrderStatus)) {
|
||||||
|
tabs.add(workOrderStatus as WorkerOrderStatusTab)
|
||||||
|
return [...tabs]
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares = input.shares || []
|
||||||
|
const activeShares = shares.filter(
|
||||||
|
(share) =>
|
||||||
|
share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted',
|
||||||
|
)
|
||||||
|
const representative = pickWorkerRepresentativeShare(shares)
|
||||||
|
let representativeStatus = representative
|
||||||
|
? resolveWorkerShareStatus(workOrderStatus, representative.status)
|
||||||
|
: workOrderStatus
|
||||||
|
if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) {
|
||||||
|
representativeStatus = WORK_ORDER_STATUS.OPEN
|
||||||
|
} else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) {
|
||||||
|
representativeStatus = WORK_ORDER_STATUS.PROBLEM
|
||||||
|
}
|
||||||
|
if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab)
|
||||||
|
|
||||||
|
for (const share of activeShares) {
|
||||||
|
if (share.status === 'joined') {
|
||||||
|
// 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。
|
||||||
|
if (!input.hasPendingHandling) tabs.add('in_progress')
|
||||||
|
} else if (share.status === 'submitted') {
|
||||||
|
tabs.add('pending_acceptance')
|
||||||
|
} else if (share.status === 'accepted') {
|
||||||
|
tabs.add('accepted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...tabs]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「VIP 免审核,待补图」判定,口径同 `buildWorkOrderWhere` 的 vipEvidencePendingWhere。 */
|
||||||
|
export function isWorkerOrderVipEvidencePending(
|
||||||
|
workOrder: Pick<WorkOrderRow, 'status' | 'acceptance_json'>,
|
||||||
|
nowMs = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
if (String(workOrder.status || '') !== WORK_ORDER_STATUS.ACCEPTED) return false
|
||||||
|
const acceptance = mapAcceptanceForResponse(workOrder.acceptance_json) as Record<string, unknown>
|
||||||
|
if (String(acceptance.reviewMode || '') !== 'vip_auto') return false
|
||||||
|
if (String(acceptance.evidenceStatus || '') !== 'pending') return false
|
||||||
|
const dueAtMs = Date.parse(String(acceptance.evidenceDueAt || ''))
|
||||||
|
return Number.isFinite(dueAtMs) && dueAtMs > nowMs
|
||||||
|
}
|
||||||
|
|
||||||
export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||||
return {
|
return {
|
||||||
shareId: Number(share.id),
|
shareId: Number(share.id),
|
||||||
|
|||||||
@@ -245,25 +245,31 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) {
|
|||||||
buildMyOrderCacheKey(worker.id, 'overview', 0),
|
buildMyOrderCacheKey(worker.id, 'overview', 0),
|
||||||
() => getWorkerOrderOverview(worker.id),
|
() => getWorkerOrderOverview(worker.id),
|
||||||
)
|
)
|
||||||
const items = rows.map((row) => ({
|
const statusRows = rows.filter((row) => row.status !== 'all')
|
||||||
|
const items = statusRows.map((row) => ({
|
||||||
status: row.status,
|
status: row.status,
|
||||||
orderCount: row.orderCount,
|
orderCount: row.orderCount,
|
||||||
settlementAmount: row.settlementAmount,
|
settlementAmount: row.settlementAmount,
|
||||||
}))
|
}))
|
||||||
const vipEvidencePending = rows.reduce(
|
const vipEvidencePending = statusRows.reduce(
|
||||||
(summary, row) => ({
|
(summary, row) => ({
|
||||||
orderCount: summary.orderCount + row.vipEvidencePendingCount,
|
orderCount: summary.orderCount + row.vipEvidencePendingCount,
|
||||||
settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount,
|
settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount,
|
||||||
}),
|
}),
|
||||||
{ orderCount: 0, settlementAmount: 0 },
|
{ orderCount: 0, settlementAmount: 0 },
|
||||||
)
|
)
|
||||||
const all = items.reduce(
|
// 混合状态的多份额工单会同时出现在多个页签,不能再用各页签相加当「全部订单」,
|
||||||
(summary, item) => ({
|
// 否则同一工单会被重复计数;优先使用 SQL 里按 distinct 工单算好的 all 行。
|
||||||
orderCount: summary.orderCount + item.orderCount,
|
const allRow = rows.find((row) => row.status === 'all')
|
||||||
settlementAmount: summary.settlementAmount + item.settlementAmount,
|
const all = allRow
|
||||||
}),
|
? { orderCount: allRow.orderCount, settlementAmount: allRow.settlementAmount }
|
||||||
{ orderCount: 0, settlementAmount: 0 },
|
: items.reduce(
|
||||||
)
|
(summary, item) => ({
|
||||||
|
orderCount: summary.orderCount + item.orderCount,
|
||||||
|
settlementAmount: summary.settlementAmount + item.settlementAmount,
|
||||||
|
}),
|
||||||
|
{ orderCount: 0, settlementAmount: 0 },
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
items: [
|
items: [
|
||||||
...items,
|
...items,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Button, Space, Table, Tag, Typography } from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
|
||||||
|
import { DeadlineCountdown } from '@/components/DeadlineCountdown'
|
||||||
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
|
import type { WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||||
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
|
|
||||||
|
import { formatMoney, getOrderShares, getUniqueAcceptanceFiles } from './worker-order-utils'
|
||||||
|
|
||||||
|
const SHARE_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
joined: { label: '进行中', color: 'processing' },
|
||||||
|
submitted: { label: '待验收', color: 'orange' },
|
||||||
|
accepted: { label: '已验收', color: 'green' },
|
||||||
|
cancelled: { label: '已取消', color: 'default' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个工单一行,展开后展示该打手在这个工单里的每一份份额。
|
||||||
|
* 数量 / 奖励 / 押金直接取份额字段,与后台「拼单明细」同口径。
|
||||||
|
*/
|
||||||
|
export function WorkerOrderShareTable({
|
||||||
|
order,
|
||||||
|
mobile = false,
|
||||||
|
onOpenAcceptance,
|
||||||
|
onOpenCancelRequest,
|
||||||
|
onRemindAcceptance,
|
||||||
|
}: {
|
||||||
|
order: WorkOrder
|
||||||
|
mobile?: boolean
|
||||||
|
onOpenAcceptance: (order: WorkOrder, shareId: number) => void
|
||||||
|
onOpenCancelRequest: (order: WorkOrder, shareId: number) => void
|
||||||
|
onRemindAcceptance: (order: WorkOrder) => void
|
||||||
|
}) {
|
||||||
|
const shares = getOrderShares(order)
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => {
|
||||||
|
const byCreatedAt = String(left.createdAt || '').localeCompare(String(right.createdAt || ''))
|
||||||
|
return byCreatedAt !== 0 ? byCreatedAt : left.shareId - right.shareId
|
||||||
|
})
|
||||||
|
const isTimedOut = order.status === 'open' || Boolean(order.timeoutNotice?.isTimedOut)
|
||||||
|
|
||||||
|
if (shares.length === 0) return null
|
||||||
|
|
||||||
|
const columns: TableColumnsType<WorkOrderShare> = [
|
||||||
|
{
|
||||||
|
title: '份额',
|
||||||
|
width: 92,
|
||||||
|
render: (_, share) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text strong>
|
||||||
|
第 {shares.findIndex((item) => item.shareId === share.shareId) + 1} 份
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
#{share.shareId}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
width: 80,
|
||||||
|
render: (_, share) => <Typography.Text strong>{share.quantity} 份</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '奖励',
|
||||||
|
width: 110,
|
||||||
|
render: (_, share) => (
|
||||||
|
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||||
|
{formatMoney(share.shareReward)}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '押金',
|
||||||
|
width: 100,
|
||||||
|
render: (_, share) => formatMoney(share.shareDeposit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 96,
|
||||||
|
render: (_, share) => {
|
||||||
|
const meta = SHARE_STATUS_META[String(share.status)] || {
|
||||||
|
label: String(share.status || '-'),
|
||||||
|
color: 'default',
|
||||||
|
}
|
||||||
|
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '截止时间',
|
||||||
|
width: 170,
|
||||||
|
render: (_, share) => {
|
||||||
|
if (!share.deadlineAt) {
|
||||||
|
return (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
不限时
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text style={{ fontSize: 12 }}>
|
||||||
|
{formatDateTime(share.deadlineAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
{share.status === 'joined' && !isTimedOut ? (
|
||||||
|
<DeadlineCountdown deadlineAt={share.deadlineAt} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '验收信息',
|
||||||
|
width: 220,
|
||||||
|
render: (_, share) => <ShareAcceptanceCell share={share} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 190,
|
||||||
|
render: (_, share) => {
|
||||||
|
const canAccept = !isTimedOut && share.status === 'joined'
|
||||||
|
const canEdit = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
|
||||||
|
const canRemind = !isTimedOut && share.status === 'submitted'
|
||||||
|
const canCancel = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
|
||||||
|
if (!canAccept && !canEdit && !canRemind && !canCancel) {
|
||||||
|
return (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
无
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space size={6} wrap>
|
||||||
|
{canAccept ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onOpenAcceptance(order, share.shareId)}
|
||||||
|
>
|
||||||
|
验收
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canEdit && share.status === 'submitted' ? (
|
||||||
|
<Button size="small" onClick={() => onOpenAcceptance(order, share.shareId)}>
|
||||||
|
补充图片
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canRemind ? (
|
||||||
|
<Button size="small" onClick={() => onRemindAcceptance(order)}>
|
||||||
|
催验收
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canCancel ? (
|
||||||
|
<Button danger size="small" onClick={() => onOpenCancelRequest(order, share.shareId)}>
|
||||||
|
申请取消
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table<WorkOrderShare>
|
||||||
|
rowKey="shareId"
|
||||||
|
size="small"
|
||||||
|
bordered={false}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={shares}
|
||||||
|
columns={columns}
|
||||||
|
className="worker-order-share-table"
|
||||||
|
scroll={{ x: mobile ? 820 : 1080 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareAcceptanceCell({ share }: { share: WorkOrderShare }) {
|
||||||
|
const acceptanceFiles = getUniqueAcceptanceFiles(share.acceptance)
|
||||||
|
const draftFiles = getUniqueAcceptanceFiles(share.draftAcceptance)
|
||||||
|
if (acceptanceFiles.length === 0 && draftFiles.length === 0 && !share.submittedAt) {
|
||||||
|
return (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
未提交
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
{acceptanceFiles.length > 0 ? (
|
||||||
|
<ImagePreviewList files={acceptanceFiles} size={32} maxVisible={2} />
|
||||||
|
) : null}
|
||||||
|
{share.submittedAt ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
提交:{formatDateTime(share.submittedAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{draftFiles.length > 0 && !share.submittedAt ? (
|
||||||
|
<Tag color="processing">已暂存 {draftFiles.length} 张</Tag>
|
||||||
|
) : null}
|
||||||
|
{String(share.acceptance?.note || '').trim() ? (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
ellipsis={{ tooltip: share.acceptance.note }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{share.acceptance.note}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
|
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -193,6 +193,8 @@ export default function WorkerOrdersPage() {
|
|||||||
queryKey: ['worker-my-orders', status, keyword, page, pageSize],
|
queryKey: ['worker-my-orders', status, keyword, page, pageSize],
|
||||||
queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)),
|
queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)),
|
||||||
staleTime: 2_000,
|
staleTime: 2_000,
|
||||||
|
// 切页签时保留上一页数据,避免请求返回前先闪一下空列表。
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** 客户端幂等键:一次用户点击一个,用于拼单参与/抢单的服务端去重。 */
|
||||||
|
export function createRequestId(prefix = 'req'): string {
|
||||||
|
const random =
|
||||||
|
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
return `${prefix}-${random}`
|
||||||
|
}
|
||||||
@@ -89,7 +89,20 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: {
|
|||||||
}): { operation: 'upsert' | 'remove'; shouldInsert: boolean } {
|
}): { operation: 'upsert' | 'remove'; shouldInsert: boolean } {
|
||||||
const item = isRecord(input.item) ? input.item : {}
|
const item = isRecord(input.item) ? input.item : {}
|
||||||
const statusFilter = String(input.statusFilter || '')
|
const statusFilter = String(input.statusFilter || '')
|
||||||
const statusMatches = !statusFilter || statusFilter === String(item.status || '')
|
/**
|
||||||
|
* 服务端快照里的 `workerStatuses` 才是「这张单属于哪些页签」的准确答案。
|
||||||
|
*
|
||||||
|
* 同一打手在同一工单可能有多份份额(多次拼单),一张单可以同时出现在「代练中」和「待验收」;
|
||||||
|
* 而快照的 `status` 只能是一个值。旧逻辑拿单个 status 跟页签比对,只要不相等就删行,
|
||||||
|
* 于是多份额工单总会在其中一个页签里被删掉(表现为「待验收有时候没有数据」)。
|
||||||
|
* 老事件/其它来源没有该字段时,退回原来的单状态比对。
|
||||||
|
*/
|
||||||
|
const tabs = Array.isArray(item.workerStatuses)
|
||||||
|
? item.workerStatuses.map((tab) => String(tab || '')).filter(Boolean)
|
||||||
|
: []
|
||||||
|
const statusMatches =
|
||||||
|
!statusFilter ||
|
||||||
|
(tabs.length > 0 ? tabs.includes(statusFilter) : statusFilter === String(item.status || ''))
|
||||||
const statusProvable = input.explicitOrderNo || statusMatches
|
const statusProvable = input.explicitOrderNo || statusMatches
|
||||||
const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable
|
const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable
|
||||||
|
|
||||||
|
|||||||
@@ -188,3 +188,95 @@ test('非第 1 页不得插入,只做就地更新', () => {
|
|||||||
assert.equal(verdict.operation, 'upsert')
|
assert.equal(verdict.operation, 'upsert')
|
||||||
assert.equal(verdict.shouldInsert, false)
|
assert.equal(verdict.shouldInsert, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一打手在同一个工单多次拼单:一张单可以同时属于「代练中」和「待验收」,
|
||||||
|
* 但快照的 status 只能是一个值。前端若按单个 status 删行,另一个页签就会莫名其妙变空
|
||||||
|
* (线上「待验收有时候没有数据」)。服务端现在带 workerStatuses,按集合判归属。
|
||||||
|
*/
|
||||||
|
test('多份额工单:快照 status 是进行中,但属于待验收时不得从待验收页签删行', () => {
|
||||||
|
const snapshot = buildRow({
|
||||||
|
status: 'in_progress',
|
||||||
|
workerStatuses: ['in_progress', 'pending_acceptance'],
|
||||||
|
})
|
||||||
|
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||||
|
item: snapshot,
|
||||||
|
statusFilter: 'pending_acceptance',
|
||||||
|
keyword: '',
|
||||||
|
operation: 'upsert',
|
||||||
|
page: 1,
|
||||||
|
explicitOrderNo: false,
|
||||||
|
})
|
||||||
|
assert.equal(verdict.operation, 'upsert', '待验收页签必须保留这张多份额工单')
|
||||||
|
assert.equal(verdict.shouldInsert, true)
|
||||||
|
|
||||||
|
const updated = patchWorkerWorkOrderListEnvelope(
|
||||||
|
buildEnvelope([buildRow({ status: 'pending_acceptance' })]),
|
||||||
|
{ operation: verdict.operation, data: snapshot },
|
||||||
|
verdict.shouldInsert,
|
||||||
|
)
|
||||||
|
assert.equal(itemsOf(updated).length, 1)
|
||||||
|
assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('多份额工单:workerStatuses 同时也是代练中页签的归属依据', () => {
|
||||||
|
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||||
|
item: buildRow({
|
||||||
|
status: 'in_progress',
|
||||||
|
workerStatuses: ['in_progress', 'pending_acceptance'],
|
||||||
|
}),
|
||||||
|
statusFilter: 'in_progress',
|
||||||
|
keyword: '',
|
||||||
|
operation: 'upsert',
|
||||||
|
page: 1,
|
||||||
|
explicitOrderNo: false,
|
||||||
|
})
|
||||||
|
assert.equal(verdict.operation, 'upsert')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('不在页签集合里时才允许删行(例如已验收后离开待验收页签)', () => {
|
||||||
|
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||||
|
item: buildRow({ status: 'accepted', workerStatuses: ['accepted'] }),
|
||||||
|
statusFilter: 'pending_acceptance',
|
||||||
|
keyword: '',
|
||||||
|
operation: 'upsert',
|
||||||
|
page: 1,
|
||||||
|
explicitOrderNo: false,
|
||||||
|
})
|
||||||
|
assert.equal(verdict.operation, 'remove')
|
||||||
|
assert.equal(verdict.shouldInsert, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('VIP 待补图页签也靠 workerStatuses 判定,不会被当成状态不符删掉', () => {
|
||||||
|
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||||
|
item: buildRow({ status: 'accepted', workerStatuses: ['accepted', 'vip_evidence_pending'] }),
|
||||||
|
statusFilter: 'vip_evidence_pending',
|
||||||
|
keyword: '',
|
||||||
|
operation: 'upsert',
|
||||||
|
page: 1,
|
||||||
|
explicitOrderNo: false,
|
||||||
|
})
|
||||||
|
assert.equal(verdict.operation, 'upsert')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有 workerStatuses 的旧快照仍按单个 status 比对(保持既有行为)', () => {
|
||||||
|
const mismatch = resolveWorkerMyOrderRealtimeVerdict({
|
||||||
|
item: buildRow({ status: 'accepted' }),
|
||||||
|
statusFilter: 'pending_acceptance',
|
||||||
|
keyword: '',
|
||||||
|
operation: 'upsert',
|
||||||
|
page: 1,
|
||||||
|
explicitOrderNo: false,
|
||||||
|
})
|
||||||
|
assert.equal(mismatch.operation, 'remove')
|
||||||
|
|
||||||
|
const match = resolveWorkerMyOrderRealtimeVerdict({
|
||||||
|
item: buildRow({ status: 'pending_acceptance' }),
|
||||||
|
statusFilter: 'pending_acceptance',
|
||||||
|
keyword: '',
|
||||||
|
operation: 'upsert',
|
||||||
|
page: 1,
|
||||||
|
explicitOrderNo: false,
|
||||||
|
})
|
||||||
|
assert.equal(match.operation, 'upsert')
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
# 拼单多次参与改造方案(同一打手可多次参与、每份独立计时)
|
||||||
|
|
||||||
|
> 状态:**方案(待确认,未执行)**
|
||||||
|
> 日期:2026-10-23
|
||||||
|
> 代码基线:工作区当前 HEAD(迁移 001–103)
|
||||||
|
> 目标:同一打手对同一拼单工单可重复参与,每次参与新增一条 `work_order_shares`;每份固定自己的截止时间;超时/验收/撤单按份额独立。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论速览
|
||||||
|
|
||||||
|
**已确认的七项口径(2026-10-23)**
|
||||||
|
|
||||||
|
| 决策 | 确认结果 | 落地要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 免押额度 | **跟着母单走**(工单维度累计,等价方案 B) | 免押额度在该工单只抵扣一次:`累计需求 − 免押额度 − 本工单已冻结`,只有差额进入本份 `share_deposit` |
|
||||||
|
| D2 活跃单计数 | **按不同工单数** | 同一工单拼 3 份只占 1 个名额;改为 `COUNT(DISTINCT work_order_id)` |
|
||||||
|
| D3 撤单粒度 | **整单 + 单份额都要支持** | 不传 `shareId` = 撤销该打手在该工单的全部有效份额;传 `shareId` = 只撤这一份 |
|
||||||
|
| D4 验收方式 | **全部验收 + 单独验收都要有** | 统一验收 = 逐份额结算的批处理;单独验收按 `shareId` |
|
||||||
|
| D5 我的订单列表 | **一行一工单 + 展开份额** | 列表不拆行,工单行内嵌 `myShares[]`,每份独立展示与操作 |
|
||||||
|
| D6 防误触 | **客户端 `requestId` 幂等** | 份额表加 `request_id` + 唯一索引;重复请求返回原份额 |
|
||||||
|
| D7 验收通知 | **拼单按 `shareId` 去重;统一验收也要通知** | 每份提交各一条待办;统一验收完成再发一条整单完成通知 |
|
||||||
|
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 能不能做 | 能,改造集中在工单模块,不触及订单/履约/凭证链路 |
|
||||||
|
| 最大阻塞 | `work_order_shares` 上的 `UNIQUE(work_order_id, worker_id)`(迁移 008),以及 7 处 `getWorkOrderShare(orderId, workerId)` 假设「一人一条」 |
|
||||||
|
| 需要新增的列 | `deadline_at`(本份截止)、`timeout_minutes` / `timeout_policy`(参与时快照)、`cancelled_reason` |
|
||||||
|
| 需要新增的索引 | 份额到期扫描索引、打手份额列表索引;**删除**唯一键 |
|
||||||
|
| 影响接口 | 打手端 5 个(参与、提交验收、草稿、补证据、退单申请)+ 返回结构 `myShare → myShares` |
|
||||||
|
| 影响前端 | 大厅「已拼」按钮逻辑、我的订单列表/详情、验收弹窗、后台工单详情份额表 |
|
||||||
|
| 数据迁移 | 只加列 + 回填 + 删唯一键,**不改历史行**;历史合并份额保持原样 |
|
||||||
|
| 工作量估算 | 后端 2 人日、前端 2 人日、测试 1 人日(不含评审与联调) |
|
||||||
|
| 需要你拍板 | 押金/免押分摊方式、活跃单计数口径、退单申请粒度等 7 项(见第十三节) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状:四个硬阻塞
|
||||||
|
|
||||||
|
| # | 阻塞 | 位置 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| B1 | `UNIQUE(work_order_id, worker_id)` | 迁移 `008_work_order_sharing.sql:32`,约束名 `work_order_shares_work_order_id_worker_id_key` | 第二次参与直接违反唯一键 |
|
||||||
|
| B2 | 「按工单 + 打手」只取一条份额 | `getWorkOrderShare(workOrderId, workerId)`,被 7 处调用:验收提交、补证据、草稿、流转记录、备注、已读、我的订单 | 多份额后行为不确定(当前 `LIMIT 1` 取任意一条) |
|
||||||
|
| B3 | 超时时间由「母单当前配置」实时算 | `listOverdueWorkOrderShares`:`wos.created_at + wo.timeout_minutes`;`submitWorkerSharingAcceptance` 同口径 | 母单改时限会追溯影响已参与份额;且不是「参与时固化」 |
|
||||||
|
| B4 | 前端单值模型 | `myShare` 在 `worker-order-utils.tsx`、`WorkerHallPage`、`WorkerOrderMobileList`、`WorkerOrderTableColumns`、`types/worker-platform.ts` 共 30+ 处 | 大厅按钮 `disabled={… || Boolean(order.myShare)}`,第二次根本点不了 |
|
||||||
|
|
||||||
|
补充事实:现有 41,383 条份额里,同一 `(work_order_id, worker_id)` 只有一条(唯一键保证),所以删键不会产生历史冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、目标语义(改造后的不变式)
|
||||||
|
|
||||||
|
1. **一次参与 = 一条 `work_order_shares` 记录**,`quantity` 表示「本次参与份数」(默认 1,「立即抢单」= 承接全部剩余)。
|
||||||
|
2. **份额截止时间在参与瞬间固化**:`deadline_at = 参与时间 + 母单当时的 timeout_minutes`;母单之后改配置不影响已参与份额(`timeout_minutes = 0` 时 `deadline_at = NULL`,不限时)。
|
||||||
|
3. **超时只处置该份额**:份额 `joined → cancelled`(按快照 `timeout_policy` 决定押金释放/扣除),同工单其他份额状态不变。
|
||||||
|
4. **验收只针对该份额**:`joined → submitted`(打手提交)、`submitted → accepted`(后台验收 + 结算);同工单其他份额不受影响。
|
||||||
|
5. **撤单只针对该份额**:`joined/submitted → cancelled`,只退/扣该份额的 `share_deposit`。
|
||||||
|
6. **母单状态只由份额集合推导**(见第七节),不再由单个份额操作直接改写。
|
||||||
|
7. **押金按份额落账**:`share_deposit` 是该份额实际冻结的增量金额,释放/扣除都只针对它;同一打手在同一工单的多条份额互不覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据库方案(迁移 `104_work_order_share_multi_participation.sql`)
|
||||||
|
|
||||||
|
### 4.1 DDL 草案
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 104_work_order_share_multi_participation.sql
|
||||||
|
-- 目标:同一打手可对同一拼单工单多次参与,每份独立计时/验收/撤单。
|
||||||
|
|
||||||
|
ALTER TABLE work_order_shares
|
||||||
|
ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN work_order_shares.deadline_at IS '本份额截止时间:参与时 = created_at + 母单当时 timeout_minutes;0 分钟为 NULL';
|
||||||
|
COMMENT ON COLUMN work_order_shares.timeout_minutes IS '参与时固化的时限(分钟),母单后续改配置不影响';
|
||||||
|
COMMENT ON COLUMN work_order_shares.timeout_policy IS '参与时固化的超时策略 reopen/cancel_release/cancel_deduct';
|
||||||
|
COMMENT ON COLUMN work_order_shares.cancelled_reason IS '取消来源:timeout/worker_request/admin/problem/settlement';
|
||||||
|
COMMENT ON COLUMN work_order_shares.request_id IS '客户端幂等键(一次点击一个),重复请求返回原份额';
|
||||||
|
|
||||||
|
-- 回填历史份额(按当时母单配置一次性固化)
|
||||||
|
UPDATE work_order_shares s
|
||||||
|
SET timeout_minutes = COALESCE(w.timeout_minutes, 0),
|
||||||
|
timeout_policy = COALESCE(NULLIF(w.timeout_policy, ''), 'reopen'),
|
||||||
|
deadline_at = CASE
|
||||||
|
WHEN COALESCE(w.timeout_minutes, 0) > 0
|
||||||
|
THEN s.created_at + (w.timeout_minutes || ' minutes')::interval
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
FROM work_orders w
|
||||||
|
WHERE w.id = s.work_order_id
|
||||||
|
AND s.timeout_minutes = 0;
|
||||||
|
|
||||||
|
-- 删除「一人一条」约束(必须先删,否则第二次参与报 23505)
|
||||||
|
ALTER TABLE work_order_shares
|
||||||
|
DROP CONSTRAINT IF EXISTS work_order_shares_work_order_id_worker_id_key;
|
||||||
|
|
||||||
|
-- 新增索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_due
|
||||||
|
ON work_order_shares (deadline_at)
|
||||||
|
WHERE status = 'joined';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker_order
|
||||||
|
ON work_order_shares (worker_id, work_order_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_order_status
|
||||||
|
ON work_order_shares (work_order_id, status, created_at ASC);
|
||||||
|
|
||||||
|
-- 幂等键:同一打手同一个 request_id 只能产生一条份额
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request
|
||||||
|
ON work_order_shares (worker_id, request_id)
|
||||||
|
WHERE request_id <> '';
|
||||||
|
```
|
||||||
|
|
||||||
|
> 项目约定:迁移必须幂等、不改历史文件;`ALTER TABLE … DROP CONSTRAINT IF EXISTS` 也符合现有风格(059/099 都用 DO 块判存在,这里用 `IF EXISTS` 更短,建议套 DO 块与团队风格一致)。
|
||||||
|
|
||||||
|
### 4.2 迁移前的数据体检(只读)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1) 同一打手是否存在重复份额(预期 0)
|
||||||
|
SELECT work_order_id, worker_id, COUNT(*)
|
||||||
|
FROM work_order_shares GROUP BY 1,2 HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- 2) 有无份额缺少母单(外键保证应为 0)
|
||||||
|
SELECT COUNT(*) FROM work_order_shares s
|
||||||
|
LEFT JOIN work_orders w ON w.id = s.work_order_id WHERE w.id IS NULL;
|
||||||
|
|
||||||
|
-- 3) 已加入但母单已终态的脏数据
|
||||||
|
SELECT s.status, w.status, COUNT(*) FROM work_order_shares s
|
||||||
|
JOIN work_orders w ON w.id = s.work_order_id
|
||||||
|
GROUP BY 1,2 ORDER BY 3 DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 为什么不动 `quantity` 语义
|
||||||
|
|
||||||
|
`quantity` 仍然是「本次参与份数」。历史行的一条记录可能代表多份(旧逻辑合并),新行默认 1 份,二者在结算公式 `share_reward = quantity × unit_reward` 下完全兼容,**不需要拆历史行**。拆行会重写历史资金归属,风险大于收益。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、押金与免押额度(**需要你选择**)
|
||||||
|
|
||||||
|
现状:`resolveWorkOrderShareDepositAmount` 按「该打手在本工单累计份额」计算应冻结总额,并与已冻结额取 max(不回退)。拆成多行后,必须明确免押额度怎么分摊,否则可被拆分套利。
|
||||||
|
|
||||||
|
| 方案 | 规则 | 优点 | 缺点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **A 每份独立全额免押** | 每份 `max(0, round(required × q/total) − free)` | 实现最简单 | **可套利**:免押 5000、每份需求 1000 时可无限拆成 0 押金份额 |
|
||||||
|
| **B 工单累计口径(已确认)** | 先算本工单累计需求 `round(required × 累计份数/total) − 免押额度`,再减去该打手在该工单已冻结合计,差额即本份 `share_deposit`(不为负) | 经济语义与现在完全一致;不会因拆分多冻结或多免押 | 需要一次 SUM 查询;「已冻结不回退」的既有特性保留 |
|
||||||
|
| **C 按份数比例分摊免押** | `freePerUnit = floor(free / total)`,每份 `max(0, round(required×q/total) − freePerUnit×q)`,尾差归最后一份 | 每份可独立解释 | 需要记录「免押已分摊额度」,多一张表或加列;尾差处理易错 |
|
||||||
|
|
||||||
|
**已确认采用 B(免押额度跟着母单走)**:当前线上语义就是「累计抵扣、只补差额」,B 不改变任何已验证的金额结果;A 会出现新的套利路径;C 引入尾差与额外状态。
|
||||||
|
|
||||||
|
### 5.1 计算式(定稿)
|
||||||
|
|
||||||
|
```text
|
||||||
|
// 本工单该打手已参与份数(含本次,排除 cancelled)
|
||||||
|
cumQty = Σ quantity(该 worker 在该 order 的非 cancelled 份额) + 本次 qty
|
||||||
|
cumRequired = round(required_deposit_amount × cumQty / sharing_total_quantity)
|
||||||
|
cumFree = 等级 depositFreeAmount + 个人 personal_deposit_free_amount // 母单维度只用一次
|
||||||
|
cumDeposit = max(0, cumRequired − cumFree)
|
||||||
|
frozenSoFar = Σ share_deposit(该 worker 在该 order 的非 cancelled 份额)
|
||||||
|
本份冻结 = max(0, cumDeposit − frozenSoFar)
|
||||||
|
本份 share_deposit = 本份冻结
|
||||||
|
```
|
||||||
|
|
||||||
|
- 免押额度在**同一母单**内累计只抵扣一次,不会因为拆成多份而重复使用。
|
||||||
|
- `frozenSoFar` 用 `FOR UPDATE` 读钱包同一事务内求和,天然串行。
|
||||||
|
- 释放/扣除只针对该条 `share_deposit`,不影响同工单其他份额。
|
||||||
|
|
||||||
|
|
||||||
|
> 注意:无论选哪个,`share_deposit` 必须记录**该份额实际冻结的增量**,释放/扣除只针对它,避免一次取消把同工单其他份额的押金也解冻。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、计时与超时独立化
|
||||||
|
|
||||||
|
### 6.1 参与时固化
|
||||||
|
|
||||||
|
```text
|
||||||
|
timeoutMinutes = work_orders.timeout_minutes(参与瞬间读取)
|
||||||
|
deadline_at = timeoutMinutes > 0 ? now + timeoutMinutes 分钟 : NULL
|
||||||
|
timeout_policy = work_orders.timeout_policy(参与瞬间快照)
|
||||||
|
```
|
||||||
|
|
||||||
|
落在 `INSERT work_order_shares` 里,**与冻结押金同一事务**。
|
||||||
|
|
||||||
|
### 6.2 超时扫描改为按份额
|
||||||
|
|
||||||
|
`listOverdueWorkOrderShares` 改为:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT wos.id, wos.work_order_id, wos.worker_id, wos.quantity, wos.share_deposit,
|
||||||
|
wos.timeout_minutes, wos.timeout_policy, wos.created_at
|
||||||
|
FROM work_order_shares wos
|
||||||
|
JOIN work_orders wo ON wo.id = wos.work_order_id
|
||||||
|
WHERE wos.status = 'joined'
|
||||||
|
AND wos.deadline_at IS NOT NULL
|
||||||
|
AND wos.deadline_at < NOW()
|
||||||
|
AND wo.status NOT IN ('accepted', 'cancelled')
|
||||||
|
ORDER BY wos.deadline_at ASC
|
||||||
|
LIMIT $1
|
||||||
|
```
|
||||||
|
|
||||||
|
变化点:不再读母单 `timeout_minutes`(改读份额快照)、不再要求母单必须 `open`(避免母单被误置 `pending_acceptance` 后份额卡死)。
|
||||||
|
|
||||||
|
### 6.3 处置只影响该份额
|
||||||
|
|
||||||
|
`settleOverdueWorkOrderShare` 保留现有押金分支(release / deduct),只改两处:
|
||||||
|
|
||||||
|
1. 策略从 `share.timeout_policy` 取,不再从母单取;
|
||||||
|
2. 结尾把「无条件 `UPDATE work_orders SET status='open'`」替换为 `recomputeWorkOrderSharingStatusWithClient`(见第七节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、母单状态收敛(新增统一函数)
|
||||||
|
|
||||||
|
现状问题:`settleOverdueWorkOrderShare` 结尾会把母单强行置 `open` 并重置 `hall_queued_at`,多份额下会把「其他份额都已提交、本该待验收」的母单踢回大厅。
|
||||||
|
|
||||||
|
### 7.1 新函数
|
||||||
|
|
||||||
|
```text
|
||||||
|
recomputeWorkOrderSharingStatusWithClient(client, workOrderId, now) -> WorkOrderRow
|
||||||
|
|
||||||
|
allocated = Σ quantity(status != 'cancelled')
|
||||||
|
joined = COUNT(status = 'joined')
|
||||||
|
submitted = COUNT(status = 'submitted')
|
||||||
|
accepted = COUNT(status = 'accepted')
|
||||||
|
total = sharing_total_quantity
|
||||||
|
|
||||||
|
if allocated >= total && joined == 0 && submitted > 0 → pending_acceptance
|
||||||
|
else if allocated < total → open(若当前是 pending_acceptance 则回退 open)
|
||||||
|
else if allocated >= total && joined > 0 → open(还有份额未提交)
|
||||||
|
else if allocated == 0 → open(全部取消,重新可参与)
|
||||||
|
母单为 accepted / cancelled 时不动
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 所有调用点
|
||||||
|
|
||||||
|
| 触发 | 调用 |
|
||||||
|
| --- | --- |
|
||||||
|
| 份额参与 | `open`(保持) |
|
||||||
|
| 份额提交验收 | `recompute`(替换现有的内联判断) |
|
||||||
|
| 份额验收 | `recompute`(最后一个份额验收后母单 `accepted`) |
|
||||||
|
| 份额超时 | `recompute` |
|
||||||
|
| 后台撤单份额 | `recompute` |
|
||||||
|
| 打手退单申请通过 | `recompute` |
|
||||||
|
| 统一验收 | `recompute`(等价于全部 accepted → 母单 accepted) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、接口变更
|
||||||
|
|
||||||
|
### 8.1 打手端
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变化 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/v1/worker/hall/orders/:workOrderId/join-sharing` | body `quantity` 语义改为「本次份数」;新增 `requestId`(幂等);**不再拒绝已参与过的打手** |
|
||||||
|
| POST | `/api/v1/worker/hall/orders/:workOrderId/grab` | 拼单工单:`takeRemaining=true` 创建**新**份额(承接全部剩余);同样支持 `requestId` |
|
||||||
|
| POST | `/api/v1/worker/orders/:workOrderId/submit-acceptance` | body 新增可选 `shareId`;不传且该工单存在多条有效份额 → 400 `work_order_share_id_required` |
|
||||||
|
| POST | `/api/v1/worker/orders/:workOrderId/acceptance-draft` | 同上 |
|
||||||
|
| POST | `/api/v1/worker/orders/:workOrderId/acceptance-evidence` | 同上 |
|
||||||
|
| POST | `/api/v1/worker/orders/:workOrderId/cancel-requests` | body 可选 `shareId`:传 = 只申请撤该份额;不传 = 申请撤销我在该工单的全部有效份额 |
|
||||||
|
| GET | `/api/v1/worker/orders/:workOrderId` | 返回新增 `myShares[]`;`myShare` 保留为「最新一条未完成,否则最新一条」 |
|
||||||
|
| GET | `/api/v1/worker/orders` | 列表项新增 `myShares[]`(一行一工单,见 D5) |
|
||||||
|
|
||||||
|
> 兼容策略:所有新增 `shareId` 都是**可选**的;只有「该工单该打手有多条有效份额」时才强制。老前端不会立刻坏。
|
||||||
|
|
||||||
|
### 8.2 后台端
|
||||||
|
|
||||||
|
已有按份额的接口,无需新增:
|
||||||
|
|
||||||
|
- `POST /admin/worker-platform/orders/:id/shares/:shareId/accept`
|
||||||
|
- `POST /admin/worker-platform/orders/:id/shares/:shareId/cancel`
|
||||||
|
- `GET /admin/worker-platform/orders/:id/sharing` 已返回 `shares[]`(新增 `deadlineAt`、`timeoutMinutes`、`cancelledReason`)
|
||||||
|
|
||||||
|
需要调整的只是展示层:`sharingWorkers` 目前把同一打手的多份额合并成一行(`mapWorkOrderSharingWorkers` 累加 quantity),建议保留汇总但**新增 `sharingShares` 明细分页**,否则后台无法逐份撤单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、后端改动清单(文件级)
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| `db/migrations/104_*.sql` | 新增(DDL + 回填 + 删唯一键 + 索引) |
|
||||||
|
| `repositories/worker-platform/types.ts` | `WorkOrderShareRow` 加 4 个字段 |
|
||||||
|
| `repositories/worker-platform/work-order-share-repo.ts` | **核心**:`joinWorkOrderShare` 去掉「合并到 existingShare」分支,改为每次都 INSERT;押金改累计口径;写入 deadline 快照;新增 `getWorkOrderShareById`、`listWorkOrderSharesByWorkerInOrder`;`getWorkOrderShare` 保留但改名语义为「主份额」 |
|
||||||
|
| `repositories/worker-platform/work-order-share-repo.ts` | `submitWorkOrderShareAcceptance` / `updateWorkOrderShareDraftAcceptance` / `updateWorkOrderShareAcceptanceRecord` 改为接收 `shareId`(workerId 仅做归属校验) |
|
||||||
|
| `repositories/worker-platform/work-order-query-repo.ts` | `listOverdueWorkOrderShares` 改按 `deadline_at`;`countWorkerActiveOrdersWithClient` 改为按不同工单计数(见 9.1) |
|
||||||
|
| `repositories/worker-platform/work-order-settlement-repo.ts` | `settleOverdueWorkOrderShare` 用份额快照 + 调用 `recompute`;`acceptWorkOrderShareAndSettle` 结尾调用 `recompute`;`cancelWorkOrderShare` 调用 `recompute` |
|
||||||
|
| `repositories/worker-platform/worker-cancel-request-repo.ts` | 创建退单申请必须定位到具体 `shareId`;审核通过后 `recompute` |
|
||||||
|
| `services/worker-platform/worker-hall-action-service.ts` | 拼单参与不再阻塞已参与;deadline 由 repo 计算 |
|
||||||
|
| `services/worker-platform/worker-acceptance-service.ts` | 3 处 `getWorkOrderShare` 改为「按 shareId 或唯一份额解析」;超时判定用 `share.deadline_at` |
|
||||||
|
| `services/worker-platform/worker-my-order-service.ts` | 3 处授权判断改为「存在任一有效份额」;响应补 `myShares` |
|
||||||
|
| `services/worker-platform/work-order-display-mappers.ts` | `mapWorkOrderForWorker` 接收 `myShares[]`;`myShare` 取主份额;`timeoutAt` 用 `share.deadline_at`;`mapWorkOrderShare` 输出新字段;`mapWorkOrderSharingWorkers` 增加明细 |
|
||||||
|
| `services/worker-platform/admin-work-order-sharing-service.ts` | 返回 `sharingShares` 明细 |
|
||||||
|
| `routes/worker/hall.ts`、`routes/worker/orders.ts` | 透传 `shareId` |
|
||||||
|
| `routes/admin/worker-platform/orders.ts` | 无新增(沿用 shareId 路由) |
|
||||||
|
| `services/worker-platform/index.ts` | 导出新增函数 |
|
||||||
|
|
||||||
|
**新增内部函数**:`recomputeWorkOrderSharingStatusWithClient`(建议放 `work-order-share-repo.ts`,因为它只读份额表)。
|
||||||
|
|
||||||
|
### 9.1 活跃单计数(D2 已确认:按不同工单)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*)::int AS total
|
||||||
|
FROM (
|
||||||
|
SELECT wo.id
|
||||||
|
FROM work_orders wo
|
||||||
|
WHERE wo.assigned_worker_id = $1 AND wo.status = 'in_progress'
|
||||||
|
UNION
|
||||||
|
SELECT wos.work_order_id
|
||||||
|
FROM work_order_shares wos
|
||||||
|
JOIN work_orders swo ON swo.id = wos.work_order_id
|
||||||
|
WHERE wos.worker_id = $1
|
||||||
|
AND wos.status = 'joined'
|
||||||
|
AND swo.status = 'open'
|
||||||
|
) AS active;
|
||||||
|
```
|
||||||
|
|
||||||
|
`UNION` 自带去重;整单接取与拼单在同一工单上互斥,正常情况下两侧不会重叠。对只有一条份额的打手,结果与旧口径完全一致,无回归。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、前端改动清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| `types/worker-platform.ts` | `myShares?: WorkOrderShare[]`;`WorkOrderShare` 加 `deadlineAt/timeoutMinutes/cancelledReason` |
|
||||||
|
| `pages/worker/WorkerHallPage.tsx` | 去掉 `disabled={… || Boolean(order.myShare)}`;按钮文案改「再拼 1 份 / 抢剩余 N 份」;显示「已参与 2 次 · 共 3 份」 |
|
||||||
|
| `pages/worker/worker-order-utils.tsx` | 从 `order.myShare` 改为「当前选中份额」上下文;`canSubmitAcceptance`、`canRequestCancel` 等按份额判断 |
|
||||||
|
| `pages/worker/WorkerOrdersPage.tsx` + `WorkerOrderMobileList.tsx` + `WorkerOrderTableColumns.tsx` | 一行一工单,展开显示我的份额列表(每份:份数、状态、倒计时、押金、报酬、操作) |
|
||||||
|
| `pages/worker/WorkerOrderDetailDrawer.tsx` | 份额 Tab / 列表;每份独立「提交验收 / 申请退单 / 补证据」 |
|
||||||
|
| `pages/worker/WorkerOrderModals.tsx` | 验收、退单弹窗带 `shareId` |
|
||||||
|
| `pages/admin/panels/WorkOrdersPanel.tsx` | 份额表加「参与时间、截止时间、超时策略」列;统一验收按钮在存在 `joined` 份额时禁用(接口已拦) |
|
||||||
|
| `pages/admin/panels/AfterSalesPanel.tsx` | 份额下拉在「同一打手多份额」时显示参与时间区分(已有 shareId,仅展示优化) |
|
||||||
|
| `pages/admin/panels/CancelRequestsPanel.tsx` | 显示份额份数与参与时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、并发与幂等
|
||||||
|
|
||||||
|
| 场景 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 两个请求同时参与同一工单 | `SELECT … FROM work_orders FOR UPDATE` 已存在,份额求和在同一事务内,天然串行,不会超卖 |
|
||||||
|
| 同一打手快速点两次 | 客户端每次点击生成一个 `requestId`;服务端按 `(worker_id, request_id)` 先查后插,唯一索引兜底;命中则返回原份额并标记 `duplicated: true`,不重复冻结 |
|
||||||
|
| 超时任务与打手提交验收并发 | 两者都锁份额行(`FOR UPDATE`);后到者读到 `status != 'joined'` 直接返回,不会重复处置 |
|
||||||
|
| 统一验收与单份额验收并发 | 都 `FOR UPDATE` 锁工单;仍建议补「已结算份额」的显式标记(见风险 R3) |
|
||||||
|
| 退单申请与超时并发 | 退单申请锁工单;超时处置也锁工单;后到者按当前状态失败返回 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、测试计划
|
||||||
|
|
||||||
|
**单元/集成(必须新增)**
|
||||||
|
|
||||||
|
1. 同一打手连续参与 3 次 → 3 条记录,`quantity` 各自独立,`deadline_at` 依次递增。
|
||||||
|
2. 参与时母单 `timeout_minutes=60`,随后母单改为 120 → 已有份额 `deadline_at` 不变。
|
||||||
|
3. 份额 A 超时 → 只有 A 变 `cancelled`,B 仍 `joined`;母单仍 `open`。
|
||||||
|
4. 份额 A、B 都提交,A 超时(不可能,已提交)→ 跳过;验证只扫 `joined`。
|
||||||
|
5. 最后一条 `joined` 份额超时 → 母单按 `recompute` 变为 `pending_acceptance`(其余已提交)或 `open`(其余仍 joined)。
|
||||||
|
6. 全部份额取消 → 母单回 `open`,剩余份数 = 全部。
|
||||||
|
7. 多份额分别验收 → 每笔结算金额 = 该份额 `share_reward` + `share_deposit`,互不重复。
|
||||||
|
8. 押金累计口径:两次参与后 `Σ share_deposit = max(0, 累计需求 − 免押)`;取消其中一份只释放它自己。
|
||||||
|
9. 退单申请指定 shareId → 只撤该份额;不传 → 撤该打手在该工单的全部有效份额。
|
||||||
|
10. 并发参与同一工单(两个打手)→ 总份数不超 `sharing_total_quantity`。
|
||||||
|
11. 同一 `requestId` 重复提交 → 只产生一条份额,第二次返回原份额、不重复冻结押金。
|
||||||
|
12. 整单退单(不传 shareId)→ 该打手在该工单的全部有效份额一并撤销,其他打手份额不动。
|
||||||
|
13. 拼单验收通知:每份提交各产生一条后台待办(按 shareId 去重);统一验收完成后再产生一条整单完成通知。
|
||||||
|
14. 历史单回归:改造前创建的合并份额仍能正常验收、超时、退单。
|
||||||
|
|
||||||
|
**SQL 护栏**:`npm run check:sql`(迁移不可变、热点索引存在)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、上线与回滚
|
||||||
|
|
||||||
|
**上线顺序**
|
||||||
|
|
||||||
|
1. 迁移 104(加列 → 回填 → 删唯一键 → 建索引)。
|
||||||
|
2. 后端发布(新逻辑兼容旧前端:`shareId` 可选、`myShare` 保留)。
|
||||||
|
3. 前端发布(切到 `myShares`)。
|
||||||
|
4. 观察 3 天:份额表增长、超时任务耗时、验收重复结算告警。
|
||||||
|
|
||||||
|
**回滚**
|
||||||
|
|
||||||
|
- 代码回滚安全(旧代码读不到新列,但 `myShares` 字段多余无影响)。
|
||||||
|
- **数据回滚不安全**:重新加回唯一键前必须合并重复份额。提供脚本草案:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 回滚前合并:同一工单同一打手的多条份额合并成一条(只适用于未验收的 joined)
|
||||||
|
-- 已验收/已结算的份额不要合并,否则资金归属会被改写
|
||||||
|
```
|
||||||
|
|
||||||
|
结论:**迁移发布后建议不轻易回滚**,改用开关控制前端是否展示多份额入口(如需灰度,可加 `worker_sharing_multi_participation` 配置键)。
|
||||||
|
|
||||||
|
**灰度建议**:先在 `worker_hall` 配置里加布尔开关 `allowRepeatParticipation`,默认关闭;开关打开才允许第二次参与,其余逻辑改造后向后兼容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、风险清单
|
||||||
|
|
||||||
|
| # | 风险 | 影响 | 缓解 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| R1 | 免押额度被拆分套利 | 资金 | 选方案 B(累计口径) |
|
||||||
|
| R2 | 活跃单计数把每份算 1 个名额 | 打手体验 / 可接单量 | 决策 D2;建议按「不同工单数」计 |
|
||||||
|
| R3 | 统一验收与逐份验收并发重复结算(原有隐患被放大) | 资金 | 验收时加 `settled_at` 或 `settlement_ledger_id` 幂等标记 |
|
||||||
|
| R4 | 前端遗漏 `myShare` 判断导致按钮永久禁用 | 功能不可用 | 全量替换 + 组件测试 |
|
||||||
|
| R5 | 超时任务扫描量上升(份额数 > 工单数) | 性能 | `idx_work_order_shares_due` 部分索引 + 每轮 limit |
|
||||||
|
| R6 | 后台通知按工单去重,第二份提交不提醒 | 运营漏验收 | 分享单的 dedupeKey 加 `shareId`(决策 D7) |
|
||||||
|
| R7 | 历史合并份额与新单份额混存 | 口径解释成本 | 文档标注;后台展示「本次参与份数」 |
|
||||||
|
| R8 | `worker_work_order_notes` / `views` 仍按工单+打手唯一 | 多份额共用一条备注 | 视为「对工单的备注」,不拆分;文档说明 |
|
||||||
|
| R9 | 母单「已份数分配完」但份额被取消后重新开放 | 大厅可见性 | `recompute` 里重置 `hall_queued_at`,避免已看过的人看不到 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十五、决策记录(已全部确认)
|
||||||
|
|
||||||
|
| # | 决策 | 选项 | 我的建议 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| D1 | 免押额度怎么分摊 | A 每份全额 / **B 工单累计** / C 按份数比例 | ✅ **已确认:跟着母单走(B)** |
|
||||||
|
| D2 | 「最大同时接单量」怎么算 | A 每份算 1 单 / **B 按不同工单计** | ✅ **已确认:B** |
|
||||||
|
| D3 | 撤单/退单申请粒度 | A 只支持整单 / B 只支持份额 | ✅ **已确认:整单 + 单份额都支持** |
|
||||||
|
| D4 | 保留「统一验收」吗 | A 保留 / B 取消 | ✅ **已确认:两种都要**(统一验收 = 逐份结算批处理) |
|
||||||
|
| D5 | 我的订单列表形态 | **A 一行一工单 + 展开份额** / B 一行一份额 | ✅ **已确认:A** |
|
||||||
|
| D6 | 是否需要防误触 | A 不做 / **C 客户端 requestId 幂等** | ✅ **已确认:C** |
|
||||||
|
| D7 | 验收通知去重 | A 按工单 / **B 拼单按 shareId** | ✅ **已确认:B;统一验收也要通知** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十六、执行顺序(范围已确认:一次性做完)
|
||||||
|
|
||||||
|
1. 先跑第四节的数据体检 SQL,确认无重复份额、无脏数据。
|
||||||
|
2. 写迁移 `104`(加列 + 回填 + 删唯一键 + 索引 + `request_id` 幂等索引),本地 `db:migrate` 验证,再跑 `check:sql`。
|
||||||
|
3. 仓库层:份额创建(每次 INSERT + 幂等 + 押金累计 + deadline 快照)→ 超时(按份额 deadline)→ 验收(按 shareId)→ 撤单(整单 / 单份额两种)→ 新增 `recomputeWorkOrderSharingStatusWithClient` 并替换 7 处内联状态判断。
|
||||||
|
4. 服务层与路由:`shareId` 可选兼容、`requestId` 透传、活跃单按不同工单计数。
|
||||||
|
5. 通知:拼单验收待办按 `shareId` 去重;统一验收完成后再发一条整单完成通知。
|
||||||
|
6. 前端:大厅按钮(可重复参与 + 每次生成 requestId)→ 我的订单(一行一工单 + 展开份额 + 每份独立操作)→ 后台份额表(参与时间/截止时间/逐份验收与撤单)。
|
||||||
|
7. 验证:`npm run check`(后端 + 前端)+ Docker 内跑测试,最后手工走一遍:参与 2 次 → 一份超时 → 一份单独验收 → 剩余份数统一验收 → 核对母单状态与钱包流水。
|
||||||
|
|
||||||
|
> 待你启动 Docker Desktop 后开始第 2 步;代码改动本身不依赖容器,但迁移验证与 PostgreSQL 集成测试必须用容器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十七、执行结果(2026-10-23 一次性完成)
|
||||||
|
|
||||||
|
| 项 | 状态 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 数据体检 | ✅ | 0 重复 `(work_order_id, worker_id)`、0 孤立份额;103 条历史脏状态仅记录未动资金 |
|
||||||
|
| 迁移 104 | ✅ | `db:migrate` 成功;psql 确认 5 列、唯一键已删、4 个索引;宿主机 `npm run check:sql` 通过 |
|
||||||
|
| 仓库层 | ✅ | 每次参与 INSERT 新份额;`(worker_id, request_id)` 先查后插幂等;押金按累计口径只补差额;`deadline_at/timeout_minutes/timeout_policy` 参与时快照;`recomputeWorkOrderSharingStatusWithClient` 成为母单状态唯一出口 |
|
||||||
|
| 超时 / 撤单 | ✅ | 超时只处置单份并写 `cancelled_reason='timeout'`;单份撤单 `admin`、打手退单 `worker_request`;整单 + 单份额两种粒度都支持;均不再各自 UPDATE 母单状态 |
|
||||||
|
| 服务层 / 路由 | ✅ | 大厅与我的订单透传 `requestId`;多份额验收未传 `shareId` 时返回 400 `work_order_share_id_required`;活跃单按不同工单去重 |
|
||||||
|
| 通知 | ✅ | 拼单验收待办 dedupeKey 带 `shareId`;新增 `order_accepted` 整单完成通知(含统一验收) |
|
||||||
|
| 前端 | ✅ | 大厅可重复参与(每次生成 `requestId`,显示「已参与 N 次 · 共 M 份」);我的订单一行一工单 + 展开份额 + 逐份验收/退单;后台份额表新增「状态+撤单原因 / 参与时间 / 截止时间 / 超时策略」并保留逐份验收与单人撤单 |
|
||||||
|
| 测试 | ✅ | 后端 750 项:735 通过、10 跳过、5 项为既有 Windows/CRLF 环境失败(与本次改动文件无关);前端 143 项全通过 |
|
||||||
|
| 冒烟走查 | ✅ | 真实库脚本化验证:同一打手 2 次参与生成 2 条份额(押金 2000 + 3000,累计口径)→ `requestId` 重放返回原份额且不重复扣款 → 母单改超时配置不影响已固化 deadline → 单份超时只取消该份、另一份保持 joined、母单仍 open → 补满并逐份提交后母单才转 `pending_acceptance`;脚本数据已全部清理(0 残留) |
|
||||||
|
|
||||||
|
### 已知遗留
|
||||||
|
|
||||||
|
- 后台 `mapWorkOrderSharingWorkers` 仍按打手汇总一行(用于列表角标),明细操作走份额表,属设计保留。
|
||||||
|
- 5 项后端既有测试失败:`backup-db.sh` 在容器内缺 `/deploy` 文件;3 项为 CRLF 下源码字符串守卫断言;1 项 `http-cache` 路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。
|
||||||
|
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# 打手工单深挖:抢单 / 拼单 / 好友赠送 / 结算(2026-10-23)
|
||||||
|
|
||||||
|
> 状态:**梳理稿(待评审,未登记索引)**
|
||||||
|
> 落笔日期:2026-10-23
|
||||||
|
> 代码基线:工作区当前 HEAD(迁移 001–103)
|
||||||
|
> 范围:`work_orders` 从「可抢」到「已验收 + 资金结算」的完整闭环,含拼单份额、好友赠送、超时/问题单/退单申请、售后与欠款
|
||||||
|
> 不在范围:寄卖提报与匹配算法、订单进单、领取页(见 `项目全流程与数据库设计梳理-2026-10-23.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、一页结论
|
||||||
|
|
||||||
|
1. **一条工单只有两个资金入口**:接单时冻结押金(`deposit_freeze`),验收时结算报酬 + 释放押金(`reward_settlement` / `deposit_release`,或延迟解冻)。
|
||||||
|
2. **押金不是「按单」而是「按接单关系的未结押金」**:`getOutstandingDepositAmountWithClient(workerId, workOrderId)` 是唯一口径,同一工单多次接取/退回也只处理当前仍冻结的部分。
|
||||||
|
3. **拼单的免押额度按「该打手在本工单的累计份额」抵扣一次**,且**已冻结金额不会因为追加份数变少而自动回退**(`Math.max(当前冻结, 新要求)`)。
|
||||||
|
4. **好友赠送是「三态叠加」**:`work_orders.status`(生命周期)+ `gift_phase`(赠送阶段)+ `gift_available_at`(冷却到点)。`gift_ready` **不落库**,由到点实时计算。
|
||||||
|
5. **确认加好友是一道资金分水岭**:`friend_added_at` 一旦写入,任何「释放押金」的处置都会被强制改成「扣除押金」,寄卖卖家的发布押金同口径一并没收。
|
||||||
|
6. **超时、问题单、退单申请三条处置路径共用一套押金策略词表**:`reopen / cancel_release / cancel_deduct`(问题单另有 `return_to_worker`,不碰押金)。
|
||||||
|
7. **验收有两条免审通道**:内部打手(`worker_type='internal'`)与 VIP(`autoAcceptWithoutEvidence`,可无图提交,但留 24 小时补证据窗口);**好友赠送单一律人工验收**。
|
||||||
|
8. **退单是唯一会掉等级/封号的常规路径**:VIP1 累计 5 次通过 → 直接禁用账号并吊销会话;其他等级每 10 次通过 → 降一级并把 `level_penalty_accepted_count` 抬高,变相提高升级门槛。
|
||||||
|
9. **资金全部落在 `worker_wallet_ledgers`**(只追加),但**余额没有「由流水重算」的校验**,也没有会计期间;它仍然是业务钱包,不是总账。
|
||||||
|
10. **锁序不统一**:抢单先锁钱包再更新工单,指派先锁工单再锁钱包,拼单先锁工单再锁钱包——`grabWorkOrder` 是唯一的「钱包优先」路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、单据与资金模型
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 单据
|
||||||
|
WO["work_orders<br/>生命周期状态"]
|
||||||
|
WS["work_order_shares<br/>拼单份额"]
|
||||||
|
EV["work_order_events<br/>只追加时间线"]
|
||||||
|
end
|
||||||
|
subgraph 资金
|
||||||
|
W["worker_wallets<br/>available / frozen_deposit<br/>pending_unfreeze / frozen_withdraw"]
|
||||||
|
L["worker_wallet_ledgers<br/>只追加流水"]
|
||||||
|
U["worker_deposit_unfreezes<br/>延迟解冻队列"]
|
||||||
|
end
|
||||||
|
WO -->|"整单接取"| D1["deposit_freeze"]
|
||||||
|
WS -->|"按份额接取"| D1
|
||||||
|
WO -->|"验收"| D2["reward_settlement / deposit_release"]
|
||||||
|
WS -->|"份额验收"| D2
|
||||||
|
D2 -->|"unfreezeDays > 0"| U
|
||||||
|
U -->|"到期任务"| D3["deposit_unfreeze"]
|
||||||
|
WO -->|"超时/问题单/退单"| D4["deposit_release 或 deposit_deduction"]
|
||||||
|
D1 --> L
|
||||||
|
D2 --> L
|
||||||
|
D3 --> L
|
||||||
|
D4 --> L
|
||||||
|
L --> W
|
||||||
|
```
|
||||||
|
|
||||||
|
**唯一的余额写入方式**(除提现外)都是「`SELECT … FOR UPDATE` 读钱包 → 计算 next → `UPDATE` → `INSERT ledger`」,没有任何一处只更新余额而不写流水。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、抢单
|
||||||
|
|
||||||
|
### 3.1 入口与校验顺序
|
||||||
|
|
||||||
|
`POST /api/v1/worker/hall/orders/:workOrderId/grab` → `grabWorkerHallOrder`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant W as 打手
|
||||||
|
participant S as hall-action-service
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
W->>S: grab(workOrderId)
|
||||||
|
S->>S: 会话有效 → 读打手 + 等级权限 + 大厅配置
|
||||||
|
S->>S: visibleAfterIso = now - visibleDelaySeconds(等级可见延迟)
|
||||||
|
S->>DB: 读工单
|
||||||
|
alt workOrder.sharing_enabled
|
||||||
|
S->>DB: joinWorkOrderShare(takeRemaining=true)
|
||||||
|
else 整单抢单
|
||||||
|
S->>DB: countWorkerActiveOrders
|
||||||
|
S->>S: 校验 maxActiveOrders / 可用余额 ≥ 冻结额
|
||||||
|
S->>DB: grabWorkOrder(...)(单事务)
|
||||||
|
S->>DB: work_order_events grabbed + 实时事件
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
整单抢单在**事务内**再次做全部校验(防并发),并条件更新:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE work_orders
|
||||||
|
SET status='in_progress', assigned_worker_id=$1, last_assigned_worker_id=$1,
|
||||||
|
assigned_at=$2, deadline_at=$3,
|
||||||
|
gift_phase = CASE WHEN acceptance_mode='friend_gift' THEN 'material_required' ELSE '' END,
|
||||||
|
booster_material_json='{}', friend_added_at=NULL, gift_available_at=NULL,
|
||||||
|
<CLEAR_DELIVERY_RESIDUE_SQL>, updated_at=$2
|
||||||
|
WHERE id=$4 AND status='open' AND assigned_worker_id IS NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
`CLEAR_DELIVERY_RESIDUE_SQL` 清掉上一手的 `submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / worker_note`,但**不动买家资料**(`material.collect`)和寄卖留痕(`consignmentHistory`)。
|
||||||
|
|
||||||
|
### 3.2 冻结押金怎么算
|
||||||
|
|
||||||
|
```text
|
||||||
|
整单:freezeAmount = max(0, work_order.required_deposit_amount - depositFreeAmount)
|
||||||
|
拼单:nextRequired = round(required_deposit_amount × nextQuantity / totalQuantity)
|
||||||
|
requiredShareDeposit = max(0, nextRequired - depositFreeAmount)
|
||||||
|
share_deposit = max(已冻结的 share_deposit, requiredShareDeposit) // 不回退
|
||||||
|
本次冻结 = share_deposit - 已冻结
|
||||||
|
```
|
||||||
|
|
||||||
|
`depositFreeAmount = 等级 permission_json.depositFreeAmount + worker_users.personal_deposit_free_amount`(两者叠加,个人额度是等级之上的追加额度)。
|
||||||
|
|
||||||
|
> 注意:`deposit_threshold_amount`(押金门槛)目前只读写/展示,不参与冻结计算;自动生成的工单把 `required_deposit_amount` 与 `deposit_threshold_amount` 都写成 0(见风险 W1)。
|
||||||
|
|
||||||
|
### 3.3 大厅可见性
|
||||||
|
|
||||||
|
| 规则 | 实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 等级可见延迟 | `permission_json.visibleDelaySeconds`;未配置时按等级键默认:vip1 20s / vip2 15s / vip3 10s / vip4 5s / vip5 0s |
|
||||||
|
| 大厅总量 | `worker_hall.maxVisibleOrders`(默认 50,上限 500) |
|
||||||
|
| 分类上限 | `worker_hall.categoryMaxVisibleOrders`(分类 ID → 上限) |
|
||||||
|
| 拼满隐藏 | `excludeFilledSharing` |
|
||||||
|
|
||||||
|
可见延迟**不是拦截**,而是「只把发布时间早于 `now - delay` 的单放进候选集」,抢单时在事务内再校验一次容量,防止延迟期内被绕过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、拼单
|
||||||
|
|
||||||
|
### 4.1 数据模型
|
||||||
|
|
||||||
|
`work_order_shares`:`UNIQUE(work_order_id, worker_id)`,一份行代表一个打手在该工单的全部份额。
|
||||||
|
|
||||||
|
| 列 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `quantity` | 该打手累计份数(同一工单可追加) |
|
||||||
|
| `unit_reward` / `share_reward` | 单份报酬 / 累计报酬 |
|
||||||
|
| `share_deposit` | 该打手是否该单的累计冻结额(不是本次增量) |
|
||||||
|
| `status` | `joined → submitted → accepted / cancelled` |
|
||||||
|
| `acceptance_json` / `draft_acceptance_json` | 验收资料 / 草稿 |
|
||||||
|
|
||||||
|
### 4.2 参与规则
|
||||||
|
|
||||||
|
- 普通参与:指定 `quantity`(默认 1),不得超过剩余份数 `sharing_total_quantity - Σ(非 cancelled 份额)`。
|
||||||
|
- 「立即抢单」:`takeRemaining=true`,在事务锁内**承接全部剩余份数**;如果已有自己的 `joined` 份额,则在此基础上追加,否则报「数量已满」。
|
||||||
|
- 整单接取者不能再拼单(`work_order_owner_conflict`)。
|
||||||
|
- 重复参与被唯一键挡住;同一打手多次参与只能走「追加份数」路径。
|
||||||
|
- 活跃单上限只在**新建份额**时校验(追加不重复计入)。
|
||||||
|
|
||||||
|
### 4.3 母单状态收敛
|
||||||
|
|
||||||
|
```text
|
||||||
|
份额 joined → 母单保持 open(可继续参与)
|
||||||
|
份额 submitted → 仍 open;若「份数已全部分配」且「没有 joined 份额」→ 母单转 pending_acceptance
|
||||||
|
单份额验收 → 该份额 accepted;若这是最后一个未结算份额 → 母单 accepted 并补跑寄卖钩子
|
||||||
|
全部取消 → 母单回 open
|
||||||
|
```
|
||||||
|
|
||||||
|
`submitWorkOrderShareAcceptance` 里那句 `WHERE id=$2 AND status='open'` 是关键:只有母单还在 `open` 时才允许被自动推进,避免与后台手动验收并发覆盖。
|
||||||
|
|
||||||
|
### 4.4 两种验收姿势
|
||||||
|
|
||||||
|
| 方式 | 入口 | 行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单独验收某份额 | `POST /admin/worker-platform/orders/:id/shares/:shareId/accept` | 只结算该份额:份额报酬 + 份额押金;已结算份额不会在统一验收时重复结算 |
|
||||||
|
| 统一验收母单 | `POST /admin/worker-platform/orders/:id/accept` | 先检查所有份额都已 `submitted`(有任一 `joined` 直接 409 `sharing_pending_submissions`),再逐个结算未结算份额,最后结算整单打手(如有) |
|
||||||
|
|
||||||
|
> 设计上的一个细节:统一验收时 `settledShareCount` 用「`status !== 'accepted'`」统计,已经单独验收过的份额会被跳过——这是避免重复发钱的唯一保护,没有幂等键。
|
||||||
|
|
||||||
|
### 4.5 已提交份额不能绕过
|
||||||
|
|
||||||
|
问题单的 `reopen / cancel_*` 与超时处置都会先检查 `getActiveWorkOrderShareCountWithClient`;有活跃份额就拒绝整单处置(`work_order_has_active_shares`)。这是为了防止「母单被整单带走,份额验收入口全部锁死」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、好友赠送(friend_gift)
|
||||||
|
|
||||||
|
### 5.1 三态叠加
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> material_required: 建单时 acceptance_mode=friend_gift
|
||||||
|
material_required --> material_submitted: 打手提交本人资料(昵称/ID/区服/系统 + 主页截图)
|
||||||
|
material_submitted --> friend_countdown: 后台确认已加好友(记 friend_added_at,算 gift_available_at)
|
||||||
|
friend_countdown --> gift_ready: 到点(不落库,实时计算)
|
||||||
|
gift_ready --> pending_acceptance: 打手提交赠送凭证
|
||||||
|
pending_acceptance --> accepted: 后台人工验收(必审)
|
||||||
|
material_submitted --> material_required: 后台重置资料
|
||||||
|
friend_countdown --> material_required: 后台重置资料
|
||||||
|
```
|
||||||
|
|
||||||
|
- `work_orders.status` 在整条链路上保持 `in_progress`(或 `problem`),**不因赠送阶段改变**;前端展示用 `resolveWorkOrderGiftPhase()`。
|
||||||
|
- 冷却时长 = `gift_cooldown_hours` 小时 + `gift_cooldown_minutes` 分钟;两者都为 0 时按默认 **72 小时** 兜底(`DEFAULT_GIFT_COOLDOWN_HOURS`),上限 30 天。
|
||||||
|
- 确认加好友时,`deadline_at` 被顺延为「冷却结束 + `timeout_minutes`」,即**冷却期不计入打手超时**。
|
||||||
|
- 提交验收时如果 `gift_phase !== friend_countdown` 或未到点,直接 409 `work_order_gift_cooldown_active`。
|
||||||
|
|
||||||
|
### 5.2 押金分水岭
|
||||||
|
|
||||||
|
```text
|
||||||
|
friend_added_at IS NULL → 取消/退回/超时:按策略正常退还
|
||||||
|
friend_added_at 有值 → 取消/退回/超时:释放类一律转为扣除(deposit_deduction)
|
||||||
|
寄卖卖家的发布押金同口径没收
|
||||||
|
```
|
||||||
|
|
||||||
|
实现集中在 `isWorkOrderGiftDepositForfeitable()`,被超时处置、问题单处置、退单审核三处共用。语义是:**好友已经加了,赠送动作由打手/卖家完成,此时脱离订单属于套利**。
|
||||||
|
|
||||||
|
### 5.3 内部现货变体(寄卖)
|
||||||
|
|
||||||
|
寄卖库存绑定工单走 `internal_stock` 时没有接单打手,流程被压缩:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assigned_worker_id = null,material.consignment.fulfillmentType = internal_stock
|
||||||
|
→ parkWorkOrderWaitingFriendAdd:status=in_progress,gift_phase=material_required(语义变成「待加好友」)
|
||||||
|
→ 客服线下通知买家加好友 → 手动确认 → 进入冷却
|
||||||
|
→ 冷却结束:后台可直接验收(无需打手交图)
|
||||||
|
```
|
||||||
|
|
||||||
|
`hasInternalConsignmentFulfillment()` 是识别标志:`assigned_worker_id=0` + `material.consignment.inventoryId` + 非 `worker_deposit` + 无 `consignment.workerId`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、验收
|
||||||
|
|
||||||
|
### 6.1 打手提交(整单)
|
||||||
|
|
||||||
|
| 规则 | 细节 |
|
||||||
|
| --- | --- |
|
||||||
|
| 归属 | 必须是 `assigned_worker_id` 本人(寄卖卖家走 `asConsignmentSupplier` 例外) |
|
||||||
|
| 状态 | `in_progress` / `problem` |
|
||||||
|
| 超时前置 | 已过期则先执行超时处置再抛 409(接口惰性判定) |
|
||||||
|
| 图片 | 默认必须至少 1 张(`work_order_acceptance_image_required`);VIP/内部打手可不交图 |
|
||||||
|
| 赠送单 | 必须 `friend_countdown` 且已到点;**一律人工验收**,VIP/内部免审不适用 |
|
||||||
|
| 草稿 | 可先存 `draft_acceptance_json`,提交时优先取「本次上传 + 草稿里已有的图」并去重 |
|
||||||
|
|
||||||
|
免审条件:`worker_type === 'internal'` **或** 等级 `permission_json.autoAcceptWithoutEvidence === true`(且不是拼单份额参与,`canWorkerAutoAcceptWholeOrder` 明确排除拼单)。
|
||||||
|
|
||||||
|
免审且无图时,验收资料写入:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "reviewMode": "vip_auto", "evidenceStatus": "pending", "evidenceDueAt": "<now+24h>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
补证据走 `supplementWorkerAcceptedOrderEvidence`(验收后仍可补)。
|
||||||
|
|
||||||
|
### 6.2 催促
|
||||||
|
|
||||||
|
- 提交满 **10 分钟** 才能催;
|
||||||
|
- 每 **30 分钟** 一次,最多 **3 次**;
|
||||||
|
- 只支持整单(拼单份额不能催);
|
||||||
|
- 催的是 `admin_notifications` 的同一个待办(`reminder_count` 累加),不会刷屏。
|
||||||
|
|
||||||
|
### 6.3 后台验收
|
||||||
|
|
||||||
|
- 只接受 `pending_acceptance`(`capabilities.canAccept`)。
|
||||||
|
- 历史拼单母单如果还是 `open`,验收前自动推成 `pending_acceptance`。
|
||||||
|
- 内部现货好友赠送在 `gift_ready` 且 `in_progress` 时,后台可直接验收(自动补 `submitted_at`)。
|
||||||
|
- 有未提交份额 → 409 `work_order_sharing_pending_submissions`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、结算(核心)
|
||||||
|
|
||||||
|
### 7.1 一个事务里做完 8 件事
|
||||||
|
|
||||||
|
`acceptWorkOrderAndSettle`(整单)与 `acceptWorkOrderShareAndSettle`(份额)结构一致:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["锁工单(pending_acceptance)+ 锁全部非取消份额"] --> B{"还有 joined 份额?"}
|
||||||
|
B -->|是| X["409 sharing_pending_submissions"]
|
||||||
|
B -->|否| C["工单置 accepted / 份额置 accepted"]
|
||||||
|
C --> D["释放寄卖发布押金(仅整单,按 material.consignment.sourceSubmissionId)"]
|
||||||
|
D --> E["逐份额结算:售后欠款抵扣 → 报酬 → 押金"]
|
||||||
|
E --> F["整单打手结算:押金 → 报酬 → 欠款抵扣"]
|
||||||
|
F --> G["寄卖供货货款(internal_stock 时补给卖家)"]
|
||||||
|
G --> H["等级提升判定"]
|
||||||
|
H --> I["写 work_order_events accepted"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 资金等式(单次结算)
|
||||||
|
|
||||||
|
```text
|
||||||
|
releaseAmount = min(该工单未结押金, 钱包 frozen_deposit_amount)
|
||||||
|
debtOffset = min(售后未结欠款, 本次报酬) // FIFO,按欠款创建时间
|
||||||
|
shouldDelay = rewardUnfreezeEnabled && unfreezeDays > 0
|
||||||
|
pendingDelta = (shouldDelay ? 报酬 : 0) + (unfreezeDays > 0 ? 押金 : 0)
|
||||||
|
available' = available + (shouldDelay ? 0 : 报酬) + (unfreezeDays > 0 ? 0 : 押金) − debtOffset
|
||||||
|
frozen' = max(0, frozen − releaseAmount)
|
||||||
|
total_settled += 报酬
|
||||||
|
```
|
||||||
|
|
||||||
|
默认财务配置(`worker_finance` 配置键):
|
||||||
|
|
||||||
|
| 项 | 默认 | 范围 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `depositUnfreezeDays` | 3 天 | 0–30 |
|
||||||
|
| `rewardUnfreezeEnabled` | 开启 | 布尔 |
|
||||||
|
| `consignmentPublishDepositFen` | 2000 分(¥20) | 0–1,000,000 |
|
||||||
|
| `consignmentWhitelistEnabled` | 开启 | 布尔 |
|
||||||
|
|
||||||
|
### 7.3 流水类型对照
|
||||||
|
|
||||||
|
| 场景 | ledger_type | 金额符号 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 接单冻结 | `deposit_freeze` | − |
|
||||||
|
| 验收即释放押金 | `deposit_release` | + |
|
||||||
|
| 验收延迟解冻(入队) | `deposit_pending_unfreeze` | +(余额不加,进 pending_unfreeze) |
|
||||||
|
| 到期解冻 | `deposit_unfreeze` | + |
|
||||||
|
| 验收即结算报酬 | `reward_settlement` | + |
|
||||||
|
| 验收延迟解冻报酬 | `reward_pending_unfreeze` | + |
|
||||||
|
| 拼单报酬 | `sharing_reward` | + |
|
||||||
|
| 售后抵扣 | `after_sales_debt_offset` | − |
|
||||||
|
| 售后从可用余额扣 | `after_sales_balance_deduction` | − |
|
||||||
|
| 超时/退单/问题单扣押金 | `deposit_deduction` | − |
|
||||||
|
| 提现冻结 / 支付 / 解冻 | `withdraw_freeze` / `withdraw_paid` / `withdraw_release` | − / − / + |
|
||||||
|
| 后台手动加款 | `manual_credit` | + |
|
||||||
|
|
||||||
|
### 7.4 延迟解冻
|
||||||
|
|
||||||
|
- 入队:`worker_deposit_unfreezes(worker_id, work_order_id, amount, fund_type='deposit'|'reward', unfreeze_at, status='pending')`。
|
||||||
|
- 出队:定时任务 `deposit-unfreeze`(60s,scanLimit 100)→ `settleDueDepositUnfreezes`。
|
||||||
|
- **到期判定按「当前配置天数」回溯**:管理员把 `depositUnfreezeDays` 调小后,存量队列会提前视为到期(`retroactiveDays`),不会卡在旧到期时间。
|
||||||
|
- 提现前后台会先跑一次 `settleDueDepositUnfreezes({workerId})`,避免「到期了但还没解冻、余额显示不足」。
|
||||||
|
|
||||||
|
### 7.5 售后抵扣顺序
|
||||||
|
|
||||||
|
```text
|
||||||
|
验收报酬 → 先抵扣 open 欠款(FIFO)→ 抵扣部分写 after_sales_debt_offset
|
||||||
|
→ 欠款结清时 cases.debt_amount 归零、status 从 recovery_pending 变 closed
|
||||||
|
→ 报酬不足抵扣的部分继续留在 worker_after_sales_debts.outstanding
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 等级提升 / 降级
|
||||||
|
|
||||||
|
| 方向 | 触发 | 规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 升 | 每次验收后 `maybeUpgradeWorkerLevelWithClient` | `有效验收数 = 累计已验收单数 − level_penalty_accepted_count`;取 `upgradeThreshold ≤ 有效数` 的最高等级;`is_level_locked` 时完全不动;只升不降 |
|
||||||
|
| 降 | 退单申请审核通过 | 非 VIP1:每 **10 次**通过降一级,并把 `level_penalty_accepted_count` 抬到「实际验收数 − 新等级门槛」,提高重新升级成本 |
|
||||||
|
| 封 | 退单申请审核通过 | VIP1 累计 **5 次**通过 → `status=disabled`、`session_version+1`、吊销全部会话 |
|
||||||
|
|
||||||
|
`worker_level_logs.reason` 取值:`auto` / `cancel_request_penalty` / `cancel_request_ban`。
|
||||||
|
|
||||||
|
### 7.7 寄卖钩子
|
||||||
|
|
||||||
|
| 钩子 | 时机 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `releaseConsignmentPublishDepositBySubmissionWithClient` | 整单验收 | 释放卖家发布押金 |
|
||||||
|
| `settleConsignmentSupplierPayoutByWorkOrderWithClient` | 验收后 | 内部现货履约时订单没有接单打手,货款补给供货卖家;接单人就是卖家本人时不重复付款 |
|
||||||
|
| `releaseConsignmentInventoryBindingForWorkOrderWithClient` | 超时/退单/问题单/取消 | 把占用的现货放回在库池,避免永久 `consumed` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、三条处置路径的押金矩阵
|
||||||
|
|
||||||
|
| 处置 | 入口 | 目标状态 | `reopen` | `cancel_release` | `cancel_deduct` | `return_to_worker` |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 超时 | 定时任务 / 接口惰性 | open / cancelled | 退押金 | 退押金 | 扣押金 | — |
|
||||||
|
| 问题单 | 后台 `problem/resolve` | open / cancelled / 恢复 | 退押金 | 退押金 | 扣押金 | **不动押金**,恢复到 `marked_problem` 的 `from_status` |
|
||||||
|
| 退单申请 | 后台审核通过 | open(重新上架) | — | 退押金 | — | — |
|
||||||
|
|
||||||
|
补充规则:
|
||||||
|
|
||||||
|
- 三种路径都先检查「是否还有活跃份额」;有则拒绝整单处置。
|
||||||
|
- **已确认加好友的赠送单**:`cancel_release` 会被改写成 `cancel_deduct`,事件类型也按实际资金动作记(`timeout_cancel_deduct` / `problem_cancel_deduct`)。
|
||||||
|
- 超时与问题单会释放寄卖现货绑定;`return_to_worker` 不会(单还在进行)。
|
||||||
|
- 退单申请:待审核期间订单与押金保持原状;审核通过**在同一事务**里撤单、退押金、执行等级处罚。
|
||||||
|
- 打手每天最多提 **5 次**退单申请(按 Asia/Shanghai 自然日,`pending + approved` 计数),同一工单+份额不能有两条 pending。
|
||||||
|
- 拼单份额超时只在母单 `open` 时生效(份额 `joined` 且 `created_at + timeout_minutes` 已过),否则跳过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、售后与欠款
|
||||||
|
|
||||||
|
### 9.1 生命周期
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> awaiting_worker_response: 后台建单(必须已验收)
|
||||||
|
awaiting_worker_response --> under_review: 打手提交说明 + 证据
|
||||||
|
awaiting_worker_response --> under_review: 后台直接处置
|
||||||
|
under_review --> closed: not_upheld(不成立)
|
||||||
|
under_review --> closed: remedied(已补救)
|
||||||
|
under_review --> recovery_pending: upheld 且追缴 > 报酬/可扣押金 → 产生欠款
|
||||||
|
under_review --> closed: upheld 且全额扣到
|
||||||
|
recovery_pending --> closed: 欠款结清(验收抵扣 / 充值抵扣 / 后台代还)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 建单约束
|
||||||
|
|
||||||
|
| 约束 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工单必须 `accepted` | `work_order_not_accepted` |
|
||||||
|
| 指定份额时必须已验收 | `work_order_share_not_accepted` |
|
||||||
|
| 必须能定位责任打手 | `worker_missing`;内部现货寄卖单没有接单打手时,自动反查供货卖家(`getConsignmentSupplierByWorkOrderWithClient`)作为责任人 |
|
||||||
|
| 同一「工单 + 打手 + 份额」同时只能有 1 条进行中 case | 由部分唯一索引 `idx_worker_after_sales_cases_active_target` 保证:`UNIQUE(work_order_id, worker_id, COALESCE(work_order_share_id,0)) WHERE status IN ('awaiting_worker_response','under_review','recovery_pending')`,插入用 `ON CONFLICT DO NOTHING`,冲突即 `active_case_exists` |
|
||||||
|
| 投诉说明必填、≤ 2000 字 | |
|
||||||
|
| 问题类型默认 `fake_evidence` | |
|
||||||
|
|
||||||
|
### 9.3 追缴顺序
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 该工单尚未解冻的押金(worker_deposit_unfreezes pending, fund_type='deposit')
|
||||||
|
→ 走 deductPendingDepositUnfreeze,直接从队列扣,不进可用余额
|
||||||
|
2. 可用余额(available_amount)
|
||||||
|
3. 仍不足 → 写入 worker_after_sales_debts(status='open', outstanding_amount)
|
||||||
|
→ case.status = recovery_pending
|
||||||
|
```
|
||||||
|
|
||||||
|
后续抵扣:
|
||||||
|
|
||||||
|
- 该打手下一次验收报酬 → 自动 FIFO 抵扣(`offsetWorkerAfterSalesDebtsWithClient`);
|
||||||
|
- 充值入账 → `repayWorkerAfterSalesDebtsFromAvailableWithClient`;
|
||||||
|
- 后台手动代还 → `repayAfterSalesCaseDebt`。
|
||||||
|
|
||||||
|
每一笔抵扣都会写 `worker_after_sales_case_events`(`debt_offset` / `debt_settled`)+ 对应钱包流水,两边都有痕迹。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、发现的隐患(按资金风险排序)
|
||||||
|
|
||||||
|
| # | 隐患 | 证据 | 后果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| W1 | 自动建单硬编码 `required_deposit_amount=0`、`deposit_threshold_amount=0` | `sync-worker-orders-service.ts` | 自动单永远无押金约束,模板里的押金配置是死配置 |
|
||||||
|
| W2 | 抢单先锁钱包、指派/拼单先锁工单 | `work-order-grab-repo.ts` vs 同文件 `assignWorkOrderToWorker` / `work-order-share-repo.ts` | 并发抢单+指派可 40P01 死锁;目前只靠客户端重试 |
|
||||||
|
| W3 | 统一验收的「已结算份额」判断只看 `status !== 'accepted'`,没有幂等键 | `acceptWorkOrderAndSettle` | 极端并发下(两次验收同时进入)可能重复发报酬;`FOR UPDATE` 锁工单能挡住大部分,但没有业务幂等 |
|
||||||
|
| W4 | 发单金额 > 用户付款只在前端提示 | `publishAdminWorkOrder` 只校验 `reward_amount > 0` | 直接调 API 可发亏损单;`source_order_amount_fen` 只是记录,不参与校验 |
|
||||||
|
| W5 | 发布工单时电子凭证核销失败只写事件,不回滚发布 | `publishAdminWorkOrder` 的 `voucherConsume.ok=false` 分支 | 券没核销但工单已在大厅;指派路径却会拦截,同一规则两套行为 |
|
||||||
|
| W6 | 钱包余额没有「由流水重算」校验,也没有月度关账 | `worker_wallets` 可变字段 | 任何一次漏写/重复写流水都不会被系统发现,只能人工对账 |
|
||||||
|
| W7 | `work_orders.task_id` 是历史遗留列(迁移 091 已注释废弃) | 仍参与部分 mapper | 新人容易误以为「工单必须挂履约任务」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、两笔完整流水示例
|
||||||
|
|
||||||
|
> 先记一条硬约束:**好友赠送与拼单互斥**。建单时 `sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode) ? false : ...`,所以不存在「好友赠送 + 拼单份额」的单子。
|
||||||
|
|
||||||
|
### 11.1 拼单单
|
||||||
|
|
||||||
|
设定:`sharing_total_quantity=3`、`unit_reward=3000 分`、`required_deposit_amount=6000 分`;打手 A 等级免押 1000 分、活跃上限 3;`depositUnfreezeDays=3`、`rewardUnfreezeEnabled=true`。
|
||||||
|
|
||||||
|
| 步骤 | 动作 | 钱包变化 | 关键字段/流水 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | A 参与 2 份 | nextRequired = 6000×2/3 = 4000;requiredShare = 4000−1000 = 3000;冻结 3000 | `work_order_shares(quantity=2, share_deposit=3000)`,`deposit_freeze −3000` |
|
||||||
|
| 2 | A 追加 1 份 | nextRequired = 6000;requiredShare = 5000;本次冻结 2000 | `quantity=3, share_deposit=5000`,`deposit_freeze −2000` |
|
||||||
|
| 3 | 提交验收 | 份数已满且无 `joined` 份额 → 母单 `open → pending_acceptance` | `share.status=submitted` |
|
||||||
|
| 4 | 验收该份额 | 报酬 9000 延迟解冻;押金 5000 延迟解冻 | `pending_unfreeze +14000`,`reward_pending_unfreeze +9000`,`deposit_pending_unfreeze +5000` |
|
||||||
|
| 5 | 3 天后任务 | 到期释放 | `deposit_unfreeze +14000`,`pending_unfreeze −14000` |
|
||||||
|
|
||||||
|
### 11.2 好友赠送单
|
||||||
|
|
||||||
|
设定:`acceptance_mode=friend_gift`、`required_deposit_amount=5000`、免押 0、冷却 72h、`timeout_minutes=120`。
|
||||||
|
|
||||||
|
| 步骤 | 动作 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 打手抢单 | 冻结 5000(`deposit_freeze`);`gift_phase=material_required`;`deadline_at=now+2h` |
|
||||||
|
| 2 | 提交本人资料 | `gift_phase=material_submitted`;`friend_added_at=NULL` |
|
||||||
|
| 3 | 客服确认加好友 | `friend_added_at=now`;`gift_available_at=now+72h`;`deadline_at=now+72h+2h`(冷却不计超时) |
|
||||||
|
| 4 | 冷却中尝试提交 | 409 `work_order_gift_cooldown_active` |
|
||||||
|
| 5 | 到点后提交赠送凭证 | `status=pending_acceptance`;**不触发免审**(赠送单强制人工) |
|
||||||
|
| 6 | 客服验收 | 押金 5000 → 延迟解冻;报酬按 `reward_amount` 入账(同样按 `rewardUnfreezeEnabled` 决定是否延迟) |
|
||||||
|
| 7′ | 若第 3 步之后打手退单 | 押金从「应释放」改为 `deposit_deduction −5000`;寄卖发布押金同口径没收 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、待确认问题(等你回答后我再补口径)
|
||||||
|
|
||||||
|
1. **押金策略的业务意图**:`required_deposit_amount` 现在是「按单金额」还是「按 SKU 单价 × 份数」?自动单为什么固定为 0——是刻意豁免,还是历史遗留?
|
||||||
|
2. **延迟解冻的初衷**:`depositUnfreezeDays=3` + `rewardUnfreezeEnabled=true` 是为了防售后,还是为了压打手现金流?这决定售后追缴是否应该优先扣「待解冻队列」而不是可用余额。
|
||||||
|
3. **VIP1 五次退单封号**这条规则现在还在执行吗?线上是否有误封申诉流程(代码里只有 `review_note`)?
|
||||||
|
4. **好友赠送冷却 72h** 是否要按品类差异化?现在只有模板上的 `gift_cooldown_hours/minutes` 两个字段。
|
||||||
|
5. **拼单是否允许「一人多份 + 全部单人完成」**?当前逻辑允许,且验收后一次性结算全部份额——如果业务上要求分散风险,这条需要改。
|
||||||
@@ -0,0 +1,874 @@
|
|||||||
|
# order_site 全流程与数据库设计梳理(2026-10-23)
|
||||||
|
|
||||||
|
> 状态:**梳理稿(待评审,尚未登记到 `docs/README.md`)**
|
||||||
|
> 落笔日期:2026-10-23
|
||||||
|
> 代码基线:工作区当前 HEAD;数据库迁移 `001`–`103`(共 103 个文件,本地库 `schema_migrations` 记录 103 条,已全量应用)
|
||||||
|
> 核对方式:源码走查 + 本地 PostgreSQL 实读表结构 / 约束 / 索引 + 本地库状态与数据量统计
|
||||||
|
> 说明:本地库与生产同量级(`orders` 约 37.5 万行),本文引用的数据量均为**本地库实测**,不与生产承诺一致,仅用于判断结构合理性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、先看结论(10 条)
|
||||||
|
|
||||||
|
1. 系统早已不是「快手自动发货工具」,而是**三来源订单履约 + 打手众包接单 + 寄卖仓库 + 电子凭证核销 + 客服备用金台账**的一体化平台。
|
||||||
|
2. 订单来源只有三个:`kuaishou-91`(91 回调 + 快手 send-code 两条通道,**同一个来源**)、`pdd`、`douyin-mashida`。代码层的来源键定义在 `apps/backend/src/services/product-match/types.ts`。
|
||||||
|
3. **核心不变式:一个订单 = 骨架(`orders` + `order_items`)+ 买家凭证(快手电子凭证 / 抖音券)+ 一个执行载体**。载体只有三种:`affiliate_dash` 自动发货、`work_orders` 工单、`manual` 人工(当前无显式表,靠「无任务且无工单」判定)。
|
||||||
|
4. 履约任务按「订单项 × 数量」拆单元:`fulfillment_tasks` 的 `UNIQUE(order_item_id, unit_index)` 是整套领取链接体系的基座。
|
||||||
|
5. 领取链接 = `claimLinkBase + '/' + claim_tokens.token`,一任务一 token(`claim_tokens.task_id` 唯一);`CLAIM_TOKEN_TTL_HOURS=0` 表示永久有效。
|
||||||
|
6. 打手侧是一条**独立业务线**:工单状态机 `pending_material → unassigned → open → in_progress → pending_acceptance → accepted`,另有 `problem`、`cancelled`;资金走「钱包 + 流水」模型,不是会计总账。
|
||||||
|
7. 寄卖是第三个业务面:提报 → 审核入库(`INV…`)→ 规格匹配(系统/区服/系列/钻级/璀璨)→ 履约(内部现货 or 指派打手)→ 结算与撤销押金策略。
|
||||||
|
8. 备用金台账(迁移 100)已落地:`fund_accounts`(主管池 / 客服账户)+ `fund_entries`(只追加流水,余额只能由流水变动)+ `manual_expenses` + `fund_reconciliations`。
|
||||||
|
9. 数据库现状:**60 张业务表 + `schema_migrations`**,113 个外键、21 个索引的 `work_orders` 是全库索引最多的表;配置不入表结构,统一存 `app_config_entries`(本地 18 个配置键)。
|
||||||
|
10. 主要架构性风险仍是 2026-10-17 报告列的那几处:资金门禁只在前端、Webhook 先落库后处理、`/collect` `/redeem` 无第二因子、抢单/指派锁序相反、单实例依赖进程内存。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、系统地图
|
||||||
|
|
||||||
|
### 2.1 运行拓扑
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U1["外部平台<br/>91卡券 / 快手 / 拼多多 / 抖音码速达"] --> C
|
||||||
|
U2["买家<br/>领取页 / H5 / 兑换页"] --> C
|
||||||
|
U3["打手<br/>worker.khhao.com"] --> C
|
||||||
|
U4["后台运营 / 客服 / 财务"] --> C
|
||||||
|
C["Caddy :80/443<br/>反代 + 静态缓存 + 压缩"] --> F["frontend<br/>React 19 + Vite(一套产物三个入口)"]
|
||||||
|
C --> B["backend<br/>Express 5 + TS<br/>routes → services → repositories → pg"]
|
||||||
|
B --> P[("PostgreSQL 16")]
|
||||||
|
B --> O["对象存储<br/>生产 OSS / 本地 MinIO"]
|
||||||
|
B --> X["外部 API<br/>affiliate-dash / 快手 OpenAPI / 抖音码速达 / PDD"]
|
||||||
|
B --> M["进程内存<br/>限流桶 / 调度器 / SSE / 列表缓存"]
|
||||||
|
```
|
||||||
|
|
||||||
|
单实例假设写在代码里:限流、定时任务、SSE 订阅、列表缓存全部在进程内存(`middleware/rate-limit.ts`、`services/realtime/`),扩容前必须先外置。
|
||||||
|
|
||||||
|
### 2.2 后端入口与路由挂载
|
||||||
|
|
||||||
|
入口 `apps/backend/src/app.ts`:
|
||||||
|
|
||||||
|
| 挂载点 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/health`、`/health/live`、`/health/ready` | 健康检查;未 ready 时其余请求统一 503 |
|
||||||
|
| `/api/v1/open/douyin-mashida` | **必须在全局 body parser 之前**挂载,签名基于原始 JSON 字符串 |
|
||||||
|
| `/api/v1/open/affiliate-dash` | 发货平台异步通知(验签 + 时间戳容差) |
|
||||||
|
| `/api/v1/open/91` | 91 卡券异步下单 / 查询(验签) |
|
||||||
|
| `/api/v1/open/kuaishou-industry` | 快手行业电子凭证 send-code / destroy / query / consume |
|
||||||
|
| `/api/v1/redeem` | 抖音券兑换页接口 |
|
||||||
|
| `/api/v1/claim` | 买家领取页(token 与 H5 两种入口) |
|
||||||
|
| `/api/v1/collect` | 待补全订单资料收集 |
|
||||||
|
| `/api/v1/pdd/extension` | 拼多多浏览器扩展上报 Cookie / 公钥 |
|
||||||
|
| `/api/v1/files` | 文件对象读取 |
|
||||||
|
| `/api/v1/worker` | 打手端(公开鉴权路由 + 会话路由) |
|
||||||
|
| `/api/dcauth` | 设备认证(dcauth 协议) |
|
||||||
|
| `/api/v1/admin` | 后台 API(auth 路由放行,其余全部 `requireAdminSession`) |
|
||||||
|
| `/s` | 短链跳转 |
|
||||||
|
|
||||||
|
### 2.3 分层与模块职责
|
||||||
|
|
||||||
|
| 层 | 目录 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 路由 | `src/routes/**` | 参数解析、限流、鉴权、调用 service |
|
||||||
|
| 领域 | `src/domain/**` | 状态机与纯策略(任务、工单、寄卖、押金、验收模式) |
|
||||||
|
| 服务 | `src/services/**` | 订单、履约、工单平台、平台对接、财务、寄卖、调度 |
|
||||||
|
| 仓储 | `src/repositories/**` | 全部 SQL(含 `worker-platform/*` 大仓储) |
|
||||||
|
| 迁移 | `src/db/migrations` | 001 基线 + 102 个增量 |
|
||||||
|
| 前端 | `apps/frontend/src/pages/**` | `admin` 后台 / `worker` 打手端 / `claim` 领取页 / `redeem` 兑换页 / `collect` 补资料 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、端到端主流程
|
||||||
|
|
||||||
|
### 3.1 总览(一条单的完整生命周期)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["来源进单<br/>91 / PDD / 抖音"] --> B["upsertOrderFromSource<br/>advisory lock 去重 + 状态合并"]
|
||||||
|
B --> C["order_items 落库<br/>resolveOrderItemForFulfillment 判定载体"]
|
||||||
|
C --> D{"isConfigured?"}
|
||||||
|
D -->|否| E["待补全<br/>91 返回 orderStatus=10"]
|
||||||
|
C -->|是| F["syncDeliveryTasksForOrder<br/>按 order_item × quantity 建任务单元"]
|
||||||
|
F --> G{"executor_key"}
|
||||||
|
G -->|affiliate_dash| H["自动发货<br/>建 affiliate-dash 单 + 领取链接"]
|
||||||
|
G -->|manual_dispatch| I["人工履约<br/>manual_review 待办"]
|
||||||
|
C --> J["syncWorkerOrdersForSourceOrder<br/>product-match 匹配接单模板"]
|
||||||
|
J -->|命中且 auto_create| K["work_orders<br/>待补素材 / 待发布"]
|
||||||
|
K --> L["打手大厅抢单 / 后台指派"]
|
||||||
|
L --> M["代练履约 → 提交验收"]
|
||||||
|
M --> N["验收 → 结算报酬 / 释放押金 / 等级进度"]
|
||||||
|
H --> O["买家领取页 claimUrl<br/>填 UID → 绑定 → 提交发货"]
|
||||||
|
O --> P["affiliate-dash delivered 回调"]
|
||||||
|
P --> Q["任务 completed / redeemed<br/>快手电子凭证核销闭环"]
|
||||||
|
N --> R["钱包流水 + 售后 / 欠款"]
|
||||||
|
Q --> S["后台报表 / 备用金台账 / 审计"]
|
||||||
|
R --> S
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 三个来源的进单通道
|
||||||
|
|
||||||
|
| sourceKey(业务口径) | 实际 provider / platform | 入口 | 数据形态 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/91/orders/create` | 一单一条,`productNo` 解析出商品名与店铺 ID | 91 是订单骨架权威源 |
|
||||||
|
| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/kuaishou-industry/send-code` | 快手电子凭证补全/兜底 | 与 91 共享店铺键空间,**不是新来源** |
|
||||||
|
| `pdd` | `pdd` / `pdd` | 定时任务 `pdd_order_sync`(20s)拉取 | 两段式 `pdd_raw_orders → orders` | 浏览器扩展上报 Cookie |
|
||||||
|
| `douyin-mashida` | `douyin_mashida` / `doudian` | `POST /api/v1/open/douyin-mashida/message` | 消息推送,先落 `webhook_events` 去重 | 抖店订单 + DYC 券 |
|
||||||
|
|
||||||
|
本地库实测分布:91 368,355 单;PDD 5,563 单;抖音 1,541 单。
|
||||||
|
|
||||||
|
#### 3.2.1 91 卡券(下单 + 查询)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant K as 91卡券
|
||||||
|
participant API as backend /open/91
|
||||||
|
participant S as order-service
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
K->>API: POST /orders/create(验签)
|
||||||
|
API->>API: buildOpen91SourceEvent<br/>provider=91kaquan, platform=kuaishou<br/>totalAmount=maxAmount(分)
|
||||||
|
API->>S: upsertOrderFromSource(event)
|
||||||
|
S->>DB: pg_advisory_xact_lock(hashtextextended('provider|platform|orderNo'))
|
||||||
|
S->>DB: upsert orders + replace order_items
|
||||||
|
S->>S: 载体判定 → 建任务 / 建工单
|
||||||
|
API-->>K: orderStatus=10(未生成 claimUrl)或成功
|
||||||
|
K->>API: POST /orders/query
|
||||||
|
API-->>K: orderStatus=20 + cards(claimUrl 加密卡密)<br/>或 10(处理中)/ 30(失败)
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 订单幂等键是 `UNIQUE(provider, platform, shop_id, platform_order_id)`,事务内先取 advisory lock,避免并发重复建单。
|
||||||
|
- 状态合并用优先级表:`created(0) < paid(1) < closed(2) < refunded(3)`,`pay_status` 同理;`pending_config` 这类历史状态优先级最低,会被新事件覆盖。
|
||||||
|
- 91 查询的三种状态:`10` 处理中/未就绪,`20` 已发货(`cards` 为加密后的 claimUrl),`30` 失败(`pending_config`、`manual_failed` 或任务失败)。
|
||||||
|
- 历史遗留:库中仍有 25 条 `order_status='pending_config'` 的 91 老单(最近一条 2026-08-11),当前代码不再写入该状态,只作为查询口径识别。
|
||||||
|
|
||||||
|
#### 3.2.2 快手行业电子凭证(send-code 四条接口 + 两条出站回调)
|
||||||
|
|
||||||
|
- 券号与 91 订单解耦:`voucher_code = 'KSV' + Crockford Base32(10 字节随机)`,`UNIQUE(voucher_code)`,冲突重试 5 次。
|
||||||
|
- **幂等键是 `UNIQUE(oid, unit_index)`**:重复 `send-code` 返回同一批 `eticket.id`;`unit_index = 1..num`。
|
||||||
|
- 未找到 91 任务时券先落库(`task_id` 为空),后续由 `voucher-binding-service` 绑定,支持「快手先发码、91 后建单」。
|
||||||
|
- 出站回调用 `send_callback_status/attempt_count/last_error/response_json/sent_at` 记录,失败可重推。
|
||||||
|
- 核销流水号幂等:`consume_serial_num = CONSUME-{voucher_code}`;状态只有 `UNUSED / CONSUMED / DESTROYED`。
|
||||||
|
- 本地库实测:`CONSUMED` 376,179、`DESTROYED` 26,815、`UNUSED` 106 —— 说明核销链路是真实主路径。
|
||||||
|
|
||||||
|
#### 3.2.3 拼多多(两段式)
|
||||||
|
|
||||||
|
```text
|
||||||
|
pdd_order_sync 定时任务(默认 20s / pageSize 10)
|
||||||
|
→ pdd-client 拉订单(Cookie + anti-content)
|
||||||
|
→ upsert pdd_raw_orders(UNIQUE(order_sn))
|
||||||
|
→ processPendingPddRawOrders 逐条消费
|
||||||
|
→ pdd-order-mapper 归一化
|
||||||
|
→ upsertOrderFromSource
|
||||||
|
→ sync_status: pending → synced / ignored / failed
|
||||||
|
→ 后台可对 failed 的原始单做 replay
|
||||||
|
```
|
||||||
|
|
||||||
|
设计意图:原始报文与业务订单分离,失败可重放,不会因为映射问题丢单。断路器与 Cookie 过期识别在 `pdd-sync-circuit-breaker.ts` / `isPddCookieExpiredError`。
|
||||||
|
|
||||||
|
#### 3.2.4 抖音码速达(推送 → 建单 → 发券 → 领取)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant D as 抖音码速达
|
||||||
|
participant N as notify-service
|
||||||
|
participant O as order-service
|
||||||
|
participant C as certificate-service
|
||||||
|
D->>N: POST /message?topic&pub_time&sign(原始 body)
|
||||||
|
N->>N: 验签(data 原始字符串)
|
||||||
|
N->>N: eventId = f(topic, pub_time, data)
|
||||||
|
N->>N: insertWebhookEventOnce(先落库去重)
|
||||||
|
alt topic = trade_TradePaid
|
||||||
|
N->>D: 拉订单详情(网关)
|
||||||
|
N->>O: upsertOrderFromSource
|
||||||
|
N->>C: 生成 / 同步 DYC 券(redeem_certificates)
|
||||||
|
N->>D: 推送给买家的领取链接
|
||||||
|
else trade_TradeCanceled / refund_RefundSuccess
|
||||||
|
N->>C: 券失效 + 订单状态收敛
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
- 抖音 `sku_order_list[].code` 是**内部 SKU**,`product_name` 是引流标题;`sku_code` 恒存内部 SKU,标题只进 `snapshot.productTitle`,永不参与匹配。
|
||||||
|
- 券表 `redeem_certificates` 自带状态机 `issued / synced / verified / invalid` 与同步状态 `pending / success / failed`,核销走 `coupons.verifyV3`。
|
||||||
|
- **已知缺陷**:`webhook_events` 先插入后处理,处理失败时平台重推会被判 `duplicate` 丢弃(详见风险清单)。
|
||||||
|
|
||||||
|
### 3.3 载体判定(本系统最关键的一步)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["order_item"] --> B{"来源"}
|
||||||
|
B -->|91 / 快手| C["productNo → affiliate-dash sku 映射<br/>常量表 + 后台 skuMapping 覆盖"]
|
||||||
|
B -->|抖音| D["内部 SKU → affiliate-dash sku 映射"]
|
||||||
|
C --> E["resolveFulfillmentRoute"]
|
||||||
|
D --> E
|
||||||
|
E --> F{"路由顺序"}
|
||||||
|
F --> G["1. 履约路由总开关<br/>关闭 = 停止自动履约"]
|
||||||
|
F --> H["2. 指定匹配(显式映射)"]
|
||||||
|
F --> I["3. 商品名规则 exact / contains"]
|
||||||
|
F --> J["4. 内部 SKU 规则"]
|
||||||
|
F --> K["5. 全局执行器优先级"]
|
||||||
|
F --> L["6. unmatchedExecutorKey(生产为空)"]
|
||||||
|
G --> M["snapshot.fulfillmentRoute<br/>selectedExecutorKey / reason / candidates / skipped"]
|
||||||
|
H --> M
|
||||||
|
I --> M
|
||||||
|
J --> M
|
||||||
|
K --> M
|
||||||
|
L --> M
|
||||||
|
M --> N{"selectedExecutorKey"}
|
||||||
|
N -->|affiliate_dash| O["isConfigured=true<br/>建自动履约任务"]
|
||||||
|
N -->|manual_dispatch| P["isConfigured=true<br/>建人工任务"]
|
||||||
|
N -->|空| Q["isConfigured=false<br/>待补全 / 交给发单层判定"]
|
||||||
|
```
|
||||||
|
|
||||||
|
判定结果全部写进 `order_items.item_snapshot_json`:
|
||||||
|
|
||||||
|
| 快照键 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `fulfillmentRoute` | `selectedExecutorKey`、`reason`、`candidates`、`skipped`、`matchedRule` |
|
||||||
|
| `classification`(抖音) | `channel: affiliate_dash / unmatched`、`canonicalSku` |
|
||||||
|
| `affiliateDash` | 命中的 `sku`、`productNo`、`skuName`、`matchMode` |
|
||||||
|
| `isConfigured` | 是否已配置可执行载体 |
|
||||||
|
| `productTitle`(抖音) | 引流长标题(只读展示) |
|
||||||
|
| `kuaishouSendCode` | 快手 send-code 补全的卖家/SKU 上下文 |
|
||||||
|
|
||||||
|
**载体互斥守护**:`sync-worker-orders-service.isAffiliateDashCarrierSnapshot()` 会跳过 `classification.channel === 'affiliate_dash'` 的订单项,避免自动发货与工单双通道同时消费一个订单项。
|
||||||
|
|
||||||
|
### 3.4 履约任务与领取链接
|
||||||
|
|
||||||
|
1. `syncDeliveryTasksForOrder` 对每个订单项按 `quantity` 补建 `unit_index = 1..n`,重复执行只补缺口。
|
||||||
|
2. 执行器来自 `snapshot.fulfillmentRoute`:
|
||||||
|
- `affiliate_dash` → 绑定 `fulfillment_profiles.profile_key='affiliate_dash'`,`requires_claim=true`,初始状态 `paid`;
|
||||||
|
- `manual_dispatch` → `profile_key='manual_review'`,初始状态 `manual_review`;
|
||||||
|
- 未支付 → `pending_payment`。
|
||||||
|
3. 已支付订单立即 `preparePaidFulfillmentTask`(执行器注册表:`affiliate_dash` / `manual` / `legacy-retired` 只读桩)。
|
||||||
|
4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`),`claimUrl = claimBaseUrl + '/' + token`。
|
||||||
|
5. 抖音走独立基址 `claimLinkBase`(后台配置),**刻意不回退** `orders.claimBaseUrl`。
|
||||||
|
6. `orders.claim_status`(迁移 099)= `active / paused / closed`,用于暂停或关闭整单兑换,是后台处置链接失效问题的新开关。
|
||||||
|
|
||||||
|
### 3.5 affiliate-dash 自动履约
|
||||||
|
|
||||||
|
```text
|
||||||
|
建任务(executor=affiliate_dash,requires_claim=true)
|
||||||
|
→ preparePaidFulfillmentTask:调 affiliate-dash 建单,拿到 orderNo / bindUrl / qrUrl
|
||||||
|
→ 生成 claim token 与 claimUrl(91 查询即可返回 20)
|
||||||
|
→ 建单成功时即核销快手电子凭证(consumeStatus=success;失败只告警)
|
||||||
|
→ 买家在领取页:查 delivery → 填 UID → bind → 轮询 bind-result → submit
|
||||||
|
→ affiliate-dash 回调 order.shipping.updated(delivered)
|
||||||
|
→ 任务 completed / redeemed;回调只做状态收敛与兜底补核销
|
||||||
|
```
|
||||||
|
|
||||||
|
任务上下文固定形状 `context_json.affiliateDash`(`AffiliateDashFlow`),包含 `sku / orderNo / clientOrderNo / orderStatus / canShip / bindUrl / qrUrl / bound / submitStatus / consumeStatus / lastSyncedAt`。
|
||||||
|
|
||||||
|
### 3.6 买家侧三个页面
|
||||||
|
|
||||||
|
| 入口 | 路由 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 主站领取页 | `/#/claim/:token` | 快手渠道主路径,按 `executorKey` 渲染不同步骤 |
|
||||||
|
| H5 领取页 | `/h5/:orderNo`(BrowserRouter 分支) | 抖音等按订单号承接,`/api/v1/claim/h5/...` |
|
||||||
|
| 兑换页 | `/redeem` | 抖音 DYC 券兑换/核销 |
|
||||||
|
| 补资料页 | `/collect` | 待补全订单资料收集(**仅凭订单号,无第二因子**) |
|
||||||
|
|
||||||
|
领取页快照与轮询逻辑在 `pages/claim/claim-snapshot.ts`、`claim-poll.ts`;`affiliate-dash` 的具体步骤在 `ClaimAffiliateDashSteps.tsx`(查 delivery → UID → bind → 轮询 → submit)。
|
||||||
|
|
||||||
|
### 3.7 打手众包:从匹配到结算
|
||||||
|
|
||||||
|
#### 3.7.1 接单模板匹配(product-match 引擎)
|
||||||
|
|
||||||
|
匹配引擎是**纯函数**(`services/product-match/engine.ts`),来源差异只在适配器:
|
||||||
|
|
||||||
|
| 概念 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 条目 `entry` | 一条接单模板(`work_product_rules`)或其映射(`work_product_rule_mappings`)编译而来 |
|
||||||
|
| 作用域 | `sourceKeys`、`shopKeys`、`qtyMin/qtyMax`:配了就必须命中 |
|
||||||
|
| 条件组 | `all`(全部命中)、`any`(至少一条)、`bonus`(命中加分、不中不淘汰) |
|
||||||
|
| 匹配模式 | `exact`、`contains`、`series`(剥离「N个」后比系列,兼容秘钥/密钥)、`series-exact` |
|
||||||
|
| 权重 | `skuId/relSkuId 900 > skuText/skuNick 800 > series 600 > skuCode 500 > itemId 300 > itemTitle 200`(contains 降档,文本另有「具体度」加成上限 80) |
|
||||||
|
| 结果 | 唯一最高分 → `matched`;并列最高 → `ambiguous`(**不猜**);无候选 → `unmatched` |
|
||||||
|
|
||||||
|
每次匹配都会写 `work_product_match_logs`(含 `candidates_json`、`raw_payload_json`),后台有「匹配测试 / 匹配日志 / 日志清理」三个入口。
|
||||||
|
|
||||||
|
#### 3.7.2 建单规则
|
||||||
|
|
||||||
|
```text
|
||||||
|
for each order_item:
|
||||||
|
if snapshot.classification.channel == 'affiliate_dash' → skip(载体已被自动发货占用)
|
||||||
|
if 该订单项已有工单 → skip(already_exists,不纠正旧单)
|
||||||
|
match = 匹配引擎
|
||||||
|
无命中/歧义 → skip + 日志
|
||||||
|
命中但 auto_create=false 且 autoOnly → skip
|
||||||
|
platform_order_id 已挂在**其他**订单下 → skip(防撞号)
|
||||||
|
建单:
|
||||||
|
status = 素材必填项齐全 ? unassigned : pending_material
|
||||||
|
reward = 单价 × SKU 数量(unit_price_fen)或固定 reward_amount
|
||||||
|
sharing(拼单)按模板 sharing_total_quantity / unit_reward 计算
|
||||||
|
acceptance_mode = normal / friend_gift(好友赠送)
|
||||||
|
timeout_minutes + timeout_policy(reopen / cancel_release / cancel_deduct)
|
||||||
|
material = 来源摘要 +(抖音)已有券快照
|
||||||
|
pending_material → 生成后台「待补素材」通知
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.7.3 工单生命周期
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending_material
|
||||||
|
pending_material --> unassigned: 素材补全
|
||||||
|
pending_material --> cancelled: 取消
|
||||||
|
unassigned --> open: 发布到大厅
|
||||||
|
unassigned --> in_progress: 后台指派
|
||||||
|
open --> in_progress: 打手抢单 / 拼单参与
|
||||||
|
in_progress --> pending_acceptance: 打手提交验收
|
||||||
|
pending_acceptance --> accepted: 验收通过(结算)
|
||||||
|
pending_acceptance --> problem: 发起问题单
|
||||||
|
problem --> open: 退回大厅
|
||||||
|
problem --> cancelled: 取消
|
||||||
|
in_progress --> open: 超时按 reopen 策略
|
||||||
|
in_progress --> cancelled: 超时按 cancel 策略
|
||||||
|
open --> cancelled: 取消
|
||||||
|
accepted --> [*]
|
||||||
|
cancelled --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **抢单**:单事务内 `ensureWallet → 校验活跃单上限 → 校验可用余额 ≥ 押金 → 条件更新 `WHERE status='open' AND assigned_worker_id IS NULL` → 冻结押金(`deposit_freeze` 流水)→ 写 `work_order_events`。
|
||||||
|
- **指派**:先 `SELECT … FOR UPDATE` 锁工单,再锁钱包(与抢单顺序相反,见风险 R4)。
|
||||||
|
- **拼单**:`work_order_shares` 一份一行,`UNIQUE(work_order_id, worker_id)`;份额状态 `joined → submitted → accepted / cancelled`;母单可长期 `open`,最后一个份额验收后母单转 `accepted`。
|
||||||
|
- **好友赠送**:`acceptance_mode='friend_gift'`,`gift_phase`:`material_required → material_submitted → friend_countdown → gift_ready`,冷却结束才能提交赠送凭证;确认加好友后不再退押金(防套利)。
|
||||||
|
- **超时**:定时任务 `work-order-timeout`(默认 60s,scanLimit 50)扫描 `deadline_at`,按 `timeout_policy`(`reopen` 退回大厅 / `cancel_release` 取消并退押金 / `cancel_deduct` 取消并扣押金)处置;已确认加好友的赠送单即使配 `cancel_release` 也会转为扣款。超时只写 `timeout_*` 事件并释放寄卖现货,**不直接改等级计数**(等级处罚来自退单审核通过次数,见 3.7.6)。
|
||||||
|
- **验收与结算**(`acceptWorkOrderAndSettle`):锁工单与份额 → 报酬入账(可延迟解冻)→ 释放/延迟解冻押金(`worker_deposit_unfreezes` 队列)→ 抵扣售后欠款 → 升级等级 → 写 `worker_wallet_ledgers`。
|
||||||
|
- **售后**:`worker_after_sales_cases`(问题类型、投诉、验收快照、处置动作、退款)+ `worker_after_sales_debts`(欠款余额)+ `worker_after_sales_case_events`(时间线)。
|
||||||
|
|
||||||
|
#### 3.7.4 打手资金模型
|
||||||
|
|
||||||
|
| 表 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `worker_wallets` | 可用余额、冻结押金、累计入账、累计结算、待解冻、提现冻结 |
|
||||||
|
| `worker_wallet_ledgers` | 只追加流水,13 种类型(见下) |
|
||||||
|
| `worker_finance_requests` | 充值 / 提现申请与审核 |
|
||||||
|
| `worker_withdrawal_accounts` | 提现账户(微信 / 支付宝二维码) |
|
||||||
|
| `worker_deposit_unfreezes` | 延迟解冻队列(押金、报酬两类) |
|
||||||
|
|
||||||
|
实测流水类型分布(本地库):`reward_settlement 108,623`、`sharing_reward 34,295`、`deposit_freeze 8,283`、`deposit_release 6,705`、`withdraw_paid 6,381`、`withdraw_freeze 5,833`、`deposit_deduction 920`、`deposit_pending_unfreeze / deposit_unfreeze 各 621`、`manual_credit 398`、`withdraw_release 121`、`after_sales_balance_deduction 20`、`after_sales_debt_offset 9`。
|
||||||
|
|
||||||
|
#### 3.7.5 等级、权限与风控
|
||||||
|
|
||||||
|
- `worker_levels.permission_json` 承载:免押额度、最大同时接单量、大厅可见延迟、自动验收(VIP)。
|
||||||
|
- `worker_level_logs` 记录升降级;`worker_users.is_level_locked` 可锁定等级。
|
||||||
|
- `worker_sessions` 会话落库 + `session_version`,设备数与设备类型可限制。
|
||||||
|
- `worker_risk_events` 记录刷单/异常行为与处置(ban / unban / release),`middleware/worker-risk-control.ts` 做抢单前置限流与拦截。
|
||||||
|
- 提现黑名单:`worker_users.is_withdrawal_blacklisted` + 原因 + 时间。
|
||||||
|
|
||||||
|
> 抢单/拼单/好友赠送/验收/结算的逐行细节(含押金矩阵、资金等式、退单处罚规则)见 [打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md](打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md)。
|
||||||
|
|
||||||
|
### 3.8 寄卖仓库
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["卖家提报 consignment_submissions<br/>游戏/系统/区服/系列/钻级/璀璨/要价/凭证"] --> B{"客服审核"}
|
||||||
|
B -->|驳回| C["rejected(可改后重提)"]
|
||||||
|
B -->|通过| D["入库 consignment_inventories<br/>inventory_no = INV…<br/>status=in_stock"]
|
||||||
|
D --> E{"订单匹配"}
|
||||||
|
E -->|规格匹配| F["占用 consumed<br/>bound_order_id / bound_work_order_id"]
|
||||||
|
E -->|不匹配| D
|
||||||
|
F --> G{"履约方式"}
|
||||||
|
G -->|internal_stock| H["内部现货交付<br/>客服操作"]
|
||||||
|
G -->|worker_deposit| I["指派打手代练<br/>冻结打手押金"]
|
||||||
|
H --> J["验收 → 释放发布押金 + 结货款"]
|
||||||
|
I --> J
|
||||||
|
F -.->|撤销| K["revoke:打手押金 + 卖家押金<br/>auto / refund / forfeit / keep_frozen"]
|
||||||
|
```
|
||||||
|
|
||||||
|
匹配口径(`consignment-matching-service.ts`):
|
||||||
|
|
||||||
|
- **系统 / 区服 / 系列必须一致**(区服「互通」、系统「通用」视为通配);
|
||||||
|
- **钻级与璀璨低配不充高配**:号主 2 钻不能配 1 钻库存;
|
||||||
|
- 三态比对:`match` / `mismatch` / `unknown`,`unknown`(任一侧没值)**不算精确匹配**,只能进部分匹配候选;
|
||||||
|
- 人工指定时用 `classifyConsignmentMatch` 返回 `conflicts`,把「差在哪」显式告诉客服;
|
||||||
|
- 建议价从接单模板反推(`work_product_rules` 商品名 → 规格解析 → 最接近档位兜底,命中多条取最高价)。
|
||||||
|
|
||||||
|
卖家视角的单一阶段由 `deriveConsignmentItemStage` 从「提报 + 库存 + 工单」三套状态推导:`pending_review → in_stock → matched_waiting_friend → gift_cooldown → gift_ready → gift_submitted → fulfilling → pending_acceptance → completed`,终态 `rejected / archived / cancelled`。
|
||||||
|
|
||||||
|
押金策略(`domain/consignment-deposit-policy.ts`):
|
||||||
|
|
||||||
|
| 场景 | `auto` 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 打手履约押金撤销 | 全额退还;要扣必须显式 `forfeit` |
|
||||||
|
| 卖家发布押金撤销 | 作废(下架)→ 退还;回库(继续挂卖)→ 保持冻结 |
|
||||||
|
| 显式覆盖 | 必须填原因(`isDepositHandlingOverridden` 校验) |
|
||||||
|
|
||||||
|
### 3.9 客服备用金台账(迁移 100)
|
||||||
|
|
||||||
|
```text
|
||||||
|
公司 → 主管池(fund_accounts.account_type='supervisor')
|
||||||
|
主管 → 客服账户('agent',responsible_supervisor_id 指向主管)
|
||||||
|
客服 → 支出明细(manual_expenses:平台充值 / 采购 / 赔付 / 运费 / 其他)
|
||||||
|
```
|
||||||
|
|
||||||
|
四条硬约束(写在建表注释里):
|
||||||
|
|
||||||
|
1. 余额只能由 `fund_entries` 变动,禁止手工改;
|
||||||
|
2. 发放 / 缴回必须同时写主管与客服两条方向相反的流水,共用 `transfer_no`;
|
||||||
|
3. 支出必须同时写客服负数流水与 `manual_expenses` 明细(`UNIQUE(fund_entry_id)`);
|
||||||
|
4. 已记账流水不修改不删除,差异走 `adjustment`。
|
||||||
|
|
||||||
|
对账:`fund_reconciliations`(`pending / confirmed / discrepancy`),DB 级校验 `difference_fen = actual_balance_fen - system_balance_fen`。
|
||||||
|
|
||||||
|
权限点:`fund.view / issue / expense / reconcile / adjust / export`,按角色(admin / finance / operator / support)分别在迁移里授权。
|
||||||
|
|
||||||
|
### 3.10 后台治理
|
||||||
|
|
||||||
|
| 能力 | 实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 登录 | 用户名密码 + 短信二次(可选) + 设备认证(`admin_user_devices` / `admin_device_challenges`) |
|
||||||
|
| 会话 | `admin_sessions` 落库 + `session_version`,可远程踢设备 |
|
||||||
|
| RBAC | 4 个内置角色(admin / finance / operator / support)+ 29 个权限点 + 72 条角色授权 |
|
||||||
|
| 审计 | `admin_audit_logs`(actor / action / target / payload),实测 55 万行 |
|
||||||
|
| 通知 | `admin_notifications`(类型 / 优先级 / 去重键 / 提醒次数 / 可见性 / 声音) |
|
||||||
|
| 实时 | `realtime_events` + SSE,后台列表与工单详情增量刷新 |
|
||||||
|
| 定时任务 | 后台可视化管理(启用、间隔、立即执行、运行状态) |
|
||||||
|
|
||||||
|
### 3.11 定时任务
|
||||||
|
|
||||||
|
| id | 类型 | 默认间隔 | 作用 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `work-order-timeout` | `work_order_timeout` | 60s(scanLimit 50) | 工单超时处置 + 打手超时计数 |
|
||||||
|
| `deposit-unfreeze` | `deposit_unfreeze` | 60s(scanLimit 100) | 押金/报酬延迟解冻出队 |
|
||||||
|
| `pdd_order_sync` | `pdd_order_sync` | 20s(pageSize 10) | 拼多多订单拉取 |
|
||||||
|
| `data-retention` | `data_retention` | 24h | 清理原始报文、匹配日志、任务事件、webhook、审计;回填 `source_order_amount_fen` |
|
||||||
|
|
||||||
|
调度器在进程内(`services/scheduler/scheduler-service.ts`),同一任务运行中会跳过本轮;配置存 `app_config_entries.scheduled_jobs`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、状态机一页汇总
|
||||||
|
|
||||||
|
### 4.1 订单 `orders`
|
||||||
|
|
||||||
|
| 字段 | 取值 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `order_status` | `created / paid / closed / refunded`(另有历史值 `pending_config`、`manual_failed`) | 合并时按优先级只升不降 |
|
||||||
|
| `pay_status` | `unpaid / failed / paid / refunded` | 与 `paid_at` 联动 |
|
||||||
|
| `claim_status` | `active / paused / closed` | 兑换有效性开关(迁移 099) |
|
||||||
|
|
||||||
|
> 注意:`order_status` **不能代表履约完成**。本地库中 375,432 条 `paid` 订单里,绝大多数并没有「订单级完成」状态,履约进度只能看 `fulfillment_tasks.task_status` 或 `work_orders.status`。
|
||||||
|
|
||||||
|
### 4.2 履约任务 `fulfillment_tasks`
|
||||||
|
|
||||||
|
`pending_payment → paid → link_generated → claimed → pending_binding_prepare → waiting_binding → role_confirmed → redeeming → dispatched_pending_return → completed / redeemed`,旁支 `retry_pending`、`manual_review`、`failed`、`expired`、`closed`。
|
||||||
|
|
||||||
|
状态转移约束定义在 `domain/task-status.ts`(`TASK_TRANSITIONS`),终态 `completed / redeemed / expired / closed`。
|
||||||
|
|
||||||
|
### 4.3 接单工单 `work_orders` / 份额 `work_order_shares`
|
||||||
|
|
||||||
|
- 工单:`pending_material / unassigned / open / in_progress / pending_acceptance / problem / accepted / cancelled`(终态 accepted、cancelled)。
|
||||||
|
- 份额:`joined / submitted / accepted / cancelled`。
|
||||||
|
- 好友赠送额外有 `gift_phase`:`material_required / material_submitted / friend_countdown / gift_ready`。
|
||||||
|
|
||||||
|
### 4.4 寄卖
|
||||||
|
|
||||||
|
| 对象 | 取值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `consignment_submissions.status` | `pending_review / approved / rejected` |
|
||||||
|
| `consignment_inventories.status` | `in_stock / consumed / archived` |
|
||||||
|
| `consignment_inventories.allocation_status` | `待安排 / 已安排`(中文枚举,历史遗留) |
|
||||||
|
| `source_type` | `manual / worker_portal / recycle` |
|
||||||
|
| 卖家可感知阶段 | `pending_review / in_stock / matched_waiting_friend / gift_cooldown / gift_ready / gift_submitted / fulfilling / pending_acceptance / completed / rejected / archived / cancelled` |
|
||||||
|
|
||||||
|
### 4.5 凭证与券
|
||||||
|
|
||||||
|
| 对象 | 取值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `kuaishou_industry_vouchers.status` | `UNUSED / CONSUMED / DESTROYED` |
|
||||||
|
| `kuaishou_industry_vouchers.send_callback_status` | `success / failed / pending`(回调快手发码结果) |
|
||||||
|
| `redeem_certificates.status` | `issued / synced / verified / invalid` |
|
||||||
|
| `redeem_certificates.sync_status` | `pending / success / failed` |
|
||||||
|
| `kuaishou_cloud_task_states.ticket_status` | 快手云端取号/绑号状态(配合 `dispatch_status`、`consume_status`) |
|
||||||
|
|
||||||
|
### 4.6 资金与售后
|
||||||
|
|
||||||
|
| 对象 | 取值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `worker_finance_requests.status` | `pending / approved / rejected / cancelled` |
|
||||||
|
| `worker_finance_requests.request_type` | `recharge / withdraw` |
|
||||||
|
| `worker_deposit_unfreezes.status` | 待解冻 / 已释放(`released_at` 落地) |
|
||||||
|
| `worker_after_sales_cases.status` | 待响应 / 已响应 / 已处置 / 已退款(配合 `resolution_action`) |
|
||||||
|
| `worker_after_sales_debts.status` | 未结清 / 已结清 |
|
||||||
|
| `fund_entries.entry_type` | `opening / funding / issue / expense / return / adjustment` |
|
||||||
|
| `fund_reconciliations.status` | `pending / confirmed / discrepancy` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、数据库设计
|
||||||
|
|
||||||
|
### 5.1 总览(本地库实读)
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 业务表 | **59** 张(+ `schema_migrations`) |
|
||||||
|
| 迁移文件 | 103 个(001 基线 + 002–103 增量),全部已应用 |
|
||||||
|
| 外键 | 91 |
|
||||||
|
| 唯一约束 | 29 |
|
||||||
|
| CHECK 约束 | 28 |
|
||||||
|
| 索引 | 268(含主键与唯一索引) |
|
||||||
|
| 主键 | 绝大多数是 `BIGSERIAL`;钱包、工单备注、工单已读等关系表用复合主键 |
|
||||||
|
| 时间类型 | 统一 `TIMESTAMPTZ`(不存本地时间) |
|
||||||
|
| 金额类型 | 打手侧 `INTEGER`(分);`orders.total_amount` 也是 `INTEGER`;资金台账、PDD 与工单来源金额用 `BIGINT`(分);**没有 `NUMERIC`,也没有浮点金额** |
|
||||||
|
| JSON | 变化快的扩展信息一律 `JSONB`(快照、上下文、凭证原文),不建宽表 |
|
||||||
|
|
||||||
|
设计风格非常一致:**关系表 + `*_json` 扩展 + 只追加流水**。代价是 JSONB 里的字段无法被 DB 约束保护(如 `context_json.affiliateDash` 全靠 TS 类型)。
|
||||||
|
|
||||||
|
### 5.2 分域表清单
|
||||||
|
|
||||||
|
#### A. 订单与履约主链路(12)
|
||||||
|
|
||||||
|
| 表 | 行数(估) | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `orders` | 375,459 | 订单骨架;`UNIQUE(provider, platform, shop_id, platform_order_id)` |
|
||||||
|
| `order_items` | 375,263 | 订单项;`item_snapshot_json` 是载体判定的全部证据 |
|
||||||
|
| `fulfillment_tasks` | 107,111 | 履约单元;`UNIQUE(order_item_id, unit_index)`、`UNIQUE(task_no)` |
|
||||||
|
| `claim_tokens` | 107,083 | 领取 token;`UNIQUE(task_id)`、`UNIQUE(token)` |
|
||||||
|
| `task_events` | 1,310,742 | 任务事件流水(最大表) |
|
||||||
|
| `task_runtime_contexts` | 45,976 | 运行时上下文(登录态、角色、截图、产物) |
|
||||||
|
| `fulfillment_profiles` | 4 | 履约档案(affiliate_dash / manual_review / legacy 桩) |
|
||||||
|
| `fulfillment_profile_requirements` | 少量 | 档案的凭证要求 |
|
||||||
|
| `pdd_raw_orders` | 5,068 | PDD 原始单,`UNIQUE(order_sn)`,`sync_status` 四态 |
|
||||||
|
| `webhook_events` | 110,233 | 外部回调去重表(`provider + event_id`) |
|
||||||
|
| `short_links` | 95 | 短链(目标 URL 哈希 + 访问计数) |
|
||||||
|
| `file_assets` | 289,260 | 对象存储资产(缩略图/中图/URL/场景/上传者) |
|
||||||
|
|
||||||
|
#### B. 凭证(3)
|
||||||
|
|
||||||
|
| 表 | 行数(估) | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `kuaishou_industry_vouchers` | 403,299 | 快手电子凭证;`UNIQUE(voucher_code)`、`UNIQUE(oid, unit_index)` |
|
||||||
|
| `kuaishou_cloud_task_states` | 46,894 | 快手云端取号/绑号/核销状态(一任务一行) |
|
||||||
|
| `redeem_certificates` | 1,541 | 抖音 DYC 券,含平台券号、平台状态、同步错误 |
|
||||||
|
|
||||||
|
#### C. 接单平台(25)
|
||||||
|
|
||||||
|
| 表 | 行数(估) | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `worker_users` | 1,905 | 打手账号(30 列:等级、邀请、改名卡、冻结、黑名单、寄卖权限、等级锁) |
|
||||||
|
| `worker_levels` / `worker_level_logs` | 6 / 133 | 等级定义(`permission_json`)与升降级流水 |
|
||||||
|
| `worker_sessions` | 32,727 | 会话(设备、IP、UA、过期、吊销) |
|
||||||
|
| `worker_sms_codes` | 5,206 | 短信验证码(哈希、失败次数) |
|
||||||
|
| `worker_wallets` | 1,905 | 钱包(可用、冻结押金、待解冻、提现冻结、累计) |
|
||||||
|
| `worker_wallet_ledgers` | 172,830 | 钱包流水(13 种 `ledger_type`) |
|
||||||
|
| `worker_finance_requests` | 6,769 | 充值/提现申请(含冻结金额与审核人) |
|
||||||
|
| `worker_withdrawal_accounts` | 776 | 提现账户(微信/支付宝二维码 JSON) |
|
||||||
|
| `worker_deposit_unfreezes` | 621 | 延迟解冻队列(`fund_type` 区分押金/报酬) |
|
||||||
|
| `work_categories` | 30 | 工单分类(支持父级与图标) |
|
||||||
|
| `work_orders` | 156,043 | 工单主表(44 列,全库最宽) |
|
||||||
|
| `work_order_events` | 1,258,811 | 工单事件流水 |
|
||||||
|
| `work_order_shares` | 41,383 | 拼单份额 |
|
||||||
|
| `worker_work_order_notes` | 13,604 | 打手备注(一工单一打手一行) |
|
||||||
|
| `worker_work_order_views` | 147,356 | 已读/素材已读 |
|
||||||
|
| `worker_order_cancel_requests` | 953 | 打手取消申请与审核 |
|
||||||
|
| `worker_order_feedbacks` | 501 | 工单反馈/申诉 |
|
||||||
|
| `worker_after_sales_cases` | 39 | 售后问题单 |
|
||||||
|
| `worker_after_sales_debts` | 4 | 售后欠款 |
|
||||||
|
| `worker_after_sales_case_events` | 93 | 售后时间线 |
|
||||||
|
| `worker_risk_events` | 209 | 风控事件与处置 |
|
||||||
|
| `work_product_rules` | 710 | 接单模板(29 列:匹配、奖励、押金、拼单、时限、验收模式) |
|
||||||
|
| `work_product_rule_mappings` | 707 | 模板映射(卖家/SKU/标题,`sku_match_mode`) |
|
||||||
|
| `work_product_match_logs` | 10,000 | 匹配诊断日志(保留量受清理任务控制) |
|
||||||
|
|
||||||
|
#### D. 寄卖(2)
|
||||||
|
|
||||||
|
| 表 | 行数(估) | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `consignment_submissions` | 877 | 提报单(`submission_no` 唯一、要价、押金、审核结果指向库存) |
|
||||||
|
| `consignment_inventories` | 784 | 库存(`INV…` 唯一、38 列:规格、凭证、绑定订单/工单、卖家、指派打手) |
|
||||||
|
|
||||||
|
#### E. 后台与权限(11)
|
||||||
|
|
||||||
|
`admin_users`(40)、`admin_sessions`(3,077)、`admin_login_logs`(4,936)、`admin_roles`(4)、`admin_permissions`(29)、`admin_role_permissions`(72)、`admin_audit_logs`(556,360)、`admin_notifications`(166,883)、`admin_sms_codes`、`admin_user_devices`、`admin_device_challenges`。
|
||||||
|
|
||||||
|
#### F. 配置与实时(2)
|
||||||
|
|
||||||
|
`app_config_entries`(18 行,全部平台/业务配置)、`realtime_events`(600,滚动保留)。
|
||||||
|
|
||||||
|
#### G. 资金台账(4)
|
||||||
|
|
||||||
|
`fund_accounts`、`fund_entries`、`manual_expenses`、`fund_reconciliations`(本地均为空表,属于新落地模块)。
|
||||||
|
|
||||||
|
### 5.3 核心表字段速查
|
||||||
|
|
||||||
|
#### `orders`
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | bigserial PK | |
|
||||||
|
| `provider` / `platform` | text NOT NULL | 例如 `91kaquan` / `kuaishou` |
|
||||||
|
| `shop_id` / `shop_name` | text | 店铺键空间(参与唯一键) |
|
||||||
|
| `platform_order_id` | text NOT NULL | 平台单号 |
|
||||||
|
| `order_status` / `pay_status` | text | 见 4.1 |
|
||||||
|
| `buyer_id` / `buyer_name` / `receiver_contact` | text | 买家信息 |
|
||||||
|
| `total_amount` | integer | **用户付款金额(分)**,三家口径已统一为「实付」;91 = `maxAmount` |
|
||||||
|
| `currency` | text | 默认 CNY |
|
||||||
|
| `raw_payload_json` | jsonb | 原始报文(保留期由 data-retention 控制) |
|
||||||
|
| `paid_at` | timestamptz | 支付/成团时间,建单时用作 `created_at` 优先值 |
|
||||||
|
| `claim_status` / `claim_status_reason` / `claim_status_updated_at` | text / text / timestamptz | 兑换有效性开关 |
|
||||||
|
|
||||||
|
索引(12 个)覆盖:唯一键、`(provider, platform, order_status, created_at)`、平台单号、店铺筛选、claim 状态等(迁移 070/074/086/099)。
|
||||||
|
|
||||||
|
#### `order_items`
|
||||||
|
|
||||||
|
`id`、`order_id`(FK CASCADE)、`sku_code`、`sku_name`、`quantity`、`spec_json`、`item_snapshot_json`。
|
||||||
|
|
||||||
|
没有金额字段,也没有 `canonical_product_id`:**多商品订单无法直接分摊收入**(缺口 C5)。
|
||||||
|
|
||||||
|
#### `fulfillment_tasks`
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `order_id` / `order_item_id` / `unit_index` | 归属与单元序号;`UNIQUE(order_item_id, unit_index)` |
|
||||||
|
| `task_no` | `DT…` 唯一编号 |
|
||||||
|
| `provider / platform / shop_id / shop_name / platform_order_id` | 冗余来源字段(便于按来源查询,避免 join) |
|
||||||
|
| `profile_id` / `executor_key` | 履约档案与执行器 |
|
||||||
|
| `task_status` / `delivery_status` / `result_code` / `result_message` | 主状态机 + 交付状态 |
|
||||||
|
| `claim_token` / `claim_expires_at` | 历史冗余列;实际 token 在 `claim_tokens`(查询时 `LEFT JOIN` 出 `primary_claim_token`) |
|
||||||
|
| `automation_mode` / `requires_claim` / `user_action_status` | 自动/人工、是否需要用户领取 |
|
||||||
|
| `attempt_count` / `last_error` | 重试 |
|
||||||
|
| `context_json` | 执行器上下文(`affiliateDash`、`kuaishouIndustryVoucher` 等) |
|
||||||
|
| `claimed_at` / `role_confirmed_at` / `redeemed_at` | 关键时间点 |
|
||||||
|
|
||||||
|
#### `work_orders`(44 列,按用途分组)
|
||||||
|
|
||||||
|
| 组 | 列 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标识 | `id`、`work_order_no`(唯一)、`order_id`、`order_item_id`、`task_id`(历史遗留,已注释废弃)、`platform_order_id`、`product_rule_id`、`category_id` |
|
||||||
|
| 商品 | `product_name` |
|
||||||
|
| 状态 | `status`、`acceptance_mode`、`gift_phase`、`admin_note`、`problem_note` |
|
||||||
|
| 金额 | `reward_amount`、`required_deposit_amount`、`deposit_threshold_amount`、`source_order_amount_fen`、`sharing_unit_reward` |
|
||||||
|
| 归属 | `assigned_worker_id`、`last_assigned_worker_id` |
|
||||||
|
| 拼单 | `sharing_enabled`、`sharing_total_quantity`、`sharing_unit_reward` |
|
||||||
|
| 时限 | `deadline_at`、`timeout_minutes`、`timeout_policy` |
|
||||||
|
| 好友赠送 | `friend_added_at`、`gift_available_at`、`gift_cooldown_hours`、`gift_cooldown_minutes` |
|
||||||
|
| 内容 | `material_json`、`requirement_json`、`acceptance_json`、`draft_acceptance_json`、`booster_material_json`、`worker_note` |
|
||||||
|
| 大厅 | `hall_queued_at`、`pinned_at`、`published_at` |
|
||||||
|
| 时间 | `assigned_at`、`submitted_at`、`accepted_at`、`created_at`、`updated_at` |
|
||||||
|
|
||||||
|
21 个索引,覆盖:状态+时间、分类+状态、店铺筛选、大厅队列、统计口径、关键词查询等(迁移 031/047/051/073/083/085/087/088/090/103)。
|
||||||
|
|
||||||
|
#### `worker_wallets` / `worker_wallet_ledgers`
|
||||||
|
|
||||||
|
`worker_wallets`:`worker_id` PK/FK、`available_amount`、`frozen_deposit_amount`、`pending_unfreeze_amount`、`frozen_withdraw_amount`、`total_credited_amount`、`total_settled_amount`。
|
||||||
|
|
||||||
|
`worker_wallet_ledgers`:`worker_id`、`ledger_type`、`amount`(有符号)、`balance_after`、`frozen_after`、`related_work_order_id`、`audit_status`、`note`、`payload_json`。
|
||||||
|
|
||||||
|
> 余额是**可变字段**,没有「由流水重算」的校验;这是财务化的第一道缺口(见第七节)。
|
||||||
|
|
||||||
|
#### `consignment_inventories`(38 列)
|
||||||
|
|
||||||
|
`inventory_no`、`game_name`、`category_id`、`item_name`、`system`、`server_zone`、`series`、`diamond_level`、`brilliance_count`、`cost_price_fen`、`seller_*`(联系人/游戏 ID/收款/手机)、`credentials_json`(**游戏账号密码**)、`screenshots_json`、`status`、`allocation_status`、`bound_order_id`、`bound_work_order_id`、`bound_platform_order_id`、`shop_name`、`source_type`、`source_submission_id`、`worker_id`、`assigned_worker_id`、`deposit_amount_fen`、`inbound_at`、`consumed_at`。
|
||||||
|
|
||||||
|
#### `fund_*`
|
||||||
|
|
||||||
|
- `fund_accounts`:`account_no` 唯一、`account_type`(supervisor/agent)、`owner_admin_user_id`、`responsible_supervisor_id`(自引用)、`balance_fen >= 0`、`status`。
|
||||||
|
- `fund_entries`:`entry_no` 唯一、`account_id`、`entry_type`(6 种)、`amount_fen <> 0`(有符号)、`balance_after_fen`、`transfer_no`、`counterparty_account_id`、`reconciliation_id`、`evidence_json`。
|
||||||
|
- `manual_expenses`:`expense_no` 唯一、`account_id`、`amount_fen > 0`、`category`(5 种)、`payee_type`(4 种)、`order_id`/`order_item_id`、`fund_entry_id` 唯一(一支出对一流水)。
|
||||||
|
- `fund_reconciliations`:`period_start/end`、`system_balance_fen`、`actual_balance_fen`、`difference_fen`(CHECK 等于差值)、`status`、`confirmed_by`。
|
||||||
|
|
||||||
|
### 5.4 关系图(核心链路)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
orders ||--o{ order_items : "1:N"
|
||||||
|
orders ||--o{ fulfillment_tasks : "1:N"
|
||||||
|
order_items ||--o{ fulfillment_tasks : "1:N (unit_index)"
|
||||||
|
fulfillment_tasks ||--|| claim_tokens : "1:1 (task_id unique)"
|
||||||
|
fulfillment_tasks ||--o{ task_events : "1:N"
|
||||||
|
fulfillment_tasks ||--o| task_runtime_contexts : "1:0..1"
|
||||||
|
fulfillment_tasks ||--o| kuaishou_cloud_task_states : "1:0..1"
|
||||||
|
orders ||--o{ kuaishou_industry_vouchers : "1:N"
|
||||||
|
fulfillment_tasks |o--o{ kuaishou_industry_vouchers : "task_id 可空"
|
||||||
|
orders ||--o{ redeem_certificates : "local_order_id"
|
||||||
|
order_items ||--o| work_orders : "1:0..1"
|
||||||
|
work_orders ||--o{ work_order_shares : "1:N"
|
||||||
|
work_orders ||--o{ work_order_events : "1:N"
|
||||||
|
worker_users ||--|| worker_wallets : "1:1"
|
||||||
|
worker_users ||--o{ worker_wallet_ledgers : "1:N"
|
||||||
|
worker_users ||--o{ worker_sessions : "1:N"
|
||||||
|
worker_users ||--o{ worker_finance_requests : "1:N"
|
||||||
|
work_orders ||--o{ worker_deposit_unfreezes : "1:N"
|
||||||
|
work_orders ||--o{ worker_after_sales_cases : "1:N"
|
||||||
|
worker_after_sales_cases ||--o{ worker_after_sales_debts : "1:N"
|
||||||
|
consignment_submissions ||--o| consignment_inventories : "审核产出"
|
||||||
|
consignment_inventories |o--o{ work_orders : "bound_work_order_id"
|
||||||
|
admin_users ||--o{ fund_accounts : "owner"
|
||||||
|
fund_accounts ||--o{ fund_entries : "1:N"
|
||||||
|
fund_entries ||--|| manual_expenses : "1:1 (fund_entry_id)"
|
||||||
|
fund_accounts ||--o{ fund_reconciliations : "1:N"
|
||||||
|
pdd_raw_orders |o--|| orders : "synced_order_id"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 约束与索引策略(值得记住的几条)
|
||||||
|
|
||||||
|
1. **唯一键即幂等键**:`orders(provider, platform, shop_id, platform_order_id)`、`fulfillment_tasks(order_item_id, unit_index)`、`kuaishou_industry_vouchers(oid, unit_index)`、`pdd_raw_orders(order_sn)`、`work_order_shares(work_order_id, worker_id)`、`fund_entries(entry_no)`、`manual_expenses(fund_entry_id)`。
|
||||||
|
2. **DDL 层不枚举业务状态**:`orders` / `fulfillment_tasks` / `work_orders` 的状态列**没有 CHECK**,状态机只存在于 TS(`domain/*.ts`)。好处是迁移成本低,坏处是脏状态只能靠代码兜底(`pending_config` 就是遗留证据)。寄卖与资金表则加了 CHECK,口径不完全统一。
|
||||||
|
3. **索引高度针对后台查询**:迁移 070/071/073/074/082/084–090 全是后台列表/统计/模糊搜索的覆盖索引,其中 073 用 `pg_trgm` GIN 支撑工单关键词(游戏 ID)搜索。
|
||||||
|
4. **JSONB 不做 GIN**:除搜索字段外,`snapshot/context` 的查询都走「先取行再在应用层判断」,这也是解析逻辑必须集中在少数几个 service 的原因。
|
||||||
|
5. **软删/归档**:业务表基本不物理删除(`ON DELETE CASCADE` 只用于任务→事件、订单→订单项等从属关系),保留靠 `status` 与归档字段。
|
||||||
|
|
||||||
|
### 5.6 配置存储(`app_config_entries`)
|
||||||
|
|
||||||
|
配置不在文件里、也不在环境变量里(环境变量只管连接串与密钥),全部走一张 key-value 表,后台保存后即时生效(部分需要重启或重载调度器):
|
||||||
|
|
||||||
|
| key | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `affiliate_dash` | 自动发货平台配置(baseUrl、商户号、密钥、skuMapping) |
|
||||||
|
| `kuaishou_industry_source` | 快手电子凭证接入(appId、secret、token、店铺) |
|
||||||
|
| `douyin_mashida` | 抖音码速达(uid/secret/baseUrl/claimLinkBase/handledTopics) |
|
||||||
|
| `pdd_source` | 拼多多(店铺、Cookie、同步开关) |
|
||||||
|
| `fulfillment_routing` | 履约路由(总开关、商品名/SKU 规则、全局优先级、unmatchedExecutorKey) |
|
||||||
|
| `scheduled_jobs` | 定时任务配置与间隔 |
|
||||||
|
| `notification` | 通用通知(Bark / WPush) |
|
||||||
|
| `worker_finance` | 打手财务策略(押金、解冻天数、提现规则) |
|
||||||
|
| `worker_product_match` | 匹配引擎开关(`legacyFallbackEnabled`) |
|
||||||
|
| `worker_hall` | 大厅可见性、容量、分类上限 |
|
||||||
|
| `worker_announcement` | 打手端公告 |
|
||||||
|
| `worker_platform_notifications` | 打手端通知模板 |
|
||||||
|
| `worker_risk_control` | 风控阈值与处罚 |
|
||||||
|
| `worker_mock_shops` | 开发/联调假店铺 |
|
||||||
|
| 历史键 | `cloudtentacles_*`、`kuaishou_feifei`(已下线通道,仅保留数据) |
|
||||||
|
|
||||||
|
### 5.7 迁移演进(001–103)
|
||||||
|
|
||||||
|
| 区段 | 主题 |
|
||||||
|
| --- | --- |
|
||||||
|
| 001 | 基线:后台、配置、履约档案、订单/订单项/任务/领取 token、任务事件、快手券、短链 |
|
||||||
|
| 002–003 | 登录日志、电子凭证类型 |
|
||||||
|
| 004–012 | **接单平台落地**:等级、打手、钱包、流水、分类、工单、工单事件、文件、商品规则、财务申请、拼单、超时、解冻、短信 |
|
||||||
|
| 013–028 | 回调去重、权限修补、永久 claim token、工单备注/置顶、提现账户、通知 |
|
||||||
|
| 029–040 | 会话、实时事件、搜索索引、匹配上下文/目录/SKU 模式、匹配日志、后台会话 |
|
||||||
|
| 041–058 | 排行榜、自动激活、取消申请、反馈、来源规则、冻结原因、性能护栏、大厅队列、售后、改名卡、赠送验收、解冻配置、素材已读 |
|
||||||
|
| 059–076 | **RBAC**、提现冻结、管理备注、取消措辞、赠送冷却、个人免押、数据保留、筛选索引、财务角色、搜索重建、分类父子、提现黑名单 |
|
||||||
|
| 077–082 | **寄卖**(库存、打手押金、解除收集链接、分配状态、白名单) |
|
||||||
|
| 081 | **PDD 原始单**两段式 |
|
||||||
|
| 083–093 | 工单来源金额、多组覆盖索引、风控事件、遗留列注释、autovacuum |
|
||||||
|
| 094–098 | **抖音券** `redeem_certificates`、平台券号、任务 claim 提交(后废弃)、等级锁 |
|
||||||
|
| 099–103 | 订单兑换有效性、**备用金台账**、后台显示名、短信/设备认证、店铺筛选索引 |
|
||||||
|
|
||||||
|
约定(README):不改历史迁移;结构变更一律新增;空库按序执行;后端启动自动 `up`。
|
||||||
|
|
||||||
|
### 5.8 实测状态分布(本地库,用于验证对业务的理解)
|
||||||
|
|
||||||
|
| 维度 | 分布 |
|
||||||
|
| --- | --- |
|
||||||
|
| 订单 | `paid/paid/active` 375,432;`pending_config` 25;`manual_failed` 1;`closed` 1 |
|
||||||
|
| 履约任务 | `redeemed+delivered` 49,527;`completed+delivered` 45,658;`closed+cancelled` 5,158;`closed+closed` 3,238;`waiting_binding+pending` 956 |
|
||||||
|
| 工单 | `accepted` 131,786;`pending_material` 21,509;`cancelled` 1,075;`unassigned` 760;`in_progress` 612;`pending_acceptance` 174;`open` 101;`problem` 26 |
|
||||||
|
| 快手券 | `CONSUMED` 376,179;`DESTROYED` 26,815;`UNUSED` 106 |
|
||||||
|
| 抖音券 | `verified` 1,337;`invalid` 123;`synced` 81 |
|
||||||
|
| 财务申请 | 提现 approve 6,381;充值 reject 151;提现 reject 129;充值 approve 91 |
|
||||||
|
|
||||||
|
两个值得注意的信号:
|
||||||
|
|
||||||
|
1. `pending_material` 占工单 13.8%(21,509 条),说明**接单素材缺口是常态**,补素材是运营主要工作量之一。
|
||||||
|
2. `unassigned` 只有 760,而 `open` 只有 101 —— 大量工单没有经过大厅,直接被后台指派或同步生成后长期停在待补素材。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、外部集成与安全边界
|
||||||
|
|
||||||
|
| 链路 | 验签 | 幂等/去重 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 91 卡券 | 签名 + 时间戳容差 | `orders` 唯一键 + advisory lock | 卡密用 `buildOpen91Cards` 加密返回 |
|
||||||
|
| 快手电子凭证 | 签名(`crypto.ts`) | `UNIQUE(oid, unit_index)` | 4 个入站接口 + 2 个出站回调 |
|
||||||
|
| affiliate-dash 通知 | 验签 + 时间戳 | `webhook_events` | 失败重推会被判 duplicate(风险 R2) |
|
||||||
|
| 抖音码速达 | 原始 body 验签 | `webhook_events`(先落库) | 同上 |
|
||||||
|
| PDD | Cookie / anti-content | `pdd_raw_orders(order_sn)` + 断路器 | 无回调,纯拉取 |
|
||||||
|
|
||||||
|
其他安全面:
|
||||||
|
|
||||||
|
- 后台/打手口令都是 scrypt + 盐,会话落库 + `session_version`。
|
||||||
|
- 日志统一脱敏(敏感 key、URL 参数、JSON 字段),有单测。
|
||||||
|
- 生产强制:session secret 长度、显式 CORS 白名单、存储凭据完整。
|
||||||
|
- 明显缺口:`/collect/*`、`/redeem/*` 只凭订单号;`/files/object` 无鉴权;Caddy 无 CSP/HSTS/X-Frame-Options;后台 token 存 localStorage。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、风险与设计缺口(按优先级)
|
||||||
|
|
||||||
|
### P0 · 资金与一致性
|
||||||
|
|
||||||
|
| # | 问题 | 位置 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| R1 | 发单金额高于用户付款只有前端提醒,后端无统一门禁 | `frontend/pages/admin/panels/work-order-payment.ts`;`admin-work-order-management-service.ts` | 直接调 API 可发亏损单 |
|
||||||
|
| R2 | 寄卖审核可由请求体改 `workerId` 并按改后归属结算货款 | `consignment-admin-service.ts`、`consignment-supplier-repo.ts` | 钱结给错误的人 |
|
||||||
|
| R3 | 寄卖审核详情全量返回游戏账号密码,只需 `consignment.view` | `consignment-admin-service.ts` | 权限粒度过粗 |
|
||||||
|
| R4 | 抢单先锁钱包、指派先锁工单(锁序相反) | `work-order-grab-repo.ts` | 并发下 40P01 死锁 |
|
||||||
|
| R5 | 自动工单硬编码零押金,模板押金字段不生效 | `sync-worker-orders-service.ts` | 自动发单无押金约束 |
|
||||||
|
| R6 | 发布工单时凭证核销失败不回滚,指派却拦截 | `admin-work-order-management-service.ts` / `...assignment-service.ts` | 同一规则两套行为 |
|
||||||
|
|
||||||
|
### P1 · 可靠性
|
||||||
|
|
||||||
|
| # | 问题 |
|
||||||
|
| --- | --- |
|
||||||
|
| R7 | Webhook 先落库后处理,失败后平台重推被永久跳过 |
|
||||||
|
| R8 | 抖音回调同步串行调上游,单次超时 20s,与 R7 叠加不可恢复 |
|
||||||
|
| R9 | `/collect/*`、`/redeem/*` 无第二因子 |
|
||||||
|
| R10 | `/collect/upload` 可匿名上传任意文件到 OSS |
|
||||||
|
| R11 | `/files/object` 无鉴权;claim token 默认永久 |
|
||||||
|
| R12 | 无 CSP/HSTS;后台 token 在 localStorage |
|
||||||
|
| R13 | 先到先匹配,后到的快手补全数据不纠正既有工单 |
|
||||||
|
| R14 | 单实例耦合(限流/调度/SSE/缓存都在进程内) |
|
||||||
|
|
||||||
|
### P2 · 可维护性
|
||||||
|
|
||||||
|
巨型文件(`WorkOrdersPanel.tsx` 3,686 行、`AdminTaskDetailPage.tsx` 1,925 行、`work-order-query-repo.ts` 1,409 行、`consignment-repo.ts` 1,410 行、`work-order-settlement-repo.ts` 1,181 行);前端无组件测试/E2E;无 CI;迁移启动自动执行且无 down。
|
||||||
|
|
||||||
|
### 数据模型层面的四个缺口
|
||||||
|
|
||||||
|
| # | 缺口 | 后果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | 没有「人工块」载体:无自动任务且无工单的已支付订单项只靠「无任务且无工单」推断 | 人工履约不可监管、不可计量 |
|
||||||
|
| D2 | 无商品主数据 `product_catalog` / `channel_product_mappings` | 同一商品名对应 27–35 个 `sku_code`,销量统计不可靠 |
|
||||||
|
| D3 | 无成本版本与快照 `cost_items/cost_versions/cost_snapshots` | 改价不可追溯,历史毛利不可重算;自动发货 `cost_amount` 只映射不落库 |
|
||||||
|
| D4 | `order_items` 无金额字段 | 多商品订单无法分摊收入 |
|
||||||
|
|
||||||
|
> 这几条与 `docs/成本中心与三块履约模型.md`、`docs/drafts/*` 的草案一致,草案尚未进迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、我看到的「三个业务面」边界图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 订单面
|
||||||
|
O["orders / order_items"]
|
||||||
|
T["fulfillment_tasks / claim_tokens"]
|
||||||
|
V["vouchers / redeem_certificates"]
|
||||||
|
end
|
||||||
|
subgraph 工单面
|
||||||
|
W["work_orders / work_order_shares"]
|
||||||
|
WW["worker_users / wallets / ledgers"]
|
||||||
|
P["work_product_rules / mappings"]
|
||||||
|
end
|
||||||
|
subgraph 寄卖面
|
||||||
|
CS["consignment_submissions"]
|
||||||
|
CI["consignment_inventories"]
|
||||||
|
end
|
||||||
|
subgraph 财务面
|
||||||
|
FA["fund_accounts / fund_entries"]
|
||||||
|
ME["manual_expenses"]
|
||||||
|
end
|
||||||
|
O --> T
|
||||||
|
O --> V
|
||||||
|
O --> W
|
||||||
|
P --> W
|
||||||
|
W --> WW
|
||||||
|
CI --> O
|
||||||
|
CS --> CI
|
||||||
|
CI --> W
|
||||||
|
ME --> FA
|
||||||
|
W -. 影子总账未落地 .-> FA
|
||||||
|
```
|
||||||
|
|
||||||
|
三个面共享的只有两件事:**订单项**(`order_items.id` 是所有链路的锚点)与**打手钱包**(工单与寄卖都用它)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、需要你确认的问题
|
||||||
|
|
||||||
|
见对话中的提问(不在文档内固化,确认后我再补「口径确认」章节)。
|
||||||
|
|
||||||
Reference in New Issue
Block a user