From c33e859e96074152833674f57cb087776737ff88 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 1 Aug 2026 16:31:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8D=95=E5=B9=B3=E5=8F=B0=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=8B=BC=E5=8D=95=E4=B8=8E=E5=8F=96=E6=B6=88=E6=8E=A5?= =?UTF-8?q?=E5=8D=95=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拼单功能: - 工单/物品规则支持拼单配置(启用、总数量、单价),总价=单价×数量三方联动,启用拼单时工单金额自动同步拼单总价 - 拼单参与按数量冻结比例押金,验收通过后按份额结算报酬,升级统计按数量计入 - 抢单大厅拼单入口:紫色渐变大按钮带单价展示,拼单进度/剩余份数,弹窗输入数量实时预估报酬与押金 - 我的订单展示拼单份额(数量×单价、状态),拼单者各自提交验收,全部提交后管理员统一验收 - 新增 work_order_shares 表与迁移 008 取消接单: - 进行中订单可取消(押金全额退还、工单回大厅),30 天内限 5 次防滥用 --- .../db/migrations/008_work_order_sharing.sql | 49 ++ .../src/repositories/worker-platform-repo.ts | 617 +++++++++++++++++- .../src/routes/admin/worker-platform.ts | 31 + apps/backend/src/routes/worker.ts | 33 + .../worker-platform-service.ts | 382 ++++++++++- .../pages/admin/AdminWorkerPlatformPage.tsx | 353 ++++++++++ .../src/pages/worker/WorkerHallPage.tsx | 143 +++- .../src/pages/worker/WorkerOrdersPage.tsx | 80 ++- .../src/services/admin/worker-platform.ts | 26 + apps/frontend/src/services/worker.ts | 14 + apps/frontend/src/styles/main.css | 44 +- apps/frontend/src/types/worker-platform.ts | 34 + 12 files changed, 1745 insertions(+), 61 deletions(-) create mode 100644 apps/backend/src/db/migrations/008_work_order_sharing.sql diff --git a/apps/backend/src/db/migrations/008_work_order_sharing.sql b/apps/backend/src/db/migrations/008_work_order_sharing.sql new file mode 100644 index 00000000..80290fac --- /dev/null +++ b/apps/backend/src/db/migrations/008_work_order_sharing.sql @@ -0,0 +1,49 @@ +-- 008_work_order_sharing.sql —— 接单工单拼单能力。 +-- +-- 说明: +-- 1. 工单可开启拼单:sharing_total_quantity 总数量、sharing_unit_reward 单价(分), +-- 打手按单价×数量参与,验收统一通过后按份额结算; +-- 2. 拼单与整单抢单不互斥(未拼满时整单仍可抢走); +-- 3. 每个打手在同一拼单工单最多一个份额(UNIQUE 约束)。 + +ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS sharing_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS sharing_total_quantity INTEGER NOT NULL DEFAULT 1; +ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS sharing_unit_reward INTEGER NOT NULL DEFAULT 0; + +-- 物品规则同样支持拼单配置,自动创建工单时带入 +ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS sharing_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS sharing_total_quantity INTEGER NOT NULL DEFAULT 1; +ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS sharing_unit_reward INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS work_order_shares ( + id BIGSERIAL PRIMARY KEY, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 1, + unit_reward INTEGER NOT NULL DEFAULT 0, + share_reward INTEGER NOT NULL DEFAULT 0, + share_deposit INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'joined', + acceptance_json JSONB NOT NULL DEFAULT '{}'::jsonb, + submitted_at TIMESTAMPTZ, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (work_order_id, worker_id) +); + +CREATE INDEX IF NOT EXISTS idx_work_order_shares_work_order + ON work_order_shares(work_order_id); +CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker + ON work_order_shares(worker_id, status, id DESC); + +COMMENT ON COLUMN work_orders.sharing_enabled IS '是否启用拼单(多打手按数量参与同一工单)'; +COMMENT ON COLUMN work_orders.sharing_total_quantity IS '拼单总数量'; +COMMENT ON COLUMN work_orders.sharing_unit_reward IS '拼单单价(分/个),按此结算份额报酬'; +COMMENT ON TABLE work_order_shares IS '拼单参与份额:打手参与拼单工单的记录'; +COMMENT ON COLUMN work_order_shares.quantity IS '该打手拼单数量'; +COMMENT ON COLUMN work_order_shares.unit_reward IS '参与时的拼单单价快照(分)'; +COMMENT ON COLUMN work_order_shares.share_reward IS '份额报酬 = quantity × unit_reward(分)'; +COMMENT ON COLUMN work_order_shares.share_deposit IS '份额押金(按总量比例分摊,分)'; +COMMENT ON COLUMN work_order_shares.status IS 'joined 已参与 / submitted 已提交验收 / accepted 已验收 / cancelled 已取消'; +COMMENT ON COLUMN work_order_shares.acceptance_json IS '拼单者各自的验收资料'; diff --git a/apps/backend/src/repositories/worker-platform-repo.ts b/apps/backend/src/repositories/worker-platform-repo.ts index 360878e6..c64b2b35 100644 --- a/apps/backend/src/repositories/worker-platform-repo.ts +++ b/apps/backend/src/repositories/worker-platform-repo.ts @@ -108,6 +108,9 @@ export type WorkProductRuleRow = { reward_amount: number required_deposit_amount: number deposit_threshold_amount: number + sharing_enabled: boolean + sharing_total_quantity: number + sharing_unit_reward: number requirement_json: string | Record sort_order: number created_at: string @@ -115,6 +118,24 @@ export type WorkProductRuleRow = { category_name?: string } +export type WorkOrderShareRow = { + id: number + work_order_id: number + worker_id: number + quantity: number + unit_reward: number + share_reward: number + share_deposit: number + status: string + acceptance_json: string | Record + submitted_at: string | null + accepted_at: string | null + created_at: string + updated_at: string + worker_username?: string + worker_display_name?: string +} + export type WorkOrderRow = { id: number work_order_no: string @@ -133,6 +154,9 @@ export type WorkOrderRow = { requirement_json: string | Record acceptance_json: string | Record problem_note: string + sharing_enabled: boolean + sharing_total_quantity: number + sharing_unit_reward: number published_at: string | null assigned_at: string | null submitted_at: string | null @@ -167,6 +191,7 @@ type ListInput = { keyword?: string workerId?: number categoryId?: number + workerSharingId?: number } type ProductRuleListInput = { @@ -214,6 +239,9 @@ type CreateWorkOrderInput = { rewardAmount: number requiredDepositAmount: number depositThresholdAmount: number + sharingEnabled?: boolean + sharingTotalQuantity?: number + sharingUnitReward?: number materialJson: string requirementJson: string now: string @@ -853,16 +881,284 @@ export async function getWorkerFinanceRequestSummary(workerId: number | string) export async function countWorkerAcceptedOrders(workerId: number | string): Promise { const result = await query<{ total: number }>( ` - SELECT COUNT(*)::int AS total - FROM work_orders - WHERE assigned_worker_id = $1 - AND status = 'accepted' + SELECT + (SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted') + + + COALESCE( + (SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'), + 0 + ) AS total `, [Number(workerId)], ) return Number(result.rows[0]?.total || 0) } +const WORK_ORDER_SHARE_SELECT = ` + SELECT + wos.*, + wu.username AS worker_username, + wu.display_name AS worker_display_name + FROM work_order_shares wos + LEFT JOIN worker_users wu ON wu.id = wos.worker_id +` + +export async function listWorkOrderShares( + workOrderId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.work_order_id = $1 ORDER BY wos.id ASC`, + [Number(workOrderId)], + ) + return result.rows +} + +export async function listWorkOrderSharesByOrderIds( + workOrderIds: number[], +): Promise { + if (workOrderIds.length === 0) return [] + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = ANY($1::bigint[]) + ORDER BY wos.id ASC`, + [workOrderIds], + ) + return result.rows +} + +export async function listWorkerSharesByWorker( + workerId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.worker_id = $1 AND wos.status != 'cancelled' + ORDER BY wos.id DESC`, + [Number(workerId)], + ) + return result.rows +} + +export async function countWorkOrderPendingSharingSubmissions( + workOrderId: number | string, +): Promise { + const result = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status = 'joined' + `, + [Number(workOrderId)], + ) + return Number(result.rows[0]?.total || 0) +} + +export async function getWorkOrderShare( + workOrderId: number | string, + workerId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = $1 AND wos.worker_id = $2 LIMIT 1`, + [Number(workOrderId), Number(workerId)], + ) + return result.rows[0] || null +} + +export async function joinWorkOrderShare(input: { + workOrderId: number + workerId: number + quantity: number + maxActiveOrders: number + now: string +}): Promise<{ + share: WorkOrderShareRow | null + failureReason: + | 'work_order_not_open' + | 'sharing_disabled' + | 'work_order_owner_conflict' + | 'sharing_quantity_full' + | 'worker_active_order_limit' + | 'worker_deposit_insufficient' + | null +}> { + return withTransaction(async (client) => { + await ensureWorkerWalletWithClient(client, input.workerId, input.now) + + const orderResult = await client.query( + ` + SELECT * + FROM work_orders + WHERE id = $1 + FOR UPDATE + `, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || workOrder.status !== 'open') { + return { share: null, failureReason: 'work_order_not_open' } + } + if (workOrder.sharing_enabled !== true) { + return { share: null, failureReason: 'sharing_disabled' } + } + if (Number(workOrder.assigned_worker_id || 0) === input.workerId) { + return { share: null, failureReason: 'work_order_owner_conflict' } + } + + const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) + const joinedResult = await client.query<{ total: number }>( + ` + SELECT COALESCE(SUM(quantity), 0)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + `, + [input.workOrderId], + ) + const joinedQuantity = Number(joinedResult.rows[0]?.total || 0) + const remaining = Math.max(0, totalQuantity - joinedQuantity) + if (input.quantity <= 0 || input.quantity > remaining) { + return { share: null, failureReason: 'sharing_quantity_full' } + } + + const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) + if (activeOrderCount >= input.maxActiveOrders) { + 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 shareReward = input.quantity * unitReward + const shareDeposit = Math.round( + (Number(workOrder.required_deposit_amount || 0) * input.quantity) / totalQuantity, + ) + + const wallet = await getWorkerWalletWithClient(client, input.workerId) + const available = Number(wallet?.available_amount || 0) + if (available < shareDeposit) { + return { share: null, failureReason: 'worker_deposit_insufficient' } + } + + const shareResult = await client.query<{ id: number }>( + ` + INSERT INTO work_order_shares ( + work_order_id, worker_id, quantity, unit_reward, share_reward, + share_deposit, status, acceptance_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7) + RETURNING id + `, + [ + input.workOrderId, + input.workerId, + input.quantity, + unitReward, + shareReward, + shareDeposit, + input.now, + ], + ) + + if (shareDeposit > 0) { + const nextAvailable = available - shareDeposit + const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + shareDeposit + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, nextFrozen, input.now, input.workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_freeze', $2, $3, $4, $5, $6, $7::jsonb, $8) + `, + [ + input.workerId, + shareDeposit, + nextAvailable, + nextFrozen, + input.workOrderId, + `拼单冻结押金 ${input.quantity} 份`, + JSON.stringify({ workOrderId: input.workOrderId, quantity: input.quantity }), + input.now, + ], + ) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'sharing_joined', + fromStatus: 'open', + toStatus: 'open', + payloadJson: JSON.stringify({ workerId: input.workerId, quantity: input.quantity }), + now: input.now, + }) + + const share = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [Number(shareResult.rows[0]?.id || 0)], + ) + return { share: share.rows[0] || null, failureReason: null } + }) +} + +export async function submitWorkOrderShareAcceptance(input: { + workOrderId: number + workerId: number + acceptanceJson: string + now: string +}): Promise<{ share: WorkOrderShareRow | null; failureReason: 'share_not_found' | 'share_status_invalid' | null }> { + return withTransaction(async (client) => { + const shareResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = $1 AND wos.worker_id = $2 + FOR UPDATE`, + [input.workOrderId, input.workerId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { share: null, failureReason: 'share_not_found' } + } + if (share.status !== 'joined') { + return { share, failureReason: 'share_status_invalid' } + } + await client.query( + ` + UPDATE work_order_shares + SET status = 'submitted', acceptance_json = $1::jsonb, submitted_at = $2, updated_at = $2 + WHERE id = $3 + `, + [input.acceptanceJson, input.now, Number(share.id)], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'sharing_acceptance_submitted', + fromStatus: 'joined', + toStatus: 'submitted', + payloadJson: JSON.stringify({ workerId: input.workerId, quantity: share.quantity }), + now: input.now, + }) + const updated = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [Number(share.id)], + ) + return { share: updated.rows[0] || null, failureReason: null } + }) +} + export async function maybeUpgradeWorkerLevelWithClient( client: PoolClient, workerId: number, @@ -870,10 +1166,13 @@ export async function maybeUpgradeWorkerLevelWithClient( ): Promise { const acceptedResult = await client.query<{ total: number }>( ` - SELECT COUNT(*)::int AS total - FROM work_orders - WHERE assigned_worker_id = $1 - AND status = 'accepted' + SELECT + (SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted') + + + COALESCE( + (SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'), + 0 + ) AS total `, [workerId], ) @@ -1037,6 +1336,9 @@ export async function upsertWorkProductRule(input: { rewardAmount: number requiredDepositAmount: number depositThresholdAmount: number + sharingEnabled?: boolean + sharingTotalQuantity?: number + sharingUnitReward?: number requirementJson: string sortOrder: number now: string @@ -1046,13 +1348,17 @@ export async function upsertWorkProductRule(input: { INSERT INTO work_product_rules ( rule_key, provider, platform, shop_id, sku_code, product_name, match_type, category_id, enabled, auto_create, reward_amount, - required_deposit_amount, deposit_threshold_amount, requirement_json, + required_deposit_amount, deposit_threshold_amount, + sharing_enabled, sharing_total_quantity, sharing_unit_reward, + requirement_json, sort_order, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, - $12, $13, $14::jsonb, - $15, $16, $17 + $12, $13, + $14, $15, $16, + $17::jsonb, + $18, $19, $20 ) ON CONFLICT (rule_key) DO UPDATE SET @@ -1068,6 +1374,9 @@ export async function upsertWorkProductRule(input: { reward_amount = EXCLUDED.reward_amount, required_deposit_amount = EXCLUDED.required_deposit_amount, deposit_threshold_amount = EXCLUDED.deposit_threshold_amount, + sharing_enabled = EXCLUDED.sharing_enabled, + sharing_total_quantity = EXCLUDED.sharing_total_quantity, + sharing_unit_reward = EXCLUDED.sharing_unit_reward, requirement_json = EXCLUDED.requirement_json, sort_order = EXCLUDED.sort_order, updated_at = EXCLUDED.updated_at @@ -1087,6 +1396,9 @@ export async function upsertWorkProductRule(input: { input.rewardAmount, input.requiredDepositAmount, input.depositThresholdAmount, + input.sharingEnabled === true, + toPositiveInteger(input.sharingTotalQuantity, 1), + toPositiveInteger(input.sharingUnitReward, 0), input.requirementJson, input.sortOrder, input.now, @@ -1102,10 +1414,11 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise { const { whereClause, params } = buildWorkOrderWhere({ status, keyword, workerId, categoryId, + workerSharingId, }) const totalResult = await query<{ total: number }>( ` @@ -1216,6 +1534,10 @@ export async function updateWorkOrder( | 'material_json' | 'acceptance_json' | 'problem_note' + | 'reward_amount' + | 'sharing_enabled' + | 'sharing_total_quantity' + | 'sharing_unit_reward' | 'published_at' | 'assigned_at' | 'submitted_at' @@ -1236,12 +1558,16 @@ export async function updateWorkOrder( material_json = $3::jsonb, acceptance_json = $4::jsonb, problem_note = $5, - published_at = $6, - assigned_at = $7, - submitted_at = $8, - accepted_at = $9, - updated_at = $10 - WHERE id = $11 + reward_amount = $6, + sharing_enabled = $7, + sharing_total_quantity = $8, + sharing_unit_reward = $9, + published_at = $10, + assigned_at = $11, + submitted_at = $12, + accepted_at = $13, + updated_at = $14 + WHERE id = $15 `, [ next.status, @@ -1249,6 +1575,10 @@ export async function updateWorkOrder( toJsonString(next.material_json), toJsonString(next.acceptance_json), next.problem_note || '', + toPositiveInteger(next.reward_amount, 0), + next.sharing_enabled === true, + toPositiveInteger(next.sharing_total_quantity, 1), + toPositiveInteger(next.sharing_unit_reward, 0), next.published_at || null, next.assigned_at || null, next.submitted_at || null, @@ -1355,10 +1685,120 @@ export async function grabWorkOrder(input: { }) } +export async function cancelWorkerWorkOrder(input: { + workOrderId: number + workerId: number + now: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: 'work_order_not_in_progress' | 'work_order_owner_required' | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM work_orders + WHERE id = $1 + FOR UPDATE + `, + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder || workOrder.status !== 'in_progress') { + return { order: null, failureReason: 'work_order_not_in_progress' } + } + if (Number(workOrder.assigned_worker_id || 0) !== input.workerId) { + return { order: null, failureReason: 'work_order_owner_required' } + } + + await ensureWorkerWalletWithClient(client, input.workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, input.workerId) + const releaseAmount = Math.min( + await getOutstandingDepositAmountWithClient(client, input.workerId, input.workOrderId), + Number(wallet?.frozen_deposit_amount || 0), + ) + + await client.query( + ` + UPDATE work_orders + SET + status = 'open', + assigned_worker_id = NULL, + assigned_at = NULL, + updated_at = $1 + WHERE id = $2 + `, + [input.now, input.workOrderId], + ) + + if (releaseAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 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, updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, nextFrozen, input.now, input.workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '取消接单退还押金', '{}'::jsonb, $6) + `, + [input.workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + ) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'cancelled_by_worker', + fromStatus: 'in_progress', + toStatus: 'open', + payloadJson: JSON.stringify({ releaseAmount }), + now: input.now, + }) + + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } + }) +} + +export async function countWorkerCancellationsSince( + workerId: number | string, + sinceIso: string, +): Promise { + const result = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_events + WHERE actor_type = 'worker' + AND actor_id = $1 + AND event_type = 'cancelled_by_worker' + AND created_at >= $2 + `, + [String(workerId), sinceIso], + ) + return Number(result.rows[0]?.total || 0) +} + export async function acceptWorkOrderAndSettle(input: { workOrderId: number now: string -}): Promise { +}): Promise<{ + order: WorkOrderRow | null + failureReason: 'sharing_pending_submissions' | null +}> { return withTransaction(async (client) => { const currentResult = await client.query( ` @@ -1372,7 +1812,29 @@ export async function acceptWorkOrderAndSettle(input: { ) const workOrder = currentResult.rows[0] || null if (!workOrder) { - return null + return { order: null, failureReason: null } + } + + const sharesResult = await client.query( + ` + SELECT * + FROM work_order_shares + WHERE work_order_id = $1 + AND status != 'cancelled' + ORDER BY id ASC + FOR UPDATE + `, + [input.workOrderId], + ) + const shares = sharesResult.rows + if (shares.length > 0) { + const pendingShares = shares.filter((share) => share.status === 'joined') + if (pendingShares.length > 0) { + return { + order: null, + failureReason: 'sharing_pending_submissions', + } + } } await client.query( @@ -1384,6 +1846,94 @@ export async function acceptWorkOrderAndSettle(input: { [input.now, input.workOrderId], ) + if (shares.length > 0) { + for (const share of shares) { + const shareWorkerId = Number(share.worker_id || 0) + if (!shareWorkerId) continue + await ensureWorkerWalletWithClient(client, shareWorkerId, input.now) + const wallet = await getWorkerWalletWithClient(client, shareWorkerId) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + const shareReward = Number(share.share_reward || 0) + const nextAvailable = + Number(wallet?.available_amount || 0) + releaseAmount + shareReward + 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, + total_settled_amount = total_settled_amount + $3, + updated_at = $4 + WHERE worker_id = $5 + `, + [nextAvailable, nextFrozen, shareReward, input.now, shareWorkerId], + ) + + 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, 'deposit_release', $2, $3, $4, $5, '拼单验收释放押金', $6::jsonb, $7) + `, + [ + shareWorkerId, + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ workOrderId: input.workOrderId, shareId: Number(share.id) }), + 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) + `, + [ + shareWorkerId, + shareReward, + nextAvailable, + nextFrozen, + input.workOrderId, + `拼单结算报酬 ${share.quantity} 份`, + JSON.stringify({ + workOrderId: input.workOrderId, + shareId: Number(share.id), + quantity: share.quantity, + }), + input.now, + ], + ) + } + + await client.query( + ` + UPDATE work_order_shares + SET status = 'accepted', accepted_at = $1, updated_at = $1 + WHERE id = $2 + `, + [input.now, Number(share.id)], + ) + await maybeUpgradeWorkerLevelWithClient(client, shareWorkerId, input.now) + } + } + const workerId = Number(workOrder.assigned_worker_id || 0) if (workerId > 0) { await ensureWorkerWalletWithClient(client, workerId, input.now) @@ -1441,7 +1991,7 @@ export async function acceptWorkOrderAndSettle(input: { eventType: 'accepted', fromStatus: 'pending_acceptance', toStatus: 'accepted', - payloadJson: '{}', + payloadJson: JSON.stringify({ shareCount: shares.length }), now: input.now, }) @@ -1449,7 +1999,7 @@ export async function acceptWorkOrderAndSettle(input: { await maybeUpgradeWorkerLevelWithClient(client, workerId, input.now) } - return getWorkOrderByIdWithClient(client, input.workOrderId) + return { order: await getWorkOrderByIdWithClient(client, input.workOrderId), failureReason: null } }) } @@ -1779,6 +2329,7 @@ function buildWorkOrderWhere({ keyword = '', workerId = 0, categoryId = 0, + workerSharingId = 0, }: ListInput) { const filters: string[] = [] const params: unknown[] = [] @@ -1800,6 +2351,16 @@ function buildWorkOrderWhere({ params.push(categoryId) filters.push(`wo.category_id = $${params.length}`) } + if (workerSharingId) { + params.push(workerSharingId) + filters.push( + `(wo.assigned_worker_id = $${params.length} + OR wo.id IN ( + SELECT work_order_id FROM work_order_shares + WHERE worker_id = $${params.length} AND status != 'cancelled' + ))`, + ) + } return { whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', params, @@ -1884,6 +2445,12 @@ function buildWorkProductRuleWhere({ enabled = null, keyword = '' }: ProductRule } function toJsonString(value: unknown): string { - if (typeof value === 'string') return value || '{}' - return JSON.stringify(value || {}) + if (typeof value === 'string') return value + return JSON.stringify(value ?? {}) +} + +function toPositiveInteger(value: unknown, fallback: number): number { + const parsed = Number(value ?? '') + if (!Number.isFinite(parsed)) return fallback + return Math.max(0, Math.round(parsed)) } diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 4d986113..8fd50aab 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -8,6 +8,7 @@ import { deleteAdminWorkerLevel, getAdminWorkerFinanceConfig, getAdminWorkerPlatformSummary, + getAdminWorkOrderSharing, listAdminWorkerFinanceRequests, listAdminWorkCategories, listAdminWorkProductRules, @@ -26,6 +27,7 @@ import { submitAdminWorkOrderMaterial, syncAdminWorkerOrdersFromSource, unpublishAdminWorkOrder, + updateAdminWorkOrderSharing, } from '../../services/worker-platform/worker-platform-service.js' import { createJsonHandler, requireAdminRoles } from './session.js' @@ -349,6 +351,35 @@ router.post( }), ) +router.get( + '/worker-platform/orders/:workOrderId/sharing', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => getAdminWorkOrderSharing(String(req.params.workOrderId || '')), { + successMessage: 'ok', + errorMessage: '读取拼单信息失败', + scope: '[admin/worker-platform/orders/:workOrderId/sharing]', + }), +) + +router.post( + '/worker-platform/orders/:workOrderId/sharing', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => updateAdminWorkOrderSharing(String(req.params.workOrderId || ''), req.body || {}), + { + successMessage: '拼单配置已保存', + errorMessage: '保存拼单配置失败', + scope: '[admin/worker-platform/orders/:workOrderId/sharing]', + audit: (req, data) => ({ + action: 'work_order_sharing_config_updated', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/problem', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index c3209dd4..f0a7bfd1 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -4,12 +4,14 @@ import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middlewa import { uploadSingleFile } from './file-upload.js' import { uploadFileAsset } from '../services/file-storage/file-storage-service.js' import { + cancelWorkerOrder, changeWorkerPassword, createWorkerRechargeRequest, createWorkerWithdrawRequest, getWorkerProfile, getWorkerSessionSummary, grabWorkerHallOrder, + joinWorkerSharingOrder, listWorkerProfileFinanceRequests, listWorkerProfileWalletLedgers, listWorkerHallOrders, @@ -182,6 +184,24 @@ router.post( ), ) +router.post( + '/hall/orders/:workOrderId/join-sharing', + requireActiveWorker, + createRouteHandler( + (req) => + joinWorkerSharingOrder( + String(req.params.workOrderId || ''), + req.body || {}, + getRequiredWorkerSession(req), + ), + { + successMessage: '拼单成功', + errorMessage: '拼单失败', + scope: '[worker/hall/orders/:workOrderId/join-sharing]', + }, + ), +) + router.get( '/orders', requireActiveWorker, @@ -210,6 +230,19 @@ router.post( ), ) +router.post( + '/orders/:workOrderId/cancel', + requireActiveWorker, + createRouteHandler( + (req) => cancelWorkerOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), + { + successMessage: '已取消接单', + errorMessage: '取消接单失败', + scope: '[worker/orders/:workOrderId/cancel]', + }, + ), +) + router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)) }) diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.ts b/apps/backend/src/services/worker-platform/worker-platform-service.ts index c8e2d3e1..55f07c18 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.ts @@ -11,6 +11,9 @@ import { countWorkerActiveOrders, countWorkCategoryUsages, countWorkerLevelUsages, + countWorkOrderPendingSharingSubmissions, + cancelWorkerWorkOrder, + countWorkerCancellationsSince, createWorkOrder, createWorkOrderEvent, createWorkerFinanceRequest, @@ -24,23 +27,29 @@ import { getWorkerLevelById, getWorkerLevelByKey, getWorkerUserByInviteCode, - incrementWorkerSessionVersion, - getWorkOrderById, - getWorkOrderByOrderItemId, getWorkerUserById, getWorkerUserByUsername, - listAllWorkCategories, - listWorkerFinanceRequests, - listPendingMaterialWorkOrdersByPlatformOrderId, - listWorkerWalletLedgers, + getWorkOrderById, + getWorkOrderByOrderItemId, + getWorkOrderShare, grabWorkOrder, + incrementWorkerSessionVersion, + joinWorkOrderShare, + listAllWorkCategories, + listPendingMaterialWorkOrdersByPlatformOrderId, + listWorkerFinanceRequests, + listWorkerSharesByWorker, + listWorkerWalletLedgers, listWorkCategories, listWorkOrders, listWorkProductRules, listWorkerLevels, listWorkerUsers, + listWorkOrderShares, + listWorkOrderSharesByOrderIds, resolveProblemWorkOrder, reviewWorkerFinanceRequest, + submitWorkOrderShareAcceptance, updateWorkOrder, updateWorkerPassword, updateWorkerUser, @@ -52,6 +61,7 @@ import { type WorkOrderRow, type WorkProductRuleRow, type WorkerLevelRow, + type WorkOrderShareRow, type WorkerWalletLedgerRow, type WorkerUserRow, type GrabWorkOrderFailureReason, @@ -88,6 +98,8 @@ const DEFAULT_CATEGORY_KEY = 'default' const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000 const INVITE_CODE_LENGTH = 8 const INVITE_CODE_RETRY_LIMIT = 5 +const WORKER_CANCEL_LIMIT_PER_WINDOW = 5 +const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000 const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([ 'worker_auth_required', 'worker_auth_invalid', @@ -652,8 +664,16 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const worker = await getRequiredWorker(session.workerId) const permissions = resolveWorkerPermissions(worker) const categories = (await listWorkCategories()).map(mapWorkCategory) + const myShares = await listWorkerSharesByWorker(worker.id) + const myShareByOrderId = new Map( + myShares + .filter((share) => Number(share.work_order_id)) + .map((share) => [Number(share.work_order_id), share]), + ) return { - items: items.map((item) => mapWorkOrderForWorker(item, permissions)), + items: items.map((item) => + mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), + ), pagination: { page, pageSize, total }, categories, } @@ -711,15 +731,104 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker pageSize, status: String(query.status || '').trim(), keyword: String(query.keyword || '').trim(), - workerId: worker.id, + workerSharingId: worker.id, }) const permissions = resolveWorkerPermissions(worker) + const myShares = await listWorkerSharesByWorker(worker.id) + const myShareByOrderId = new Map( + myShares.map((share) => [Number(share.work_order_id), share]), + ) return { - items: items.map((item) => mapWorkOrderForWorker(item, permissions)), + items: items.map((item) => + mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), + ), pagination: { page, pageSize, total }, } } +export async function joinWorkerSharingOrder( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const permissions = resolveWorkerPermissions(worker) + const quantity = normalizePositiveInteger(payload.quantity, 1) + const now = nowIso() + const result = await joinWorkOrderShare({ + workOrderId: Number(workOrderId), + workerId: Number(worker.id), + quantity, + maxActiveOrders: permissions.maxActiveOrders, + now, + }) + if (!result.share) { + throw resolveWorkOrderShareJoinError(result.failureReason) + } + return { share: mapWorkOrderShare(result.share) } +} + +function resolveWorkOrderShareJoinError( + failureReason: 'work_order_not_open' | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null, +): Error { + const messages: Record = { + work_order_not_open: '该订单当前不可拼单', + sharing_disabled: '该订单未启用拼单', + work_order_owner_conflict: '你已整单接取该订单,无需拼单', + sharing_quantity_full: '拼单数量已满或超出剩余数量', + worker_active_order_limit: '已达到当前等级最大同时接单量', + worker_deposit_insufficient: '余额不足,无法冻结所需押金', + } + return createHttpError(messages[failureReason || ''] || '拼单失败', { + statusCode: 409, + errorCode: `work_order_sharing_${failureReason || 'failed'}`, + }) +} + +export async function cancelWorkerOrder( + workOrderId: number | string, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const now = nowIso() + const since = new Date(Date.now() - WORKER_CANCEL_LIMIT_WINDOW_MS).toISOString() + const cancelCount = await countWorkerCancellationsSince(worker.id, since) + if (cancelCount >= WORKER_CANCEL_LIMIT_PER_WINDOW) { + throw createHttpError( + `30 天内取消接单已达 ${WORKER_CANCEL_LIMIT_PER_WINDOW} 次上限,请谨慎接单`, + { + statusCode: 409, + errorCode: 'worker_cancel_limit_reached', + }, + ) + } + const result = await cancelWorkerWorkOrder({ + workOrderId: Number(workOrderId), + workerId: Number(worker.id), + now, + }) + if (result.failureReason === 'work_order_owner_required') { + throw createHttpError('只能取消自己接的订单', { + statusCode: 403, + errorCode: 'work_order_owner_required', + }) + } + if (!result.order) { + throw createHttpError('当前订单状态不能取消,仅进行中的订单可取消', { + statusCode: 409, + errorCode: 'work_order_cancel_status_invalid', + }) + } + return { + order: mapWorkOrderForWorker( + result.order, + resolveWorkerPermissions(await getRequiredWorker(session.workerId)), + ), + } +} + export async function submitWorkerOrderAcceptance( workOrderId: number | string, payload: JsonObject = {}, @@ -727,7 +836,12 @@ export async function submitWorkerOrderAcceptance( ) { requireActiveWorkerSession(session) const workOrder = await getRequiredWorkOrder(workOrderId) - if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) { + const workerId = Number(session.workerId) + const sharingShare = await getWorkOrderShare(workOrder.id, workerId) + if (sharingShare) { + return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) + } + if (Number(workOrder.assigned_worker_id || 0) !== workerId) { throw createHttpError('只能提交自己的订单', { statusCode: 403, errorCode: 'work_order_owner_required', @@ -784,6 +898,60 @@ export async function submitWorkerOrderAcceptance( } } +async function submitWorkerSharingAcceptance( + workOrder: WorkOrderRow, + sharingShare: WorkOrderShareRow, + payload: JsonObject = {}, + session: WorkerSession, +) { + if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) { + throw createHttpError('该拼单已验收完成', { + statusCode: 409, + errorCode: 'work_order_sharing_accepted', + }) + } + const now = nowIso() + const files = normalizeUploadedFiles(payload.files) + const imageUrls = [ + ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), + ...normalizeStringArray(payload.imageUrls), + ] + if (imageUrls.length === 0) { + throw createHttpError('请上传验收图片', { + statusCode: 400, + errorCode: 'work_order_acceptance_image_required', + }) + } + const acceptance = { + note: String(payload.note || '').trim(), + files, + imageUrls: [...new Set(imageUrls)], + submittedAt: now, + } + const result = await submitWorkOrderShareAcceptance({ + workOrderId: Number(workOrder.id), + workerId: Number(session.workerId), + acceptanceJson: JSON.stringify(acceptance), + now, + }) + if (result.failureReason === 'share_status_invalid') { + throw createHttpError('当前拼单份额已提交,请勿重复提交', { + statusCode: 409, + errorCode: 'work_order_sharing_share_submitted', + }) + } + if (!result.share) { + throw createHttpError('未找到拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + return { + share: mapWorkOrderShare(result.share), + sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)), + } +} + export async function collectLookupWorkOrder(payload: JsonObject = {}) { const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() if (!orderNo) { @@ -995,12 +1163,39 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { } const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0) - if (rewardAmount <= 0) { + const sharingEnabled = normalizeBoolean( + payload.sharingEnabled ?? payload.sharing_enabled, + false, + ) + const sharingUnitReward = normalizeAmountFen( + payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, + 0, + ) + const sharingTotalAmount = normalizeAmountFen( + payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan, + 0, + ) + const sharingTotalQuantity = resolveSharingQuantity( + sharingTotalAmount, + normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1), + sharingUnitReward, + ) + const resolvedSharingTotalAmount = + sharingTotalAmount > 0 ? sharingTotalAmount : sharingUnitReward * sharingTotalQuantity + if (sharingEnabled) { + if (resolvedSharingTotalAmount <= 0) { + throw createHttpError('启用拼单时请填写拼单总价或单价', { + statusCode: 400, + errorCode: 'work_product_rule_sharing_amount_invalid', + }) + } + } else if (rewardAmount <= 0) { throw createHttpError('接单金额必须大于 0', { statusCode: 400, errorCode: 'work_product_rule_reward_invalid', }) } + const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount const depositThresholdAmount = normalizeAmountFen( payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, @@ -1008,8 +1203,8 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { ) const requiredDepositAmount = payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined - ? rewardAmount > depositThresholdAmount - ? rewardAmount + ? finalRewardAmount > depositThresholdAmount + ? finalRewardAmount : 0 : normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0) const fields = normalizeRequirementFieldsFromPayload(payload) @@ -1030,9 +1225,12 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { null, enabled: normalizeBoolean(payload.enabled, true), autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false), - rewardAmount, + rewardAmount: finalRewardAmount, requiredDepositAmount, depositThresholdAmount, + sharingEnabled, + sharingTotalQuantity, + sharingUnitReward, requirementJson: JSON.stringify({ fields }), sortOrder: normalizeInteger(payload.sortOrder, 100), now: nowIso(), @@ -1188,12 +1386,113 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { status: String(query.status || '').trim(), keyword: String(query.keyword || '').trim(), }) + const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id))) + const sharesByOrderId = new Map() + for (const share of shares) { + const orderId = Number(share.work_order_id) + const bucket = sharesByOrderId.get(orderId) || [] + bucket.push(share) + sharesByOrderId.set(orderId, bucket) + } return { - items: items.map(mapWorkOrderAdmin), + 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 { + ...mapWorkOrderAdmin(item), + sharingProgress: { + joinedQuantity, + pendingSubmissionCount, + }, + } + }), pagination: { page, pageSize, total }, } } +export async function getAdminWorkOrderSharing(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + const shares = await listWorkOrderShares(workOrder.id) + return { + order: mapWorkOrderAdmin(workOrder), + shares: shares.map(mapWorkOrderShare), + } +} + +export async function updateAdminWorkOrderSharing( + workOrderId: number | string, + payload: JsonObject = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN].includes(workOrder.status as never)) { + throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', { + statusCode: 409, + errorCode: 'work_order_sharing_config_status_invalid', + }) + } + const enabled = normalizeBoolean( + payload.enabled ?? payload.sharingEnabled ?? payload.sharing_enabled, + false, + ) + const unitReward = normalizeAmountFen( + payload.unitReward ?? payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, + 0, + ) + const totalAmount = normalizeAmountFen( + payload.totalAmount ?? payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan, + 0, + ) + const totalQuantity = resolveSharingQuantity( + totalAmount, + normalizePositiveInteger( + payload.totalQuantity ?? payload.sharingTotalQuantity ?? payload.sharing_total_quantity, + 1, + ), + unitReward, + ) + const resolvedTotalAmount = totalAmount > 0 ? totalAmount : unitReward * totalQuantity + const now = nowIso() + const updated = await updateWorkOrder(workOrder.id, { + sharing_enabled: enabled, + sharing_total_quantity: totalQuantity, + sharing_unit_reward: unitReward, + reward_amount: enabled ? resolvedTotalAmount : workOrder.reward_amount, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'sharing_config_updated', + fromStatus: workOrder.status, + toStatus: updated?.status || workOrder.status, + payloadJson: JSON.stringify({ + enabled, + totalQuantity, + unitReward, + totalAmount: resolvedTotalAmount, + }), + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +function resolveSharingQuantity( + totalAmount: number, + fallbackQuantity: number, + unitReward: number, +): number { + if (totalAmount > 0 && unitReward > 0) { + return Math.max(1, Math.round(totalAmount / unitReward)) + } + return Math.max(1, fallbackQuantity) +} + export async function submitAdminWorkOrderMaterial( workOrderId: number | string, payload: JsonObject = {}, @@ -1450,7 +1749,16 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) { }) } const now = nowIso() - const updated = await acceptWorkOrderAndSettle({ workOrderId: workOrder.id, now }) + const { order: updated, failureReason } = await acceptWorkOrderAndSettle({ + workOrderId: workOrder.id, + now, + }) + if (failureReason === 'sharing_pending_submissions') { + throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', { + statusCode: 409, + errorCode: 'work_order_sharing_pending_submissions', + }) + } if (!updated) { throw createHttpError('验收失败,订单状态可能已变化', { statusCode: 409, @@ -1521,6 +1829,9 @@ export async function syncWorkerOrdersForSourceOrder( rewardAmount, requiredDepositAmount, depositThresholdAmount, + sharingEnabled: rule.sharing_enabled === true, + sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), + sharingUnitReward: Number(rule.sharing_unit_reward || 0), materialJson: JSON.stringify({ source: { orderId: Number(order.id), @@ -1781,6 +2092,11 @@ function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) { depositThresholdAmount: Number( rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, ), + sharing: { + enabled: rule.sharing_enabled === true, + totalQuantity: Number(rule.sharing_total_quantity || 1), + unitReward: Number(rule.sharing_unit_reward || 0), + }, requirement: { fields: normalizeRequirementFields(requirement.fields), }, @@ -1883,6 +2199,11 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) { status: workOrder.status, rewardAmount: Number(workOrder.reward_amount || 0), requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), + sharing: { + enabled: workOrder.sharing_enabled === true, + totalQuantity: Number(workOrder.sharing_total_quantity || 1), + unitReward: Number(workOrder.sharing_unit_reward || 0), + }, depositThresholdAmount: Number( workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, ), @@ -1909,6 +2230,7 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) { function mapWorkOrderForWorker( workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }, + myShare?: WorkOrderShareRow | null, ) { const mapped = mapWorkOrderAdmin(workOrder) const { @@ -1922,6 +2244,32 @@ function mapWorkOrderForWorker( return { ...visibleOrder, freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), + myShare: myShare ? mapWorkOrderShare(myShare) : null, + } +} + +function mapWorkOrderShare(share: WorkOrderShareRow) { + return { + shareId: Number(share.id), + workOrderId: Number(share.work_order_id), + workerId: Number(share.worker_id), + quantity: Number(share.quantity || 0), + unitReward: Number(share.unit_reward || 0), + shareReward: Number(share.share_reward || 0), + shareDeposit: Number(share.share_deposit || 0), + status: share.status, + acceptance: safeParseJson(share.acceptance_json), + submittedAt: share.submitted_at, + acceptedAt: share.accepted_at, + createdAt: share.created_at, + updatedAt: share.updated_at, + worker: share.worker_id + ? { + workerId: Number(share.worker_id), + username: share.worker_username || '', + displayName: share.worker_display_name || share.worker_username || '', + } + : null, } } diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 7ac78b7d..d5e9e837 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -5,6 +5,7 @@ import { ReloadOutlined, SendOutlined, SyncOutlined, + TeamOutlined, WarningOutlined, } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -44,6 +45,7 @@ import { fetchAdminWorkerFinanceConfig, fetchAdminWorkerFinanceRequests, fetchAdminWorkCategories, + fetchAdminWorkOrderSharing, fetchAdminWorkProductRules, fetchAdminWorkerLevels, fetchAdminWorkerPlatformSummary, @@ -61,12 +63,14 @@ import { submitAdminWorkOrderMaterial, syncAdminWorkerOrdersFromSource, unpublishAdminWorkOrder, + updateAdminWorkOrderSharing, } from '@/services/admin' import type { CollectField, UploadedFile, WorkCategory, WorkOrder, + WorkOrderShare, WorkProductRule, WorkerFinanceConfig, WorkerFinanceRequest, @@ -161,15 +165,84 @@ function WorkOrdersPanel() { const [problemOrder, setProblemOrder] = useState(null) const [resolutionOrder, setResolutionOrder] = useState(null) const [materialOrder, setMaterialOrder] = useState(null) + const [sharingOrder, setSharingOrder] = useState(null) const [problemForm] = Form.useForm() const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() + const [sharingForm] = Form.useForm<{ + enabled?: boolean + totalQuantity?: number + unitReward?: number + totalAmount?: number + }>() const ordersQuery = useQuery({ queryKey: ['admin-worker-platform-orders', status, page, pageSize], queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }), }) const ordersPagination = ordersQuery.data?.data.pagination + const sharingQuery = useQuery({ + queryKey: ['admin-worker-platform-order-sharing', sharingOrder?.workOrderId], + queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)), + enabled: Boolean(sharingOrder), + }) + + function openSharingConfigModal(row: WorkOrder) { + setSharingOrder(row) + const unitRewardFen = Number(row.sharing?.unitReward || 0) + const totalQuantity = Number(row.sharing?.totalQuantity || 1) + const unitRewardYuan = unitRewardFen / 100 + sharingForm.setFieldsValue({ + enabled: row.sharing?.enabled, + totalQuantity, + unitReward: unitRewardYuan, + totalAmount: unitRewardYuan * totalQuantity, + }) + } + + function syncSharingForm(next: { + totalQuantity?: number + unitReward?: number + totalAmount?: number + }) { + const values = sharingForm.getFieldsValue() + const unitReward = Number(next.unitReward ?? values.unitReward ?? 0) + const totalQuantity = Number(next.totalQuantity ?? values.totalQuantity ?? 1) + const totalAmount = Number(next.totalAmount ?? values.totalAmount ?? 0) + if (next.totalAmount !== undefined) { + const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1 + sharingForm.setFieldsValue({ + totalQuantity: quantity, + totalAmount: quantity * unitReward, + }) + return + } + sharingForm.setFieldsValue({ + totalAmount: unitReward * totalQuantity, + }) + } + + async function submitSharingConfig(values: { + enabled?: boolean + totalQuantity?: number + unitReward?: number + totalAmount?: number + }) { + if (!sharingOrder) return + const succeeded = await runAction( + () => + updateAdminWorkOrderSharing(sharingOrder.workOrderId, { + enabled: values.enabled !== false, + totalQuantity: Number(values.totalQuantity || 1), + unitReward: Number(values.unitReward || 0), + totalAmount: Number(values.totalAmount || 0), + }), + '拼单配置已保存', + ) + if (!succeeded) return + setSharingOrder(null) + sharingForm.resetFields() + } async function refreshAll() { await Promise.all([ @@ -299,6 +372,26 @@ function WorkOrdersPanel() { width: 110, render: (_, row) => formatMoney(row.requiredDepositAmount), }, + { + title: '拼单', + width: 190, + render: (_, row) => + row.sharing?.enabled ? ( +
+ + 单价 {formatMoney(row.sharing.unitReward)} / 共 {row.sharing.totalQuantity} 份 + + + 已拼 {row.sharingProgress?.joinedQuantity || 0} / {row.sharing.totalQuantity} 份 + {row.sharingProgress?.pendingSubmissionCount + ? ` · ${row.sharingProgress.pendingSubmissionCount} 人待提交` + : ''} + +
+ ) : ( + 未启用 + ), + }, { title: '状态', width: 130, @@ -336,6 +429,14 @@ function WorkOrdersPanel() { render: (_, row) => ( + {['pending_material', 'unassigned', 'open'].includes(row.status) ? ( + + ) : null} {row.status === 'pending_material' ? ( - +
+ +
+
+ {order.sharing?.enabled ? ( + + ) : null} + +
) @@ -424,6 +503,48 @@ export default function WorkerHallPage() { ) : null} + + setSharingOrder(null)} + onOk={submitSharing} + okText="确认拼单" + cancelText="取消" + okButtonProps={{ disabled: sharingQuantity <= 0 }} + > + {sharingOrder ? ( + + + + + {sharingOrder.sharingProgress?.joinedQuantity || 0} /{' '} + {sharingOrder.sharing?.totalQuantity || 0} 份 + + + setSharingQuantity(Number(value) || 1)} + addonAfter="份" + /> + + + {formatMoney((sharingOrder.sharing?.unitReward || 0) * sharingQuantity)} + + + {formatMoney(resolveSharingDeposit(sharingOrder, sharingQuantity))} + + + + ) : null} + ) } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 425a76ff..ec584e10 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -29,7 +29,11 @@ import { useState } from 'react' import ImagePreviewList from '@/components/files/ImagePreviewList' import ImageUpload from '@/components/files/ImageUpload' -import { fetchWorkerMyOrders, submitWorkerAcceptance } from '@/services/worker' +import { + cancelWorkerOrder, + fetchWorkerMyOrders, + submitWorkerAcceptance, +} from '@/services/worker' import type { CollectField, UploadedFile, WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' @@ -63,7 +67,7 @@ type DetailFieldItem = { } export default function WorkerOrdersPage() { - const { message } = App.useApp() + const { message, modal } = App.useApp() const queryClient = useQueryClient() const [status, setStatus] = useState('') const [keywordInput, setKeywordInput] = useState('') @@ -141,6 +145,26 @@ export default function WorkerOrdersPage() { setPage(1) } + function openCancelModal(order: WorkOrder) { + modal.confirm({ + title: '确认取消接单?', + content: + '取消后订单将重新回到抢单大厅,冻结押金全额退还。30 天内取消次数有限,请谨慎操作。', + okText: '确认取消', + cancelText: '再想想', + okButtonProps: { danger: true }, + onOk: async () => { + try { + await cancelWorkerOrder(order.workOrderId) + message.success('已取消接单,押金已退还') + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '取消接单失败') + } + }, + }) + } + const columns: TableColumnsType = [ { title: '订单信息', @@ -177,6 +201,26 @@ export default function WorkerOrdersPage() { ), }, + { + title: '我的拼单', + width: 170, + render: (_, row) => + row.myShare ? ( +
+ + {formatSharingShareStatus(row.myShare.status)} + + + {row.myShare.quantity} 份 × {formatMoney(row.myShare.unitReward)} + + + 份额报酬:{formatMoney(row.myShare.shareReward)} + +
+ ) : ( + - + ), + }, { title: '当前状态', width: 180, @@ -221,7 +265,7 @@ export default function WorkerOrdersPage() { }, { title: '操作', - width: 200, + width: 280, render: (_, row) => ( ) : null} + {canCancelOrder(row) ? ( + + ) : null} ), }, @@ -380,6 +432,11 @@ export default function WorkerOrdersPage() { {formatStatus(detailOrder.status)} + {canCancelOrder(detailOrder) ? ( + + ) : null} {canSubmitAcceptance(detailOrder) ? (