Compare commits
4
Commits
fb485f7e36
...
4ef52f11ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef52f11ba | ||
|
|
96ae744dbb | ||
|
|
a6ccb2e2d8 | ||
|
|
14b595b0db |
@@ -78,11 +78,14 @@ function normalizeDays(value: unknown) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
|
||||
const SHANGHAI_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
function formatShanghaiDate(value: Date) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(value)
|
||||
return SHANGHAI_DATE_FORMATTER.format(value)
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
* 2026-10-30 生产事故:打手双击「提交验收」,第二个请求把已验收的工单写回
|
||||
* 待验收,随后重复结算撞钱包流水 event_key 唯一索引,订单永久卡在待验收,
|
||||
* 后台手动验收也一直 500。这里用源码契约锁住两个修复点。
|
||||
*/
|
||||
test('整单结算先检查奖励流水:已入账只补状态,不再动钱', async () => {
|
||||
const source = await readFile(new URL('./work-order-settlement-repo.ts', import.meta.url), 'utf8')
|
||||
|
||||
const guardIndex = source.indexOf(
|
||||
'const rewardAlreadySettled = await hasSettledRewardLedgerWithClient',
|
||||
)
|
||||
const settleIndex = source.indexOf(
|
||||
'await ensureWorkerWalletWithClient(client, workerId, input.now)',
|
||||
)
|
||||
assert.ok(guardIndex > 0, '整单结算缺少已入账检查')
|
||||
assert.ok(settleIndex > guardIndex, '已入账检查必须发生在钱包结算之前')
|
||||
assert.ok(
|
||||
source.includes(
|
||||
"ledger_type IN ('reward_settlement', 'reward_pending_unfreeze', 'sharing_reward')",
|
||||
),
|
||||
'幂等检查必须覆盖整单与拼单两种奖励流水',
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单份额结算同样先检查该份额是否已入账', async () => {
|
||||
const source = await readFile(new URL('./work-order-settlement-repo.ts', import.meta.url), 'utf8')
|
||||
|
||||
const firstGuard = source.indexOf('const rewardAlreadySettled')
|
||||
const secondGuard = source.indexOf('const rewardAlreadySettled', firstGuard + 1)
|
||||
assert.ok(firstGuard > 0, '整单结算缺少已入账检查')
|
||||
assert.ok(secondGuard > firstGuard, '拼单份额结算缺少已入账检查')
|
||||
assert.ok(
|
||||
source.slice(secondGuard, secondGuard + 220).includes('shareId: input.shareId'),
|
||||
'拼单份额必须按 shareId 判断是否已入账',
|
||||
)
|
||||
})
|
||||
|
||||
test('提交验收改原子状态更新,禁止把 accepted 无条件写回待验收', async () => {
|
||||
const management = await readFile(
|
||||
new URL('./work-order-management-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(management.includes('export async function markWorkOrderAcceptanceSubmitted'))
|
||||
assert.ok(management.includes("AND status IN ('in_progress', 'problem')"))
|
||||
assert.ok(management.includes("draft_acceptance_json = '{}'::jsonb"))
|
||||
|
||||
const service = await readFile(
|
||||
new URL('../../services/worker-platform/worker-acceptance-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.equal(service.includes('updateWorkOrder(workOrder.id, {'), false)
|
||||
assert.ok(service.includes('markWorkOrderAcceptanceSubmitted('))
|
||||
assert.ok(service.includes('work_order_submit_status_invalid'))
|
||||
})
|
||||
@@ -132,6 +132,37 @@ export async function updateWorkOrder(
|
||||
return getWorkOrderById(workOrderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子地把工单推进到「待验收」。
|
||||
*
|
||||
* 只允许从进行中/问题状态进入:重复提交(双击 / 前端重试)不能把已经验收的工单
|
||||
* 再写回待验收,否则会把已入账的结算冲掉(2026-10-30 生产事故)。
|
||||
* 返回 null 表示状态已变化,调用方按幂等成功或冲突处理。
|
||||
*/
|
||||
export async function markWorkOrderAcceptanceSubmitted(input: {
|
||||
workOrderId: number | string
|
||||
acceptanceJson: WorkOrderRow['acceptance_json']
|
||||
now: string
|
||||
}): Promise<WorkOrderRow | null> {
|
||||
const result = await query<{ id: number }>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
status = 'pending_acceptance',
|
||||
acceptance_json = $1::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
AND status IN ('in_progress', 'problem')
|
||||
RETURNING id
|
||||
`,
|
||||
[toJsonString(input.acceptanceJson), input.now, Number(input.workOrderId)],
|
||||
)
|
||||
if (!result.rows[0]) return null
|
||||
return getWorkOrderById(input.workOrderId)
|
||||
}
|
||||
|
||||
/** 将未分配工单原子发布到大厅,并在事务内记录发布事件。 */
|
||||
export async function publishUnassignedWorkOrder(input: {
|
||||
workOrderId: number | string
|
||||
|
||||
@@ -50,6 +50,33 @@ async function upgradeWorkerLevelsAfterCommit(workerIds: number[], now: string)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 该发放单元是否已经入过账。
|
||||
*
|
||||
* 验收可能被重复提交(双击 / 前端重试):第一次已经结算成功,第二次把状态写回
|
||||
* 待验收后如果再次结算,就会撞 `uq_worker_wallet_ledger_event_key` 让整笔事务回滚
|
||||
* (2026-10-30 生产事故)。凡是已入账的发放单元,只补验收状态,绝不再动钱。
|
||||
*/
|
||||
async function hasSettledRewardLedgerWithClient(
|
||||
client: PoolClient,
|
||||
input: { workerId: number; workOrderId: number; shareId?: number | null },
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM worker_wallet_ledgers
|
||||
WHERE worker_id = $1
|
||||
AND related_work_order_id = $2
|
||||
AND ledger_type IN ('reward_settlement', 'reward_pending_unfreeze', 'sharing_reward')
|
||||
AND COALESCE(NULLIF(payload_json ->> 'shareId', ''), '0') = $3
|
||||
) AS exists
|
||||
`,
|
||||
[input.workerId, input.workOrderId, input.shareId ? String(input.shareId) : '0'],
|
||||
)
|
||||
return result.rows[0]?.exists === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼单母单收尾:标记已验收,并补寄卖的发布押金释放与供货结算。
|
||||
*
|
||||
@@ -371,32 +398,38 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
|
||||
const workerId = Number(workOrder.assigned_worker_id || 0)
|
||||
if (workerId > 0) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
const releaseAmount = Math.min(
|
||||
await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const rewardAmount = Number(workOrder.reward_amount || 0)
|
||||
const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
||||
// 重复验收(双击/重试)时奖励已经入账:只补验收状态,不能再结算一次。
|
||||
const rewardAlreadySettled = await hasSettledRewardLedgerWithClient(client, {
|
||||
workerId,
|
||||
amount: rewardAmount,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
})
|
||||
const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0)
|
||||
const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0
|
||||
const pendingUnfreezeAmount =
|
||||
(shouldDelayUnfreeze ? rewardAmount : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0)
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) +
|
||||
(shouldDelayUnfreeze ? 0 : rewardAmount) +
|
||||
(input.unfreezeDays > 0 ? 0 : releaseAmount) -
|
||||
debtOffsetAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
if (!rewardAlreadySettled) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
const releaseAmount = Math.min(
|
||||
await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const rewardAmount = Number(workOrder.reward_amount || 0)
|
||||
const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
||||
workerId,
|
||||
amount: rewardAmount,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
})
|
||||
const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0)
|
||||
const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0
|
||||
const pendingUnfreezeAmount =
|
||||
(shouldDelayUnfreeze ? rewardAmount : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0)
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) +
|
||||
(shouldDelayUnfreeze ? 0 : rewardAmount) +
|
||||
(input.unfreezeDays > 0 ? 0 : releaseAmount) -
|
||||
debtOffsetAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
|
||||
await client.query(
|
||||
`
|
||||
await client.query(
|
||||
`
|
||||
UPDATE worker_wallets
|
||||
SET
|
||||
available_amount = $1,
|
||||
@@ -406,111 +439,112 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
updated_at = $5
|
||||
WHERE worker_id = $6
|
||||
`,
|
||||
[nextAvailable, nextFrozen, pendingUnfreezeAmount, rewardAmount, input.now, workerId],
|
||||
)
|
||||
[nextAvailable, nextFrozen, pendingUnfreezeAmount, rewardAmount, input.now, workerId],
|
||||
)
|
||||
|
||||
if (releaseAmount > 0 && input.unfreezeDays > 0) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: releaseAmount,
|
||||
fundType: 'deposit',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
if (rewardAmount > 0 && shouldDelayUnfreeze) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: rewardAmount,
|
||||
fundType: 'reward',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
if (releaseAmount > 0 && input.unfreezeDays > 0) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: releaseAmount,
|
||||
fundType: 'deposit',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
if (rewardAmount > 0 && shouldDelayUnfreeze) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: rewardAmount,
|
||||
fundType: 'reward',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
|
||||
if (releaseAmount > 0) {
|
||||
if (input.unfreezeDays > 0) {
|
||||
await client.query(
|
||||
`
|
||||
if (releaseAmount > 0) {
|
||||
if (input.unfreezeDays > 0) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'deposit_pending_unfreeze', $2, $3, $4, $5, '验收通过押金待解冻', $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
workerId,
|
||||
releaseAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({
|
||||
workOrderId: input.workOrderId,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
} else {
|
||||
await client.query(
|
||||
`
|
||||
[
|
||||
workerId,
|
||||
releaseAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({
|
||||
workOrderId: input.workOrderId,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
} else {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '验收通过释放押金', '{}'::jsonb, $6)
|
||||
`,
|
||||
[workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now],
|
||||
)
|
||||
[workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rewardAmount > 0) {
|
||||
const rewardLedgerType = shouldDelayUnfreeze
|
||||
? 'reward_pending_unfreeze'
|
||||
: 'reward_settlement'
|
||||
await client.query(
|
||||
`
|
||||
if (rewardAmount > 0) {
|
||||
const rewardLedgerType = shouldDelayUnfreeze
|
||||
? 'reward_pending_unfreeze'
|
||||
: 'reward_settlement'
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
|
||||
`,
|
||||
[
|
||||
workerId,
|
||||
rewardLedgerType,
|
||||
rewardAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
shouldDelayUnfreeze ? '验收通过报酬待解冻' : '验收通过结算报酬',
|
||||
JSON.stringify(
|
||||
shouldDelayUnfreeze
|
||||
? { workOrderId: input.workOrderId, unfreezeDays: input.unfreezeDays }
|
||||
: {},
|
||||
),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
for (const debtOffset of debtOffsets) {
|
||||
await client.query(
|
||||
`
|
||||
[
|
||||
workerId,
|
||||
rewardLedgerType,
|
||||
rewardAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
shouldDelayUnfreeze ? '验收通过报酬待解冻' : '验收通过结算报酬',
|
||||
JSON.stringify(
|
||||
shouldDelayUnfreeze
|
||||
? { workOrderId: input.workOrderId, unfreezeDays: input.unfreezeDays }
|
||||
: {},
|
||||
),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
for (const debtOffset of debtOffsets) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'after_sales_debt_offset', $2, $3, $4, $5, '验收报酬自动抵扣售后欠款', $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
workerId,
|
||||
-debtOffset.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({ caseId: debtOffset.caseId, workOrderId: input.workOrderId }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
[
|
||||
workerId,
|
||||
-debtOffset.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({ caseId: debtOffset.caseId, workOrderId: input.workOrderId }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,127 +627,137 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
||||
|
||||
const workerId = Number(share.worker_id || 0)
|
||||
if (workerId > 0) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
const releaseAmount = Math.min(
|
||||
Number(share.share_deposit || 0),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const shareReward = Number(share.share_reward || 0)
|
||||
const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
||||
// 该份额已结算过(重复提交把份额状态写回 submitted)时,只补状态不再打款。
|
||||
const rewardAlreadySettled = await hasSettledRewardLedgerWithClient(client, {
|
||||
workerId,
|
||||
amount: shareReward,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
shareId: input.shareId,
|
||||
})
|
||||
const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0)
|
||||
const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0
|
||||
const pendingUnfreezeAmount =
|
||||
(shouldDelayUnfreeze ? shareReward : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0)
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) +
|
||||
(shouldDelayUnfreeze ? 0 : shareReward) +
|
||||
(input.unfreezeDays > 0 ? 0 : releaseAmount) -
|
||||
debtOffsetAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
await client.query(
|
||||
`UPDATE worker_wallets
|
||||
if (!rewardAlreadySettled) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
const releaseAmount = Math.min(
|
||||
Number(share.share_deposit || 0),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const shareReward = Number(share.share_reward || 0)
|
||||
const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
||||
workerId,
|
||||
amount: shareReward,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
})
|
||||
const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0)
|
||||
const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0
|
||||
const pendingUnfreezeAmount =
|
||||
(shouldDelayUnfreeze ? shareReward : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0)
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) +
|
||||
(shouldDelayUnfreeze ? 0 : shareReward) +
|
||||
(input.unfreezeDays > 0 ? 0 : releaseAmount) -
|
||||
debtOffsetAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
await client.query(
|
||||
`UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2,
|
||||
pending_unfreeze_amount = pending_unfreeze_amount + $3,
|
||||
total_settled_amount = total_settled_amount + $4, updated_at = $5
|
||||
WHERE worker_id = $6`,
|
||||
[nextAvailable, nextFrozen, pendingUnfreezeAmount, shareReward, input.now, workerId],
|
||||
)
|
||||
[nextAvailable, nextFrozen, pendingUnfreezeAmount, shareReward, input.now, workerId],
|
||||
)
|
||||
|
||||
if (releaseAmount > 0 && input.unfreezeDays > 0) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: releaseAmount,
|
||||
fundType: 'deposit',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
if (shareReward > 0 && shouldDelayUnfreeze) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: shareReward,
|
||||
fundType: 'reward',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
if (releaseAmount > 0) {
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
if (releaseAmount > 0 && input.unfreezeDays > 0) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: releaseAmount,
|
||||
fundType: 'deposit',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
if (shareReward > 0 && shouldDelayUnfreeze) {
|
||||
await enqueueDepositUnfreezeWithClient(client, {
|
||||
workerId,
|
||||
workOrderId: input.workOrderId,
|
||||
amount: shareReward,
|
||||
fundType: 'reward',
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
if (releaseAmount > 0) {
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`,
|
||||
[
|
||||
workerId,
|
||||
input.unfreezeDays > 0 ? 'deposit_pending_unfreeze' : 'deposit_release',
|
||||
releaseAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
input.unfreezeDays > 0 ? '拼单份额验收押金待解冻' : '拼单份额验收释放押金',
|
||||
JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
if (shareReward > 0) {
|
||||
const rewardLedgerType = shouldDelayUnfreeze ? 'reward_pending_unfreeze' : 'sharing_reward'
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
[
|
||||
workerId,
|
||||
input.unfreezeDays > 0 ? 'deposit_pending_unfreeze' : 'deposit_release',
|
||||
releaseAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
input.unfreezeDays > 0 ? '拼单份额验收押金待解冻' : '拼单份额验收释放押金',
|
||||
JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
if (shareReward > 0) {
|
||||
const rewardLedgerType = shouldDelayUnfreeze
|
||||
? 'reward_pending_unfreeze'
|
||||
: 'sharing_reward'
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`,
|
||||
[
|
||||
workerId,
|
||||
rewardLedgerType,
|
||||
shareReward,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
shouldDelayUnfreeze
|
||||
? '拼单份额验收报酬待解冻'
|
||||
: `拼单份额验收结算报酬 ${share.quantity} 份`,
|
||||
JSON.stringify(
|
||||
[
|
||||
workerId,
|
||||
rewardLedgerType,
|
||||
shareReward,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
shouldDelayUnfreeze
|
||||
? {
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: input.shareId,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
}
|
||||
: { workOrderId: input.workOrderId, shareId: input.shareId },
|
||||
),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
for (const debtOffset of debtOffsets) {
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
? '拼单份额验收报酬待解冻'
|
||||
: `拼单份额验收结算报酬 ${share.quantity} 份`,
|
||||
JSON.stringify(
|
||||
shouldDelayUnfreeze
|
||||
? {
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: input.shareId,
|
||||
unfreezeDays: input.unfreezeDays,
|
||||
}
|
||||
: { workOrderId: input.workOrderId, shareId: input.shareId },
|
||||
),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
for (const debtOffset of debtOffsets) {
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'after_sales_debt_offset', $2, $3, $4, $5, '验收报酬自动抵扣售后欠款', $6::jsonb, $7)`,
|
||||
[
|
||||
workerId,
|
||||
-debtOffset.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({
|
||||
caseId: debtOffset.caseId,
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: input.shareId,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
[
|
||||
workerId,
|
||||
-debtOffset.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({
|
||||
caseId: debtOffset.caseId,
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: input.shareId,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,22 +63,24 @@ export async function getAdminDashboardSummary() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
|
||||
const CHINA_DAY_PARTS_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
/** 返回中国时区当天对应的 UTC 查询范围,便于使用时间索引。 */
|
||||
function resolveChinaTodayRange(now: string) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.formatToParts(new Date(now))
|
||||
.reduce<Record<string, string>>((result, part) => {
|
||||
result[part.type] = part.value
|
||||
return result
|
||||
}, {})
|
||||
const year = Number(parts.year || 0)
|
||||
const month = Number(parts.month || 1)
|
||||
const day = Number(parts.day || 1)
|
||||
let year = 0
|
||||
let month = 1
|
||||
let day = 1
|
||||
for (const part of CHINA_DAY_PARTS_FORMATTER.formatToParts(new Date(now))) {
|
||||
if (part.type === 'year') year = Number(part.value)
|
||||
else if (part.type === 'month') month = Number(part.value)
|
||||
else if (part.type === 'day') day = Number(part.value)
|
||||
}
|
||||
const todayStart = Date.UTC(year || 0, (month || 1) - 1, day || 1, -8)
|
||||
const tomorrowStart = todayStart + 24 * 60 * 60 * 1000
|
||||
|
||||
|
||||
@@ -455,15 +455,25 @@ function parseFinanceBoundary(value: string, fallbackDate: string, end: boolean)
|
||||
: parsed.toISOString()
|
||||
}
|
||||
|
||||
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
|
||||
const CHINA_DATE_PARTS_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: CHINA_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
function chinaDateParts(value: string) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: CHINA_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(new Date(value))
|
||||
const result = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||
return `${result.year}-${result.month}-${result.day}`
|
||||
const parts = CHINA_DATE_PARTS_FORMATTER.formatToParts(new Date(value))
|
||||
let year = ''
|
||||
let month = ''
|
||||
let day = ''
|
||||
for (const part of parts) {
|
||||
if (part.type === 'year') year = part.value
|
||||
else if (part.type === 'month') month = part.value
|
||||
else if (part.type === 'day') day = part.value
|
||||
}
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function chinaDateToUtcIso(value: string) {
|
||||
|
||||
@@ -526,15 +526,18 @@ function normalizeTimestamp(value: unknown): string {
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
|
||||
const CHINA_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
function toChinaDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date)
|
||||
return CHINA_DATE_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatFen(value: number): string {
|
||||
|
||||
@@ -486,14 +486,17 @@ function mapSalesFact(row: SalesFactRow) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐行调用会线性放大。 */
|
||||
const SHANGHAI_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
function formatDateOnly(value: string | Date): string {
|
||||
if (value instanceof Date) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(value)
|
||||
return SHANGHAI_DATE_FORMATTER.format(value)
|
||||
}
|
||||
return String(value || '').slice(0, 10)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,21 @@ import { OPEN_91_DEFAULT_FAIL_CODE, OPEN_91_SUCCESS_MESSAGE } from './config.js'
|
||||
import { isOpen91FailedTaskStatus } from '../../domain/task-status.js'
|
||||
import { normalizeOpen91String } from './payload.js'
|
||||
|
||||
/**
|
||||
* 模块级只构造一次:`new Intl.DateTimeFormat()` 每次都会重新装载 ICU locale 数据,
|
||||
* 单次约 0.5ms。该格式化器按响应字段逐条调用,放在函数内会随响应行数线性放大。
|
||||
*/
|
||||
const OPEN_91_EXPIRE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
|
||||
export function buildOpen91SuccessResponse(data: unknown, message = OPEN_91_SUCCESS_MESSAGE) {
|
||||
return {
|
||||
code: 200,
|
||||
@@ -57,18 +72,12 @@ export function formatOpen91ExpireTime(value: unknown = '') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date)
|
||||
const parts = OPEN_91_EXPIRE_FORMATTER.formatToParts(date)
|
||||
|
||||
const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||
const byType: Record<string, string> = {}
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'literal') byType[part.type] = part.value
|
||||
}
|
||||
return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}`
|
||||
}
|
||||
|
||||
|
||||
@@ -842,6 +842,20 @@ export async function findCertificateOrThrow(certNo: string): Promise<RedeemCert
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块级只构造一次:`new Intl.DateTimeFormat()` 每次都会重新装载 ICU locale 数据,
|
||||
* 单次约 0.5ms。凭证按条签发/查询时会逐条调用 formatGrantTime,放在函数内会线性放大。
|
||||
*/
|
||||
const GRANT_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
/** 平台的 grant_time 形如 `YYYY-MM-DD HH:MM:SS`(北京时间)。 */
|
||||
export function formatGrantTime(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
@@ -849,18 +863,12 @@ export function formatGrantTime(iso: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(date)
|
||||
const parts = GRANT_TIME_FORMATTER.formatToParts(date)
|
||||
|
||||
const pick = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value || ''
|
||||
const byType: Record<string, string> = {}
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'literal') byType[part.type] = part.value
|
||||
}
|
||||
|
||||
return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}:${pick('second')}`
|
||||
return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}`
|
||||
}
|
||||
|
||||
@@ -82,13 +82,16 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
|
||||
const SHANGHAI_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
function today() {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date())
|
||||
return SHANGHAI_DATE_FORMATTER.format(new Date())
|
||||
}
|
||||
|
||||
function shiftDays(days: number) {
|
||||
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
createWorkOrderEvent,
|
||||
listWorkOrderSharesByWorkerInOrder,
|
||||
markWorkOrderAcceptanceSubmitted,
|
||||
settleOverdueWorkOrder,
|
||||
settleOverdueWorkOrderShare,
|
||||
submitWorkOrderShareAcceptance,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderAcceptanceRecord,
|
||||
updateWorkOrderDraftAcceptance,
|
||||
updateWorkOrderShareAcceptanceRecord,
|
||||
@@ -82,7 +82,7 @@ export async function submitWorkerOrderAcceptance(
|
||||
} = {},
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
let workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const workerId = Number(session.workerId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
if (!options.asConsignmentSupplier) {
|
||||
@@ -184,28 +184,54 @@ export async function submitWorkerOrderAcceptance(
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
acceptance_json: acceptance,
|
||||
draft_acceptance_json: {},
|
||||
submitted_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
await createWorkOrderEvent({
|
||||
const transitioned = await markWorkOrderAcceptanceSubmitted({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'worker',
|
||||
actorId: String(session.workerId),
|
||||
eventType: 'acceptance_submitted',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
payloadJson: JSON.stringify({
|
||||
imageCount: imageUrls.length,
|
||||
hasVideo: Boolean(video),
|
||||
autoAccepted: canAutoAcceptWithoutEvidence,
|
||||
evidenceDueAt: acceptance.evidenceDueAt || null,
|
||||
}),
|
||||
acceptanceJson: acceptance,
|
||||
now,
|
||||
})
|
||||
let updated = transitioned
|
||||
if (transitioned) {
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'worker',
|
||||
actorId: String(session.workerId),
|
||||
eventType: 'acceptance_submitted',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
payloadJson: JSON.stringify({
|
||||
imageCount: imageUrls.length,
|
||||
hasVideo: Boolean(video),
|
||||
autoAccepted: canAutoAcceptWithoutEvidence,
|
||||
evidenceDueAt: acceptance.evidenceDueAt || null,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
} else {
|
||||
// 重复提交(双击/前端重试):第一个请求可能已经验收或正在结算。
|
||||
// 绝不能把 accepted 再写回待验收,只按当前状态幂等处理。
|
||||
const latest = await getRequiredWorkOrder(workOrder.id).catch(() => null)
|
||||
const ownerMatched =
|
||||
options.asConsignmentSupplier || Number(latest?.assigned_worker_id || 0) === workerId
|
||||
if (
|
||||
!latest ||
|
||||
!ownerMatched ||
|
||||
![WORK_ORDER_STATUS.PENDING_ACCEPTANCE, WORK_ORDER_STATUS.ACCEPTED].includes(
|
||||
latest.status as never,
|
||||
)
|
||||
) {
|
||||
throw createHttpError('当前状态不能提交验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_submit_status_invalid',
|
||||
})
|
||||
}
|
||||
if (latest.status === WORK_ORDER_STATUS.ACCEPTED) {
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(latest.id), workerIds: [workerId] })
|
||||
return { order: mapWorkOrderForWorker(latest, resolveWorkerPermissions(worker)) }
|
||||
}
|
||||
// 上一请求已推进到待验收但结算未完成:用已有验收资料重试结算,不重复写资料。
|
||||
workOrder = latest
|
||||
updated = latest
|
||||
}
|
||||
// 好友赠送模式必须由后台人工验收赠送凭证,内部打手与 VIP 免审核直通均不适用。
|
||||
if (
|
||||
!isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
|
||||
|
||||
@@ -40,13 +40,16 @@ export function resolveFinanceDateRange(
|
||||
return { fromDate, toDate }
|
||||
}
|
||||
|
||||
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
|
||||
const SHANGHAI_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
export function formatShanghaiDate(value: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(value)
|
||||
return SHANGHAI_DATE_FORMATTER.format(value)
|
||||
}
|
||||
|
||||
export function shiftDate(date: string, days: number): string {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildErrorPayload, createHttpError, resolveRouteErrorLogLevel } from './http.js'
|
||||
import {
|
||||
buildErrorPayload,
|
||||
buildSuccessPayload,
|
||||
createHttpError,
|
||||
resolveRouteErrorLogLevel,
|
||||
} from './http.js'
|
||||
|
||||
test('expected worker session expiry is debug-level', () => {
|
||||
assert.equal(
|
||||
@@ -33,3 +38,131 @@ test('generic 5xx still uses fallback message', () => {
|
||||
const payload = buildErrorPayload(error, '服务内部错误')
|
||||
assert.equal(payload.msg, '服务内部错误')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 响应日期格式化:格式必须与旧实现逐字节一致,同时不能再每次构造 ICU formatter。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 修复前的实现,作为等价性比对的参考实现。 */
|
||||
function legacyFormatResponseDateTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value || '')
|
||||
.replace('T', ' ')
|
||||
.replace('Z', '')
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(date)
|
||||
const tokens = Object.fromEntries(
|
||||
parts.filter((item) => item.type !== 'literal').map((item) => [item.type, item.value]),
|
||||
)
|
||||
return `${tokens.year}-${tokens.month}-${tokens.day} ${tokens.hour}:${tokens.minute}:${tokens.second}`
|
||||
}
|
||||
|
||||
test('ISO 日期时间按 Asia/Shanghai 渲染成 YYYY-MM-DD HH:mm:ss', () => {
|
||||
const payload = buildSuccessPayload({ createdAt: '2026-10-30T12:50:32.466Z' })
|
||||
assert.equal(payload.data.createdAt, '2026-10-30 20:50:32')
|
||||
})
|
||||
|
||||
test('嵌套对象与数组中的日期都会被格式化', () => {
|
||||
const payload = buildSuccessPayload({
|
||||
order: {
|
||||
acceptedAt: '2026-10-30T16:00:00Z',
|
||||
items: [
|
||||
{ submittedAt: '2026-10-30T00:00:00+08:00' },
|
||||
{ submittedAt: '2026-10-30T23:59:59Z' },
|
||||
],
|
||||
},
|
||||
empty: null,
|
||||
count: 3,
|
||||
})
|
||||
assert.deepEqual(payload.data, {
|
||||
order: {
|
||||
acceptedAt: '2026-10-31 00:00:00',
|
||||
items: [{ submittedAt: '2026-10-30 00:00:00' }, { submittedAt: '2026-10-31 07:59:59' }],
|
||||
},
|
||||
empty: null,
|
||||
count: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test('非日期字符串与近似字符串保持原样', () => {
|
||||
const payload = buildSuccessPayload({
|
||||
a: '2026-10-30 12:50:32',
|
||||
b: '2026-10-30',
|
||||
c: 'work_order_123',
|
||||
d: '2026-10-30T12:50',
|
||||
e: '',
|
||||
})
|
||||
assert.deepEqual(payload.data, {
|
||||
a: '2026-10-30 12:50:32',
|
||||
b: '2026-10-30',
|
||||
c: 'work_order_123',
|
||||
d: '2026-10-30T12:50',
|
||||
e: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('非法日期字符串回退为去掉 T/Z 的原文(与旧行为一致)', () => {
|
||||
const payload = buildSuccessPayload({ bad: '2026-13-45T99:99:99Z' })
|
||||
assert.equal(payload.data.bad, legacyFormatResponseDateTime('2026-13-45T99:99:99Z'))
|
||||
})
|
||||
|
||||
test('Date 实例被格式化为同一时区的字符串', () => {
|
||||
const payload = buildSuccessPayload({ at: new Date('2026-10-30T12:50:32.466Z') })
|
||||
assert.equal(payload.data.at, '2026-10-30 20:50:32')
|
||||
})
|
||||
|
||||
test('与旧实现逐字节一致(跨时区/毫秒/闰日/跨年语料)', () => {
|
||||
const corpus = [
|
||||
'2026-10-30T12:50:32.466Z',
|
||||
'2026-10-30T12:50:32Z',
|
||||
'2026-10-30T12:50:32.000Z',
|
||||
'2026-10-30T00:00:00Z',
|
||||
'2026-10-30T15:59:59Z',
|
||||
'2026-10-30T16:00:00Z',
|
||||
'2024-02-29T23:59:59Z',
|
||||
'2026-01-01T00:00:00Z',
|
||||
'2025-12-31T16:30:00Z',
|
||||
'2026-10-30T00:00:00+08:00',
|
||||
'2026-10-30T00:00:00-05:00',
|
||||
'2026-06-15T08:09:10+02:30',
|
||||
'1999-12-31T23:59:59Z',
|
||||
'2030-07-04T12:34:56.789Z',
|
||||
' 2026-10-30T12:50:32Z ',
|
||||
'not-a-date',
|
||||
'',
|
||||
]
|
||||
for (const value of corpus) {
|
||||
const actual = buildSuccessPayload({ v: value }).data.v
|
||||
assert.equal(
|
||||
actual,
|
||||
legacyFormatResponseDateTime(value),
|
||||
`mismatch for ${JSON.stringify(value)}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('大列表响应只格式化一次 formatter(性能回归防护)', () => {
|
||||
const rows = Array.from({ length: 200 }, (_, index) => ({
|
||||
id: index,
|
||||
createdAt: '2026-10-30T12:50:32.000Z',
|
||||
acceptedAt: '2026-10-30T13:50:32.000Z',
|
||||
}))
|
||||
const startedAt = process.hrtime.bigint()
|
||||
const payload = buildSuccessPayload({ rows })
|
||||
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
|
||||
assert.equal(payload.data.rows.length, 200)
|
||||
assert.equal(payload.data.rows[199].createdAt, '2026-10-30 20:50:32')
|
||||
// 旧实现 400 个日期约需 200ms+;这里给足余量,只防止"又回到每次 new"。
|
||||
assert.ok(elapsedMs < 100, `格式化 400 个日期耗时异常:${elapsedMs.toFixed(1)}ms`)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,29 @@ import { logDebug, logError, logWarn } from './logger.js'
|
||||
|
||||
const RESPONSE_TIME_ZONE = 'Asia/Shanghai'
|
||||
|
||||
/**
|
||||
* 响应日期格式化器。
|
||||
*
|
||||
* 必须放在模块级只构造一次:`new Intl.DateTimeFormat()` 每次都会重新装载 ICU
|
||||
* locale 数据,单次约 0.5~0.8ms。此前它被写在 formatResponseDateTime 内部,
|
||||
* 而该函数对「每个响应里的每个日期字段」都会调用,实测吃掉了后端主线程约 30%
|
||||
* 的 CPU(约 0.33 核)。提到模块级后单次降到 ~0.05ms。
|
||||
*/
|
||||
const RESPONSE_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: RESPONSE_TIME_ZONE,
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
/** ISO 日期时间字面量匹配(与 isIsoDateTimeString 的原正则等价)。 */
|
||||
const ISO_DATE_TIME_PATTERN =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/
|
||||
|
||||
export type HttpErrorLike = {
|
||||
statusCode?: number
|
||||
errorCode?: string
|
||||
@@ -185,12 +208,14 @@ function normalizeResponseDateTime(value: unknown): unknown {
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, currentValue]) => [
|
||||
key,
|
||||
normalizeResponseDateTime(currentValue),
|
||||
]),
|
||||
)
|
||||
// 用显式循环代替 Object.fromEntries(Object.entries(...)):后者会为每个对象
|
||||
// 额外分配一个 entries 数组和一个 map 中间数组,大列表响应下会明显推高 GC。
|
||||
const source = value as Record<string, unknown>
|
||||
const normalized: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(source)) {
|
||||
normalized[key] = normalizeResponseDateTime(source[key])
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
if (isIsoDateTimeString(value)) {
|
||||
@@ -201,10 +226,15 @@ function normalizeResponseDateTime(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function isIsoDateTimeString(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value.trim())
|
||||
)
|
||||
if (typeof value !== 'string') return false
|
||||
const text = value.trim()
|
||||
// 先用 O(1) 的位置判断挡掉绝大多数普通字符串,避免对每个字段值都跑正则。
|
||||
// 正则要求形如 YYYY-MM-DDTHH:MM:SS,因此这些位置必然固定。
|
||||
if (text.length < 19) return false
|
||||
if (text.charCodeAt(4) !== 45 || text.charCodeAt(7) !== 45 || text.charCodeAt(10) !== 84) {
|
||||
return false
|
||||
}
|
||||
return ISO_DATE_TIME_PATTERN.test(text)
|
||||
}
|
||||
|
||||
function formatResponseDateTime(value: string): string {
|
||||
@@ -216,22 +246,40 @@ function formatResponseDateTime(value: string): string {
|
||||
.replace('Z', '')
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: RESPONSE_TIME_ZONE,
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(date)
|
||||
const parts = RESPONSE_DATE_FORMATTER.formatToParts(date)
|
||||
|
||||
const tokens = Object.fromEntries(
|
||||
parts.filter((item) => item.type !== 'literal').map((item) => [item.type, item.value]),
|
||||
)
|
||||
let year = ''
|
||||
let month = ''
|
||||
let day = ''
|
||||
let hour = ''
|
||||
let minute = ''
|
||||
let second = ''
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case 'year':
|
||||
year = part.value
|
||||
break
|
||||
case 'month':
|
||||
month = part.value
|
||||
break
|
||||
case 'day':
|
||||
day = part.value
|
||||
break
|
||||
case 'hour':
|
||||
hour = part.value
|
||||
break
|
||||
case 'minute':
|
||||
minute = part.value
|
||||
break
|
||||
case 'second':
|
||||
second = part.value
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return `${tokens.year}-${tokens.month}-${tokens.day} ${tokens.hour}:${tokens.minute}:${tokens.second}`
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
type RouteHandlerOptions = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PictureOutlined } from '@ant-design/icons'
|
||||
import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import VideoUpload from '@/components/files/VideoUpload'
|
||||
@@ -109,6 +110,22 @@ export function WorkerOrderModals({
|
||||
onSubmitGiftMaterial: (values: GiftMaterialFormValues) => void | Promise<void>
|
||||
}) {
|
||||
const feedbackItems = feedbacks.filter((item) => item.workOrderId === feedbackOrder?.workOrderId)
|
||||
// 验收提交/保存加锁:双击或慢响应时只允许一个请求在飞,避免后端重复结算。
|
||||
const acceptanceBusyRef = useRef(false)
|
||||
const [acceptanceBusy, setAcceptanceBusy] = useState(false)
|
||||
|
||||
async function runAcceptanceAction(action: () => void | Promise<void>) {
|
||||
if (acceptanceBusyRef.current) return
|
||||
acceptanceBusyRef.current = true
|
||||
setAcceptanceBusy(true)
|
||||
try {
|
||||
await action()
|
||||
} finally {
|
||||
acceptanceBusyRef.current = false
|
||||
setAcceptanceBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -237,18 +254,33 @@ export function WorkerOrderModals({
|
||||
<Space>
|
||||
<Button onClick={onCloseAcceptance}>取消</Button>
|
||||
{canSupplementAcceptedEvidence(editingOrder) ? (
|
||||
<Button type="primary" onClick={() => acceptanceForm.submit()}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={acceptanceBusy}
|
||||
disabled={acceptanceBusy}
|
||||
onClick={() => acceptanceForm.submit()}
|
||||
>
|
||||
补充图片
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => void onSaveAcceptanceImages(acceptanceForm.getFieldsValue())}
|
||||
disabled={acceptanceBusy}
|
||||
onClick={() =>
|
||||
void runAcceptanceAction(() =>
|
||||
onSaveAcceptanceImages(acceptanceForm.getFieldsValue()),
|
||||
)
|
||||
}
|
||||
>
|
||||
{hasSubmittedAcceptance(editingOrder) ? '保存修改' : '保存暂存'}
|
||||
</Button>
|
||||
{canSubmitAcceptance(editingOrder) ? (
|
||||
<Button type="primary" onClick={() => acceptanceForm.submit()}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={acceptanceBusy}
|
||||
disabled={acceptanceBusy}
|
||||
onClick={() => acceptanceForm.submit()}
|
||||
>
|
||||
提交验收
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -258,7 +290,11 @@ export function WorkerOrderModals({
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Form form={acceptanceForm} layout="vertical" onFinish={onSubmitAcceptance}>
|
||||
<Form
|
||||
form={acceptanceForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => runAcceptanceAction(() => onSubmitAcceptance(values))}
|
||||
>
|
||||
{!editingOrder || !canSupplementAcceptedEvidence(editingOrder) ? (
|
||||
<Form.Item label="完成说明" name="note">
|
||||
<Input.TextArea rows={3} placeholder="可补充代练结果、注意事项或截图说明。" />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
* 2026-10-30 生产事故:提交验收按钮没有在途锁,双击会发两个请求;第二个请求
|
||||
* 把已验收的工单写回待验收并触发重复结算。按钮必须在请求结束前保持 loading/禁用。
|
||||
*/
|
||||
test('打手端验收提交按钮有在途锁,双击只会发一个请求', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../src/pages/worker/WorkerOrderModals.tsx', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(source.includes('acceptanceBusyRef'), '缺少同步在途锁 ref')
|
||||
assert.ok(source.includes('runAcceptanceAction'), '缺少统一的验收动作包装')
|
||||
assert.ok(
|
||||
(source.match(/loading={acceptanceBusy}/g) || []).length >= 2,
|
||||
'提交验收/补充图片按钮都应显示 loading',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('onFinish={(values) => runAcceptanceAction(() => onSubmitAcceptance(values))}'),
|
||||
'表单提交也必须走在途锁',
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user