完善拼单状态与份额验收流程
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
-- 修复历史拼单:份数已拼满且所有参与者均已提交时,母工单应进入待验收。
|
||||||
|
UPDATE work_orders wo
|
||||||
|
SET
|
||||||
|
status = 'pending_acceptance',
|
||||||
|
submitted_at = COALESCE(
|
||||||
|
wo.submitted_at,
|
||||||
|
(
|
||||||
|
SELECT MAX(wos.submitted_at)
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id
|
||||||
|
AND wos.status != 'cancelled'
|
||||||
|
),
|
||||||
|
NOW()
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE wo.status = 'open'
|
||||||
|
AND wo.sharing_enabled = true
|
||||||
|
AND COALESCE((
|
||||||
|
SELECT SUM(wos.quantity)
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id
|
||||||
|
AND wos.status != 'cancelled'
|
||||||
|
), 0) >= wo.sharing_total_quantity
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id
|
||||||
|
AND wos.status = 'joined'
|
||||||
|
);
|
||||||
@@ -250,6 +250,9 @@ export type WorkOrderRow = {
|
|||||||
timeout_policy: string
|
timeout_policy: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
/** 拼单聚合字段,由工单查询统一计算。 */
|
||||||
|
active_share_quantity?: number
|
||||||
|
pending_share_count?: number
|
||||||
category_name?: string
|
category_name?: string
|
||||||
worker_username?: string
|
worker_username?: string
|
||||||
worker_display_name?: string
|
worker_display_name?: string
|
||||||
@@ -285,11 +288,15 @@ export type ListInput = {
|
|||||||
categoryId?: number
|
categoryId?: number
|
||||||
workerSharingId?: number
|
workerSharingId?: number
|
||||||
visibleAfterIso?: string
|
visibleAfterIso?: string
|
||||||
|
/** 抢单大厅隐藏已被拼完的工单。 */
|
||||||
|
excludeFilledSharing?: boolean
|
||||||
/** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */
|
/** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */
|
||||||
vipEvidencePending?: boolean
|
vipEvidencePending?: boolean
|
||||||
/** 置顶订单优先展示(抢单大厅使用) */
|
/** 置顶订单优先展示(抢单大厅使用) */
|
||||||
pinnedFirst?: boolean
|
pinnedFirst?: boolean
|
||||||
sort?: 'id_desc' | 'worker_claimed_at_desc'
|
sort?: 'id_desc' | 'worker_claimed_at_desc'
|
||||||
|
/** 后台使用拼单聚合业务状态;大厅和打手列表使用原始生命周期。 */
|
||||||
|
useOperationalStatus?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkOrderStatistics = {
|
export type WorkOrderStatistics = {
|
||||||
@@ -300,6 +307,22 @@ export type WorkOrderStatistics = {
|
|||||||
statusCounts: Record<string, number>
|
statusCounts: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkOrderCapabilities = {
|
||||||
|
canEdit: boolean
|
||||||
|
canConfigureSharing: boolean
|
||||||
|
canAssign: boolean
|
||||||
|
canPin: boolean
|
||||||
|
canUnpublish: boolean
|
||||||
|
canUpdateMaterial: boolean
|
||||||
|
canAccept: boolean
|
||||||
|
canMarkProblem: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
canPublish: boolean
|
||||||
|
canCancel: boolean
|
||||||
|
canResolveProblem: boolean
|
||||||
|
canDeduct: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type ProductRuleListInput = {
|
export type ProductRuleListInput = {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ import { maybeUpgradeWorkerLevelWithClient } from './worker-repo.js'
|
|||||||
const WORK_ORDER_SELECT = `
|
const WORK_ORDER_SELECT = `
|
||||||
SELECT
|
SELECT
|
||||||
wo.*,
|
wo.*,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(wos.quantity)
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id AND wos.status != 'cancelled'
|
||||||
|
), 0)::int AS active_share_quantity,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id AND wos.status = 'joined'
|
||||||
|
), 0)::int AS pending_share_count,
|
||||||
wc.name AS category_name,
|
wc.name AS category_name,
|
||||||
wu.username AS worker_username,
|
wu.username AS worker_username,
|
||||||
wu.display_name AS worker_display_name
|
wu.display_name AS worker_display_name
|
||||||
@@ -65,6 +75,22 @@ type WorkOrderStatisticsRow = {
|
|||||||
refund_amount: number | string
|
refund_amount: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 */
|
||||||
|
function adminOperationalStatusExpression(alias = 'wo') {
|
||||||
|
return `CASE
|
||||||
|
WHEN ${alias}.status = 'open'
|
||||||
|
AND ${alias}.sharing_enabled IS TRUE
|
||||||
|
AND COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) > 0
|
||||||
|
THEN CASE
|
||||||
|
WHEN COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) >= ${alias}.sharing_total_quantity
|
||||||
|
AND COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0) = 0
|
||||||
|
THEN 'pending_acceptance'
|
||||||
|
ELSE 'in_progress'
|
||||||
|
END
|
||||||
|
ELSE ${alias}.status
|
||||||
|
END`
|
||||||
|
}
|
||||||
|
|
||||||
/** 工单统一搜索字段,兼容当前资料结构和历史资料字段。 */
|
/** 工单统一搜索字段,兼容当前资料结构和历史资料字段。 */
|
||||||
function workOrderSearchExpression(alias = 'wo') {
|
function workOrderSearchExpression(alias = 'wo') {
|
||||||
return `LOWER(
|
return `LOWER(
|
||||||
@@ -82,6 +108,27 @@ function workOrderSearchExpression(alias = 'wo') {
|
|||||||
)`
|
)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打手订单筛选使用个人份额状态,而不是拼单母工单的 open 状态。 */
|
||||||
|
function workerOrderStatusExpression(workerIdPlaceholder: string) {
|
||||||
|
return `CASE
|
||||||
|
WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status
|
||||||
|
ELSE COALESCE((
|
||||||
|
SELECT 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
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id
|
||||||
|
AND wos.worker_id = ${workerIdPlaceholder}
|
||||||
|
ORDER BY wos.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), wo.status)
|
||||||
|
END`
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWorkOrderShares(
|
export async function listWorkOrderShares(
|
||||||
workOrderId: number | string,
|
workOrderId: number | string,
|
||||||
): Promise<WorkOrderShareRow[]> {
|
): Promise<WorkOrderShareRow[]> {
|
||||||
@@ -143,10 +190,21 @@ export async function getWorkOrderShare(
|
|||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */
|
||||||
|
export function resolveWorkOrderShareJoinQuantity(input: {
|
||||||
|
quantity: number
|
||||||
|
remaining: number
|
||||||
|
takeRemaining?: boolean
|
||||||
|
}): number {
|
||||||
|
return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity
|
||||||
|
}
|
||||||
|
|
||||||
export async function joinWorkOrderShare(input: {
|
export async function joinWorkOrderShare(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
workerId: number
|
workerId: number
|
||||||
quantity: number
|
quantity: number
|
||||||
|
/** 立即抢单时在事务锁内承接全部剩余份数。 */
|
||||||
|
takeRemaining?: boolean
|
||||||
maxActiveOrders: number
|
maxActiveOrders: number
|
||||||
now: string
|
now: string
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -194,36 +252,66 @@ export async function joinWorkOrderShare(input: {
|
|||||||
)
|
)
|
||||||
const joinedQuantity = Number(joinedResult.rows[0]?.total || 0)
|
const joinedQuantity = Number(joinedResult.rows[0]?.total || 0)
|
||||||
const remaining = Math.max(0, totalQuantity - joinedQuantity)
|
const remaining = Math.max(0, totalQuantity - joinedQuantity)
|
||||||
if (input.quantity <= 0 || input.quantity > remaining) {
|
const requestedQuantity = resolveWorkOrderShareJoinQuantity({
|
||||||
|
quantity: input.quantity,
|
||||||
|
remaining,
|
||||||
|
takeRemaining: input.takeRemaining === true,
|
||||||
|
})
|
||||||
|
if (requestedQuantity <= 0 || requestedQuantity > remaining) {
|
||||||
return { share: null, failureReason: 'sharing_quantity_full' }
|
return { share: null, failureReason: 'sharing_quantity_full' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingShareResult = await client.query<WorkOrderShareRow>(
|
||||||
|
`SELECT *
|
||||||
|
FROM work_order_shares
|
||||||
|
WHERE work_order_id = $1 AND worker_id = $2
|
||||||
|
FOR UPDATE`,
|
||||||
|
[input.workOrderId, input.workerId],
|
||||||
|
)
|
||||||
|
const existingShare = existingShareResult.rows[0] || null
|
||||||
|
if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) {
|
||||||
|
return { share: null, failureReason: 'sharing_quantity_full' }
|
||||||
|
}
|
||||||
|
if (!existingShare) {
|
||||||
const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId)
|
const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId)
|
||||||
if (activeOrderCount >= input.maxActiveOrders) {
|
if (activeOrderCount >= input.maxActiveOrders) {
|
||||||
return { share: null, failureReason: 'worker_active_order_limit' }
|
return { share: null, failureReason: 'worker_active_order_limit' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingShare = await client.query<{ id: number }>(
|
|
||||||
'SELECT id FROM work_order_shares WHERE work_order_id = $1 AND worker_id = $2',
|
|
||||||
[input.workOrderId, input.workerId],
|
|
||||||
)
|
|
||||||
if (existingShare.rows[0]) {
|
|
||||||
return { share: null, failureReason: 'sharing_quantity_full' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0)
|
const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0)
|
||||||
const shareReward = input.quantity * unitReward
|
const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity
|
||||||
const shareDeposit = Math.round(
|
const shareReward = nextQuantity * unitReward
|
||||||
(Number(workOrder.required_deposit_amount || 0) * input.quantity) / totalQuantity,
|
const nextShareDeposit = Math.round(
|
||||||
|
(Number(workOrder.required_deposit_amount || 0) * nextQuantity) / totalQuantity,
|
||||||
|
)
|
||||||
|
const depositToFreeze = Math.max(
|
||||||
|
0,
|
||||||
|
nextShareDeposit - Number(existingShare?.share_deposit || 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
const available = Number(wallet?.available_amount || 0)
|
const available = Number(wallet?.available_amount || 0)
|
||||||
if (available < shareDeposit) {
|
if (available < depositToFreeze) {
|
||||||
return { share: null, failureReason: 'worker_deposit_insufficient' }
|
return { share: null, failureReason: 'worker_deposit_insufficient' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareResult = await client.query<{ id: number }>(
|
const shareResult = existingShare
|
||||||
|
? await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
UPDATE work_order_shares
|
||||||
|
SET
|
||||||
|
quantity = $1,
|
||||||
|
unit_reward = $2,
|
||||||
|
share_reward = $3,
|
||||||
|
share_deposit = $4,
|
||||||
|
updated_at = $5
|
||||||
|
WHERE id = $6
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id],
|
||||||
|
)
|
||||||
|
: await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
INSERT INTO work_order_shares (
|
INSERT INTO work_order_shares (
|
||||||
work_order_id, worker_id, quantity, unit_reward, share_reward,
|
work_order_id, worker_id, quantity, unit_reward, share_reward,
|
||||||
@@ -234,17 +322,17 @@ export async function joinWorkOrderShare(input: {
|
|||||||
[
|
[
|
||||||
input.workOrderId,
|
input.workOrderId,
|
||||||
input.workerId,
|
input.workerId,
|
||||||
input.quantity,
|
requestedQuantity,
|
||||||
unitReward,
|
unitReward,
|
||||||
shareReward,
|
shareReward,
|
||||||
shareDeposit,
|
nextShareDeposit,
|
||||||
input.now,
|
input.now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (shareDeposit > 0) {
|
if (depositToFreeze > 0) {
|
||||||
const nextAvailable = available - shareDeposit
|
const nextAvailable = available - depositToFreeze
|
||||||
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + shareDeposit
|
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + depositToFreeze
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE worker_wallets
|
UPDATE worker_wallets
|
||||||
@@ -262,12 +350,12 @@ export async function joinWorkOrderShare(input: {
|
|||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
input.workerId,
|
input.workerId,
|
||||||
shareDeposit,
|
depositToFreeze,
|
||||||
nextAvailable,
|
nextAvailable,
|
||||||
nextFrozen,
|
nextFrozen,
|
||||||
input.workOrderId,
|
input.workOrderId,
|
||||||
`拼单冻结押金 ${input.quantity} 份`,
|
`拼单冻结押金 ${requestedQuantity} 份`,
|
||||||
JSON.stringify({ workOrderId: input.workOrderId, quantity: input.quantity }),
|
JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }),
|
||||||
input.now,
|
input.now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -280,7 +368,11 @@ export async function joinWorkOrderShare(input: {
|
|||||||
eventType: 'sharing_joined',
|
eventType: 'sharing_joined',
|
||||||
fromStatus: 'open',
|
fromStatus: 'open',
|
||||||
toStatus: 'open',
|
toStatus: 'open',
|
||||||
payloadJson: JSON.stringify({ workerId: input.workerId, quantity: input.quantity }),
|
payloadJson: JSON.stringify({
|
||||||
|
workerId: input.workerId,
|
||||||
|
quantity: requestedQuantity,
|
||||||
|
takeRemaining: input.takeRemaining === true,
|
||||||
|
}),
|
||||||
now: input.now,
|
now: input.now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -302,6 +394,21 @@ export async function submitWorkOrderShareAcceptance(input: {
|
|||||||
failureReason: 'share_not_found' | 'share_status_invalid' | null
|
failureReason: 'share_not_found' | 'share_status_invalid' | null
|
||||||
}> {
|
}> {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
|
const orderResult = await client.query<{
|
||||||
|
status: string
|
||||||
|
sharing_total_quantity: number
|
||||||
|
}>(
|
||||||
|
`SELECT status, sharing_total_quantity
|
||||||
|
FROM work_orders
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE`,
|
||||||
|
[input.workOrderId],
|
||||||
|
)
|
||||||
|
const workOrder = orderResult.rows[0] || null
|
||||||
|
if (!workOrder || !['open', 'pending_acceptance'].includes(workOrder.status)) {
|
||||||
|
return { share: null, failureReason: 'share_not_found' }
|
||||||
|
}
|
||||||
|
|
||||||
const lockResult = await client.query<{ id: number }>(
|
const lockResult = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id
|
||||||
@@ -334,6 +441,36 @@ export async function submitWorkOrderShareAcceptance(input: {
|
|||||||
`,
|
`,
|
||||||
[input.acceptanceJson, input.now, Number(share.id)],
|
[input.acceptanceJson, input.now, Number(share.id)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const progressResult = await client.query<{
|
||||||
|
joined_quantity: number
|
||||||
|
pending_submission_count: number
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(quantity), 0)::int AS joined_quantity,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'joined')::int AS pending_submission_count
|
||||||
|
FROM work_order_shares
|
||||||
|
WHERE work_order_id = $1
|
||||||
|
AND status != 'cancelled'`,
|
||||||
|
[input.workOrderId],
|
||||||
|
)
|
||||||
|
const progress = progressResult.rows[0]
|
||||||
|
const allQuantityAllocated =
|
||||||
|
Number(progress?.joined_quantity || 0) >=
|
||||||
|
toPositiveInteger(workOrder.sharing_total_quantity, 1)
|
||||||
|
if (allQuantityAllocated && Number(progress?.pending_submission_count || 0) === 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE work_orders
|
||||||
|
SET
|
||||||
|
status = 'pending_acceptance',
|
||||||
|
submitted_at = COALESCE(submitted_at, $1),
|
||||||
|
updated_at = $1
|
||||||
|
WHERE id = $2
|
||||||
|
AND status = 'open'`,
|
||||||
|
[input.now, input.workOrderId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
await createWorkOrderEventWithClient(client, {
|
await createWorkOrderEventWithClient(client, {
|
||||||
workOrderId: input.workOrderId,
|
workOrderId: input.workOrderId,
|
||||||
actorType: 'worker',
|
actorType: 'worker',
|
||||||
@@ -911,7 +1048,9 @@ export async function listWorkOrders({
|
|||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
workerSharingId = 0,
|
workerSharingId = 0,
|
||||||
visibleAfterIso = '',
|
visibleAfterIso = '',
|
||||||
|
excludeFilledSharing = false,
|
||||||
vipEvidencePending = false,
|
vipEvidencePending = false,
|
||||||
|
useOperationalStatus = false,
|
||||||
pinnedFirst = false,
|
pinnedFirst = false,
|
||||||
sort = 'id_desc',
|
sort = 'id_desc',
|
||||||
}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> {
|
}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> {
|
||||||
@@ -924,7 +1063,9 @@ export async function listWorkOrders({
|
|||||||
categoryId,
|
categoryId,
|
||||||
workerSharingId,
|
workerSharingId,
|
||||||
visibleAfterIso,
|
visibleAfterIso,
|
||||||
|
excludeFilledSharing,
|
||||||
vipEvidencePending,
|
vipEvidencePending,
|
||||||
|
useOperationalStatus,
|
||||||
})
|
})
|
||||||
const totalResult = await query<{ total: number }>(
|
const totalResult = await query<{ total: number }>(
|
||||||
`
|
`
|
||||||
@@ -977,9 +1118,12 @@ export async function getWorkOrderStatistics(input: ListInput = {}): Promise<Wor
|
|||||||
AND wo.acceptance_json->>'evidenceStatus' = 'pending'
|
AND wo.acceptance_json->>'evidenceStatus' = 'pending'
|
||||||
AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||||
`
|
`
|
||||||
const statusExpression = input.vipEvidencePending
|
const operationalStatus = input.useOperationalStatus
|
||||||
? `CASE WHEN ${vipEvidencePendingWhere} THEN 'vip_evidence_pending' ELSE wo.status END`
|
? adminOperationalStatusExpression()
|
||||||
: 'wo.status'
|
: 'wo.status'
|
||||||
|
const statusExpression = input.vipEvidencePending
|
||||||
|
? `CASE WHEN ${vipEvidencePendingWhere} THEN 'vip_evidence_pending' ELSE ${operationalStatus} END`
|
||||||
|
: operationalStatus
|
||||||
const paymentExpression = `
|
const paymentExpression = `
|
||||||
CASE
|
CASE
|
||||||
WHEN COALESCE(wo.material_json->'source'->>'orderAmountFen', '') ~ '^-?[0-9]+$'
|
WHEN COALESCE(wo.material_json->'source'->>'orderAmountFen', '') ~ '^-?[0-9]+$'
|
||||||
@@ -1041,7 +1185,17 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
|||||||
`
|
`
|
||||||
WITH worker_orders AS (
|
WITH worker_orders AS (
|
||||||
SELECT
|
SELECT
|
||||||
wo.status,
|
CASE
|
||||||
|
WHEN wos.id IS NOT NULL 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 AS status,
|
||||||
wo.acceptance_json,
|
wo.acceptance_json,
|
||||||
wos.id IS NOT NULL AS is_worker_share,
|
wos.id IS NOT NULL AS is_worker_share,
|
||||||
CASE
|
CASE
|
||||||
@@ -2124,6 +2278,162 @@ export async function acceptWorkOrderAndSettle(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单独验收一个拼单份额,只结算该参与者,不影响其他份额。 */
|
||||||
|
export async function acceptWorkOrderShareAndSettle(input: {
|
||||||
|
workOrderId: number
|
||||||
|
shareId: number
|
||||||
|
unfreezeDays: number
|
||||||
|
now: string
|
||||||
|
actorType?: string
|
||||||
|
actorName?: string
|
||||||
|
}): Promise<{
|
||||||
|
order: WorkOrderRow | null
|
||||||
|
share: WorkOrderShareRow | null
|
||||||
|
failureReason: 'work_order_not_accepting' | 'share_not_submitted' | null
|
||||||
|
}> {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const orderResult = await client.query<WorkOrderRow>(
|
||||||
|
`SELECT * FROM work_orders WHERE id = $1 AND status IN ('open', 'pending_acceptance') FOR UPDATE`,
|
||||||
|
[input.workOrderId],
|
||||||
|
)
|
||||||
|
const workOrder = orderResult.rows[0] || null
|
||||||
|
if (!workOrder) {
|
||||||
|
return { order: null, share: null, failureReason: 'work_order_not_accepting' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareResult = await client.query<WorkOrderShareRow>(
|
||||||
|
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
|
||||||
|
[input.shareId, input.workOrderId],
|
||||||
|
)
|
||||||
|
const share = shareResult.rows[0] || null
|
||||||
|
if (!share || share.status !== 'submitted') {
|
||||||
|
return { order: workOrder, share, failureReason: 'share_not_submitted' }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 shouldDelayUnfreeze = input.unfreezeDays > 0
|
||||||
|
const nextAvailable =
|
||||||
|
Number(wallet?.available_amount || 0) +
|
||||||
|
shareReward +
|
||||||
|
(shouldDelayUnfreeze ? 0 : releaseAmount)
|
||||||
|
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,
|
||||||
|
shouldDelayUnfreeze ? releaseAmount : 0,
|
||||||
|
shareReward,
|
||||||
|
input.now,
|
||||||
|
workerId,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (releaseAmount > 0 && shouldDelayUnfreeze) {
|
||||||
|
await enqueueDepositUnfreezeWithClient(client, {
|
||||||
|
workerId,
|
||||||
|
workOrderId: input.workOrderId,
|
||||||
|
amount: releaseAmount,
|
||||||
|
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,
|
||||||
|
shouldDelayUnfreeze ? 'deposit_pending_unfreeze' : 'deposit_release',
|
||||||
|
releaseAmount,
|
||||||
|
nextAvailable,
|
||||||
|
nextFrozen,
|
||||||
|
input.workOrderId,
|
||||||
|
shouldDelayUnfreeze ? '拼单份额验收押金待解冻' : '拼单份额验收释放押金',
|
||||||
|
JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (shareReward > 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, 'sharing_reward', $2, $3, $4, $5, $6, $7::jsonb, $8)`,
|
||||||
|
[
|
||||||
|
workerId,
|
||||||
|
shareReward,
|
||||||
|
nextAvailable,
|
||||||
|
nextFrozen,
|
||||||
|
input.workOrderId,
|
||||||
|
`拼单份额验收结算报酬 ${share.quantity} 份`,
|
||||||
|
JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await maybeUpgradeWorkerLevelWithClient(client, workerId, input.now)
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE work_order_shares SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
||||||
|
[input.now, input.shareId],
|
||||||
|
)
|
||||||
|
const progressResult = await client.query<{ joined_quantity: number; unfinished_count: number }>(
|
||||||
|
`SELECT COALESCE(SUM(quantity), 0)::int AS joined_quantity,
|
||||||
|
COUNT(*) FILTER (WHERE status != 'accepted' AND status != 'cancelled')::int AS unfinished_count
|
||||||
|
FROM work_order_shares WHERE work_order_id = $1 AND status != 'cancelled'`,
|
||||||
|
[input.workOrderId],
|
||||||
|
)
|
||||||
|
const progress = progressResult.rows[0]
|
||||||
|
const filled =
|
||||||
|
Number(progress?.joined_quantity || 0) >= Number(workOrder.sharing_total_quantity || 1)
|
||||||
|
const allAccepted = Number(progress?.unfinished_count || 0) === 0
|
||||||
|
const nextOrderStatus = filled && allAccepted ? 'accepted' : workOrder.status
|
||||||
|
if (nextOrderStatus === 'accepted') {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
||||||
|
[input.now, input.workOrderId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await createWorkOrderEventWithClient(client, {
|
||||||
|
workOrderId: input.workOrderId,
|
||||||
|
actorType: input.actorType || 'admin',
|
||||||
|
actorId: input.actorName || '',
|
||||||
|
eventType: 'sharing_share_accepted',
|
||||||
|
fromStatus: workOrder.status,
|
||||||
|
toStatus: nextOrderStatus,
|
||||||
|
payloadJson: JSON.stringify({ shareId: input.shareId, workerId }),
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId)
|
||||||
|
const updatedShareResult = await client.query<WorkOrderShareRow>(
|
||||||
|
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||||
|
[input.shareId],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
order: updatedOrder,
|
||||||
|
share: updatedShareResult.rows[0] || null,
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveProblemWorkOrder(input: {
|
export async function resolveProblemWorkOrder(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
action: ProblemWorkOrderResolutionAction
|
action: ProblemWorkOrderResolutionAction
|
||||||
@@ -2869,7 +3179,9 @@ function buildWorkOrderWhere({
|
|||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
workerSharingId = 0,
|
workerSharingId = 0,
|
||||||
visibleAfterIso = '',
|
visibleAfterIso = '',
|
||||||
|
excludeFilledSharing = false,
|
||||||
vipEvidencePending = false,
|
vipEvidencePending = false,
|
||||||
|
useOperationalStatus = false,
|
||||||
}: ListInput) {
|
}: ListInput) {
|
||||||
const filters: string[] = []
|
const filters: string[] = []
|
||||||
const params: unknown[] = []
|
const params: unknown[] = []
|
||||||
@@ -2885,13 +3197,21 @@ function buildWorkOrderWhere({
|
|||||||
`
|
`
|
||||||
if (normalizedStatuses.length > 0) {
|
if (normalizedStatuses.length > 0) {
|
||||||
params.push(normalizedStatuses)
|
params.push(normalizedStatuses)
|
||||||
filters.push(`(wo.status = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`)
|
filters.push(`(${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`)
|
||||||
} else {
|
} else {
|
||||||
filters.push(`(${vipEvidencePendingWhere})`)
|
filters.push(`(${vipEvidencePendingWhere})`)
|
||||||
}
|
}
|
||||||
} else if (normalizedStatuses.length > 0) {
|
} else if (normalizedStatuses.length > 0) {
|
||||||
params.push(normalizedStatuses)
|
params.push(normalizedStatuses)
|
||||||
filters.push(`wo.status = ANY($${params.length}::text[])`)
|
const statusesPlaceholder = `$${params.length}`
|
||||||
|
if (workerSharingId) {
|
||||||
|
params.push(workerSharingId)
|
||||||
|
filters.push(
|
||||||
|
`${workerOrderStatusExpression(`$${params.length}`)} = ANY(${statusesPlaceholder}::text[])`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filters.push(`${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (workOrderId) {
|
if (workOrderId) {
|
||||||
params.push(workOrderId)
|
params.push(workOrderId)
|
||||||
@@ -2924,6 +3244,17 @@ function buildWorkOrderWhere({
|
|||||||
params.push(visibleAfterIso)
|
params.push(visibleAfterIso)
|
||||||
filters.push(`(wo.published_at IS NULL OR wo.published_at <= $${params.length})`)
|
filters.push(`(wo.published_at IS NULL OR wo.published_at <= $${params.length})`)
|
||||||
}
|
}
|
||||||
|
if (excludeFilledSharing) {
|
||||||
|
filters.push(`(
|
||||||
|
wo.sharing_enabled IS NOT TRUE
|
||||||
|
OR COALESCE((
|
||||||
|
SELECT SUM(wos.quantity)
|
||||||
|
FROM work_order_shares wos
|
||||||
|
WHERE wos.work_order_id = wo.id
|
||||||
|
AND wos.status != 'cancelled'
|
||||||
|
), 0) < wo.sharing_total_quantity
|
||||||
|
)`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
acceptAdminWorkOrder,
|
acceptAdminWorkOrder,
|
||||||
|
acceptAdminWorkOrderShare,
|
||||||
acceptAdminWorkOrders,
|
acceptAdminWorkOrders,
|
||||||
assignAdminWorkOrderToWorker,
|
assignAdminWorkOrderToWorker,
|
||||||
cancelAdminWorkOrder,
|
cancelAdminWorkOrder,
|
||||||
@@ -829,6 +830,30 @@ router.post(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/worker-platform/orders/:workOrderId/shares/:shareId/accept',
|
||||||
|
requireAdminRoles(['admin', 'operator']),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
acceptAdminWorkOrderShare(
|
||||||
|
String(req.params.workOrderId || ''),
|
||||||
|
String(req.params.shareId || ''),
|
||||||
|
req.adminSession?.username || '',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '拼单份额已验收通过',
|
||||||
|
errorMessage: '验收拼单份额失败',
|
||||||
|
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/accept]',
|
||||||
|
audit: (req, data) => ({
|
||||||
|
action: 'work_order_share_accepted',
|
||||||
|
targetType: 'work_order_share',
|
||||||
|
targetId: String(req.params.shareId || ''),
|
||||||
|
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
|
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
|
||||||
requireAdminRoles(['admin', 'operator']),
|
requireAdminRoles(['admin', 'operator']),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { getAdminDashboardSummary } from '../admin/admin-dashboard-service.js'
|
|||||||
import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js'
|
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,
|
||||||
mapFinanceRequest,
|
mapFinanceRequest,
|
||||||
mapWorkOrderAdmin,
|
mapWorkOrderAdmin,
|
||||||
mapWorkOrderForWorker,
|
mapWorkOrderForWorker,
|
||||||
@@ -256,7 +257,19 @@ async function publishWorkOrderSnapshots(input: {
|
|||||||
const adminSnapshot = {
|
const adminSnapshot = {
|
||||||
...mapWorkOrderAdmin(workOrder),
|
...mapWorkOrderAdmin(workOrder),
|
||||||
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(workOrderId) || 0,
|
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(workOrderId) || 0,
|
||||||
sharingProgress: { joinedQuantity, pendingSubmissionCount },
|
sharingProgress: {
|
||||||
|
joinedQuantity,
|
||||||
|
pendingSubmissionCount,
|
||||||
|
remainingQuantity: Math.max(
|
||||||
|
0,
|
||||||
|
Number(workOrder.sharing_total_quantity || 1) - joinedQuantity,
|
||||||
|
),
|
||||||
|
filled: isWorkOrderSharingFilled(
|
||||||
|
workOrder.sharing_enabled === true,
|
||||||
|
Number(workOrder.sharing_total_quantity || 1),
|
||||||
|
joinedQuantity,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
publishRealtimeEvent({
|
publishRealtimeEvent({
|
||||||
@@ -269,11 +282,16 @@ async function publishWorkOrderSnapshots(input: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (input.hallChanged) {
|
if (input.hallChanged) {
|
||||||
|
const sharingFilled = isWorkOrderSharingFilled(
|
||||||
|
workOrder.sharing_enabled === true,
|
||||||
|
Number(workOrder.sharing_total_quantity || 1),
|
||||||
|
joinedQuantity,
|
||||||
|
)
|
||||||
publishRealtimeEvent({
|
publishRealtimeEvent({
|
||||||
type: 'work_order.changed',
|
type: 'work_order.changed',
|
||||||
entityId: workOrderId,
|
entityId: workOrderId,
|
||||||
scopes: ['worker_hall'],
|
scopes: ['worker_hall'],
|
||||||
operation: workOrder.status === 'open' ? 'upsert' : 'remove',
|
operation: workOrder.status === 'open' && !sharingFilled ? 'upsert' : 'remove',
|
||||||
data: mapWorkerHallWorkOrder(workOrder, shares),
|
data: mapWorkerHallWorkOrder(workOrder, shares),
|
||||||
broadcastWorkers: true,
|
broadcastWorkers: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
|||||||
import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
import {
|
import {
|
||||||
acceptWorkOrderAndSettle,
|
acceptWorkOrderAndSettle,
|
||||||
|
acceptWorkOrderShareAndSettle,
|
||||||
addWorkerWalletCredit,
|
addWorkerWalletCredit,
|
||||||
assignWorkOrderToWorker,
|
assignWorkOrderToWorker,
|
||||||
cancelAssignedWorkOrder,
|
cancelAssignedWorkOrder,
|
||||||
@@ -43,7 +44,6 @@ import {
|
|||||||
listWorkerUsers,
|
listWorkerUsers,
|
||||||
listWorkerWithdrawalAccounts,
|
listWorkerWithdrawalAccounts,
|
||||||
listWorkOrderShares,
|
listWorkOrderShares,
|
||||||
listWorkOrderSharesByOrderIds,
|
|
||||||
sumPendingUnfreezeByOrderIds,
|
sumPendingUnfreezeByOrderIds,
|
||||||
resolveProblemWorkOrder,
|
resolveProblemWorkOrder,
|
||||||
reviewWorkerFinanceRequest,
|
reviewWorkerFinanceRequest,
|
||||||
@@ -59,7 +59,6 @@ import {
|
|||||||
type WorkOrderRow,
|
type WorkOrderRow,
|
||||||
type WorkProductMatchLogRow,
|
type WorkProductMatchLogRow,
|
||||||
type WorkProductRuleMappingRow,
|
type WorkProductRuleMappingRow,
|
||||||
type WorkOrderShareRow,
|
|
||||||
type WorkerWithdrawalAccountRow,
|
type WorkerWithdrawalAccountRow,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
import type { JsonObject } from '../../types/json.js'
|
import type { JsonObject } from '../../types/json.js'
|
||||||
@@ -135,6 +134,7 @@ import {
|
|||||||
resolveRequirementFields,
|
resolveRequirementFields,
|
||||||
resolveSkuNameQuantity,
|
resolveSkuNameQuantity,
|
||||||
resolveWorkOrderRulePricing,
|
resolveWorkOrderRulePricing,
|
||||||
|
resolveWorkOrderSharingEnabled,
|
||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
import {
|
import {
|
||||||
@@ -943,7 +943,8 @@ export async function assignAdminWorkOrderToWorker(
|
|||||||
actorName = '',
|
actorName = '',
|
||||||
) {
|
) {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (!['unassigned', 'open'].includes(workOrder.status) || workOrder.assigned_worker_id) {
|
const capabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||||
|
if (!capabilities.canAssign) {
|
||||||
throw createHttpError('只有未分配或待抢单且未指派打手的订单才能指派', {
|
throw createHttpError('只有未分配或待抢单且未指派打手的订单才能指派', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_not_assignable',
|
errorCode: 'work_order_not_assignable',
|
||||||
@@ -1284,6 +1285,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
|||||||
keyword: String(query.keyword || '').trim(),
|
keyword: String(query.keyword || '').trim(),
|
||||||
workerSharingId: workerId,
|
workerSharingId: workerId,
|
||||||
vipEvidencePending,
|
vipEvidencePending,
|
||||||
|
useOperationalStatus: true,
|
||||||
})
|
})
|
||||||
const statistics = await getWorkOrderStatistics({
|
const statistics = await getWorkOrderStatistics({
|
||||||
statuses,
|
statuses,
|
||||||
@@ -1291,32 +1293,16 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
|||||||
keyword: String(query.keyword || '').trim(),
|
keyword: String(query.keyword || '').trim(),
|
||||||
workerSharingId: workerId,
|
workerSharingId: workerId,
|
||||||
vipEvidencePending,
|
vipEvidencePending,
|
||||||
|
useOperationalStatus: true,
|
||||||
})
|
})
|
||||||
const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id)))
|
|
||||||
const sharesByOrderId = new Map<number, WorkOrderShareRow[]>()
|
|
||||||
for (const share of shares) {
|
|
||||||
const orderId = Number(share.work_order_id)
|
|
||||||
const bucket = sharesByOrderId.get(orderId) || []
|
|
||||||
bucket.push(share)
|
|
||||||
sharesByOrderId.set(orderId, bucket)
|
|
||||||
}
|
|
||||||
const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds(
|
const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds(
|
||||||
items.map((item) => Number(item.id)),
|
items.map((item) => Number(item.id)),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
items: items.map((item) => {
|
items: items.map((item) => {
|
||||||
const orderShares = sharesByOrderId.get(Number(item.id)) || []
|
|
||||||
const joinedQuantity = orderShares
|
|
||||||
.filter((share) => share.status !== 'cancelled')
|
|
||||||
.reduce((sum, share) => sum + Number(share.quantity || 0), 0)
|
|
||||||
const pendingSubmissionCount = orderShares.filter((share) => share.status === 'joined').length
|
|
||||||
return {
|
return {
|
||||||
...mapWorkOrderAdmin(item),
|
...mapWorkOrderAdmin(item),
|
||||||
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0,
|
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0,
|
||||||
sharingProgress: {
|
|
||||||
joinedQuantity,
|
|
||||||
pendingSubmissionCount,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
pagination: { page, pageSize, total },
|
pagination: { page, pageSize, total },
|
||||||
@@ -1400,13 +1386,7 @@ export async function updateAdminWorkOrderSharing(
|
|||||||
actorName = '',
|
actorName = '',
|
||||||
) {
|
) {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (
|
if (!mapWorkOrderAdmin(workOrder).capabilities.canConfigureSharing) {
|
||||||
![
|
|
||||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
|
||||||
WORK_ORDER_STATUS.UNASSIGNED,
|
|
||||||
WORK_ORDER_STATUS.OPEN,
|
|
||||||
].includes(workOrder.status as never)
|
|
||||||
) {
|
|
||||||
throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', {
|
throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_sharing_config_status_invalid',
|
errorCode: 'work_order_sharing_config_status_invalid',
|
||||||
@@ -1484,21 +1464,16 @@ export async function submitAdminWorkOrderMaterial(
|
|||||||
actorName = '',
|
actorName = '',
|
||||||
) {
|
) {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (
|
const materialCapabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||||
![
|
if (!materialCapabilities.canUpdateMaterial) {
|
||||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
|
||||||
WORK_ORDER_STATUS.UNASSIGNED,
|
|
||||||
WORK_ORDER_STATUS.IN_PROGRESS,
|
|
||||||
].includes(workOrder.status as never)
|
|
||||||
) {
|
|
||||||
throw createHttpError('当前状态不能更新订单资料', {
|
throw createHttpError('当前状态不能更新订单资料', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_material_status_invalid',
|
errorCode: 'work_order_material_status_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
|
||||||
workOrder.sharing_enabled === true &&
|
workOrder.sharing_enabled === true &&
|
||||||
|
Number(workOrder.active_share_quantity || 0) > 0 &&
|
||||||
payload.rewardAmount !== undefined
|
payload.rewardAmount !== undefined
|
||||||
) {
|
) {
|
||||||
throw createHttpError('拼单订单的份额奖励已锁定,不能在更新资料时修改价格', {
|
throw createHttpError('拼单订单的份额奖励已锁定,不能在更新资料时修改价格', {
|
||||||
@@ -1515,7 +1490,9 @@ export async function submitAdminWorkOrderMaterial(
|
|||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
||||||
const rewardAmount =
|
const rewardAmount =
|
||||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && payload.rewardAmount !== undefined
|
workOrder.sharing_enabled === true &&
|
||||||
|
Number(workOrder.active_share_quantity || 0) > 0 &&
|
||||||
|
payload.rewardAmount !== undefined
|
||||||
? normalizeAmountFen(payload.rewardAmount, workOrder.reward_amount)
|
? normalizeAmountFen(payload.rewardAmount, workOrder.reward_amount)
|
||||||
: workOrder.reward_amount
|
: workOrder.reward_amount
|
||||||
const material = {
|
const material = {
|
||||||
@@ -1528,9 +1505,10 @@ export async function submitAdminWorkOrderMaterial(
|
|||||||
source: 'admin',
|
source: 'admin',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
const hasActiveShares = Number(workOrder.active_share_quantity || 0) > 0
|
||||||
const nextStatus =
|
const nextStatus =
|
||||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
hasActiveShares || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||||
? WORK_ORDER_STATUS.IN_PROGRESS
|
? workOrder.status
|
||||||
: complete
|
: complete
|
||||||
? WORK_ORDER_STATUS.UNASSIGNED
|
? WORK_ORDER_STATUS.UNASSIGNED
|
||||||
: WORK_ORDER_STATUS.PENDING_MATERIAL
|
: WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||||
@@ -1545,7 +1523,7 @@ export async function submitAdminWorkOrderMaterial(
|
|||||||
actorType: 'admin',
|
actorType: 'admin',
|
||||||
actorId: actorName,
|
actorId: actorName,
|
||||||
eventType:
|
eventType:
|
||||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
hasActiveShares || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||||
? 'material_updated'
|
? 'material_updated'
|
||||||
: 'material_completed',
|
: 'material_completed',
|
||||||
fromStatus: workOrder.status,
|
fromStatus: workOrder.status,
|
||||||
@@ -1654,13 +1632,7 @@ export async function updateAdminWorkOrder(
|
|||||||
options: { canEditRequirementFields?: boolean; actorName?: string } = {},
|
options: { canEditRequirementFields?: boolean; actorName?: string } = {},
|
||||||
) {
|
) {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (
|
if (!mapWorkOrderAdmin(workOrder).capabilities.canEdit) {
|
||||||
![
|
|
||||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
|
||||||
WORK_ORDER_STATUS.UNASSIGNED,
|
|
||||||
WORK_ORDER_STATUS.OPEN,
|
|
||||||
].includes(workOrder.status as never)
|
|
||||||
) {
|
|
||||||
throw createHttpError('只有待完善、未分配或待抢单的工单可以编辑', {
|
throw createHttpError('只有待完善、未分配或待抢单的工单可以编辑', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_edit_status_invalid',
|
errorCode: 'work_order_edit_status_invalid',
|
||||||
@@ -1738,13 +1710,7 @@ export async function updateAdminWorkOrder(
|
|||||||
|
|
||||||
export async function deleteAdminWorkOrder(workOrderId: number | string) {
|
export async function deleteAdminWorkOrder(workOrderId: number | string) {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (
|
if (!mapWorkOrderAdmin(workOrder).capabilities.canDelete) {
|
||||||
![
|
|
||||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
|
||||||
WORK_ORDER_STATUS.UNASSIGNED,
|
|
||||||
WORK_ORDER_STATUS.CANCELLED,
|
|
||||||
].includes(workOrder.status as never)
|
|
||||||
) {
|
|
||||||
throw createHttpError('只有待完善、未分配或已取消的工单可以删除', {
|
throw createHttpError('只有待完善、未分配或已取消的工单可以删除', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_delete_status_invalid',
|
errorCode: 'work_order_delete_status_invalid',
|
||||||
@@ -1847,7 +1813,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
|||||||
|
|
||||||
export async function unpublishAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
export async function unpublishAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
|
if (!mapWorkOrderAdmin(workOrder).capabilities.canUnpublish) {
|
||||||
throw createHttpError('只有待抢单且未被接走的订单可以下架', {
|
throw createHttpError('只有待抢单且未被接走的订单可以下架', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_unpublish_status_invalid',
|
errorCode: 'work_order_unpublish_status_invalid',
|
||||||
@@ -1879,7 +1845,8 @@ export async function pinAdminWorkOrder(
|
|||||||
) {
|
) {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
const pinned = payload.pinned === true
|
const pinned = payload.pinned === true
|
||||||
if (pinned && workOrder.status !== WORK_ORDER_STATUS.OPEN) {
|
const capabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||||
|
if (pinned && (!capabilities.canPin || !mapWorkOrderAdmin(workOrder).hallAvailable)) {
|
||||||
throw createHttpError('只有待抢单订单可以置顶', {
|
throw createHttpError('只有待抢单订单可以置顶', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_pin_status_invalid',
|
errorCode: 'work_order_pin_status_invalid',
|
||||||
@@ -1909,11 +1876,7 @@ export async function markAdminWorkOrderProblem(
|
|||||||
actorName = '',
|
actorName = '',
|
||||||
) {
|
) {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (
|
if (!mapWorkOrderAdmin(workOrder).capabilities.canMarkProblem) {
|
||||||
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(
|
|
||||||
workOrder.status as never,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw createHttpError('当前状态不能标记问题单', {
|
throw createHttpError('当前状态不能标记问题单', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_problem_status_invalid',
|
errorCode: 'work_order_problem_status_invalid',
|
||||||
@@ -1989,13 +1952,21 @@ export async function resolveAdminProblemWorkOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
let workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
|
if (!mapWorkOrderAdmin(workOrder).capabilities.canAccept) {
|
||||||
throw createHttpError('只有待验收订单可以通过验收', {
|
throw createHttpError('只有待验收订单可以通过验收', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_accept_status_invalid',
|
errorCode: 'work_order_accept_status_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 历史拼单订单可能仍保留 open,验收前先补齐母工单生命周期状态。
|
||||||
|
if (workOrder.status === WORK_ORDER_STATUS.OPEN) {
|
||||||
|
const promoted = await updateWorkOrder(workOrder.id, {
|
||||||
|
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
|
updated_at: nowIso(),
|
||||||
|
})
|
||||||
|
if (promoted) workOrder = promoted
|
||||||
|
}
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
|
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
|
||||||
workOrderId: workOrder.id,
|
workOrderId: workOrder.id,
|
||||||
@@ -2028,6 +1999,46 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa
|
|||||||
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function acceptAdminWorkOrderShare(
|
||||||
|
workOrderId: number | string,
|
||||||
|
shareId: number | string,
|
||||||
|
actorName = '',
|
||||||
|
) {
|
||||||
|
const result = await acceptWorkOrderShareAndSettle({
|
||||||
|
workOrderId: Number(workOrderId),
|
||||||
|
shareId: Number(shareId),
|
||||||
|
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
|
||||||
|
now: nowIso(),
|
||||||
|
actorType: 'admin',
|
||||||
|
actorName,
|
||||||
|
})
|
||||||
|
if (result.failureReason === 'work_order_not_accepting') {
|
||||||
|
throw createHttpError('当前母工单不能验收拼单份额', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_sharing_accept_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.failureReason === 'share_not_submitted' || !result.share || !result.order) {
|
||||||
|
throw createHttpError('该拼单参与者尚未提交验收资料或已被验收', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_sharing_share_accept_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const workerId = Number(result.share.worker_id || 0)
|
||||||
|
publishWorkOrderRealtimeChange({
|
||||||
|
workOrderId: Number(workOrderId),
|
||||||
|
workerIds: workerId > 0 ? [workerId] : [],
|
||||||
|
})
|
||||||
|
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
|
||||||
|
if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) {
|
||||||
|
await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
order: mapWorkOrderAdmin(result.order),
|
||||||
|
share: mapWorkOrderShare(result.share),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
|
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
|
||||||
const workOrderIds = [
|
const workOrderIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -2187,8 +2198,13 @@ export async function getAdminWorkerPlatformSummary() {
|
|||||||
const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([
|
const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([
|
||||||
listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }),
|
listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }),
|
||||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.PENDING_MATERIAL] }),
|
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.PENDING_MATERIAL] }),
|
||||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.OPEN] }),
|
listWorkOrders({
|
||||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.IN_PROGRESS] }),
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
statuses: [WORK_ORDER_STATUS.OPEN],
|
||||||
|
excludeFilledSharing: true,
|
||||||
|
}),
|
||||||
|
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.IN_PROGRESS], useOperationalStatus: true }),
|
||||||
])
|
])
|
||||||
return {
|
return {
|
||||||
pendingWorkerCount: pendingWorkers.total,
|
pendingWorkerCount: pendingWorkers.total,
|
||||||
@@ -2270,6 +2286,10 @@ export async function syncWorkerOrdersForSourceOrder(
|
|||||||
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||||
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
||||||
})
|
})
|
||||||
|
const sharingEnabled = resolveWorkOrderSharingEnabled(
|
||||||
|
rule.sharing_enabled === true,
|
||||||
|
sharingTotalQuantity,
|
||||||
|
)
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const materialComplete = fields.every((field) => !field.required)
|
const materialComplete = fields.every((field) => !field.required)
|
||||||
const workOrder = await createWorkOrder({
|
const workOrder = await createWorkOrder({
|
||||||
@@ -2284,7 +2304,7 @@ export async function syncWorkerOrdersForSourceOrder(
|
|||||||
rewardAmount,
|
rewardAmount,
|
||||||
requiredDepositAmount: 0,
|
requiredDepositAmount: 0,
|
||||||
depositThresholdAmount: 0,
|
depositThresholdAmount: 0,
|
||||||
sharingEnabled: rule.sharing_enabled === true,
|
sharingEnabled,
|
||||||
sharingTotalQuantity,
|
sharingTotalQuantity,
|
||||||
sharingUnitReward,
|
sharingUnitReward,
|
||||||
timeoutMinutes: Number(rule.timeout_minutes || 0),
|
timeoutMinutes: Number(rule.timeout_minutes || 0),
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
import { runtimeConfig } from '../../config/runtime.js'
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
|
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||||
import {
|
import {
|
||||||
addWorkerWalletCredit,
|
addWorkerWalletCredit,
|
||||||
getWorkerUserById,
|
getWorkerUserById,
|
||||||
type WorkCategoryRow,
|
type WorkCategoryRow,
|
||||||
type WorkerFinanceRequestRow,
|
type WorkerFinanceRequestRow,
|
||||||
type WorkOrderRow,
|
type WorkOrderRow,
|
||||||
|
type WorkOrderCapabilities,
|
||||||
type WorkProductRuleMappingRow,
|
type WorkProductRuleMappingRow,
|
||||||
type WorkProductRuleRow,
|
type WorkProductRuleRow,
|
||||||
type WorkerLevelRow,
|
type WorkerLevelRow,
|
||||||
@@ -323,6 +325,62 @@ export function mapWorkOrderMaterial(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||||
|
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
|
||||||
|
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0))
|
||||||
|
const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
||||||
|
const hasActiveShares = activeShareQuantity > 0
|
||||||
|
const sharingFilled =
|
||||||
|
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
|
||||||
|
const status =
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||||
|
workOrder.sharing_enabled === true &&
|
||||||
|
hasActiveShares
|
||||||
|
? sharingFilled && pendingShareCount === 0
|
||||||
|
? WORK_ORDER_STATUS.PENDING_ACCEPTANCE
|
||||||
|
: WORK_ORDER_STATUS.IN_PROGRESS
|
||||||
|
: workOrder.status
|
||||||
|
const hallAvailable =
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||||
|
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
|
||||||
|
const editableBeforeClaim =
|
||||||
|
!workOrder.assigned_worker_id &&
|
||||||
|
(workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL ||
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||||
|
(workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares))
|
||||||
|
const capabilities: WorkOrderCapabilities = {
|
||||||
|
canEdit: editableBeforeClaim,
|
||||||
|
canConfigureSharing: editableBeforeClaim,
|
||||||
|
canAssign:
|
||||||
|
!workOrder.assigned_worker_id &&
|
||||||
|
(workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||||
|
(workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)),
|
||||||
|
canPin: hallAvailable || Boolean(workOrder.pinned_at),
|
||||||
|
canUnpublish:
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||||
|
!hasActiveShares &&
|
||||||
|
!workOrder.assigned_worker_id,
|
||||||
|
canUpdateMaterial:
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL ||
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS ||
|
||||||
|
(workOrder.status === WORK_ORDER_STATUS.OPEN && hasActiveShares),
|
||||||
|
canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
|
canMarkProblem:
|
||||||
|
status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
|
canDelete:
|
||||||
|
!workOrder.assigned_worker_id &&
|
||||||
|
!hasActiveShares &&
|
||||||
|
[
|
||||||
|
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||||
|
WORK_ORDER_STATUS.UNASSIGNED,
|
||||||
|
WORK_ORDER_STATUS.CANCELLED,
|
||||||
|
].includes(workOrder.status as never),
|
||||||
|
canPublish:
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0,
|
||||||
|
canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
|
||||||
|
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||||
|
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
workOrderId: Number(workOrder.id),
|
workOrderId: Number(workOrder.id),
|
||||||
orderId: workOrder.order_id ? Number(workOrder.order_id) : null,
|
orderId: workOrder.order_id ? Number(workOrder.order_id) : null,
|
||||||
@@ -332,7 +390,16 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
|||||||
productName: workOrder.product_name,
|
productName: workOrder.product_name,
|
||||||
categoryId: workOrder.category_id ? Number(workOrder.category_id) : null,
|
categoryId: workOrder.category_id ? Number(workOrder.category_id) : null,
|
||||||
categoryName: workOrder.category_name || '',
|
categoryName: workOrder.category_name || '',
|
||||||
status: workOrder.status,
|
status,
|
||||||
|
rawStatus: workOrder.status,
|
||||||
|
hallAvailable,
|
||||||
|
sharingProgress: {
|
||||||
|
joinedQuantity: activeShareQuantity,
|
||||||
|
pendingSubmissionCount: pendingShareCount,
|
||||||
|
remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity),
|
||||||
|
filled: sharingFilled,
|
||||||
|
},
|
||||||
|
capabilities,
|
||||||
rewardAmount: Number(workOrder.reward_amount || 0),
|
rewardAmount: Number(workOrder.reward_amount || 0),
|
||||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||||
sharing: {
|
sharing: {
|
||||||
@@ -384,17 +451,53 @@ export function mapWorkOrderForWorker(
|
|||||||
worker: _worker,
|
worker: _worker,
|
||||||
orderId: _orderId,
|
orderId: _orderId,
|
||||||
orderItemId: _orderItemId,
|
orderItemId: _orderItemId,
|
||||||
|
rawStatus: _rawStatus,
|
||||||
|
hallAvailable: _hallAvailable,
|
||||||
|
capabilities: _capabilities,
|
||||||
...visibleOrder
|
...visibleOrder
|
||||||
} = mapped
|
} = mapped
|
||||||
|
const personalStatus = resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
||||||
return {
|
return {
|
||||||
...visibleOrder,
|
...visibleOrder,
|
||||||
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
productName: myShare
|
||||||
|
? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0))
|
||||||
|
: visibleOrder.productName,
|
||||||
|
status: personalStatus,
|
||||||
|
rewardAmount: myShare ? Number(myShare.share_reward || 0) : visibleOrder.rewardAmount,
|
||||||
|
freezeDepositAmount: myShare
|
||||||
|
? Number(myShare.share_deposit || 0)
|
||||||
|
: resolveFreezeDepositAmount(workOrder, permissions),
|
||||||
|
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
|
||||||
|
submittedAt: myShare?.submitted_at || visibleOrder.submittedAt,
|
||||||
|
acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt,
|
||||||
workerNote,
|
workerNote,
|
||||||
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||||
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
|
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */
|
||||||
|
export function resolveWorkerShareProductName(productName: unknown, quantity: number): string {
|
||||||
|
const text = String(productName || '').trim()
|
||||||
|
const normalizedQuantity = Math.max(1, Number(quantity) || 1)
|
||||||
|
return text.replace(
|
||||||
|
/(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/,
|
||||||
|
(_matched, _oldQuantity: string, spacing: string, unit: string) =>
|
||||||
|
`${normalizedQuantity}${spacing}${unit}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把份额状态映射成打手端订单状态,避免母工单 open 被误显示为已超时。 */
|
||||||
|
export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: unknown): string {
|
||||||
|
if (!shareStatus) return workOrderStatus
|
||||||
|
if (['accepted', 'cancelled', 'problem'].includes(workOrderStatus)) return workOrderStatus
|
||||||
|
if (shareStatus === 'joined') return WORK_ORDER_STATUS.IN_PROGRESS
|
||||||
|
if (shareStatus === 'submitted') return WORK_ORDER_STATUS.PENDING_ACCEPTANCE
|
||||||
|
if (shareStatus === 'accepted') return WORK_ORDER_STATUS.ACCEPTED
|
||||||
|
if (shareStatus === 'cancelled') return WORK_ORDER_STATUS.CANCELLED
|
||||||
|
return workOrderStatus
|
||||||
|
}
|
||||||
|
|
||||||
export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||||
return {
|
return {
|
||||||
shareId: Number(share.id),
|
shareId: Number(share.id),
|
||||||
@@ -405,8 +508,8 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
|||||||
shareReward: Number(share.share_reward || 0),
|
shareReward: Number(share.share_reward || 0),
|
||||||
shareDeposit: Number(share.share_deposit || 0),
|
shareDeposit: Number(share.share_deposit || 0),
|
||||||
status: share.status,
|
status: share.status,
|
||||||
acceptance: safeParseJson(share.acceptance_json),
|
acceptance: mapAcceptanceForResponse(share.acceptance_json),
|
||||||
draftAcceptance: safeParseJson(share.draft_acceptance_json),
|
draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json),
|
||||||
submittedAt: share.submitted_at,
|
submittedAt: share.submitted_at,
|
||||||
acceptedAt: share.accepted_at,
|
acceptedAt: share.accepted_at,
|
||||||
createdAt: share.created_at,
|
createdAt: share.created_at,
|
||||||
@@ -1017,6 +1120,26 @@ export function resolveWorkOrderRulePricing(input: {
|
|||||||
return { sharingTotalQuantity, sharingUnitReward, rewardAmount }
|
return { sharingTotalQuantity, sharingUnitReward, rewardAmount }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 接单模板仅在实际总份数大于 1 时生成拼单工单。 */
|
||||||
|
export function resolveWorkOrderSharingEnabled(
|
||||||
|
configuredEnabled: boolean,
|
||||||
|
totalQuantity: number,
|
||||||
|
): boolean {
|
||||||
|
return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断拼单工单是否已经没有剩余可承接份数。 */
|
||||||
|
export function isWorkOrderSharingFilled(
|
||||||
|
sharingEnabled: boolean,
|
||||||
|
totalQuantity: number,
|
||||||
|
joinedQuantity: number,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
sharingEnabled &&
|
||||||
|
Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CHINESE_DIGITS: Record<string, number> = {
|
const CHINESE_DIGITS: Record<string, number> = {
|
||||||
零: 0,
|
零: 0,
|
||||||
一: 1,
|
一: 1,
|
||||||
|
|||||||
@@ -3,24 +3,29 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
WorkOrderRow,
|
WorkOrderRow,
|
||||||
|
WorkOrderShareRow,
|
||||||
WorkProductRuleMappingRow,
|
WorkProductRuleMappingRow,
|
||||||
WorkProductRuleRow,
|
WorkProductRuleRow,
|
||||||
WorkerUserRow,
|
WorkerUserRow,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
|
import { resolveWorkOrderShareJoinQuantity } from '../../repositories/worker-platform/index.js'
|
||||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||||
import {
|
import {
|
||||||
assertWithdrawChannelAllowedForAmount,
|
assertWithdrawChannelAllowedForAmount,
|
||||||
assertWorkerLoginAllowed,
|
assertWorkerLoginAllowed,
|
||||||
|
isWorkOrderSharingFilled,
|
||||||
isWorkerPasswordStrong,
|
isWorkerPasswordStrong,
|
||||||
normalizeWithdrawChannel,
|
normalizeWithdrawChannel,
|
||||||
normalizeRequirementFields,
|
normalizeRequirementFields,
|
||||||
mapWorkOrderForWorker,
|
mapWorkOrderForWorker,
|
||||||
|
mapWorkOrderAdmin,
|
||||||
maskWorkerLeaderboardDisplayName,
|
maskWorkerLeaderboardDisplayName,
|
||||||
resolveCollectSubmitTargetWorkOrder,
|
resolveCollectSubmitTargetWorkOrder,
|
||||||
resolveMatchingProductRuleDecision,
|
resolveMatchingProductRuleDecision,
|
||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
resolveSkuNameQuantity,
|
resolveSkuNameQuantity,
|
||||||
resolveWorkOrderRulePricing,
|
resolveWorkOrderRulePricing,
|
||||||
|
resolveWorkOrderSharingEnabled,
|
||||||
validateWorkerPassword,
|
validateWorkerPassword,
|
||||||
} from './index.js'
|
} from './index.js'
|
||||||
|
|
||||||
@@ -582,6 +587,32 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('模板启用拼单但实际只有一份时生成普通工单', () => {
|
||||||
|
assert.equal(resolveWorkOrderSharingEnabled(true, 1), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('模板启用拼单且实际大于一份时生成拼单工单', () => {
|
||||||
|
assert.equal(resolveWorkOrderSharingEnabled(true, 2), true)
|
||||||
|
assert.equal(resolveWorkOrderSharingEnabled(false, 3), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拼单工单立即抢单承接全部剩余份数', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWorkOrderShareJoinQuantity({ quantity: 0, remaining: 12, takeRemaining: true }),
|
||||||
|
12,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveWorkOrderShareJoinQuantity({ quantity: 0, remaining: 0, takeRemaining: true }),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拼单份数全部承接后不再显示在抢单大厅', () => {
|
||||||
|
assert.equal(isWorkOrderSharingFilled(true, 15, 15), true)
|
||||||
|
assert.equal(isWorkOrderSharingFilled(true, 15, 14), false)
|
||||||
|
assert.equal(isWorkOrderSharingFilled(false, 15, 15), false)
|
||||||
|
})
|
||||||
|
|
||||||
function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
|
function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -611,6 +642,26 @@ function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWorkOrderShareRow(overrides: Partial<WorkOrderShareRow> = {}): WorkOrderShareRow {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
work_order_id: 1,
|
||||||
|
worker_id: 1,
|
||||||
|
quantity: 2,
|
||||||
|
unit_reward: 700,
|
||||||
|
share_reward: 1400,
|
||||||
|
share_deposit: 200,
|
||||||
|
status: 'joined',
|
||||||
|
acceptance_json: {},
|
||||||
|
draft_acceptance_json: {},
|
||||||
|
submitted_at: null,
|
||||||
|
accepted_at: null,
|
||||||
|
created_at: '2026-08-18T09:09:08.000Z',
|
||||||
|
updated_at: '2026-08-18T09:09:08.000Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('mapWorkOrderForWorker reflects the personal first-view state', () => {
|
test('mapWorkOrderForWorker reflects the personal first-view state', () => {
|
||||||
const workOrder = buildWorkOrderRow({ assigned_worker_id: 1 })
|
const workOrder = buildWorkOrderRow({ assigned_worker_id: 1 })
|
||||||
const permissions = resolveWorkerPermissions(buildWorkerUserRow())
|
const permissions = resolveWorkerPermissions(buildWorkerUserRow())
|
||||||
@@ -622,6 +673,62 @@ test('mapWorkOrderForWorker reflects the personal first-view state', () => {
|
|||||||
assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined)
|
assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('mapWorkOrderForWorker 使用个人拼单份数、金额、状态和接单时间', () => {
|
||||||
|
const workOrder = buildWorkOrderRow({
|
||||||
|
product_name: '指挥官秘钥15个',
|
||||||
|
status: 'open',
|
||||||
|
reward_amount: 10_500,
|
||||||
|
sharing_enabled: true,
|
||||||
|
sharing_total_quantity: 15,
|
||||||
|
sharing_unit_reward: 700,
|
||||||
|
})
|
||||||
|
const share = buildWorkOrderShareRow()
|
||||||
|
const mapped = mapWorkOrderForWorker(
|
||||||
|
workOrder,
|
||||||
|
resolveWorkerPermissions(buildWorkerUserRow()),
|
||||||
|
share,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(mapped.productName, '指挥官秘钥2个')
|
||||||
|
assert.equal(mapped.rewardAmount, 1400)
|
||||||
|
assert.equal(mapped.freezeDepositAmount, 200)
|
||||||
|
assert.equal(mapped.status, 'in_progress')
|
||||||
|
assert.equal(mapped.assignedAt, '2026-08-18T09:09:08.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
|
||||||
|
const partial = mapWorkOrderAdmin(
|
||||||
|
buildWorkOrderRow({
|
||||||
|
status: 'open',
|
||||||
|
sharing_enabled: true,
|
||||||
|
sharing_total_quantity: 15,
|
||||||
|
active_share_quantity: 3,
|
||||||
|
pending_share_count: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(partial.status, 'in_progress')
|
||||||
|
assert.equal(partial.rawStatus, 'open')
|
||||||
|
assert.equal(partial.hallAvailable, true)
|
||||||
|
assert.equal(partial.capabilities.canEdit, false)
|
||||||
|
assert.equal(partial.capabilities.canConfigureSharing, false)
|
||||||
|
assert.equal(partial.capabilities.canAssign, false)
|
||||||
|
assert.equal(partial.capabilities.canUpdateMaterial, true)
|
||||||
|
|
||||||
|
const filled = mapWorkOrderAdmin(
|
||||||
|
buildWorkOrderRow({
|
||||||
|
status: 'open',
|
||||||
|
sharing_enabled: true,
|
||||||
|
sharing_total_quantity: 15,
|
||||||
|
active_share_quantity: 15,
|
||||||
|
pending_share_count: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(filled.status, 'pending_acceptance')
|
||||||
|
assert.equal(filled.hallAvailable, false)
|
||||||
|
assert.equal(filled.capabilities.canAccept, true)
|
||||||
|
assert.equal(filled.capabilities.canUnpublish, false)
|
||||||
|
})
|
||||||
|
|
||||||
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
|
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
|
||||||
const workOrder = buildWorkOrderRow()
|
const workOrder = buildWorkOrderRow()
|
||||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
|
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
listWorkOrderEventsByOrderId,
|
listWorkOrderEventsByOrderId,
|
||||||
listWorkOrderShares,
|
listWorkOrderShares,
|
||||||
|
listWorkOrderSharesByOrderIds,
|
||||||
listWorkerLevels,
|
listWorkerLevels,
|
||||||
listDueDepositUnfreezes,
|
listDueDepositUnfreezes,
|
||||||
listOverdueWorkOrders,
|
listOverdueWorkOrders,
|
||||||
@@ -1131,19 +1132,46 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
|||||||
keyword: String(query.keyword || '').trim(),
|
keyword: String(query.keyword || '').trim(),
|
||||||
categoryId: categoryId || 0,
|
categoryId: categoryId || 0,
|
||||||
visibleAfterIso,
|
visibleAfterIso,
|
||||||
|
excludeFilledSharing: true,
|
||||||
pinnedFirst: true,
|
pinnedFirst: true,
|
||||||
})
|
})
|
||||||
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
||||||
const myShares = await listWorkerSharesByWorker(worker.id)
|
const orderIds = items.map((item) => Number(item.id))
|
||||||
|
const shares = await listWorkOrderSharesByOrderIds(orderIds)
|
||||||
const myShareByOrderId = new Map(
|
const myShareByOrderId = new Map(
|
||||||
myShares
|
shares
|
||||||
.filter((share) => Number(share.work_order_id))
|
.filter(
|
||||||
|
(share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id),
|
||||||
|
)
|
||||||
.map((share) => [Number(share.work_order_id), share]),
|
.map((share) => [Number(share.work_order_id), share]),
|
||||||
)
|
)
|
||||||
|
const sharingProgressByOrderId = new Map<
|
||||||
|
number,
|
||||||
|
{ joinedQuantity: number; pendingSubmissionCount: number }
|
||||||
|
>()
|
||||||
|
for (const share of shares) {
|
||||||
|
if (share.status === 'cancelled') continue
|
||||||
|
const orderId = Number(share.work_order_id)
|
||||||
|
const progress = sharingProgressByOrderId.get(orderId) || {
|
||||||
|
joinedQuantity: 0,
|
||||||
|
pendingSubmissionCount: 0,
|
||||||
|
}
|
||||||
|
progress.joinedQuantity += Number(share.quantity || 0)
|
||||||
|
if (share.status === 'joined') progress.pendingSubmissionCount += 1
|
||||||
|
sharingProgressByOrderId.set(orderId, progress)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
items: items.map((item) =>
|
items: items.map((item) => {
|
||||||
mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))),
|
const myShare = myShareByOrderId.get(Number(item.id))
|
||||||
),
|
return {
|
||||||
|
...mapWorkOrderForWorker(item, permissions),
|
||||||
|
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||||
|
sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || {
|
||||||
|
joinedQuantity: 0,
|
||||||
|
pendingSubmissionCount: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
pagination: { page, pageSize, total },
|
pagination: { page, pageSize, total },
|
||||||
categories,
|
categories,
|
||||||
visibleDelaySeconds,
|
visibleDelaySeconds,
|
||||||
@@ -1180,14 +1208,6 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
|||||||
requireActiveWorkerSession(session)
|
requireActiveWorkerSession(session)
|
||||||
const worker = await getRequiredWorker(session.workerId)
|
const worker = await getRequiredWorker(session.workerId)
|
||||||
const permissions = resolveWorkerPermissions(worker)
|
const permissions = resolveWorkerPermissions(worker)
|
||||||
const activeCount = await countWorkerActiveOrders(worker.id)
|
|
||||||
if (activeCount >= permissions.maxActiveOrders) {
|
|
||||||
throw createHttpError('已达到当前等级最大同时接单量', {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: 'worker_active_order_limit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
|
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
|
||||||
throw createHttpError('该订单已不可抢', {
|
throw createHttpError('该订单已不可抢', {
|
||||||
@@ -1196,6 +1216,34 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (workOrder.sharing_enabled) {
|
||||||
|
const result = await joinWorkOrderShare({
|
||||||
|
workOrderId: Number(workOrder.id),
|
||||||
|
workerId: Number(worker.id),
|
||||||
|
quantity: 0,
|
||||||
|
takeRemaining: true,
|
||||||
|
maxActiveOrders: permissions.maxActiveOrders,
|
||||||
|
now: nowIso(),
|
||||||
|
})
|
||||||
|
if (!result.share) {
|
||||||
|
throw resolveWorkOrderShareJoinError(result.failureReason)
|
||||||
|
}
|
||||||
|
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||||
|
workerIds: [Number(worker.id)],
|
||||||
|
hallChanged: true,
|
||||||
|
walletChangedWorkerIds: [Number(worker.id)],
|
||||||
|
})
|
||||||
|
return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = await countWorkerActiveOrders(worker.id)
|
||||||
|
if (activeCount >= permissions.maxActiveOrders) {
|
||||||
|
throw createHttpError('已达到当前等级最大同时接单量', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'worker_active_order_limit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions)
|
const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions)
|
||||||
if (Number(worker.available_amount || 0) < freezeAmount) {
|
if (Number(worker.available_amount || 0) < freezeAmount) {
|
||||||
throw createHttpError('余额不足,无法冻结所需押金', {
|
throw createHttpError('余额不足,无法冻结所需押金', {
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ function patchAdminWorkOrderCaches(
|
|||||||
patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
|
patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 状态聚合会影响分页统计,实时行快照不能单独修正全量统计,因此同步刷新查询。
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchAdminTaskCaches(queryClient: ReturnType<typeof useQueryClient>, tasks: unknown[]) {
|
function patchAdminTaskCaches(queryClient: ReturnType<typeof useQueryClient>, tasks: unknown[]) {
|
||||||
|
|||||||
@@ -333,7 +333,12 @@ function upsertById(items: unknown[], nextItem: unknown, idKey: string, insert =
|
|||||||
(item) => Number((item as Record<string, unknown>)?.[idKey] || 0) === id,
|
(item) => Number((item as Record<string, unknown>)?.[idKey] || 0) === id,
|
||||||
)
|
)
|
||||||
if (index < 0) return insert ? [nextItem, ...items] : items
|
if (index < 0) return insert ? [nextItem, ...items] : items
|
||||||
return items.map((item, itemIndex) => (itemIndex === index ? nextItem : item))
|
return items.map((item, itemIndex) => {
|
||||||
|
if (itemIndex !== index) return item
|
||||||
|
if (!isRecord(item) || !isRecord(nextItem)) return nextItem
|
||||||
|
// 大厅广播是公共快照,合并后保留当前打手私有的 myShare 等字段。
|
||||||
|
return { ...item, ...nextItem }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import ImageUpload from '@/components/files/ImageUpload'
|
|||||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
import {
|
import {
|
||||||
acceptAdminWorkOrder,
|
acceptAdminWorkOrder,
|
||||||
|
acceptAdminWorkOrderShare,
|
||||||
acceptAdminWorkOrders,
|
acceptAdminWorkOrders,
|
||||||
assignAdminWorkOrderToWorker,
|
assignAdminWorkOrderToWorker,
|
||||||
cancelAdminWorkOrder,
|
cancelAdminWorkOrder,
|
||||||
@@ -741,16 +742,18 @@ export default function WorkOrdersPanel() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (_, row) => (
|
render: (_, row) => {
|
||||||
|
return (
|
||||||
<Space wrap size={4}>
|
<Space wrap size={4}>
|
||||||
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
|
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
|
||||||
{row.status === 'open' && row.pinnedAt ? (
|
{row.rawStatus === 'open' && row.pinnedAt ? (
|
||||||
<Tag color="gold" icon={<PushpinOutlined />}>
|
<Tag color="gold" icon={<PushpinOutlined />}>
|
||||||
置顶
|
置顶
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '打手',
|
title: '打手',
|
||||||
@@ -768,6 +771,16 @@ export default function WorkOrdersPanel() {
|
|||||||
render: (_, row) => {
|
render: (_, row) => {
|
||||||
const isVipAutoAcceptance = row.acceptance?.reviewMode === 'vip_auto'
|
const isVipAutoAcceptance = row.acceptance?.reviewMode === 'vip_auto'
|
||||||
const evidencePending = row.acceptance?.evidenceStatus === 'pending'
|
const evidencePending = row.acceptance?.evidenceStatus === 'pending'
|
||||||
|
if (row.sharing?.enabled) {
|
||||||
|
return (
|
||||||
|
<Button type="link" size="small" onClick={() => openSharingConfigModal(row)}>
|
||||||
|
查看拼单验收图
|
||||||
|
{row.sharingProgress?.pendingSubmissionCount
|
||||||
|
? `(${row.sharingProgress.pendingSubmissionCount}人待提交)`
|
||||||
|
: ''}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={4}>
|
<Space direction="vertical" size={4}>
|
||||||
<ImagePreviewList
|
<ImagePreviewList
|
||||||
@@ -791,37 +804,37 @@ export default function WorkOrdersPanel() {
|
|||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button onClick={() => openDetailDrawer(row)}>详情</Button>
|
<Button onClick={() => openDetailDrawer(row)}>详情</Button>
|
||||||
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
|
{row.capabilities?.canEdit ? (
|
||||||
<Button icon={<EditOutlined />} onClick={() => openEditModal(row)}>
|
<Button icon={<EditOutlined />} onClick={() => openEditModal(row)}>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{['pending_material', 'unassigned', 'cancelled'].includes(row.status) ? (
|
{row.capabilities?.canDelete ? (
|
||||||
<Button danger icon={<DeleteOutlined />} onClick={() => requestDeleteWorkOrder(row)}>
|
<Button danger icon={<DeleteOutlined />} onClick={() => requestDeleteWorkOrder(row)}>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
|
{row.sharing?.enabled || row.capabilities?.canConfigureSharing ? (
|
||||||
<Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}>
|
<Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}>
|
||||||
拼单配置
|
{row.sharing?.enabled && !row.capabilities?.canConfigureSharing ? '拼单明细' : '拼单配置'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{['unassigned', 'open'].includes(row.status) ? (
|
{row.capabilities?.canAssign ? (
|
||||||
<Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}>
|
<Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}>
|
||||||
指派并核销
|
指派并核销
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status === 'in_progress' && row.worker ? (
|
{row.capabilities?.canCancel ? (
|
||||||
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
|
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
|
||||||
撤单
|
撤单
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{['pending_material', 'in_progress'].includes(row.status) ? (
|
{row.capabilities?.canUpdateMaterial ? (
|
||||||
<Button icon={<FormOutlined />} onClick={() => openMaterialModal(row)}>
|
<Button icon={<FormOutlined />} onClick={() => openMaterialModal(row)}>
|
||||||
{row.status === 'in_progress' ? '更新资料' : '补资料'}
|
{row.status === 'in_progress' ? '更新资料' : '补资料'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status === 'unassigned' ? (
|
{row.capabilities?.canPublish ? (
|
||||||
<Button
|
<Button
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
disabled={Number(row.rewardAmount || 0) <= 0}
|
disabled={Number(row.rewardAmount || 0) <= 0}
|
||||||
@@ -833,7 +846,7 @@ export default function WorkOrdersPanel() {
|
|||||||
发布并核销
|
发布并核销
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status === 'open' ? (
|
{row.capabilities?.canPin ? (
|
||||||
<Button
|
<Button
|
||||||
icon={<PushpinOutlined />}
|
icon={<PushpinOutlined />}
|
||||||
title={row.pinnedAt ? '取消置顶' : '置顶后抢单大厅优先展示'}
|
title={row.pinnedAt ? '取消置顶' : '置顶后抢单大厅优先展示'}
|
||||||
@@ -847,7 +860,7 @@ export default function WorkOrdersPanel() {
|
|||||||
{row.pinnedAt ? '取消置顶' : '置顶'}
|
{row.pinnedAt ? '取消置顶' : '置顶'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status === 'open' ? (
|
{row.capabilities?.canUnpublish ? (
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -864,7 +877,7 @@ export default function WorkOrdersPanel() {
|
|||||||
下架
|
下架
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status === 'pending_acceptance' ? (
|
{row.capabilities?.canAccept ? (
|
||||||
<Button
|
<Button
|
||||||
icon={<CheckOutlined />}
|
icon={<CheckOutlined />}
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -873,17 +886,17 @@ export default function WorkOrdersPanel() {
|
|||||||
验收
|
验收
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status === 'accepted' && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
|
{row.capabilities?.canDeduct && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
|
||||||
<Button icon={<WarningOutlined />} danger onClick={() => openDeductModal(row)}>
|
<Button icon={<WarningOutlined />} danger onClick={() => openDeductModal(row)}>
|
||||||
扣押金
|
扣押金
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
|
{row.capabilities?.canMarkProblem ? (
|
||||||
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
|
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
|
||||||
问题单
|
问题单
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status === 'problem' ? (
|
{row.capabilities?.canResolveProblem ? (
|
||||||
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
|
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
|
||||||
处置
|
处置
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1572,13 +1585,14 @@ export default function WorkOrdersPanel() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
|
title={`${sharingOrder?.capabilities?.canConfigureSharing ? '拼单配置' : '拼单明细'} · ${sharingOrder?.productName || ''}`}
|
||||||
open={Boolean(sharingOrder)}
|
open={Boolean(sharingOrder)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setSharingOrder(null)
|
setSharingOrder(null)
|
||||||
sharingForm.resetFields()
|
sharingForm.resetFields()
|
||||||
}}
|
}}
|
||||||
onOk={() => sharingForm.submit()}
|
onOk={() => sharingForm.submit()}
|
||||||
|
footer={sharingOrder?.capabilities?.canConfigureSharing ? undefined : null}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={720}
|
width={720}
|
||||||
>
|
>
|
||||||
@@ -1593,6 +1607,7 @@ export default function WorkOrdersPanel() {
|
|||||||
unitReward: 0,
|
unitReward: 0,
|
||||||
}}
|
}}
|
||||||
onFinish={submitSharingConfig}
|
onFinish={submitSharingConfig}
|
||||||
|
disabled={!sharingOrder.capabilities?.canConfigureSharing}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="启用拼单"
|
label="启用拼单"
|
||||||
@@ -1689,6 +1704,54 @@ export default function WorkOrdersPanel() {
|
|||||||
render: (_, share) =>
|
render: (_, share) =>
|
||||||
share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-',
|
share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '验收图',
|
||||||
|
width: 150,
|
||||||
|
render: (_, share) => (
|
||||||
|
<ImagePreviewList
|
||||||
|
files={Array.isArray(share.acceptance?.files) ? share.acceptance.files : []}
|
||||||
|
imageUrls={Array.isArray(share.acceptance?.imageUrls) ? share.acceptance.imageUrls : []}
|
||||||
|
size={44}
|
||||||
|
maxVisible={2}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
render: (_, share) => String(share.acceptance?.note || '').trim() || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 110,
|
||||||
|
render: (_, share) =>
|
||||||
|
share.status === 'submitted' ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
void runAction(
|
||||||
|
() => acceptAdminWorkOrderShare(sharingOrder.workOrderId, share.shareId),
|
||||||
|
'该拼单参与者已验收',
|
||||||
|
).then((succeeded) => {
|
||||||
|
if (succeeded) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [
|
||||||
|
'admin-worker-platform-order-sharing',
|
||||||
|
sharingOrder.workOrderId,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
单独验收
|
||||||
|
</Button>
|
||||||
|
) : share.status === 'accepted' ? (
|
||||||
|
<Tag color="green">已验收</Tag>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">待提交</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1796,8 +1859,7 @@ function ProductNameEditableCell({
|
|||||||
const savingRef = useRef(false)
|
const savingRef = useRef(false)
|
||||||
const clickTimerRef = useRef<number | null>(null)
|
const clickTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const editable =
|
const editable = order.capabilities?.canEdit === true
|
||||||
['pending_material', 'unassigned', 'open'].includes(order.status) && !order.worker
|
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current)
|
if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current)
|
||||||
@@ -1880,8 +1942,7 @@ function AmountEditableCell({
|
|||||||
const [draft, setDraft] = useState<number | null>(null)
|
const [draft, setDraft] = useState<number | null>(null)
|
||||||
const savingRef = useRef(false)
|
const savingRef = useRef(false)
|
||||||
|
|
||||||
const editable =
|
const editable = order.capabilities?.canEdit === true
|
||||||
['pending_material', 'unassigned', 'open'].includes(order.status) && !order.worker
|
|
||||||
const currentFen = Number(order[field] || 0)
|
const currentFen = Number(order[field] || 0)
|
||||||
const currentYuan = currentFen / 100
|
const currentYuan = currentFen / 100
|
||||||
const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null
|
const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ import {
|
|||||||
} from '@/services/worker'
|
} from '@/services/worker'
|
||||||
import type { WorkOrder, WorkerUser } from '@/types/worker-platform'
|
import type { WorkOrder, WorkerUser } from '@/types/worker-platform'
|
||||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||||
|
import {
|
||||||
|
resolveHallDepositAmount,
|
||||||
|
resolveHallProductName,
|
||||||
|
resolveHallRemainingQuantity,
|
||||||
|
resolveHallRewardAmount,
|
||||||
|
} from '@/utils/worker-product-name'
|
||||||
|
|
||||||
type HallSummary = {
|
type HallSummary = {
|
||||||
pendingAcceptanceCount: number
|
pendingAcceptanceCount: number
|
||||||
@@ -203,10 +209,7 @@ export default function WorkerHallPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveSharingRemaining(order: WorkOrder): number {
|
function resolveSharingRemaining(order: WorkOrder): number {
|
||||||
const sharing = order.sharing
|
return resolveHallRemainingQuantity(order)
|
||||||
if (!sharing?.enabled) return 0
|
|
||||||
const progress = order.sharingProgress
|
|
||||||
return Math.max(0, sharing.totalQuantity - (progress?.joinedQuantity || 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSharingDeposit(order: WorkOrder, quantity: number): number {
|
function resolveSharingDeposit(order: WorkOrder, quantity: number): number {
|
||||||
@@ -387,6 +390,8 @@ export default function WorkerHallPage() {
|
|||||||
) : (
|
) : (
|
||||||
orders.map((order) => {
|
orders.map((order) => {
|
||||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||||
|
const productName = resolveHallProductName(order) || '未命名商品'
|
||||||
|
const sharingRemaining = resolveSharingRemaining(order)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={order.workOrderId} className="worker-hall-mobile-card">
|
<article key={order.workOrderId} className="worker-hall-mobile-card">
|
||||||
@@ -409,7 +414,8 @@ export default function WorkerHallPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{order.sharing?.enabled ? (
|
{order.sharing?.enabled ? (
|
||||||
<Tag color="purple" className="worker-hall-mobile-tag">
|
<Tag color="purple" className="worker-hall-mobile-tag">
|
||||||
拼单 剩{resolveSharingRemaining(order)}份
|
拼单 {order.sharingProgress?.joinedQuantity || 0}/
|
||||||
|
{order.sharing.totalQuantity} · 剩{resolveSharingRemaining(order)}份
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{Number(order.timeoutMinutes || 0) > 0 ? (
|
{Number(order.timeoutMinutes || 0) > 0 ? (
|
||||||
@@ -429,14 +435,11 @@ export default function WorkerHallPage() {
|
|||||||
|
|
||||||
{/* 商品标题与保证金说明 */}
|
{/* 商品标题与保证金说明 */}
|
||||||
<div className="worker-hall-mobile-card-main">
|
<div className="worker-hall-mobile-card-main">
|
||||||
<strong
|
<strong className="worker-hall-mobile-card-title" title={productName}>
|
||||||
className="worker-hall-mobile-card-title"
|
{productName}
|
||||||
title={order.productName || '未命名商品'}
|
|
||||||
>
|
|
||||||
{order.productName || '未命名商品'}
|
|
||||||
</strong>
|
</strong>
|
||||||
<div className="worker-hall-mobile-card-meta">
|
<div className="worker-hall-mobile-card-meta">
|
||||||
<span>所需保证金: {formatMoney(order.freezeDepositAmount)}</span>
|
<span>所需保证金: {formatMoney(resolveHallDepositAmount(order))}</span>
|
||||||
{order.sharing?.enabled ? (
|
{order.sharing?.enabled ? (
|
||||||
<span> · 拼单单价 {formatMoney(order.sharing.unitReward)}/份</span>
|
<span> · 拼单单价 {formatMoney(order.sharing.unitReward)}/份</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -448,12 +451,14 @@ export default function WorkerHallPage() {
|
|||||||
<div className="worker-hall-mobile-card-price-group">
|
<div className="worker-hall-mobile-card-price-group">
|
||||||
<span className="worker-hall-mobile-card-price-label">接单报酬</span>
|
<span className="worker-hall-mobile-card-price-label">接单报酬</span>
|
||||||
<strong className="worker-hall-mobile-card-price-val">
|
<strong className="worker-hall-mobile-card-price-val">
|
||||||
{formatMoney(order.rewardAmount)}
|
{formatMoney(resolveHallRewardAmount(order))}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="worker-hall-mobile-card-btn-group">
|
<div className="worker-hall-mobile-card-btn-group">
|
||||||
{order.sharing?.enabled && !disabledReason ? (
|
{order.sharing?.enabled &&
|
||||||
|
(sharingRemaining > 0 || order.myShare) &&
|
||||||
|
!disabledReason ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="middle"
|
size="middle"
|
||||||
@@ -466,6 +471,7 @@ export default function WorkerHallPage() {
|
|||||||
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sharingRemaining > 0 || !order.sharing?.enabled ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="middle"
|
size="middle"
|
||||||
@@ -476,6 +482,7 @@ export default function WorkerHallPage() {
|
|||||||
>
|
>
|
||||||
{disabledReason || '立即抢单'}
|
{disabledReason || '立即抢单'}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -613,6 +620,8 @@ export default function WorkerHallPage() {
|
|||||||
<div className="worker-hall-card-grid">
|
<div className="worker-hall-card-grid">
|
||||||
{orders.map((order) => {
|
{orders.map((order) => {
|
||||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||||
|
const productName = resolveHallProductName(order) || '未命名商品'
|
||||||
|
const sharingRemaining = resolveSharingRemaining(order)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={order.workOrderId} className="worker-hall-card">
|
<article key={order.workOrderId} className="worker-hall-card">
|
||||||
@@ -626,11 +635,8 @@ export default function WorkerHallPage() {
|
|||||||
置顶
|
置顶
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
<strong
|
<strong className="worker-hall-card-title-text" title={productName}>
|
||||||
className="worker-hall-card-title-text"
|
{productName}
|
||||||
title={order.productName || '未命名商品'}
|
|
||||||
>
|
|
||||||
{order.productName || '未命名商品'}
|
|
||||||
</strong>
|
</strong>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
{order.categoryName || '默认分类'}
|
{order.categoryName || '默认分类'}
|
||||||
@@ -661,10 +667,10 @@ export default function WorkerHallPage() {
|
|||||||
|
|
||||||
<div className="worker-hall-card-amount-row">
|
<div className="worker-hall-card-amount-row">
|
||||||
<div className="worker-hall-card-amount">
|
<div className="worker-hall-card-amount">
|
||||||
{formatMoney(order.rewardAmount)}
|
{formatMoney(resolveHallRewardAmount(order))}
|
||||||
</div>
|
</div>
|
||||||
<Typography.Text type="secondary" className="worker-hall-card-deposit">
|
<Typography.Text type="secondary" className="worker-hall-card-deposit">
|
||||||
所需保证金:{formatMoney(order.freezeDepositAmount)}
|
所需保证金:{formatMoney(resolveHallDepositAmount(order))}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -676,7 +682,9 @@ export default function WorkerHallPage() {
|
|||||||
|
|
||||||
<div className="worker-hall-card-actions">
|
<div className="worker-hall-card-actions">
|
||||||
<div className="worker-hall-card-grab-row">
|
<div className="worker-hall-card-grab-row">
|
||||||
{order.sharing?.enabled && !disabledReason ? (
|
{order.sharing?.enabled &&
|
||||||
|
(sharingRemaining > 0 || order.myShare) &&
|
||||||
|
!disabledReason ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -689,6 +697,7 @@ export default function WorkerHallPage() {
|
|||||||
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sharingRemaining > 0 || !order.sharing?.enabled ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -698,6 +707,7 @@ export default function WorkerHallPage() {
|
|||||||
>
|
>
|
||||||
{disabledReason || '立即抢单'}
|
{disabledReason || '立即抢单'}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -713,7 +723,7 @@ export default function WorkerHallPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`拼单参与 · ${sharingOrder?.productName || ''}`}
|
title={`拼单参与 · ${sharingOrder ? resolveHallProductName(sharingOrder) : ''}`}
|
||||||
width={isMobile ? '92%' : 520}
|
width={isMobile ? '92%' : 520}
|
||||||
open={Boolean(sharingOrder)}
|
open={Boolean(sharingOrder)}
|
||||||
confirmLoading={submittingSharing}
|
confirmLoading={submittingSharing}
|
||||||
@@ -831,7 +841,7 @@ function resolveGrabDisabledReason(
|
|||||||
worker: WorkerUser | undefined,
|
worker: WorkerUser | undefined,
|
||||||
remainingSlots: number | null,
|
remainingSlots: number | null,
|
||||||
) {
|
) {
|
||||||
if (remainingSlots !== null && remainingSlots <= 0) {
|
if (remainingSlots !== null && remainingSlots <= 0 && !order.myShare) {
|
||||||
return '已达到当前等级最大同时接单量'
|
return '已达到当前等级最大同时接单量'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,7 +850,7 @@ function resolveGrabDisabledReason(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availableAmount = Number(worker.wallet.availableAmount || 0)
|
const availableAmount = Number(worker.wallet.availableAmount || 0)
|
||||||
const freezeAmount = Number(order.freezeDepositAmount || 0)
|
const freezeAmount = resolveHallDepositAmount(order)
|
||||||
if (availableAmount < freezeAmount) {
|
if (availableAmount < freezeAmount) {
|
||||||
return `余额不足,还差 ${formatMoney(freezeAmount - availableAmount)}`
|
return `余额不足,还差 ${formatMoney(freezeAmount - availableAmount)}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,6 +464,12 @@ export function acceptAdminWorkOrder(workOrderId: number) {
|
|||||||
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`)
|
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function acceptAdminWorkOrderShare(workOrderId: number, shareId: number) {
|
||||||
|
return apiPost<{ order: WorkOrder; share: WorkOrderShare }>(
|
||||||
|
`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/accept`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function acceptAdminWorkOrders(workOrderIds: number[]) {
|
export function acceptAdminWorkOrders(workOrderIds: number[]) {
|
||||||
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
|
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
|
||||||
workOrderIds,
|
workOrderIds,
|
||||||
|
|||||||
@@ -353,6 +353,23 @@ export type WorkOrder = {
|
|||||||
categoryId?: number | null
|
categoryId?: number | null
|
||||||
categoryName: string
|
categoryName: string
|
||||||
status: WorkOrderStatus
|
status: WorkOrderStatus
|
||||||
|
rawStatus?: WorkOrderStatus
|
||||||
|
hallAvailable?: boolean
|
||||||
|
capabilities?: {
|
||||||
|
canEdit: boolean
|
||||||
|
canConfigureSharing: boolean
|
||||||
|
canAssign: boolean
|
||||||
|
canPin: boolean
|
||||||
|
canUnpublish: boolean
|
||||||
|
canUpdateMaterial: boolean
|
||||||
|
canAccept: boolean
|
||||||
|
canMarkProblem: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
canPublish: boolean
|
||||||
|
canCancel: boolean
|
||||||
|
canResolveProblem: boolean
|
||||||
|
canDeduct: boolean
|
||||||
|
}
|
||||||
rewardAmount: number
|
rewardAmount: number
|
||||||
requiredDepositAmount?: number
|
requiredDepositAmount?: number
|
||||||
depositThresholdAmount?: number
|
depositThresholdAmount?: number
|
||||||
@@ -362,6 +379,8 @@ export type WorkOrder = {
|
|||||||
sharingProgress?: {
|
sharingProgress?: {
|
||||||
joinedQuantity: number
|
joinedQuantity: number
|
||||||
pendingSubmissionCount: number
|
pendingSubmissionCount: number
|
||||||
|
remainingQuantity?: number
|
||||||
|
filled?: boolean
|
||||||
}
|
}
|
||||||
pendingUnfreezeAmount?: number
|
pendingUnfreezeAmount?: number
|
||||||
timeoutMinutes?: number
|
timeoutMinutes?: number
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
type HallProduct = {
|
||||||
|
productName?: string
|
||||||
|
rewardAmount?: number
|
||||||
|
freezeDepositAmount?: number
|
||||||
|
sharing?: {
|
||||||
|
enabled: boolean
|
||||||
|
totalQuantity: number
|
||||||
|
unitReward?: number
|
||||||
|
}
|
||||||
|
sharingProgress?: {
|
||||||
|
joinedQuantity: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCT_QUANTITY_PATTERN = /(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/
|
||||||
|
|
||||||
|
/** 计算大厅当前仍可承接的份数。 */
|
||||||
|
export function resolveHallRemainingQuantity(order: HallProduct): number {
|
||||||
|
if (!order.sharing?.enabled) return 0
|
||||||
|
const totalQuantity = Math.max(1, Number(order.sharing.totalQuantity) || 1)
|
||||||
|
const joinedQuantity = Math.max(0, Number(order.sharingProgress?.joinedQuantity || 0))
|
||||||
|
return Math.max(0, totalQuantity - joinedQuantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 大厅拼单标题展示剩余可承接数量,避免把原始总规格误认为剩余库存。 */
|
||||||
|
export function resolveHallProductName(order: HallProduct): string {
|
||||||
|
const productName = String(order.productName || '').trim()
|
||||||
|
if (!productName || !order.sharing?.enabled) return productName
|
||||||
|
|
||||||
|
const remainingQuantity = resolveHallRemainingQuantity(order)
|
||||||
|
return productName.replace(
|
||||||
|
PRODUCT_QUANTITY_PATTERN,
|
||||||
|
(_matched, _quantity: string, spacing: string, unit: string) =>
|
||||||
|
`${remainingQuantity}${spacing}${unit}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼单卡片金额展示一次性抢走全部剩余份数可获得的报酬。 */
|
||||||
|
export function resolveHallRewardAmount(order: HallProduct): number {
|
||||||
|
if (!order.sharing?.enabled) return Number(order.rewardAmount || 0)
|
||||||
|
return resolveHallRemainingQuantity(order) * Number(order.sharing.unitReward || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼单卡片保证金按剩余份数占总份数的比例展示。 */
|
||||||
|
export function resolveHallDepositAmount(order: HallProduct): number {
|
||||||
|
if (!order.sharing?.enabled) return Number(order.freezeDepositAmount || 0)
|
||||||
|
const totalQuantity = Math.max(1, Number(order.sharing.totalQuantity) || 1)
|
||||||
|
return Math.round(
|
||||||
|
(Number(order.freezeDepositAmount || 0) * resolveHallRemainingQuantity(order)) / totalQuantity,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveHallDepositAmount,
|
||||||
|
resolveHallProductName,
|
||||||
|
resolveHallRemainingQuantity,
|
||||||
|
resolveHallRewardAmount,
|
||||||
|
} from '../src/utils/worker-product-name.ts'
|
||||||
|
|
||||||
|
test('拼单大厅标题按剩余份数更新阿拉伯数字规格', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveHallProductName({
|
||||||
|
productName: '指挥官密钥15个',
|
||||||
|
sharing: { enabled: true, totalQuantity: 15 },
|
||||||
|
sharingProgress: { joinedQuantity: 3 },
|
||||||
|
}),
|
||||||
|
'指挥官密钥12个',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拼单大厅标题兼容中文数字规格', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveHallProductName({
|
||||||
|
productName: '指挥官密钥十五份',
|
||||||
|
sharing: { enabled: true, totalQuantity: 15 },
|
||||||
|
sharingProgress: { joinedQuantity: 4 },
|
||||||
|
}),
|
||||||
|
'指挥官密钥11份',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('普通单保留原始标题', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveHallProductName({
|
||||||
|
productName: '指挥官密钥1个',
|
||||||
|
sharing: { enabled: false, totalQuantity: 1 },
|
||||||
|
sharingProgress: { joinedQuantity: 0 },
|
||||||
|
}),
|
||||||
|
'指挥官密钥1个',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('立即抢单的份数、报酬和保证金都按剩余数量计算', () => {
|
||||||
|
const order = {
|
||||||
|
productName: '指挥官密钥15个',
|
||||||
|
rewardAmount: 10_500,
|
||||||
|
freezeDepositAmount: 1_500,
|
||||||
|
sharing: { enabled: true, totalQuantity: 15, unitReward: 700 },
|
||||||
|
sharingProgress: { joinedQuantity: 3 },
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(resolveHallRemainingQuantity(order), 12)
|
||||||
|
assert.equal(resolveHallRewardAmount(order), 8_400)
|
||||||
|
assert.equal(resolveHallDepositAmount(order), 1_200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拼单份数用完后剩余报酬和保证金归零', () => {
|
||||||
|
const order = {
|
||||||
|
rewardAmount: 10_500,
|
||||||
|
freezeDepositAmount: 1_500,
|
||||||
|
sharing: { enabled: true, totalQuantity: 15, unitReward: 700 },
|
||||||
|
sharingProgress: { joinedQuantity: 15 },
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(resolveHallRemainingQuantity(order), 0)
|
||||||
|
assert.equal(resolveHallRewardAmount(order), 0)
|
||||||
|
assert.equal(resolveHallDepositAmount(order), 0)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user