接单平台新增拼单与取消接单能力
拼单功能: - 工单/物品规则支持拼单配置(启用、总数量、单价),总价=单价×数量三方联动,启用拼单时工单金额自动同步拼单总价 - 拼单参与按数量冻结比例押金,验收通过后按份额结算报酬,升级统计按数量计入 - 抢单大厅拼单入口:紫色渐变大按钮带单价展示,拼单进度/剩余份数,弹窗输入数量实时预估报酬与押金 - 我的订单展示拼单份额(数量×单价、状态),拼单者各自提交验收,全部提交后管理员统一验收 - 新增 work_order_shares 表与迁移 008 取消接单: - 进行中订单可取消(押金全额退还、工单回大厅),30 天内限 5 次防滥用
This commit is contained in:
@@ -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 '拼单者各自的验收资料';
|
||||||
@@ -108,6 +108,9 @@ export type WorkProductRuleRow = {
|
|||||||
reward_amount: number
|
reward_amount: number
|
||||||
required_deposit_amount: number
|
required_deposit_amount: number
|
||||||
deposit_threshold_amount: number
|
deposit_threshold_amount: number
|
||||||
|
sharing_enabled: boolean
|
||||||
|
sharing_total_quantity: number
|
||||||
|
sharing_unit_reward: number
|
||||||
requirement_json: string | Record<string, unknown>
|
requirement_json: string | Record<string, unknown>
|
||||||
sort_order: number
|
sort_order: number
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -115,6 +118,24 @@ export type WorkProductRuleRow = {
|
|||||||
category_name?: string
|
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<string, unknown>
|
||||||
|
submitted_at: string | null
|
||||||
|
accepted_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
worker_username?: string
|
||||||
|
worker_display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkOrderRow = {
|
export type WorkOrderRow = {
|
||||||
id: number
|
id: number
|
||||||
work_order_no: string
|
work_order_no: string
|
||||||
@@ -133,6 +154,9 @@ export type WorkOrderRow = {
|
|||||||
requirement_json: string | Record<string, unknown>
|
requirement_json: string | Record<string, unknown>
|
||||||
acceptance_json: string | Record<string, unknown>
|
acceptance_json: string | Record<string, unknown>
|
||||||
problem_note: string
|
problem_note: string
|
||||||
|
sharing_enabled: boolean
|
||||||
|
sharing_total_quantity: number
|
||||||
|
sharing_unit_reward: number
|
||||||
published_at: string | null
|
published_at: string | null
|
||||||
assigned_at: string | null
|
assigned_at: string | null
|
||||||
submitted_at: string | null
|
submitted_at: string | null
|
||||||
@@ -167,6 +191,7 @@ type ListInput = {
|
|||||||
keyword?: string
|
keyword?: string
|
||||||
workerId?: number
|
workerId?: number
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
|
workerSharingId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductRuleListInput = {
|
type ProductRuleListInput = {
|
||||||
@@ -214,6 +239,9 @@ type CreateWorkOrderInput = {
|
|||||||
rewardAmount: number
|
rewardAmount: number
|
||||||
requiredDepositAmount: number
|
requiredDepositAmount: number
|
||||||
depositThresholdAmount: number
|
depositThresholdAmount: number
|
||||||
|
sharingEnabled?: boolean
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
materialJson: string
|
materialJson: string
|
||||||
requirementJson: string
|
requirementJson: string
|
||||||
now: string
|
now: string
|
||||||
@@ -853,16 +881,284 @@ export async function getWorkerFinanceRequestSummary(workerId: number | string)
|
|||||||
export async function countWorkerAcceptedOrders(workerId: number | string): Promise<number> {
|
export async function countWorkerAcceptedOrders(workerId: number | string): Promise<number> {
|
||||||
const result = await query<{ total: number }>(
|
const result = await query<{ total: number }>(
|
||||||
`
|
`
|
||||||
SELECT COUNT(*)::int AS total
|
SELECT
|
||||||
FROM work_orders
|
(SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted')
|
||||||
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)],
|
[Number(workerId)],
|
||||||
)
|
)
|
||||||
return Number(result.rows[0]?.total || 0)
|
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<WorkOrderShareRow[]> {
|
||||||
|
const result = await query<WorkOrderShareRow>(
|
||||||
|
`${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<WorkOrderShareRow[]> {
|
||||||
|
if (workOrderIds.length === 0) return []
|
||||||
|
const result = await query<WorkOrderShareRow>(
|
||||||
|
`${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<WorkOrderShareRow[]> {
|
||||||
|
const result = await query<WorkOrderShareRow>(
|
||||||
|
`${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<number> {
|
||||||
|
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<WorkOrderShareRow | null> {
|
||||||
|
const result = await query<WorkOrderShareRow>(
|
||||||
|
`${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<WorkOrderRow>(
|
||||||
|
`
|
||||||
|
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<WorkOrderShareRow>(
|
||||||
|
`${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<WorkOrderShareRow>(
|
||||||
|
`${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<WorkOrderShareRow>(
|
||||||
|
`${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(
|
export async function maybeUpgradeWorkerLevelWithClient(
|
||||||
client: PoolClient,
|
client: PoolClient,
|
||||||
workerId: number,
|
workerId: number,
|
||||||
@@ -870,10 +1166,13 @@ export async function maybeUpgradeWorkerLevelWithClient(
|
|||||||
): Promise<WorkerUserRow | null> {
|
): Promise<WorkerUserRow | null> {
|
||||||
const acceptedResult = await client.query<{ total: number }>(
|
const acceptedResult = await client.query<{ total: number }>(
|
||||||
`
|
`
|
||||||
SELECT COUNT(*)::int AS total
|
SELECT
|
||||||
FROM work_orders
|
(SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted')
|
||||||
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],
|
[workerId],
|
||||||
)
|
)
|
||||||
@@ -1037,6 +1336,9 @@ export async function upsertWorkProductRule(input: {
|
|||||||
rewardAmount: number
|
rewardAmount: number
|
||||||
requiredDepositAmount: number
|
requiredDepositAmount: number
|
||||||
depositThresholdAmount: number
|
depositThresholdAmount: number
|
||||||
|
sharingEnabled?: boolean
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
requirementJson: string
|
requirementJson: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
now: string
|
now: string
|
||||||
@@ -1046,13 +1348,17 @@ export async function upsertWorkProductRule(input: {
|
|||||||
INSERT INTO work_product_rules (
|
INSERT INTO work_product_rules (
|
||||||
rule_key, provider, platform, shop_id, sku_code, product_name,
|
rule_key, provider, platform, shop_id, sku_code, product_name,
|
||||||
match_type, category_id, enabled, auto_create, reward_amount,
|
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
|
sort_order, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10, $11,
|
$7, $8, $9, $10, $11,
|
||||||
$12, $13, $14::jsonb,
|
$12, $13,
|
||||||
$15, $16, $17
|
$14, $15, $16,
|
||||||
|
$17::jsonb,
|
||||||
|
$18, $19, $20
|
||||||
)
|
)
|
||||||
ON CONFLICT (rule_key) DO UPDATE
|
ON CONFLICT (rule_key) DO UPDATE
|
||||||
SET
|
SET
|
||||||
@@ -1068,6 +1374,9 @@ export async function upsertWorkProductRule(input: {
|
|||||||
reward_amount = EXCLUDED.reward_amount,
|
reward_amount = EXCLUDED.reward_amount,
|
||||||
required_deposit_amount = EXCLUDED.required_deposit_amount,
|
required_deposit_amount = EXCLUDED.required_deposit_amount,
|
||||||
deposit_threshold_amount = EXCLUDED.deposit_threshold_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,
|
requirement_json = EXCLUDED.requirement_json,
|
||||||
sort_order = EXCLUDED.sort_order,
|
sort_order = EXCLUDED.sort_order,
|
||||||
updated_at = EXCLUDED.updated_at
|
updated_at = EXCLUDED.updated_at
|
||||||
@@ -1087,6 +1396,9 @@ export async function upsertWorkProductRule(input: {
|
|||||||
input.rewardAmount,
|
input.rewardAmount,
|
||||||
input.requiredDepositAmount,
|
input.requiredDepositAmount,
|
||||||
input.depositThresholdAmount,
|
input.depositThresholdAmount,
|
||||||
|
input.sharingEnabled === true,
|
||||||
|
toPositiveInteger(input.sharingTotalQuantity, 1),
|
||||||
|
toPositiveInteger(input.sharingUnitReward, 0),
|
||||||
input.requirementJson,
|
input.requirementJson,
|
||||||
input.sortOrder,
|
input.sortOrder,
|
||||||
input.now,
|
input.now,
|
||||||
@@ -1102,10 +1414,11 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
|||||||
INSERT INTO work_orders (
|
INSERT INTO work_orders (
|
||||||
work_order_no, order_id, order_item_id, task_id, platform_order_id,
|
work_order_no, order_id, order_item_id, task_id, platform_order_id,
|
||||||
product_name, category_id, status, reward_amount, required_deposit_amount,
|
product_name, category_id, status, reward_amount, required_deposit_amount,
|
||||||
deposit_threshold_amount, material_json, requirement_json, created_at, updated_at
|
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
|
||||||
|
sharing_unit_reward, material_json, requirement_json, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
$11, $12::jsonb, $13::jsonb, $14, $15
|
$11, $12, $13, $14, $15::jsonb, $16::jsonb, $17, $18
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
@@ -1121,6 +1434,9 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
|||||||
input.rewardAmount,
|
input.rewardAmount,
|
||||||
input.requiredDepositAmount,
|
input.requiredDepositAmount,
|
||||||
input.depositThresholdAmount,
|
input.depositThresholdAmount,
|
||||||
|
input.sharingEnabled === true,
|
||||||
|
toPositiveInteger(input.sharingTotalQuantity, 1),
|
||||||
|
toPositiveInteger(input.sharingUnitReward, 0),
|
||||||
input.materialJson,
|
input.materialJson,
|
||||||
input.requirementJson,
|
input.requirementJson,
|
||||||
input.now,
|
input.now,
|
||||||
@@ -1179,12 +1495,14 @@ export async function listWorkOrders({
|
|||||||
keyword = '',
|
keyword = '',
|
||||||
workerId = 0,
|
workerId = 0,
|
||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
|
workerSharingId = 0,
|
||||||
}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> {
|
}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> {
|
||||||
const { whereClause, params } = buildWorkOrderWhere({
|
const { whereClause, params } = buildWorkOrderWhere({
|
||||||
status,
|
status,
|
||||||
keyword,
|
keyword,
|
||||||
workerId,
|
workerId,
|
||||||
categoryId,
|
categoryId,
|
||||||
|
workerSharingId,
|
||||||
})
|
})
|
||||||
const totalResult = await query<{ total: number }>(
|
const totalResult = await query<{ total: number }>(
|
||||||
`
|
`
|
||||||
@@ -1216,6 +1534,10 @@ export async function updateWorkOrder(
|
|||||||
| 'material_json'
|
| 'material_json'
|
||||||
| 'acceptance_json'
|
| 'acceptance_json'
|
||||||
| 'problem_note'
|
| 'problem_note'
|
||||||
|
| 'reward_amount'
|
||||||
|
| 'sharing_enabled'
|
||||||
|
| 'sharing_total_quantity'
|
||||||
|
| 'sharing_unit_reward'
|
||||||
| 'published_at'
|
| 'published_at'
|
||||||
| 'assigned_at'
|
| 'assigned_at'
|
||||||
| 'submitted_at'
|
| 'submitted_at'
|
||||||
@@ -1236,12 +1558,16 @@ export async function updateWorkOrder(
|
|||||||
material_json = $3::jsonb,
|
material_json = $3::jsonb,
|
||||||
acceptance_json = $4::jsonb,
|
acceptance_json = $4::jsonb,
|
||||||
problem_note = $5,
|
problem_note = $5,
|
||||||
published_at = $6,
|
reward_amount = $6,
|
||||||
assigned_at = $7,
|
sharing_enabled = $7,
|
||||||
submitted_at = $8,
|
sharing_total_quantity = $8,
|
||||||
accepted_at = $9,
|
sharing_unit_reward = $9,
|
||||||
updated_at = $10
|
published_at = $10,
|
||||||
WHERE id = $11
|
assigned_at = $11,
|
||||||
|
submitted_at = $12,
|
||||||
|
accepted_at = $13,
|
||||||
|
updated_at = $14
|
||||||
|
WHERE id = $15
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
next.status,
|
next.status,
|
||||||
@@ -1249,6 +1575,10 @@ export async function updateWorkOrder(
|
|||||||
toJsonString(next.material_json),
|
toJsonString(next.material_json),
|
||||||
toJsonString(next.acceptance_json),
|
toJsonString(next.acceptance_json),
|
||||||
next.problem_note || '',
|
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.published_at || null,
|
||||||
next.assigned_at || null,
|
next.assigned_at || null,
|
||||||
next.submitted_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<WorkOrderRow>(
|
||||||
|
`
|
||||||
|
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<number> {
|
||||||
|
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: {
|
export async function acceptWorkOrderAndSettle(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
now: string
|
now: string
|
||||||
}): Promise<WorkOrderRow | null> {
|
}): Promise<{
|
||||||
|
order: WorkOrderRow | null
|
||||||
|
failureReason: 'sharing_pending_submissions' | null
|
||||||
|
}> {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
const currentResult = await client.query<WorkOrderRow>(
|
const currentResult = await client.query<WorkOrderRow>(
|
||||||
`
|
`
|
||||||
@@ -1372,7 +1812,29 @@ export async function acceptWorkOrderAndSettle(input: {
|
|||||||
)
|
)
|
||||||
const workOrder = currentResult.rows[0] || null
|
const workOrder = currentResult.rows[0] || null
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
return null
|
return { order: null, failureReason: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharesResult = await client.query<WorkOrderShareRow>(
|
||||||
|
`
|
||||||
|
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(
|
await client.query(
|
||||||
@@ -1384,6 +1846,94 @@ export async function acceptWorkOrderAndSettle(input: {
|
|||||||
[input.now, input.workOrderId],
|
[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)
|
const workerId = Number(workOrder.assigned_worker_id || 0)
|
||||||
if (workerId > 0) {
|
if (workerId > 0) {
|
||||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||||
@@ -1441,7 +1991,7 @@ export async function acceptWorkOrderAndSettle(input: {
|
|||||||
eventType: 'accepted',
|
eventType: 'accepted',
|
||||||
fromStatus: 'pending_acceptance',
|
fromStatus: 'pending_acceptance',
|
||||||
toStatus: 'accepted',
|
toStatus: 'accepted',
|
||||||
payloadJson: '{}',
|
payloadJson: JSON.stringify({ shareCount: shares.length }),
|
||||||
now: input.now,
|
now: input.now,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1449,7 +1999,7 @@ export async function acceptWorkOrderAndSettle(input: {
|
|||||||
await maybeUpgradeWorkerLevelWithClient(client, workerId, input.now)
|
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 = '',
|
keyword = '',
|
||||||
workerId = 0,
|
workerId = 0,
|
||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
|
workerSharingId = 0,
|
||||||
}: ListInput) {
|
}: ListInput) {
|
||||||
const filters: string[] = []
|
const filters: string[] = []
|
||||||
const params: unknown[] = []
|
const params: unknown[] = []
|
||||||
@@ -1800,6 +2351,16 @@ function buildWorkOrderWhere({
|
|||||||
params.push(categoryId)
|
params.push(categoryId)
|
||||||
filters.push(`wo.category_id = $${params.length}`)
|
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 {
|
return {
|
||||||
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||||
params,
|
params,
|
||||||
@@ -1884,6 +2445,12 @@ function buildWorkProductRuleWhere({ enabled = null, keyword = '' }: ProductRule
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toJsonString(value: unknown): string {
|
function toJsonString(value: unknown): string {
|
||||||
if (typeof value === 'string') return value || '{}'
|
if (typeof value === 'string') return value
|
||||||
return JSON.stringify(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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
deleteAdminWorkerLevel,
|
deleteAdminWorkerLevel,
|
||||||
getAdminWorkerFinanceConfig,
|
getAdminWorkerFinanceConfig,
|
||||||
getAdminWorkerPlatformSummary,
|
getAdminWorkerPlatformSummary,
|
||||||
|
getAdminWorkOrderSharing,
|
||||||
listAdminWorkerFinanceRequests,
|
listAdminWorkerFinanceRequests,
|
||||||
listAdminWorkCategories,
|
listAdminWorkCategories,
|
||||||
listAdminWorkProductRules,
|
listAdminWorkProductRules,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
submitAdminWorkOrderMaterial,
|
submitAdminWorkOrderMaterial,
|
||||||
syncAdminWorkerOrdersFromSource,
|
syncAdminWorkerOrdersFromSource,
|
||||||
unpublishAdminWorkOrder,
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
} from '../../services/worker-platform/worker-platform-service.js'
|
} from '../../services/worker-platform/worker-platform-service.js'
|
||||||
import { createJsonHandler, requireAdminRoles } from './session.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<string, unknown>) : {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/worker-platform/orders/:workOrderId/problem',
|
'/worker-platform/orders/:workOrderId/problem',
|
||||||
requireAdminRoles(['admin', 'operator', 'support']),
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middlewa
|
|||||||
import { uploadSingleFile } from './file-upload.js'
|
import { uploadSingleFile } from './file-upload.js'
|
||||||
import { uploadFileAsset } from '../services/file-storage/file-storage-service.js'
|
import { uploadFileAsset } from '../services/file-storage/file-storage-service.js'
|
||||||
import {
|
import {
|
||||||
|
cancelWorkerOrder,
|
||||||
changeWorkerPassword,
|
changeWorkerPassword,
|
||||||
createWorkerRechargeRequest,
|
createWorkerRechargeRequest,
|
||||||
createWorkerWithdrawRequest,
|
createWorkerWithdrawRequest,
|
||||||
getWorkerProfile,
|
getWorkerProfile,
|
||||||
getWorkerSessionSummary,
|
getWorkerSessionSummary,
|
||||||
grabWorkerHallOrder,
|
grabWorkerHallOrder,
|
||||||
|
joinWorkerSharingOrder,
|
||||||
listWorkerProfileFinanceRequests,
|
listWorkerProfileFinanceRequests,
|
||||||
listWorkerProfileWalletLedgers,
|
listWorkerProfileWalletLedgers,
|
||||||
listWorkerHallOrders,
|
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(
|
router.get(
|
||||||
'/orders',
|
'/orders',
|
||||||
requireActiveWorker,
|
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) => {
|
router.use((req, res) => {
|
||||||
res.status(404).json(buildNotFoundPayload(req))
|
res.status(404).json(buildNotFoundPayload(req))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
countWorkerActiveOrders,
|
countWorkerActiveOrders,
|
||||||
countWorkCategoryUsages,
|
countWorkCategoryUsages,
|
||||||
countWorkerLevelUsages,
|
countWorkerLevelUsages,
|
||||||
|
countWorkOrderPendingSharingSubmissions,
|
||||||
|
cancelWorkerWorkOrder,
|
||||||
|
countWorkerCancellationsSince,
|
||||||
createWorkOrder,
|
createWorkOrder,
|
||||||
createWorkOrderEvent,
|
createWorkOrderEvent,
|
||||||
createWorkerFinanceRequest,
|
createWorkerFinanceRequest,
|
||||||
@@ -24,23 +27,29 @@ import {
|
|||||||
getWorkerLevelById,
|
getWorkerLevelById,
|
||||||
getWorkerLevelByKey,
|
getWorkerLevelByKey,
|
||||||
getWorkerUserByInviteCode,
|
getWorkerUserByInviteCode,
|
||||||
incrementWorkerSessionVersion,
|
|
||||||
getWorkOrderById,
|
|
||||||
getWorkOrderByOrderItemId,
|
|
||||||
getWorkerUserById,
|
getWorkerUserById,
|
||||||
getWorkerUserByUsername,
|
getWorkerUserByUsername,
|
||||||
listAllWorkCategories,
|
getWorkOrderById,
|
||||||
listWorkerFinanceRequests,
|
getWorkOrderByOrderItemId,
|
||||||
listPendingMaterialWorkOrdersByPlatformOrderId,
|
getWorkOrderShare,
|
||||||
listWorkerWalletLedgers,
|
|
||||||
grabWorkOrder,
|
grabWorkOrder,
|
||||||
|
incrementWorkerSessionVersion,
|
||||||
|
joinWorkOrderShare,
|
||||||
|
listAllWorkCategories,
|
||||||
|
listPendingMaterialWorkOrdersByPlatformOrderId,
|
||||||
|
listWorkerFinanceRequests,
|
||||||
|
listWorkerSharesByWorker,
|
||||||
|
listWorkerWalletLedgers,
|
||||||
listWorkCategories,
|
listWorkCategories,
|
||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
listWorkProductRules,
|
listWorkProductRules,
|
||||||
listWorkerLevels,
|
listWorkerLevels,
|
||||||
listWorkerUsers,
|
listWorkerUsers,
|
||||||
|
listWorkOrderShares,
|
||||||
|
listWorkOrderSharesByOrderIds,
|
||||||
resolveProblemWorkOrder,
|
resolveProblemWorkOrder,
|
||||||
reviewWorkerFinanceRequest,
|
reviewWorkerFinanceRequest,
|
||||||
|
submitWorkOrderShareAcceptance,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
updateWorkerPassword,
|
updateWorkerPassword,
|
||||||
updateWorkerUser,
|
updateWorkerUser,
|
||||||
@@ -52,6 +61,7 @@ import {
|
|||||||
type WorkOrderRow,
|
type WorkOrderRow,
|
||||||
type WorkProductRuleRow,
|
type WorkProductRuleRow,
|
||||||
type WorkerLevelRow,
|
type WorkerLevelRow,
|
||||||
|
type WorkOrderShareRow,
|
||||||
type WorkerWalletLedgerRow,
|
type WorkerWalletLedgerRow,
|
||||||
type WorkerUserRow,
|
type WorkerUserRow,
|
||||||
type GrabWorkOrderFailureReason,
|
type GrabWorkOrderFailureReason,
|
||||||
@@ -88,6 +98,8 @@ const DEFAULT_CATEGORY_KEY = 'default'
|
|||||||
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
|
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
|
||||||
const INVITE_CODE_LENGTH = 8
|
const INVITE_CODE_LENGTH = 8
|
||||||
const INVITE_CODE_RETRY_LIMIT = 5
|
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([
|
const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
|
||||||
'worker_auth_required',
|
'worker_auth_required',
|
||||||
'worker_auth_invalid',
|
'worker_auth_invalid',
|
||||||
@@ -652,8 +664,16 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
|||||||
const worker = await getRequiredWorker(session.workerId)
|
const worker = await getRequiredWorker(session.workerId)
|
||||||
const permissions = resolveWorkerPermissions(worker)
|
const permissions = resolveWorkerPermissions(worker)
|
||||||
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
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 {
|
return {
|
||||||
items: items.map((item) => mapWorkOrderForWorker(item, permissions)),
|
items: items.map((item) =>
|
||||||
|
mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))),
|
||||||
|
),
|
||||||
pagination: { page, pageSize, total },
|
pagination: { page, pageSize, total },
|
||||||
categories,
|
categories,
|
||||||
}
|
}
|
||||||
@@ -711,15 +731,104 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
|||||||
pageSize,
|
pageSize,
|
||||||
status: String(query.status || '').trim(),
|
status: String(query.status || '').trim(),
|
||||||
keyword: String(query.keyword || '').trim(),
|
keyword: String(query.keyword || '').trim(),
|
||||||
workerId: worker.id,
|
workerSharingId: worker.id,
|
||||||
})
|
})
|
||||||
const permissions = resolveWorkerPermissions(worker)
|
const permissions = resolveWorkerPermissions(worker)
|
||||||
|
const myShares = await listWorkerSharesByWorker(worker.id)
|
||||||
|
const myShareByOrderId = new Map(
|
||||||
|
myShares.map((share) => [Number(share.work_order_id), share]),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
items: items.map((item) => mapWorkOrderForWorker(item, permissions)),
|
items: items.map((item) =>
|
||||||
|
mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))),
|
||||||
|
),
|
||||||
pagination: { page, pageSize, total },
|
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<string, string> = {
|
||||||
|
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(
|
export async function submitWorkerOrderAcceptance(
|
||||||
workOrderId: number | string,
|
workOrderId: number | string,
|
||||||
payload: JsonObject = {},
|
payload: JsonObject = {},
|
||||||
@@ -727,7 +836,12 @@ export async function submitWorkerOrderAcceptance(
|
|||||||
) {
|
) {
|
||||||
requireActiveWorkerSession(session)
|
requireActiveWorkerSession(session)
|
||||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
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('只能提交自己的订单', {
|
throw createHttpError('只能提交自己的订单', {
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
errorCode: 'work_order_owner_required',
|
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 = {}) {
|
export async function collectLookupWorkOrder(payload: JsonObject = {}) {
|
||||||
const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim()
|
const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim()
|
||||||
if (!orderNo) {
|
if (!orderNo) {
|
||||||
@@ -995,12 +1163,39 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
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', {
|
throw createHttpError('接单金额必须大于 0', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
errorCode: 'work_product_rule_reward_invalid',
|
errorCode: 'work_product_rule_reward_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount
|
||||||
|
|
||||||
const depositThresholdAmount = normalizeAmountFen(
|
const depositThresholdAmount = normalizeAmountFen(
|
||||||
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
||||||
@@ -1008,8 +1203,8 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
|||||||
)
|
)
|
||||||
const requiredDepositAmount =
|
const requiredDepositAmount =
|
||||||
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
|
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
|
||||||
? rewardAmount > depositThresholdAmount
|
? finalRewardAmount > depositThresholdAmount
|
||||||
? rewardAmount
|
? finalRewardAmount
|
||||||
: 0
|
: 0
|
||||||
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
|
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
|
||||||
const fields = normalizeRequirementFieldsFromPayload(payload)
|
const fields = normalizeRequirementFieldsFromPayload(payload)
|
||||||
@@ -1030,9 +1225,12 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
|||||||
null,
|
null,
|
||||||
enabled: normalizeBoolean(payload.enabled, true),
|
enabled: normalizeBoolean(payload.enabled, true),
|
||||||
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
|
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
|
||||||
rewardAmount,
|
rewardAmount: finalRewardAmount,
|
||||||
requiredDepositAmount,
|
requiredDepositAmount,
|
||||||
depositThresholdAmount,
|
depositThresholdAmount,
|
||||||
|
sharingEnabled,
|
||||||
|
sharingTotalQuantity,
|
||||||
|
sharingUnitReward,
|
||||||
requirementJson: JSON.stringify({ fields }),
|
requirementJson: JSON.stringify({ fields }),
|
||||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||||
now: nowIso(),
|
now: nowIso(),
|
||||||
@@ -1188,12 +1386,113 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
|||||||
status: String(query.status || '').trim(),
|
status: String(query.status || '').trim(),
|
||||||
keyword: String(query.keyword || '').trim(),
|
keyword: String(query.keyword || '').trim(),
|
||||||
})
|
})
|
||||||
|
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)
|
||||||
|
}
|
||||||
return {
|
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 },
|
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(
|
export async function submitAdminWorkOrderMaterial(
|
||||||
workOrderId: number | string,
|
workOrderId: number | string,
|
||||||
payload: JsonObject = {},
|
payload: JsonObject = {},
|
||||||
@@ -1450,7 +1749,16 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const now = nowIso()
|
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) {
|
if (!updated) {
|
||||||
throw createHttpError('验收失败,订单状态可能已变化', {
|
throw createHttpError('验收失败,订单状态可能已变化', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
@@ -1521,6 +1829,9 @@ export async function syncWorkerOrdersForSourceOrder(
|
|||||||
rewardAmount,
|
rewardAmount,
|
||||||
requiredDepositAmount,
|
requiredDepositAmount,
|
||||||
depositThresholdAmount,
|
depositThresholdAmount,
|
||||||
|
sharingEnabled: rule.sharing_enabled === true,
|
||||||
|
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||||
|
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
||||||
materialJson: JSON.stringify({
|
materialJson: JSON.stringify({
|
||||||
source: {
|
source: {
|
||||||
orderId: Number(order.id),
|
orderId: Number(order.id),
|
||||||
@@ -1781,6 +2092,11 @@ function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
|
|||||||
depositThresholdAmount: Number(
|
depositThresholdAmount: Number(
|
||||||
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
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: {
|
requirement: {
|
||||||
fields: normalizeRequirementFields(requirement.fields),
|
fields: normalizeRequirementFields(requirement.fields),
|
||||||
},
|
},
|
||||||
@@ -1883,6 +2199,11 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
|||||||
status: workOrder.status,
|
status: workOrder.status,
|
||||||
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: {
|
||||||
|
enabled: workOrder.sharing_enabled === true,
|
||||||
|
totalQuantity: Number(workOrder.sharing_total_quantity || 1),
|
||||||
|
unitReward: Number(workOrder.sharing_unit_reward || 0),
|
||||||
|
},
|
||||||
depositThresholdAmount: Number(
|
depositThresholdAmount: Number(
|
||||||
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
),
|
),
|
||||||
@@ -1909,6 +2230,7 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
|||||||
function mapWorkOrderForWorker(
|
function mapWorkOrderForWorker(
|
||||||
workOrder: WorkOrderRow,
|
workOrder: WorkOrderRow,
|
||||||
permissions: { depositFreeAmount: number },
|
permissions: { depositFreeAmount: number },
|
||||||
|
myShare?: WorkOrderShareRow | null,
|
||||||
) {
|
) {
|
||||||
const mapped = mapWorkOrderAdmin(workOrder)
|
const mapped = mapWorkOrderAdmin(workOrder)
|
||||||
const {
|
const {
|
||||||
@@ -1922,6 +2244,32 @@ function mapWorkOrderForWorker(
|
|||||||
return {
|
return {
|
||||||
...visibleOrder,
|
...visibleOrder,
|
||||||
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
|
TeamOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
fetchAdminWorkerFinanceConfig,
|
fetchAdminWorkerFinanceConfig,
|
||||||
fetchAdminWorkerFinanceRequests,
|
fetchAdminWorkerFinanceRequests,
|
||||||
fetchAdminWorkCategories,
|
fetchAdminWorkCategories,
|
||||||
|
fetchAdminWorkOrderSharing,
|
||||||
fetchAdminWorkProductRules,
|
fetchAdminWorkProductRules,
|
||||||
fetchAdminWorkerLevels,
|
fetchAdminWorkerLevels,
|
||||||
fetchAdminWorkerPlatformSummary,
|
fetchAdminWorkerPlatformSummary,
|
||||||
@@ -61,12 +63,14 @@ import {
|
|||||||
submitAdminWorkOrderMaterial,
|
submitAdminWorkOrderMaterial,
|
||||||
syncAdminWorkerOrdersFromSource,
|
syncAdminWorkerOrdersFromSource,
|
||||||
unpublishAdminWorkOrder,
|
unpublishAdminWorkOrder,
|
||||||
|
updateAdminWorkOrderSharing,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type {
|
import type {
|
||||||
CollectField,
|
CollectField,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
WorkCategory,
|
WorkCategory,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
WorkProductRule,
|
WorkProductRule,
|
||||||
WorkerFinanceConfig,
|
WorkerFinanceConfig,
|
||||||
WorkerFinanceRequest,
|
WorkerFinanceRequest,
|
||||||
@@ -161,15 +165,84 @@ function WorkOrdersPanel() {
|
|||||||
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
|
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
|
||||||
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
|
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
|
||||||
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||||
const [problemForm] = Form.useForm()
|
const [problemForm] = Form.useForm()
|
||||||
const [resolutionForm] = Form.useForm()
|
const [resolutionForm] = Form.useForm()
|
||||||
const [materialForm] = Form.useForm()
|
const [materialForm] = Form.useForm()
|
||||||
|
const [sharingForm] = Form.useForm<{
|
||||||
|
enabled?: boolean
|
||||||
|
totalQuantity?: number
|
||||||
|
unitReward?: number
|
||||||
|
totalAmount?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
queryKey: ['admin-worker-platform-orders', status, page, pageSize],
|
queryKey: ['admin-worker-platform-orders', status, page, pageSize],
|
||||||
queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }),
|
queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }),
|
||||||
})
|
})
|
||||||
const ordersPagination = ordersQuery.data?.data.pagination
|
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() {
|
async function refreshAll() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -299,6 +372,26 @@ function WorkOrdersPanel() {
|
|||||||
width: 110,
|
width: 110,
|
||||||
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '拼单',
|
||||||
|
width: 190,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.sharing?.enabled ? (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Tag color="purple">
|
||||||
|
单价 {formatMoney(row.sharing.unitReward)} / 共 {row.sharing.totalQuantity} 份
|
||||||
|
</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
已拼 {row.sharingProgress?.joinedQuantity || 0} / {row.sharing.totalQuantity} 份
|
||||||
|
{row.sharingProgress?.pendingSubmissionCount
|
||||||
|
? ` · ${row.sharingProgress.pendingSubmissionCount} 人待提交`
|
||||||
|
: ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">未启用</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
width: 130,
|
width: 130,
|
||||||
@@ -336,6 +429,14 @@ 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) ? (
|
||||||
|
<Button
|
||||||
|
icon={<TeamOutlined />}
|
||||||
|
onClick={() => openSharingConfigModal(row)}
|
||||||
|
>
|
||||||
|
拼单配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{row.status === 'pending_material' ? (
|
{row.status === 'pending_material' ? (
|
||||||
<Button
|
<Button
|
||||||
icon={<FormOutlined />}
|
icon={<FormOutlined />}
|
||||||
@@ -737,6 +838,138 @@ function WorkOrdersPanel() {
|
|||||||
))}
|
))}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
|
||||||
|
open={Boolean(sharingOrder)}
|
||||||
|
onCancel={() => {
|
||||||
|
setSharingOrder(null)
|
||||||
|
sharingForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => sharingForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
{sharingOrder ? (
|
||||||
|
<Space direction="vertical" size={16} className="full-width">
|
||||||
|
<Form
|
||||||
|
form={sharingForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
enabled: false,
|
||||||
|
totalQuantity: 1,
|
||||||
|
unitReward: 0,
|
||||||
|
}}
|
||||||
|
onFinish={submitSharingConfig}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="启用拼单"
|
||||||
|
name="enabled"
|
||||||
|
valuePropName="checked"
|
||||||
|
tooltip="启用后打手可在抢单大厅按单价×数量参与拼单,未拼满时整单仍可被抢走"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('enabled') ? (
|
||||||
|
<Space size={16} wrap>
|
||||||
|
<Form.Item
|
||||||
|
label="总数量"
|
||||||
|
name="totalQuantity"
|
||||||
|
rules={[{ required: true, message: '请填写总数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
addonAfter="份"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingForm({
|
||||||
|
totalQuantity: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="单价"
|
||||||
|
name="unitReward"
|
||||||
|
rules={[{ required: true, message: '请填写单价' }]}
|
||||||
|
tooltip="每个数量份对应的报酬,修改后总价自动重算"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingForm({ unitReward: Number(value) || 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单总价"
|
||||||
|
name="totalAmount"
|
||||||
|
rules={[{ required: true, message: '请填写总价' }]}
|
||||||
|
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingForm({ totalAmount: Number(value) || 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Card size="small" title="拼单明细">
|
||||||
|
{sharingQuery.isLoading ? (
|
||||||
|
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||||||
|
) : (sharingQuery.data?.data.shares || []).length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">暂无打手参与拼单</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Table<WorkOrderShare>
|
||||||
|
rowKey="shareId"
|
||||||
|
size="small"
|
||||||
|
dataSource={sharingQuery.data?.data.shares || []}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '打手',
|
||||||
|
render: (_, share) =>
|
||||||
|
share.worker?.displayName || share.worker?.username || '-',
|
||||||
|
},
|
||||||
|
{ title: '数量', dataIndex: 'quantity' },
|
||||||
|
{
|
||||||
|
title: '份额报酬',
|
||||||
|
render: (_, share) => formatMoney(share.shareReward),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
render: (_, share) => (
|
||||||
|
<Tag color={resolveSharingShareStatusColor(share.status)}>
|
||||||
|
{formatSharingShareStatus(share.status)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交时间',
|
||||||
|
render: (_, share) =>
|
||||||
|
share.submittedAt
|
||||||
|
? formatAdminDateTime(share.submittedAt)
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -828,6 +1061,10 @@ function ProductRulesPanel() {
|
|||||||
rewardAmount?: number
|
rewardAmount?: number
|
||||||
requiredDepositAmount?: number
|
requiredDepositAmount?: number
|
||||||
depositThresholdAmount?: number
|
depositThresholdAmount?: number
|
||||||
|
sharingEnabled?: boolean
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
|
sharingTotalAmount?: number
|
||||||
fieldsText?: string
|
fieldsText?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
autoCreate?: boolean
|
autoCreate?: boolean
|
||||||
@@ -849,6 +1086,30 @@ function ProductRulesPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSharingRuleForm(next: {
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
|
sharingTotalAmount?: number
|
||||||
|
}) {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const unitReward = Number(next.sharingUnitReward ?? values.sharingUnitReward ?? 0)
|
||||||
|
const totalQuantity = Number(
|
||||||
|
next.sharingTotalQuantity ?? values.sharingTotalQuantity ?? 1,
|
||||||
|
)
|
||||||
|
const totalAmount = Number(next.sharingTotalAmount ?? values.sharingTotalAmount ?? 0)
|
||||||
|
if (next.sharingTotalAmount !== undefined) {
|
||||||
|
const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1
|
||||||
|
form.setFieldsValue({
|
||||||
|
sharingTotalQuantity: quantity,
|
||||||
|
sharingTotalAmount: quantity * unitReward,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.setFieldsValue({
|
||||||
|
sharingTotalAmount: unitReward * totalQuantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<WorkProductRule> = [
|
const columns: TableColumnsType<WorkProductRule> = [
|
||||||
{
|
{
|
||||||
title: '规则',
|
title: '规则',
|
||||||
@@ -893,6 +1154,11 @@ function ProductRulesPanel() {
|
|||||||
{row.enabled ? '启用' : '停用'}
|
{row.enabled ? '启用' : '停用'}
|
||||||
</Tag>
|
</Tag>
|
||||||
{row.autoCreate ? <Tag color="blue">自动创建</Tag> : null}
|
{row.autoCreate ? <Tag color="blue">自动创建</Tag> : null}
|
||||||
|
{row.sharing?.enabled ? (
|
||||||
|
<Tag color="purple">
|
||||||
|
拼单 {formatMoney(row.sharing.unitReward)}/份
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -912,6 +1178,10 @@ function ProductRulesPanel() {
|
|||||||
depositThresholdAmount: 200,
|
depositThresholdAmount: 200,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoCreate: false,
|
autoCreate: false,
|
||||||
|
sharingEnabled: false,
|
||||||
|
sharingTotalQuantity: 10,
|
||||||
|
sharingUnitReward: 5,
|
||||||
|
sharingTotalAmount: 50,
|
||||||
fieldsText:
|
fieldsText:
|
||||||
'gameAccount:游戏账号\nserverName:区服\nroleName:角色名',
|
'gameAccount:游戏账号\nserverName:区服\nroleName:角色名',
|
||||||
}}
|
}}
|
||||||
@@ -997,7 +1267,73 @@ function ProductRulesPanel() {
|
|||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="启用拼单"
|
||||||
|
name="sharingEnabled"
|
||||||
|
valuePropName="checked"
|
||||||
|
tooltip="开启后按下方总数量与单价生成拼单工单"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('sharingEnabled') ? (
|
||||||
|
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单总数量"
|
||||||
|
name="sharingTotalQuantity"
|
||||||
|
rules={[{ required: true, message: '请填写总数量' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
addonAfter="份"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingRuleForm({
|
||||||
|
sharingTotalQuantity: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单单价"
|
||||||
|
name="sharingUnitReward"
|
||||||
|
rules={[{ required: true, message: '请填写单价' }]}
|
||||||
|
tooltip="每个数量份对应的报酬,修改后总价自动重算"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingRuleForm({
|
||||||
|
sharingUnitReward: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="拼单总价"
|
||||||
|
name="sharingTotalAmount"
|
||||||
|
rules={[{ required: true, message: '请填写总价' }]}
|
||||||
|
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0.01}
|
||||||
|
step={1}
|
||||||
|
addonAfter="元"
|
||||||
|
onChange={(value) =>
|
||||||
|
syncSharingRuleForm({
|
||||||
|
sharingTotalAmount: Number(value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="资料字段" name="fieldsText">
|
<Form.Item label="资料字段" name="fieldsText">
|
||||||
<Input.TextArea rows={4} />
|
<Input.TextArea rows={4} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -2423,6 +2759,23 @@ function formatFinanceChannel(channel: string) {
|
|||||||
return channel || '-'
|
return channel || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSharingShareStatus(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
joined: '已参与',
|
||||||
|
submitted: '已提交验收',
|
||||||
|
accepted: '已验收',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSharingShareStatusColor(status: string) {
|
||||||
|
if (status === 'accepted') return 'green'
|
||||||
|
if (status === 'submitted') return 'gold'
|
||||||
|
if (status === 'cancelled') return 'default'
|
||||||
|
return 'purple'
|
||||||
|
}
|
||||||
|
|
||||||
function maskFinanceAccount(value: string) {
|
function maskFinanceAccount(value: string) {
|
||||||
const text = String(value || '').trim()
|
const text = String(value || '').trim()
|
||||||
if (!text) return '-'
|
if (!text) return '-'
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
Empty,
|
Empty,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
fetchWorkerMyOrders,
|
fetchWorkerMyOrders,
|
||||||
fetchWorkerProfile,
|
fetchWorkerProfile,
|
||||||
grabWorkerOrder,
|
grabWorkerOrder,
|
||||||
|
joinWorkerSharingOrder,
|
||||||
} from '@/services/worker'
|
} from '@/services/worker'
|
||||||
import type { CollectField, WorkOrder, WorkerUser } from '@/types/worker-platform'
|
import type { CollectField, WorkOrder, WorkerUser } from '@/types/worker-platform'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
@@ -43,6 +46,9 @@ export default function WorkerHallPage() {
|
|||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [grabbingId, setGrabbingId] = useState(0)
|
const [grabbingId, setGrabbingId] = useState(0)
|
||||||
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
|
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [sharingQuantity, setSharingQuantity] = useState(1)
|
||||||
|
const [submittingSharing, setSubmittingSharing] = useState(false)
|
||||||
|
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
queryKey: ['worker-hall-orders', keyword, categoryId, page, pageSize],
|
queryKey: ['worker-hall-orders', keyword, categoryId, page, pageSize],
|
||||||
@@ -135,6 +141,44 @@ export default function WorkerHallPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSharingModal(order: WorkOrder) {
|
||||||
|
setSharingOrder(order)
|
||||||
|
setSharingQuantity(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSharing() {
|
||||||
|
if (!sharingOrder) return
|
||||||
|
setSubmittingSharing(true)
|
||||||
|
try {
|
||||||
|
await joinWorkerSharingOrder(sharingOrder.workOrderId, {
|
||||||
|
quantity: sharingQuantity,
|
||||||
|
})
|
||||||
|
message.success('拼单成功,已进入我的订单')
|
||||||
|
setSharingOrder(null)
|
||||||
|
setDetailOrder(null)
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '拼单失败')
|
||||||
|
} finally {
|
||||||
|
setSubmittingSharing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSharingRemaining(order: WorkOrder): number {
|
||||||
|
const sharing = order.sharing
|
||||||
|
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 {
|
||||||
|
const sharing = order.sharing
|
||||||
|
if (!sharing?.enabled) return 0
|
||||||
|
const deposit = Number(order.requiredDepositAmount || 0)
|
||||||
|
const total = Math.max(1, sharing.totalQuantity)
|
||||||
|
return Math.round((deposit * quantity) / total)
|
||||||
|
}
|
||||||
|
|
||||||
function applyKeywordSearch() {
|
function applyKeywordSearch() {
|
||||||
setKeyword(keywordInput.trim())
|
setKeyword(keywordInput.trim())
|
||||||
setPage(1)
|
setPage(1)
|
||||||
@@ -293,6 +337,18 @@ export default function WorkerHallPage() {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{order.sharing?.enabled ? (
|
||||||
|
<div className="worker-hall-card-sharing">
|
||||||
|
<Tag color="purple">拼单</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
单价 {formatMoney(order.sharing.unitReward)} · 进度{' '}
|
||||||
|
{order.sharingProgress?.joinedQuantity || 0}/
|
||||||
|
{order.sharing.totalQuantity} · 剩{' '}
|
||||||
|
{resolveSharingRemaining(order)} 份
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<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(order.rewardAmount)}
|
||||||
@@ -315,17 +371,40 @@ export default function WorkerHallPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="worker-hall-card-actions">
|
<div className="worker-hall-card-actions">
|
||||||
<Button icon={<EyeOutlined />} onClick={() => setDetailOrder(order)}>
|
<div className="worker-hall-card-actions-row">
|
||||||
详情
|
<Button
|
||||||
</Button>
|
size="small"
|
||||||
<Button
|
type="text"
|
||||||
type="primary"
|
icon={<EyeOutlined />}
|
||||||
disabled={Boolean(disabledReason)}
|
onClick={() => setDetailOrder(order)}
|
||||||
loading={grabbingId === order.workOrderId}
|
>
|
||||||
onClick={() => submitGrab(order)}
|
详情
|
||||||
>
|
</Button>
|
||||||
立即抢单
|
</div>
|
||||||
</Button>
|
<div className="worker-hall-card-grab-row">
|
||||||
|
{order.sharing?.enabled ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
className="worker-hall-sharing-button"
|
||||||
|
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
|
||||||
|
onClick={() => openSharingModal(order)}
|
||||||
|
>
|
||||||
|
{order.myShare
|
||||||
|
? `已拼 ${order.myShare.quantity} 份`
|
||||||
|
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
disabled={Boolean(disabledReason)}
|
||||||
|
loading={grabbingId === order.workOrderId}
|
||||||
|
onClick={() => submitGrab(order)}
|
||||||
|
>
|
||||||
|
{disabledReason || '立即抢单'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
@@ -424,6 +503,48 @@ export default function WorkerHallPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`拼单参与 · ${sharingOrder?.productName || ''}`}
|
||||||
|
open={Boolean(sharingOrder)}
|
||||||
|
confirmLoading={submittingSharing}
|
||||||
|
onCancel={() => setSharingOrder(null)}
|
||||||
|
onOk={submitSharing}
|
||||||
|
okText="确认拼单"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ disabled: sharingQuantity <= 0 }}
|
||||||
|
>
|
||||||
|
{sharingOrder ? (
|
||||||
|
<Space direction="vertical" size={16} className="full-width">
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="info"
|
||||||
|
message={`单价 ${formatMoney(sharingOrder.sharing?.unitReward || 0)} / 份,剩余 ${resolveSharingRemaining(sharingOrder)} 份可拼`}
|
||||||
|
/>
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="拼单进度">
|
||||||
|
{sharingOrder.sharingProgress?.joinedQuantity || 0} /{' '}
|
||||||
|
{sharingOrder.sharing?.totalQuantity || 0} 份
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="参与数量">
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={resolveSharingRemaining(sharingOrder)}
|
||||||
|
value={sharingQuantity}
|
||||||
|
onChange={(value) => setSharingQuantity(Number(value) || 1)}
|
||||||
|
addonAfter="份"
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="预估报酬">
|
||||||
|
{formatMoney((sharingOrder.sharing?.unitReward || 0) * sharingQuantity)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="预估冻结押金">
|
||||||
|
{formatMoney(resolveSharingDeposit(sharingOrder, sharingQuantity))}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||||
import ImageUpload from '@/components/files/ImageUpload'
|
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 type { CollectField, UploadedFile, WorkOrder } from '@/types/worker-platform'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ type DetailFieldItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkerOrdersPage() {
|
export default function WorkerOrdersPage() {
|
||||||
const { message } = App.useApp()
|
const { message, modal } = App.useApp()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [keywordInput, setKeywordInput] = useState('')
|
const [keywordInput, setKeywordInput] = useState('')
|
||||||
@@ -141,6 +145,26 @@ export default function WorkerOrdersPage() {
|
|||||||
setPage(1)
|
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<WorkOrder> = [
|
const columns: TableColumnsType<WorkOrder> = [
|
||||||
{
|
{
|
||||||
title: '订单信息',
|
title: '订单信息',
|
||||||
@@ -177,6 +201,26 @@ export default function WorkerOrdersPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '我的拼单',
|
||||||
|
width: 170,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.myShare ? (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Tag color="purple">
|
||||||
|
{formatSharingShareStatus(row.myShare.status)}
|
||||||
|
</Tag>
|
||||||
|
<Typography.Text>
|
||||||
|
{row.myShare.quantity} 份 × {formatMoney(row.myShare.unitReward)}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
份额报酬:{formatMoney(row.myShare.shareReward)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">-</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '当前状态',
|
title: '当前状态',
|
||||||
width: 180,
|
width: 180,
|
||||||
@@ -221,7 +265,7 @@ export default function WorkerOrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 200,
|
width: 280,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button icon={<EyeOutlined />} onClick={() => setDetailOrder(row)}>
|
<Button icon={<EyeOutlined />} onClick={() => setDetailOrder(row)}>
|
||||||
@@ -236,6 +280,14 @@ export default function WorkerOrdersPage() {
|
|||||||
提交验收
|
提交验收
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canCancelOrder(row) ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
onClick={() => openCancelModal(row)}
|
||||||
|
>
|
||||||
|
取消接单
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -380,6 +432,11 @@ export default function WorkerOrdersPage() {
|
|||||||
<Tag color={resolveStatusColor(detailOrder.status)}>
|
<Tag color={resolveStatusColor(detailOrder.status)}>
|
||||||
{formatStatus(detailOrder.status)}
|
{formatStatus(detailOrder.status)}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
{canCancelOrder(detailOrder) ? (
|
||||||
|
<Button danger onClick={() => openCancelModal(detailOrder)}>
|
||||||
|
取消接单
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{canSubmitAcceptance(detailOrder) ? (
|
{canSubmitAcceptance(detailOrder) ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -523,9 +580,16 @@ export default function WorkerOrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canSubmitAcceptance(order: WorkOrder) {
|
function canSubmitAcceptance(order: WorkOrder) {
|
||||||
|
if (order.myShare) {
|
||||||
|
return order.myShare.status === 'joined' && order.status !== 'accepted'
|
||||||
|
}
|
||||||
return ['in_progress', 'problem'].includes(order.status)
|
return ['in_progress', 'problem'].includes(order.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canCancelOrder(order: WorkOrder) {
|
||||||
|
return !order.myShare && order.status === 'in_progress'
|
||||||
|
}
|
||||||
|
|
||||||
function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
||||||
if (!order) return []
|
if (!order) return []
|
||||||
const rawFields = Array.isArray(order.requirement?.fields)
|
const rawFields = Array.isArray(order.requirement?.fields)
|
||||||
@@ -688,3 +752,13 @@ function resolveStatusColor(status: string) {
|
|||||||
if (status === 'pending_acceptance') return 'gold'
|
if (status === 'pending_acceptance') return 'gold'
|
||||||
return 'blue'
|
return 'blue'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSharingShareStatus(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
joined: '已参与',
|
||||||
|
submitted: '已提交验收',
|
||||||
|
accepted: '已验收',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
|||||||
import type {
|
import type {
|
||||||
WorkCategory,
|
WorkCategory,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
WorkProductRule,
|
WorkProductRule,
|
||||||
WorkerFinanceConfig,
|
WorkerFinanceConfig,
|
||||||
WorkerFinanceRequest,
|
WorkerFinanceRequest,
|
||||||
@@ -97,6 +98,10 @@ export function saveAdminWorkProductRule(payload: {
|
|||||||
rewardAmount?: number
|
rewardAmount?: number
|
||||||
requiredDepositAmount?: number
|
requiredDepositAmount?: number
|
||||||
depositThresholdAmount?: number
|
depositThresholdAmount?: number
|
||||||
|
sharingEnabled?: boolean
|
||||||
|
sharingTotalQuantity?: number
|
||||||
|
sharingUnitReward?: number
|
||||||
|
sharingTotalAmount?: number
|
||||||
fieldsText?: string
|
fieldsText?: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
}) {
|
}) {
|
||||||
@@ -217,6 +222,27 @@ export function unpublishAdminWorkOrder(workOrderId: number) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminWorkOrderSharing(workOrderId: number) {
|
||||||
|
return apiGet<{ order: WorkOrder; shares: WorkOrderShare[] }>(
|
||||||
|
`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminWorkOrderSharing(
|
||||||
|
workOrderId: number,
|
||||||
|
payload: {
|
||||||
|
enabled: boolean
|
||||||
|
totalQuantity: number
|
||||||
|
unitReward: number
|
||||||
|
totalAmount?: number
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiPost<{ order: WorkOrder }>(
|
||||||
|
`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function markAdminWorkOrderProblem(workOrderId: number, note: string) {
|
export function markAdminWorkOrderProblem(workOrderId: number, note: string) {
|
||||||
return apiPost<{ order: WorkOrder }>(
|
return apiPost<{ order: WorkOrder }>(
|
||||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/problem`,
|
`/api/v1/admin/worker-platform/orders/${workOrderId}/problem`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CollectLookupResponse,
|
CollectLookupResponse,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderShare,
|
||||||
WorkerFinanceRequest,
|
WorkerFinanceRequest,
|
||||||
WorkerHallOrdersResponse,
|
WorkerHallOrdersResponse,
|
||||||
WorkerListResponse,
|
WorkerListResponse,
|
||||||
@@ -107,6 +108,13 @@ export function grabWorkerOrder(workOrderId: number) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function joinWorkerSharingOrder(workOrderId: number, payload: { quantity: number }) {
|
||||||
|
return apiPost<{ share: WorkOrderShare }>(
|
||||||
|
`/api/v1/worker/hall/orders/${workOrderId}/join-sharing`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
|
export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
|
||||||
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/worker/orders', params)
|
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/worker/orders', params)
|
||||||
}
|
}
|
||||||
@@ -121,6 +129,12 @@ export function submitWorkerAcceptance(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelWorkerOrder(workOrderId: number) {
|
||||||
|
return apiPost<{ order: WorkOrder }>(
|
||||||
|
`/api/v1/worker/orders/${workOrderId}/cancel`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function lookupCollectOrder(orderNo: string) {
|
export function lookupCollectOrder(orderNo: string) {
|
||||||
return apiPost<CollectLookupResponse>('/api/v1/collect/lookup', { orderNo })
|
return apiPost<CollectLookupResponse>('/api/v1/collect/lookup', { orderNo })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1811,13 +1811,43 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.worker-hall-card-actions {
|
.worker-hall-card-actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worker-hall-card-actions-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-hall-card-actions-row .ant-btn {
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-hall-card-grab-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-hall-card-grab-row .ant-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-hall-sharing-button {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #7c3aed) !important;
|
||||||
|
border-color: #7c3aed !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.28) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-hall-sharing-button:not(:disabled):hover {
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #6d28d9) !important;
|
||||||
|
border-color: #6d28d9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.worker-hall-detail-stack {
|
.worker-hall-detail-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -1905,11 +1935,15 @@ select {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-hall-card-actions {
|
.worker-hall-card-actions-row {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worker-hall-card-grab-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export type WorkProductRule = {
|
|||||||
rewardAmount: number
|
rewardAmount: number
|
||||||
requiredDepositAmount: number
|
requiredDepositAmount: number
|
||||||
depositThresholdAmount: number
|
depositThresholdAmount: number
|
||||||
|
sharing?: WorkOrderSharingConfig
|
||||||
requirement: {
|
requirement: {
|
||||||
fields: CollectField[]
|
fields: CollectField[]
|
||||||
}
|
}
|
||||||
@@ -171,6 +172,33 @@ export type WorkOrderStatus =
|
|||||||
| 'cancelled'
|
| 'cancelled'
|
||||||
| string
|
| string
|
||||||
|
|
||||||
|
export type WorkOrderSharingConfig = {
|
||||||
|
enabled: boolean
|
||||||
|
totalQuantity: number
|
||||||
|
unitReward: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkOrderShare = {
|
||||||
|
shareId: number
|
||||||
|
workOrderId: number
|
||||||
|
workerId: number
|
||||||
|
quantity: number
|
||||||
|
unitReward: number
|
||||||
|
shareReward: number
|
||||||
|
shareDeposit: number
|
||||||
|
status: string
|
||||||
|
acceptance: Record<string, unknown>
|
||||||
|
submittedAt: string | null
|
||||||
|
acceptedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
worker: null | {
|
||||||
|
workerId: number
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkOrder = {
|
export type WorkOrder = {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
orderId?: number | null
|
orderId?: number | null
|
||||||
@@ -184,6 +212,12 @@ export type WorkOrder = {
|
|||||||
requiredDepositAmount?: number
|
requiredDepositAmount?: number
|
||||||
depositThresholdAmount?: number
|
depositThresholdAmount?: number
|
||||||
freezeDepositAmount?: number
|
freezeDepositAmount?: number
|
||||||
|
sharing?: WorkOrderSharingConfig
|
||||||
|
myShare?: WorkOrderShare | null
|
||||||
|
sharingProgress?: {
|
||||||
|
joinedQuantity: number
|
||||||
|
pendingSubmissionCount: number
|
||||||
|
}
|
||||||
material: Record<string, unknown>
|
material: Record<string, unknown>
|
||||||
requirement: Record<string, unknown>
|
requirement: Record<string, unknown>
|
||||||
acceptance: {
|
acceptance: {
|
||||||
|
|||||||
Reference in New Issue
Block a user