feat(consignment): 内部现货占库进入赠送冷静期并闭环发布押金
打手发布冻结履约押金,驳回/作废退还,验收释放;内部匹配履约立刻占用库存并跳过打手资料进入冷静期,撤销回库时补冻押金。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString } from './shared.js'
|
||||
|
||||
/**
|
||||
* 查询寄卖提报押金尚未释放的金额(分)
|
||||
*/
|
||||
export async function getOutstandingConsignmentDepositFen(
|
||||
client: PoolClient,
|
||||
workerId: number,
|
||||
submissionId: number | string,
|
||||
): Promise<number> {
|
||||
const res = await client.query<{ outstanding: number }>(
|
||||
`
|
||||
SELECT (
|
||||
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_freeze' THEN ABS(amount) ELSE 0 END), 0)
|
||||
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_release' THEN ABS(amount) ELSE 0 END), 0)
|
||||
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)
|
||||
)::int AS outstanding
|
||||
FROM worker_wallet_ledgers
|
||||
WHERE worker_id = $1
|
||||
AND COALESCE(payload_json->>'kind', '') = 'consignment_publish'
|
||||
AND COALESCE(payload_json->>'consignmentSubmissionId', '') = $2
|
||||
`,
|
||||
[workerId, String(submissionId)],
|
||||
)
|
||||
return Math.max(0, Number(res.rows[0]?.outstanding || 0))
|
||||
}
|
||||
|
||||
export function getConsignmentDepositErrorCode(error: unknown): string | null {
|
||||
if (!(error instanceof Error)) return null
|
||||
const code = String((error as Error & { code?: string }).code || error.message || '').trim()
|
||||
if (
|
||||
code === 'consignment_deposit_insufficient' ||
|
||||
code === 'consignment_deposit_balance_inconsistent'
|
||||
) {
|
||||
return code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖发布成功:从可用余额冻结履约押金
|
||||
*/
|
||||
export async function freezeConsignmentPublishDepositWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workerId: number
|
||||
amountFen: number
|
||||
submissionId: number | string
|
||||
submissionNo: string
|
||||
now: string
|
||||
note?: string
|
||||
},
|
||||
): Promise<{ availableAfter: number; frozenAfter: number }> {
|
||||
const amount = Math.max(0, Math.round(Number(input.amountFen || 0)))
|
||||
if (amount <= 0) {
|
||||
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||
return {
|
||||
availableAfter: Number(wallet?.available_amount || 0),
|
||||
frozenAfter: Number(wallet?.frozen_deposit_amount || 0),
|
||||
}
|
||||
}
|
||||
|
||||
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||
const available = Number(wallet?.available_amount || 0)
|
||||
if (available < amount) {
|
||||
const err = new Error('consignment_deposit_insufficient')
|
||||
;(err as Error & { code?: string }).code = 'consignment_deposit_insufficient'
|
||||
throw err
|
||||
}
|
||||
|
||||
const nextAvailable = available - amount
|
||||
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + amount
|
||||
await client.query(
|
||||
`
|
||||
UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||
WHERE worker_id = $4
|
||||
`,
|
||||
[nextAvailable, nextFrozen, input.now, input.workerId],
|
||||
)
|
||||
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_freeze', $2, $3, $4, NULL, $5, $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
input.workerId,
|
||||
-amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.note || `寄卖发布冻结押金 ${input.submissionNo}`,
|
||||
toJsonString({
|
||||
kind: 'consignment_publish',
|
||||
consignmentSubmissionId: String(input.submissionId),
|
||||
submissionNo: input.submissionNo,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
|
||||
return { availableAfter: nextAvailable, frozenAfter: nextFrozen }
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖驳回或履约完成后:释放发布时冻结的押金
|
||||
*/
|
||||
export async function releaseConsignmentPublishDepositWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workerId: number
|
||||
submissionId: number | string
|
||||
note: string
|
||||
now: string
|
||||
},
|
||||
): Promise<number> {
|
||||
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||
// 锁住钱包后再读取流水,串行化同一打手的并发释放,避免重复退款或挪用其他押金。
|
||||
const outstanding = await getOutstandingConsignmentDepositFen(
|
||||
client,
|
||||
input.workerId,
|
||||
input.submissionId,
|
||||
)
|
||||
if (outstanding <= 0) return 0
|
||||
|
||||
const frozen = Number(wallet?.frozen_deposit_amount || 0)
|
||||
if (frozen < outstanding) {
|
||||
const error = new Error('consignment_deposit_balance_inconsistent')
|
||||
;(error as Error & { code?: string }).code = 'consignment_deposit_balance_inconsistent'
|
||||
throw error
|
||||
}
|
||||
const releaseAmount = outstanding
|
||||
|
||||
const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount
|
||||
const nextFrozen = Math.max(0, frozen - releaseAmount)
|
||||
await client.query(
|
||||
`
|
||||
UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||
WHERE worker_id = $4
|
||||
`,
|
||||
[nextAvailable, nextFrozen, input.now, input.workerId],
|
||||
)
|
||||
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, NULL, $5, $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
input.workerId,
|
||||
releaseAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.note,
|
||||
toJsonString({
|
||||
kind: 'consignment_publish',
|
||||
consignmentSubmissionId: String(input.submissionId),
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
return releaseAmount
|
||||
}
|
||||
|
||||
async function getConsignmentSubmissionDepositTarget(
|
||||
client: PoolClient,
|
||||
submissionId: number | string,
|
||||
): Promise<{
|
||||
workerId: number
|
||||
depositAmountFen: number
|
||||
submissionNo: string
|
||||
} | null> {
|
||||
const submission = await client.query<{
|
||||
worker_id: number | string | null
|
||||
deposit_amount_fen: number | string | null
|
||||
submission_no: string
|
||||
}>(
|
||||
`
|
||||
SELECT worker_id, deposit_amount_fen, submission_no
|
||||
FROM consignment_submissions
|
||||
WHERE id = $1
|
||||
FOR SHARE
|
||||
`,
|
||||
[submissionId],
|
||||
)
|
||||
const row = submission.rows[0]
|
||||
const workerId = Number(row?.worker_id || 0)
|
||||
if (!workerId) return null
|
||||
return {
|
||||
workerId,
|
||||
depositAmountFen: Math.max(0, Math.round(Number(row?.deposit_amount_fen || 0))),
|
||||
submissionNo: String(row?.submission_no || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseConsignmentPublishDepositBySubmissionWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
submissionId: number | string
|
||||
note: string
|
||||
now: string
|
||||
},
|
||||
): Promise<number> {
|
||||
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
|
||||
if (!target) return 0
|
||||
return releaseConsignmentPublishDepositWithClient(client, {
|
||||
workerId: target.workerId,
|
||||
submissionId: input.submissionId,
|
||||
note: input.note,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
|
||||
/** 回库或重新挂卖时:若发布押金已释放则按提报金额重新冻结,已冻结则保持。 */
|
||||
export async function ensureConsignmentPublishDepositFrozenBySubmissionWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
submissionId: number | string
|
||||
note: string
|
||||
now: string
|
||||
},
|
||||
): Promise<number> {
|
||||
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
|
||||
if (!target) return 0
|
||||
|
||||
await ensureWorkerWalletWithClient(client, target.workerId, input.now)
|
||||
await getWorkerWalletWithClient(client, target.workerId)
|
||||
const outstanding = await getOutstandingConsignmentDepositFen(
|
||||
client,
|
||||
target.workerId,
|
||||
input.submissionId,
|
||||
)
|
||||
if (outstanding > 0) return outstanding
|
||||
if (target.depositAmountFen <= 0) return 0
|
||||
|
||||
await freezeConsignmentPublishDepositWithClient(client, {
|
||||
workerId: target.workerId,
|
||||
amountFen: target.depositAmountFen,
|
||||
submissionId: input.submissionId,
|
||||
submissionNo: target.submissionNo,
|
||||
note: input.note,
|
||||
now: input.now,
|
||||
})
|
||||
return target.depositAmountFen
|
||||
}
|
||||
|
||||
export async function releaseConsignmentPublishDeposit(input: {
|
||||
workerId: number
|
||||
submissionId: number | string
|
||||
note: string
|
||||
now: string
|
||||
}): Promise<number> {
|
||||
return withTransaction((client) => releaseConsignmentPublishDepositWithClient(client, input))
|
||||
}
|
||||
@@ -542,33 +542,66 @@ export async function updateConsignmentInventory(
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
export async function archiveConsignmentInventoryWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
now: string,
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'archived',
|
||||
allow_matching = FALSE,
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
RETURNING *
|
||||
`,
|
||||
[now, id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
export async function returnConsignmentInventoryToStockWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
reason: string,
|
||||
now: string,
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
allow_matching = TRUE,
|
||||
remark = CASE
|
||||
WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']'
|
||||
ELSE remark
|
||||
END,
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = 'consumed'
|
||||
RETURNING *
|
||||
`,
|
||||
[reason, now, id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
/** 退回复库(将已出库物品恢复为在库待匹配) */
|
||||
export async function returnConsignmentInventoryToStock(
|
||||
id: number | string,
|
||||
reason: string,
|
||||
now: string,
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const sql = `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
allow_matching = TRUE,
|
||||
remark = CASE
|
||||
WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']'
|
||||
ELSE remark
|
||||
END,
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = 'consumed'
|
||||
RETURNING *
|
||||
`
|
||||
const res = await query<ConsignmentInventoryRow>(sql, [reason, now, id])
|
||||
return res.rows[0] || null
|
||||
return withTransaction((client) =>
|
||||
returnConsignmentInventoryToStockWithClient(client, id, reason, now),
|
||||
)
|
||||
}
|
||||
|
||||
/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */
|
||||
@@ -819,6 +852,36 @@ export async function createConsignmentSubmission(input: {
|
||||
depositAmountFen?: number
|
||||
now: string
|
||||
}): Promise<ConsignmentSubmissionRow> {
|
||||
return withTransaction((client) => createConsignmentSubmissionWithClient(client, input))
|
||||
}
|
||||
|
||||
export async function createConsignmentSubmissionWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
submissionNo: string
|
||||
linkId?: number | null
|
||||
gameName?: string
|
||||
categoryId?: number | null
|
||||
itemName: string
|
||||
system?: string
|
||||
serverZone?: string
|
||||
series?: string
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
sellerGameId?: string
|
||||
sellerPaymentInfo?: string
|
||||
sellerPhone?: string
|
||||
contactInfo?: string
|
||||
contentJson?: Record<string, unknown>
|
||||
screenshotsJson?: unknown[]
|
||||
askingPriceFen?: number
|
||||
workerId?: number | null
|
||||
workerName?: string
|
||||
workerPhone?: string
|
||||
depositAmountFen?: number
|
||||
now: string
|
||||
},
|
||||
): Promise<ConsignmentSubmissionRow> {
|
||||
const sql = `
|
||||
INSERT INTO consignment_submissions (
|
||||
submission_no, link_id, game_name, category_id, item_name,
|
||||
@@ -837,7 +900,7 @@ export async function createConsignmentSubmission(input: {
|
||||
)
|
||||
RETURNING *
|
||||
`
|
||||
const res = await query<ConsignmentSubmissionRow>(sql, [
|
||||
const res = await client.query<ConsignmentSubmissionRow>(sql, [
|
||||
input.submissionNo,
|
||||
input.linkId || null,
|
||||
input.gameName || '和平精英',
|
||||
@@ -870,6 +933,18 @@ export async function rejectConsignmentSubmission(input: {
|
||||
reviewerName: string
|
||||
now: string
|
||||
}): Promise<ConsignmentSubmissionRow | null> {
|
||||
return withTransaction((client) => rejectConsignmentSubmissionWithClient(client, input))
|
||||
}
|
||||
|
||||
export async function rejectConsignmentSubmissionWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
submissionId: number | string
|
||||
reviewNote: string
|
||||
reviewerName: string
|
||||
now: string
|
||||
},
|
||||
): Promise<ConsignmentSubmissionRow | null> {
|
||||
const sql = `
|
||||
UPDATE consignment_submissions
|
||||
SET
|
||||
@@ -881,7 +956,7 @@ export async function rejectConsignmentSubmission(input: {
|
||||
WHERE id = $4 AND status = 'pending_review'
|
||||
RETURNING *
|
||||
`
|
||||
const res = await query<ConsignmentSubmissionRow>(sql, [
|
||||
const res = await client.query<ConsignmentSubmissionRow>(sql, [
|
||||
input.reviewNote,
|
||||
input.reviewerName,
|
||||
input.now,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import type { WorkOrderRow } from './types.js'
|
||||
import {
|
||||
DEFAULT_GIFT_COOLDOWN_HOURS,
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
normalizeWorkOrderGiftCooldownMinutes,
|
||||
@@ -10,6 +12,20 @@ import {
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
|
||||
export const CONSIGNMENT_FULFILLMENT_TYPE = {
|
||||
INTERNAL_STOCK: 'internal_stock',
|
||||
WORKER_DEPOSIT: 'worker_deposit',
|
||||
} as const
|
||||
|
||||
const GIFT_COUNTDOWN_BLOCKED_STATUSES = new Set(['accepted', 'cancelled', 'pending_acceptance'])
|
||||
const GIFT_COUNTDOWN_ALLOWED_STATUSES = new Set([
|
||||
'unassigned',
|
||||
'pending_material',
|
||||
'in_progress',
|
||||
'problem',
|
||||
'open',
|
||||
])
|
||||
|
||||
/**
|
||||
* 工单脱离打手(回大厅、取消指派、恢复未分配等)时重置好友赠送进度,
|
||||
* 下一个打手重新走“提交资料 → 确认好友 → 冷却”流程。
|
||||
@@ -22,6 +38,136 @@ export const RESET_GIFT_FLOW_SQL = `
|
||||
gift_available_at = NULL
|
||||
`
|
||||
|
||||
export function parseWorkOrderMaterial(workOrder: {
|
||||
material_json?: unknown
|
||||
}): Record<string, unknown> {
|
||||
if (typeof workOrder.material_json === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(workOrder.material_json) as unknown
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
if (workOrder.material_json && typeof workOrder.material_json === 'object') {
|
||||
return workOrder.material_json as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function parseWorkOrderConsignment(workOrder: {
|
||||
material_json?: unknown
|
||||
}): Record<string, unknown> {
|
||||
const material = parseWorkOrderMaterial(workOrder)
|
||||
return material.consignment &&
|
||||
typeof material.consignment === 'object' &&
|
||||
!Array.isArray(material.consignment)
|
||||
? (material.consignment as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
export function hasInternalConsignmentFulfillment(workOrder: {
|
||||
assigned_worker_id?: number | null
|
||||
material_json?: unknown
|
||||
}): boolean {
|
||||
if (Number(workOrder.assigned_worker_id || 0) > 0) return false
|
||||
const consignment = parseWorkOrderConsignment(workOrder)
|
||||
if (!consignment.inventoryId) return false
|
||||
if (String(consignment.fulfillmentType || '') === CONSIGNMENT_FULFILLMENT_TYPE.WORKER_DEPOSIT) {
|
||||
return false
|
||||
}
|
||||
if (Number(consignment.workerId || 0) > 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 开始好友赠送冷却:内部寄售跳过打手资料,直接进入等 N 天。 */
|
||||
export async function startWorkOrderGiftCountdownWithClient(
|
||||
client: PoolClient,
|
||||
workOrder: WorkOrderRow,
|
||||
input: {
|
||||
now: string
|
||||
actorName?: string | undefined
|
||||
eventType?: string | undefined
|
||||
materialJson?: unknown
|
||||
extraPayload?: Record<string, unknown>
|
||||
forceFriendGift?: boolean
|
||||
},
|
||||
): Promise<WorkOrderRow | null> {
|
||||
if (GIFT_COUNTDOWN_BLOCKED_STATUSES.has(workOrder.status)) return null
|
||||
|
||||
let cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
|
||||
let cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
|
||||
if (input.forceFriendGift && cooldownHours === 0 && cooldownMinutes === 0) {
|
||||
cooldownHours = DEFAULT_GIFT_COOLDOWN_HOURS
|
||||
}
|
||||
const nextStatus = ['problem'].includes(workOrder.status) ? workOrder.status : 'in_progress'
|
||||
const clearAcceptedAt = ['accepted', 'completed'].includes(workOrder.status)
|
||||
const materialJson =
|
||||
input.materialJson === undefined
|
||||
? null
|
||||
: typeof input.materialJson === 'string'
|
||||
? input.materialJson
|
||||
: JSON.stringify(input.materialJson)
|
||||
const materialForInternalCheck =
|
||||
input.materialJson === undefined ? workOrder.material_json : input.materialJson
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
status = $1,
|
||||
acceptance_mode = CASE WHEN $7 THEN 'friend_gift' ELSE acceptance_mode END,
|
||||
gift_cooldown_hours = CASE WHEN $7 THEN $3 ELSE gift_cooldown_hours END,
|
||||
gift_phase = 'friend_countdown',
|
||||
friend_added_at = $2::timestamptz,
|
||||
gift_available_at = $2::timestamptz + ($3::int * INTERVAL '1 hour') + ($4::int * INTERVAL '1 minute'),
|
||||
deadline_at = CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $2::timestamptz + ($3::int * INTERVAL '1 hour') + ($4::int * INTERVAL '1 minute') + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END,
|
||||
accepted_at = CASE WHEN $5 THEN NULL ELSE accepted_at END,
|
||||
material_json = COALESCE($8::jsonb, material_json),
|
||||
updated_at = $2::timestamptz
|
||||
WHERE id = $6
|
||||
`,
|
||||
[
|
||||
nextStatus,
|
||||
input.now,
|
||||
cooldownHours,
|
||||
cooldownMinutes,
|
||||
clearAcceptedAt,
|
||||
workOrder.id,
|
||||
Boolean(input.forceFriendGift),
|
||||
materialJson,
|
||||
],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: Number(workOrder.id),
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: input.eventType || 'friend_confirmed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: nextStatus,
|
||||
payloadJson: JSON.stringify({
|
||||
cooldownHours,
|
||||
cooldownMinutes,
|
||||
friendAddedAt: input.now,
|
||||
internalConsignment: hasInternalConsignmentFulfillment({
|
||||
...workOrder,
|
||||
material_json: materialForInternalCheck,
|
||||
}),
|
||||
skippedWorkerMaterial: Boolean(input.forceFriendGift),
|
||||
giftPhase: WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN,
|
||||
...(input.extraPayload || {}),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
return getWorkOrderByIdWithClient(client, workOrder.id)
|
||||
}
|
||||
|
||||
/** 打手提交本人资料(昵称/ID/区服/系统 + 主页截图),进入待确认好友阶段。 */
|
||||
export async function submitWorkOrderGiftMaterial(input: {
|
||||
workOrderId: number
|
||||
@@ -102,6 +248,7 @@ export async function confirmWorkOrderFriendAdded(input: {
|
||||
| 'work_order_not_found'
|
||||
| 'work_order_mode_invalid'
|
||||
| 'work_order_gift_phase_invalid'
|
||||
| 'work_order_status_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
@@ -117,46 +264,26 @@ export async function confirmWorkOrderFriendAdded(input: {
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||
}
|
||||
if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) {
|
||||
if (!GIFT_COUNTDOWN_ALLOWED_STATUSES.has(workOrder.status)) {
|
||||
return { order: null, failureReason: 'work_order_status_invalid' }
|
||||
}
|
||||
const internalConsignment = hasInternalConsignmentFulfillment(workOrder)
|
||||
const canStartFromWorkerMaterial =
|
||||
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED
|
||||
const canStartFromInternalStock =
|
||||
internalConsignment &&
|
||||
(workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED ||
|
||||
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED)
|
||||
if (!canStartFromWorkerMaterial && !canStartFromInternalStock) {
|
||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||
}
|
||||
|
||||
const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
|
||||
const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
|
||||
// 参数必须显式转型:未知类型参数与 interval 做运算时 PostgreSQL 无法唯一解析操作符。
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
gift_phase = 'friend_countdown',
|
||||
friend_added_at = $1::timestamptz,
|
||||
gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute'),
|
||||
deadline_at = CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute') + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = $1::timestamptz
|
||||
WHERE id = $4
|
||||
`,
|
||||
[input.now, cooldownHours, cooldownMinutes, input.workOrderId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'friend_confirmed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
cooldownHours,
|
||||
cooldownMinutes,
|
||||
friendAddedAt: input.now,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
order: await startWorkOrderGiftCountdownWithClient(client, workOrder, {
|
||||
now: input.now,
|
||||
actorName: input.actorName,
|
||||
eventType: internalConsignment ? 'consignment_gift_countdown_started' : 'friend_confirmed',
|
||||
}),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './work-order-deposit-repo.js'
|
||||
import { maybeUpgradeWorkerLevelWithClient } from './worker-ranking-repo.js'
|
||||
import type { WorkOrderRow, WorkOrderShareRow } from './types.js'
|
||||
import { releaseConsignmentPublishDepositBySubmissionWithClient } from './consignment-deposit-repo.js'
|
||||
|
||||
export async function acceptWorkOrderAndSettle(input: {
|
||||
workOrderId: number
|
||||
@@ -76,6 +77,31 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
|
||||
const material =
|
||||
typeof workOrder.material_json === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(workOrder.material_json) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
: workOrder.material_json && typeof workOrder.material_json === 'object'
|
||||
? (workOrder.material_json as Record<string, unknown>)
|
||||
: {}
|
||||
const consignment =
|
||||
material.consignment && typeof material.consignment === 'object'
|
||||
? (material.consignment as Record<string, unknown>)
|
||||
: null
|
||||
const sourceSubmissionId = consignment?.sourceSubmissionId
|
||||
if (sourceSubmissionId) {
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId: String(sourceSubmissionId),
|
||||
note: `寄卖工单验收完成释放发布押金 #${input.workOrderId}`,
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
|
||||
if (shares.length > 0) {
|
||||
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
|
||||
const unsettledShares = shares.filter((share) => share.status !== 'accepted')
|
||||
|
||||
Reference in New Issue
Block a user