完善拼单状态与份额验收流程
This commit is contained in:
@@ -20,6 +20,7 @@ import { getAdminDashboardSummary } from '../admin/admin-dashboard-service.js'
|
||||
import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js'
|
||||
import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js'
|
||||
import {
|
||||
isWorkOrderSharingFilled,
|
||||
mapFinanceRequest,
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderForWorker,
|
||||
@@ -256,7 +257,19 @@ async function publishWorkOrderSnapshots(input: {
|
||||
const adminSnapshot = {
|
||||
...mapWorkOrderAdmin(workOrder),
|
||||
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(workOrderId) || 0,
|
||||
sharingProgress: { joinedQuantity, pendingSubmissionCount },
|
||||
sharingProgress: {
|
||||
joinedQuantity,
|
||||
pendingSubmissionCount,
|
||||
remainingQuantity: Math.max(
|
||||
0,
|
||||
Number(workOrder.sharing_total_quantity || 1) - joinedQuantity,
|
||||
),
|
||||
filled: isWorkOrderSharingFilled(
|
||||
workOrder.sharing_enabled === true,
|
||||
Number(workOrder.sharing_total_quantity || 1),
|
||||
joinedQuantity,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
publishRealtimeEvent({
|
||||
@@ -269,11 +282,16 @@ async function publishWorkOrderSnapshots(input: {
|
||||
})
|
||||
|
||||
if (input.hallChanged) {
|
||||
const sharingFilled = isWorkOrderSharingFilled(
|
||||
workOrder.sharing_enabled === true,
|
||||
Number(workOrder.sharing_total_quantity || 1),
|
||||
joinedQuantity,
|
||||
)
|
||||
publishRealtimeEvent({
|
||||
type: 'work_order.changed',
|
||||
entityId: workOrderId,
|
||||
scopes: ['worker_hall'],
|
||||
operation: workOrder.status === 'open' ? 'upsert' : 'remove',
|
||||
operation: workOrder.status === 'open' && !sharingFilled ? 'upsert' : 'remove',
|
||||
data: mapWorkerHallWorkOrder(workOrder, shares),
|
||||
broadcastWorkers: true,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import {
|
||||
acceptWorkOrderAndSettle,
|
||||
acceptWorkOrderShareAndSettle,
|
||||
addWorkerWalletCredit,
|
||||
assignWorkOrderToWorker,
|
||||
cancelAssignedWorkOrder,
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
listWorkerUsers,
|
||||
listWorkerWithdrawalAccounts,
|
||||
listWorkOrderShares,
|
||||
listWorkOrderSharesByOrderIds,
|
||||
sumPendingUnfreezeByOrderIds,
|
||||
resolveProblemWorkOrder,
|
||||
reviewWorkerFinanceRequest,
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
type WorkOrderRow,
|
||||
type WorkProductMatchLogRow,
|
||||
type WorkProductRuleMappingRow,
|
||||
type WorkOrderShareRow,
|
||||
type WorkerWithdrawalAccountRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
@@ -135,6 +134,7 @@ import {
|
||||
resolveRequirementFields,
|
||||
resolveSkuNameQuantity,
|
||||
resolveWorkOrderRulePricing,
|
||||
resolveWorkOrderSharingEnabled,
|
||||
resolveWorkerPermissions,
|
||||
} from './mappers.js'
|
||||
import {
|
||||
@@ -943,7 +943,8 @@ export async function assignAdminWorkOrderToWorker(
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (!['unassigned', 'open'].includes(workOrder.status) || workOrder.assigned_worker_id) {
|
||||
const capabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||
if (!capabilities.canAssign) {
|
||||
throw createHttpError('只有未分配或待抢单且未指派打手的订单才能指派', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_not_assignable',
|
||||
@@ -1284,6 +1285,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
workerSharingId: workerId,
|
||||
vipEvidencePending,
|
||||
useOperationalStatus: true,
|
||||
})
|
||||
const statistics = await getWorkOrderStatistics({
|
||||
statuses,
|
||||
@@ -1291,32 +1293,16 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
workerSharingId: workerId,
|
||||
vipEvidencePending,
|
||||
useOperationalStatus: true,
|
||||
})
|
||||
const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id)))
|
||||
const sharesByOrderId = new Map<number, WorkOrderShareRow[]>()
|
||||
for (const share of shares) {
|
||||
const orderId = Number(share.work_order_id)
|
||||
const bucket = sharesByOrderId.get(orderId) || []
|
||||
bucket.push(share)
|
||||
sharesByOrderId.set(orderId, bucket)
|
||||
}
|
||||
const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds(
|
||||
items.map((item) => Number(item.id)),
|
||||
)
|
||||
return {
|
||||
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),
|
||||
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0,
|
||||
sharingProgress: {
|
||||
joinedQuantity,
|
||||
pendingSubmissionCount,
|
||||
},
|
||||
}
|
||||
}),
|
||||
pagination: { page, pageSize, total },
|
||||
@@ -1400,13 +1386,7 @@ export async function updateAdminWorkOrderSharing(
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (
|
||||
![
|
||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
WORK_ORDER_STATUS.OPEN,
|
||||
].includes(workOrder.status as never)
|
||||
) {
|
||||
if (!mapWorkOrderAdmin(workOrder).capabilities.canConfigureSharing) {
|
||||
throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_config_status_invalid',
|
||||
@@ -1484,21 +1464,16 @@ export async function submitAdminWorkOrderMaterial(
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (
|
||||
![
|
||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
WORK_ORDER_STATUS.IN_PROGRESS,
|
||||
].includes(workOrder.status as never)
|
||||
) {
|
||||
const materialCapabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||
if (!materialCapabilities.canUpdateMaterial) {
|
||||
throw createHttpError('当前状态不能更新订单资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_status_invalid',
|
||||
})
|
||||
}
|
||||
if (
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||
workOrder.sharing_enabled === true &&
|
||||
Number(workOrder.active_share_quantity || 0) > 0 &&
|
||||
payload.rewardAmount !== undefined
|
||||
) {
|
||||
throw createHttpError('拼单订单的份额奖励已锁定,不能在更新资料时修改价格', {
|
||||
@@ -1515,7 +1490,9 @@ export async function submitAdminWorkOrderMaterial(
|
||||
const now = nowIso()
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files)
|
||||
const rewardAmount =
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && payload.rewardAmount !== undefined
|
||||
workOrder.sharing_enabled === true &&
|
||||
Number(workOrder.active_share_quantity || 0) > 0 &&
|
||||
payload.rewardAmount !== undefined
|
||||
? normalizeAmountFen(payload.rewardAmount, workOrder.reward_amount)
|
||||
: workOrder.reward_amount
|
||||
const material = {
|
||||
@@ -1528,9 +1505,10 @@ export async function submitAdminWorkOrderMaterial(
|
||||
source: 'admin',
|
||||
},
|
||||
}
|
||||
const hasActiveShares = Number(workOrder.active_share_quantity || 0) > 0
|
||||
const nextStatus =
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
? WORK_ORDER_STATUS.IN_PROGRESS
|
||||
hasActiveShares || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
? workOrder.status
|
||||
: complete
|
||||
? WORK_ORDER_STATUS.UNASSIGNED
|
||||
: WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||
@@ -1545,7 +1523,7 @@ export async function submitAdminWorkOrderMaterial(
|
||||
actorType: 'admin',
|
||||
actorId: actorName,
|
||||
eventType:
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
hasActiveShares || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
? 'material_updated'
|
||||
: 'material_completed',
|
||||
fromStatus: workOrder.status,
|
||||
@@ -1654,13 +1632,7 @@ export async function updateAdminWorkOrder(
|
||||
options: { canEditRequirementFields?: boolean; actorName?: string } = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (
|
||||
![
|
||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
WORK_ORDER_STATUS.OPEN,
|
||||
].includes(workOrder.status as never)
|
||||
) {
|
||||
if (!mapWorkOrderAdmin(workOrder).capabilities.canEdit) {
|
||||
throw createHttpError('只有待完善、未分配或待抢单的工单可以编辑', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_edit_status_invalid',
|
||||
@@ -1738,13 +1710,7 @@ export async function updateAdminWorkOrder(
|
||||
|
||||
export async function deleteAdminWorkOrder(workOrderId: number | string) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (
|
||||
![
|
||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
WORK_ORDER_STATUS.CANCELLED,
|
||||
].includes(workOrder.status as never)
|
||||
) {
|
||||
if (!mapWorkOrderAdmin(workOrder).capabilities.canDelete) {
|
||||
throw createHttpError('只有待完善、未分配或已取消的工单可以删除', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_delete_status_invalid',
|
||||
@@ -1847,7 +1813,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
|
||||
|
||||
export async function unpublishAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
|
||||
if (!mapWorkOrderAdmin(workOrder).capabilities.canUnpublish) {
|
||||
throw createHttpError('只有待抢单且未被接走的订单可以下架', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_unpublish_status_invalid',
|
||||
@@ -1879,7 +1845,8 @@ export async function pinAdminWorkOrder(
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const pinned = payload.pinned === true
|
||||
if (pinned && workOrder.status !== WORK_ORDER_STATUS.OPEN) {
|
||||
const capabilities = mapWorkOrderAdmin(workOrder).capabilities
|
||||
if (pinned && (!capabilities.canPin || !mapWorkOrderAdmin(workOrder).hallAvailable)) {
|
||||
throw createHttpError('只有待抢单订单可以置顶', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_pin_status_invalid',
|
||||
@@ -1909,11 +1876,7 @@ export async function markAdminWorkOrderProblem(
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (
|
||||
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(
|
||||
workOrder.status as never,
|
||||
)
|
||||
) {
|
||||
if (!mapWorkOrderAdmin(workOrder).capabilities.canMarkProblem) {
|
||||
throw createHttpError('当前状态不能标记问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_problem_status_invalid',
|
||||
@@ -1989,13 +1952,21 @@ export async function resolveAdminProblemWorkOrder(
|
||||
}
|
||||
|
||||
export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
|
||||
let workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (!mapWorkOrderAdmin(workOrder).capabilities.canAccept) {
|
||||
throw createHttpError('只有待验收订单可以通过验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_accept_status_invalid',
|
||||
})
|
||||
}
|
||||
// 历史拼单订单可能仍保留 open,验收前先补齐母工单生命周期状态。
|
||||
if (workOrder.status === WORK_ORDER_STATUS.OPEN) {
|
||||
const promoted = await updateWorkOrder(workOrder.id, {
|
||||
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
if (promoted) workOrder = promoted
|
||||
}
|
||||
const now = nowIso()
|
||||
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
|
||||
workOrderId: workOrder.id,
|
||||
@@ -2028,6 +1999,46 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa
|
||||
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||
}
|
||||
|
||||
export async function acceptAdminWorkOrderShare(
|
||||
workOrderId: number | string,
|
||||
shareId: number | string,
|
||||
actorName = '',
|
||||
) {
|
||||
const result = await acceptWorkOrderShareAndSettle({
|
||||
workOrderId: Number(workOrderId),
|
||||
shareId: Number(shareId),
|
||||
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
|
||||
now: nowIso(),
|
||||
actorType: 'admin',
|
||||
actorName,
|
||||
})
|
||||
if (result.failureReason === 'work_order_not_accepting') {
|
||||
throw createHttpError('当前母工单不能验收拼单份额', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_accept_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_not_submitted' || !result.share || !result.order) {
|
||||
throw createHttpError('该拼单参与者尚未提交验收资料或已被验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_share_accept_status_invalid',
|
||||
})
|
||||
}
|
||||
const workerId = Number(result.share.worker_id || 0)
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerIds: workerId > 0 ? [workerId] : [],
|
||||
})
|
||||
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
|
||||
if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) {
|
||||
await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted')
|
||||
}
|
||||
return {
|
||||
order: mapWorkOrderAdmin(result.order),
|
||||
share: mapWorkOrderShare(result.share),
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
|
||||
const workOrderIds = [
|
||||
...new Set(
|
||||
@@ -2187,8 +2198,13 @@ export async function getAdminWorkerPlatformSummary() {
|
||||
const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([
|
||||
listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }),
|
||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.PENDING_MATERIAL] }),
|
||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.OPEN] }),
|
||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.IN_PROGRESS] }),
|
||||
listWorkOrders({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
statuses: [WORK_ORDER_STATUS.OPEN],
|
||||
excludeFilledSharing: true,
|
||||
}),
|
||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.IN_PROGRESS], useOperationalStatus: true }),
|
||||
])
|
||||
return {
|
||||
pendingWorkerCount: pendingWorkers.total,
|
||||
@@ -2270,6 +2286,10 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
||||
})
|
||||
const sharingEnabled = resolveWorkOrderSharingEnabled(
|
||||
rule.sharing_enabled === true,
|
||||
sharingTotalQuantity,
|
||||
)
|
||||
const now = nowIso()
|
||||
const materialComplete = fields.every((field) => !field.required)
|
||||
const workOrder = await createWorkOrder({
|
||||
@@ -2284,7 +2304,7 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
rewardAmount,
|
||||
requiredDepositAmount: 0,
|
||||
depositThresholdAmount: 0,
|
||||
sharingEnabled: rule.sharing_enabled === true,
|
||||
sharingEnabled,
|
||||
sharingTotalQuantity,
|
||||
sharingUnitReward,
|
||||
timeoutMinutes: Number(rule.timeout_minutes || 0),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
addWorkerWalletCredit,
|
||||
getWorkerUserById,
|
||||
type WorkCategoryRow,
|
||||
type WorkerFinanceRequestRow,
|
||||
type WorkOrderRow,
|
||||
type WorkOrderCapabilities,
|
||||
type WorkProductRuleMappingRow,
|
||||
type WorkProductRuleRow,
|
||||
type WorkerLevelRow,
|
||||
@@ -323,6 +325,62 @@ export function mapWorkOrderMaterial(value: unknown) {
|
||||
}
|
||||
|
||||
export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
|
||||
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0))
|
||||
const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
||||
const hasActiveShares = activeShareQuantity > 0
|
||||
const sharingFilled =
|
||||
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
|
||||
const status =
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||
workOrder.sharing_enabled === true &&
|
||||
hasActiveShares
|
||||
? sharingFilled && pendingShareCount === 0
|
||||
? WORK_ORDER_STATUS.PENDING_ACCEPTANCE
|
||||
: WORK_ORDER_STATUS.IN_PROGRESS
|
||||
: workOrder.status
|
||||
const hallAvailable =
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
|
||||
const editableBeforeClaim =
|
||||
!workOrder.assigned_worker_id &&
|
||||
(workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL ||
|
||||
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||
(workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares))
|
||||
const capabilities: WorkOrderCapabilities = {
|
||||
canEdit: editableBeforeClaim,
|
||||
canConfigureSharing: editableBeforeClaim,
|
||||
canAssign:
|
||||
!workOrder.assigned_worker_id &&
|
||||
(workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||
(workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)),
|
||||
canPin: hallAvailable || Boolean(workOrder.pinned_at),
|
||||
canUnpublish:
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||
!hasActiveShares &&
|
||||
!workOrder.assigned_worker_id,
|
||||
canUpdateMaterial:
|
||||
workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL ||
|
||||
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS ||
|
||||
(workOrder.status === WORK_ORDER_STATUS.OPEN && hasActiveShares),
|
||||
canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
canMarkProblem:
|
||||
status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
canDelete:
|
||||
!workOrder.assigned_worker_id &&
|
||||
!hasActiveShares &&
|
||||
[
|
||||
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
WORK_ORDER_STATUS.CANCELLED,
|
||||
].includes(workOrder.status as never),
|
||||
canPublish:
|
||||
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0,
|
||||
canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
|
||||
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||
}
|
||||
return {
|
||||
workOrderId: Number(workOrder.id),
|
||||
orderId: workOrder.order_id ? Number(workOrder.order_id) : null,
|
||||
@@ -332,7 +390,16 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
productName: workOrder.product_name,
|
||||
categoryId: workOrder.category_id ? Number(workOrder.category_id) : null,
|
||||
categoryName: workOrder.category_name || '',
|
||||
status: workOrder.status,
|
||||
status,
|
||||
rawStatus: workOrder.status,
|
||||
hallAvailable,
|
||||
sharingProgress: {
|
||||
joinedQuantity: activeShareQuantity,
|
||||
pendingSubmissionCount: pendingShareCount,
|
||||
remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity),
|
||||
filled: sharingFilled,
|
||||
},
|
||||
capabilities,
|
||||
rewardAmount: Number(workOrder.reward_amount || 0),
|
||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||
sharing: {
|
||||
@@ -384,17 +451,53 @@ export function mapWorkOrderForWorker(
|
||||
worker: _worker,
|
||||
orderId: _orderId,
|
||||
orderItemId: _orderItemId,
|
||||
rawStatus: _rawStatus,
|
||||
hallAvailable: _hallAvailable,
|
||||
capabilities: _capabilities,
|
||||
...visibleOrder
|
||||
} = mapped
|
||||
const personalStatus = resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
||||
return {
|
||||
...visibleOrder,
|
||||
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
productName: myShare
|
||||
? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0))
|
||||
: visibleOrder.productName,
|
||||
status: personalStatus,
|
||||
rewardAmount: myShare ? Number(myShare.share_reward || 0) : visibleOrder.rewardAmount,
|
||||
freezeDepositAmount: myShare
|
||||
? Number(myShare.share_deposit || 0)
|
||||
: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
|
||||
submittedAt: myShare?.submitted_at || visibleOrder.submittedAt,
|
||||
acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt,
|
||||
workerNote,
|
||||
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */
|
||||
export function resolveWorkerShareProductName(productName: unknown, quantity: number): string {
|
||||
const text = String(productName || '').trim()
|
||||
const normalizedQuantity = Math.max(1, Number(quantity) || 1)
|
||||
return text.replace(
|
||||
/(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/,
|
||||
(_matched, _oldQuantity: string, spacing: string, unit: string) =>
|
||||
`${normalizedQuantity}${spacing}${unit}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 把份额状态映射成打手端订单状态,避免母工单 open 被误显示为已超时。 */
|
||||
export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: unknown): string {
|
||||
if (!shareStatus) return workOrderStatus
|
||||
if (['accepted', 'cancelled', 'problem'].includes(workOrderStatus)) return workOrderStatus
|
||||
if (shareStatus === 'joined') return WORK_ORDER_STATUS.IN_PROGRESS
|
||||
if (shareStatus === 'submitted') return WORK_ORDER_STATUS.PENDING_ACCEPTANCE
|
||||
if (shareStatus === 'accepted') return WORK_ORDER_STATUS.ACCEPTED
|
||||
if (shareStatus === 'cancelled') return WORK_ORDER_STATUS.CANCELLED
|
||||
return workOrderStatus
|
||||
}
|
||||
|
||||
export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||
return {
|
||||
shareId: Number(share.id),
|
||||
@@ -405,8 +508,8 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||
shareReward: Number(share.share_reward || 0),
|
||||
shareDeposit: Number(share.share_deposit || 0),
|
||||
status: share.status,
|
||||
acceptance: safeParseJson(share.acceptance_json),
|
||||
draftAcceptance: safeParseJson(share.draft_acceptance_json),
|
||||
acceptance: mapAcceptanceForResponse(share.acceptance_json),
|
||||
draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json),
|
||||
submittedAt: share.submitted_at,
|
||||
acceptedAt: share.accepted_at,
|
||||
createdAt: share.created_at,
|
||||
@@ -1017,6 +1120,26 @@ export function resolveWorkOrderRulePricing(input: {
|
||||
return { sharingTotalQuantity, sharingUnitReward, rewardAmount }
|
||||
}
|
||||
|
||||
/** 接单模板仅在实际总份数大于 1 时生成拼单工单。 */
|
||||
export function resolveWorkOrderSharingEnabled(
|
||||
configuredEnabled: boolean,
|
||||
totalQuantity: number,
|
||||
): boolean {
|
||||
return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1
|
||||
}
|
||||
|
||||
/** 判断拼单工单是否已经没有剩余可承接份数。 */
|
||||
export function isWorkOrderSharingFilled(
|
||||
sharingEnabled: boolean,
|
||||
totalQuantity: number,
|
||||
joinedQuantity: number,
|
||||
): boolean {
|
||||
return (
|
||||
sharingEnabled &&
|
||||
Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1)
|
||||
)
|
||||
}
|
||||
|
||||
const CHINESE_DIGITS: Record<string, number> = {
|
||||
零: 0,
|
||||
一: 1,
|
||||
|
||||
@@ -3,24 +3,29 @@ import test from 'node:test'
|
||||
|
||||
import type {
|
||||
WorkOrderRow,
|
||||
WorkOrderShareRow,
|
||||
WorkProductRuleMappingRow,
|
||||
WorkProductRuleRow,
|
||||
WorkerUserRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { resolveWorkOrderShareJoinQuantity } from '../../repositories/worker-platform/index.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import {
|
||||
assertWithdrawChannelAllowedForAmount,
|
||||
assertWorkerLoginAllowed,
|
||||
isWorkOrderSharingFilled,
|
||||
isWorkerPasswordStrong,
|
||||
normalizeWithdrawChannel,
|
||||
normalizeRequirementFields,
|
||||
mapWorkOrderForWorker,
|
||||
mapWorkOrderAdmin,
|
||||
maskWorkerLeaderboardDisplayName,
|
||||
resolveCollectSubmitTargetWorkOrder,
|
||||
resolveMatchingProductRuleDecision,
|
||||
resolveWorkerPermissions,
|
||||
resolveSkuNameQuantity,
|
||||
resolveWorkOrderRulePricing,
|
||||
resolveWorkOrderSharingEnabled,
|
||||
validateWorkerPassword,
|
||||
} from './index.js'
|
||||
|
||||
@@ -582,6 +587,32 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('模板启用拼单但实际只有一份时生成普通工单', () => {
|
||||
assert.equal(resolveWorkOrderSharingEnabled(true, 1), false)
|
||||
})
|
||||
|
||||
test('模板启用拼单且实际大于一份时生成拼单工单', () => {
|
||||
assert.equal(resolveWorkOrderSharingEnabled(true, 2), true)
|
||||
assert.equal(resolveWorkOrderSharingEnabled(false, 3), false)
|
||||
})
|
||||
|
||||
test('拼单工单立即抢单承接全部剩余份数', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderShareJoinQuantity({ quantity: 0, remaining: 12, takeRemaining: true }),
|
||||
12,
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderShareJoinQuantity({ quantity: 0, remaining: 0, takeRemaining: true }),
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单份数全部承接后不再显示在抢单大厅', () => {
|
||||
assert.equal(isWorkOrderSharingFilled(true, 15, 15), true)
|
||||
assert.equal(isWorkOrderSharingFilled(true, 15, 14), false)
|
||||
assert.equal(isWorkOrderSharingFilled(false, 15, 15), false)
|
||||
})
|
||||
|
||||
function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -611,6 +642,26 @@ function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkOrderShareRow(overrides: Partial<WorkOrderShareRow> = {}): WorkOrderShareRow {
|
||||
return {
|
||||
id: 1,
|
||||
work_order_id: 1,
|
||||
worker_id: 1,
|
||||
quantity: 2,
|
||||
unit_reward: 700,
|
||||
share_reward: 1400,
|
||||
share_deposit: 200,
|
||||
status: 'joined',
|
||||
acceptance_json: {},
|
||||
draft_acceptance_json: {},
|
||||
submitted_at: null,
|
||||
accepted_at: null,
|
||||
created_at: '2026-08-18T09:09:08.000Z',
|
||||
updated_at: '2026-08-18T09:09:08.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('mapWorkOrderForWorker reflects the personal first-view state', () => {
|
||||
const workOrder = buildWorkOrderRow({ assigned_worker_id: 1 })
|
||||
const permissions = resolveWorkerPermissions(buildWorkerUserRow())
|
||||
@@ -622,6 +673,62 @@ test('mapWorkOrderForWorker reflects the personal first-view state', () => {
|
||||
assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined)
|
||||
})
|
||||
|
||||
test('mapWorkOrderForWorker 使用个人拼单份数、金额、状态和接单时间', () => {
|
||||
const workOrder = buildWorkOrderRow({
|
||||
product_name: '指挥官秘钥15个',
|
||||
status: 'open',
|
||||
reward_amount: 10_500,
|
||||
sharing_enabled: true,
|
||||
sharing_total_quantity: 15,
|
||||
sharing_unit_reward: 700,
|
||||
})
|
||||
const share = buildWorkOrderShareRow()
|
||||
const mapped = mapWorkOrderForWorker(
|
||||
workOrder,
|
||||
resolveWorkerPermissions(buildWorkerUserRow()),
|
||||
share,
|
||||
)
|
||||
|
||||
assert.equal(mapped.productName, '指挥官秘钥2个')
|
||||
assert.equal(mapped.rewardAmount, 1400)
|
||||
assert.equal(mapped.freezeDepositAmount, 200)
|
||||
assert.equal(mapped.status, 'in_progress')
|
||||
assert.equal(mapped.assignedAt, '2026-08-18T09:09:08.000Z')
|
||||
})
|
||||
|
||||
test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
|
||||
const partial = mapWorkOrderAdmin(
|
||||
buildWorkOrderRow({
|
||||
status: 'open',
|
||||
sharing_enabled: true,
|
||||
sharing_total_quantity: 15,
|
||||
active_share_quantity: 3,
|
||||
pending_share_count: 1,
|
||||
}),
|
||||
)
|
||||
assert.equal(partial.status, 'in_progress')
|
||||
assert.equal(partial.rawStatus, 'open')
|
||||
assert.equal(partial.hallAvailable, true)
|
||||
assert.equal(partial.capabilities.canEdit, false)
|
||||
assert.equal(partial.capabilities.canConfigureSharing, false)
|
||||
assert.equal(partial.capabilities.canAssign, false)
|
||||
assert.equal(partial.capabilities.canUpdateMaterial, true)
|
||||
|
||||
const filled = mapWorkOrderAdmin(
|
||||
buildWorkOrderRow({
|
||||
status: 'open',
|
||||
sharing_enabled: true,
|
||||
sharing_total_quantity: 15,
|
||||
active_share_quantity: 15,
|
||||
pending_share_count: 0,
|
||||
}),
|
||||
)
|
||||
assert.equal(filled.status, 'pending_acceptance')
|
||||
assert.equal(filled.hallAvailable, false)
|
||||
assert.equal(filled.capabilities.canAccept, true)
|
||||
assert.equal(filled.capabilities.canUnpublish, false)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
|
||||
const workOrder = buildWorkOrderRow()
|
||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
listWorkOrders,
|
||||
listWorkOrderEventsByOrderId,
|
||||
listWorkOrderShares,
|
||||
listWorkOrderSharesByOrderIds,
|
||||
listWorkerLevels,
|
||||
listDueDepositUnfreezes,
|
||||
listOverdueWorkOrders,
|
||||
@@ -1131,19 +1132,46 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
categoryId: categoryId || 0,
|
||||
visibleAfterIso,
|
||||
excludeFilledSharing: true,
|
||||
pinnedFirst: true,
|
||||
})
|
||||
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
||||
const myShares = await listWorkerSharesByWorker(worker.id)
|
||||
const orderIds = items.map((item) => Number(item.id))
|
||||
const shares = await listWorkOrderSharesByOrderIds(orderIds)
|
||||
const myShareByOrderId = new Map(
|
||||
myShares
|
||||
.filter((share) => Number(share.work_order_id))
|
||||
shares
|
||||
.filter(
|
||||
(share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id),
|
||||
)
|
||||
.map((share) => [Number(share.work_order_id), share]),
|
||||
)
|
||||
const sharingProgressByOrderId = new Map<
|
||||
number,
|
||||
{ joinedQuantity: number; pendingSubmissionCount: number }
|
||||
>()
|
||||
for (const share of shares) {
|
||||
if (share.status === 'cancelled') continue
|
||||
const orderId = Number(share.work_order_id)
|
||||
const progress = sharingProgressByOrderId.get(orderId) || {
|
||||
joinedQuantity: 0,
|
||||
pendingSubmissionCount: 0,
|
||||
}
|
||||
progress.joinedQuantity += Number(share.quantity || 0)
|
||||
if (share.status === 'joined') progress.pendingSubmissionCount += 1
|
||||
sharingProgressByOrderId.set(orderId, progress)
|
||||
}
|
||||
return {
|
||||
items: items.map((item) =>
|
||||
mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))),
|
||||
),
|
||||
items: items.map((item) => {
|
||||
const myShare = myShareByOrderId.get(Number(item.id))
|
||||
return {
|
||||
...mapWorkOrderForWorker(item, permissions),
|
||||
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||
sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || {
|
||||
joinedQuantity: 0,
|
||||
pendingSubmissionCount: 0,
|
||||
},
|
||||
}
|
||||
}),
|
||||
pagination: { page, pageSize, total },
|
||||
categories,
|
||||
visibleDelaySeconds,
|
||||
@@ -1180,14 +1208,6 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
requireActiveWorkerSession(session)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const activeCount = await countWorkerActiveOrders(worker.id)
|
||||
if (activeCount >= permissions.maxActiveOrders) {
|
||||
throw createHttpError('已达到当前等级最大同时接单量', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_active_order_limit',
|
||||
})
|
||||
}
|
||||
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
|
||||
throw createHttpError('该订单已不可抢', {
|
||||
@@ -1196,6 +1216,34 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
})
|
||||
}
|
||||
|
||||
if (workOrder.sharing_enabled) {
|
||||
const result = await joinWorkOrderShare({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(worker.id),
|
||||
quantity: 0,
|
||||
takeRemaining: true,
|
||||
maxActiveOrders: permissions.maxActiveOrders,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!result.share) {
|
||||
throw resolveWorkOrderShareJoinError(result.failureReason)
|
||||
}
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(worker.id)],
|
||||
hallChanged: true,
|
||||
walletChangedWorkerIds: [Number(worker.id)],
|
||||
})
|
||||
return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) }
|
||||
}
|
||||
|
||||
const activeCount = await countWorkerActiveOrders(worker.id)
|
||||
if (activeCount >= permissions.maxActiveOrders) {
|
||||
throw createHttpError('已达到当前等级最大同时接单量', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_active_order_limit',
|
||||
})
|
||||
}
|
||||
|
||||
const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions)
|
||||
if (Number(worker.available_amount || 0) < freezeAmount) {
|
||||
throw createHttpError('余额不足,无法冻结所需押金', {
|
||||
|
||||
Reference in New Issue
Block a user