完善拼单状态与份额验收流程

This commit is contained in:
yml2213
2026-08-18 18:02:43 +08:00
parent 052b99b05f
commit ee79195f47
17 changed files with 1143 additions and 196 deletions
@@ -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('余额不足,无法冻结所需押金', {