feat: 增加打手工单备注

This commit is contained in:
yml2213
2026-08-15 14:22:13 +08:00
parent af323af48e
commit 112a3272d2
9 changed files with 221 additions and 2 deletions
@@ -0,0 +1,12 @@
-- 020_worker_order_notes.sql —— 打手工单备注。
--
-- 整单与拼单份额分别保存,确保拼单打手只能查看和修改自己的备注。
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS worker_note TEXT NOT NULL DEFAULT '';
ALTER TABLE work_order_shares
ADD COLUMN IF NOT EXISTS worker_note TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN work_orders.worker_note IS '整单打手维护的个人备注';
COMMENT ON COLUMN work_order_shares.worker_note IS '拼单打手维护的个人备注';
@@ -0,0 +1,14 @@
-- 021_worker_order_notes_by_worker.sql —— 按打手保存工单备注。
--
-- 工单可重新分配,拼单也有多个打手;备注必须按工单和打手独立存储。
CREATE TABLE IF NOT EXISTS worker_work_order_notes (
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,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (work_order_id, worker_id)
);
COMMENT ON TABLE worker_work_order_notes IS '打手维护的个人工单备注';
@@ -384,6 +384,50 @@ export async function updateWorkOrderShareAcceptanceRecord(input: {
return getWorkOrderShareById(share.id) return getWorkOrderShareById(share.id)
} }
export async function listWorkerWorkOrderNotes(
workerId: number | string,
workOrderIds: number[],
): Promise<Array<{ work_order_id: number; note: string }>> {
if (workOrderIds.length === 0) return []
const result = await query<{ work_order_id: number; note: string }>(
`
SELECT work_order_id, note
FROM worker_work_order_notes
WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[])
`,
[Number(workerId), workOrderIds],
)
return result.rows
}
export async function upsertWorkerWorkOrderNote(input: {
workOrderId: number
workerId: number
note: string
now: string
}): Promise<string> {
const result = await query<{ note: string }>(
`
INSERT INTO worker_work_order_notes (
work_order_id, worker_id, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $4)
ON CONFLICT (work_order_id, worker_id) DO UPDATE
SET note = EXCLUDED.note, updated_at = EXCLUDED.updated_at
RETURNING note
`,
[input.workOrderId, input.workerId, input.note, input.now],
)
return String(result.rows[0]?.note || '')
}
async function getWorkOrderShareById(shareId: number): Promise<WorkOrderShareRow | null> {
const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[shareId],
)
return result.rows[0] || null
}
export async function getWorkCategoryByKey(categoryKey: string): Promise<WorkCategoryRow | null> { export async function getWorkCategoryByKey(categoryKey: string): Promise<WorkCategoryRow | null> {
const result = await query<WorkCategoryRow>( const result = await query<WorkCategoryRow>(
'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1', 'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1',
+19
View File
@@ -21,6 +21,7 @@ import {
registerWorker, registerWorker,
sendWorkerSmsCode, sendWorkerSmsCode,
saveWorkerOrderAcceptanceDraft, saveWorkerOrderAcceptanceDraft,
saveWorkerOrderNote,
submitWorkerOrderAcceptance, submitWorkerOrderAcceptance,
} from '../services/worker-platform/index.js' } from '../services/worker-platform/index.js'
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
@@ -229,6 +230,24 @@ router.get(
}), }),
) )
router.post(
'/orders/:workOrderId/note',
requireActiveWorker,
createRouteHandler(
(req) =>
saveWorkerOrderNote(
String(req.params.workOrderId || ''),
req.body || {},
getRequiredWorkerSession(req),
),
{
successMessage: '备注已保存',
errorMessage: '保存备注失败',
scope: '[worker/orders/:workOrderId/note]',
},
),
)
router.post( router.post(
'/orders/:workOrderId/submit-acceptance', '/orders/:workOrderId/submit-acceptance',
requireActiveWorker, requireActiveWorker,
@@ -416,6 +416,7 @@ export function mapWorkOrderForWorker(
workOrder: WorkOrderRow, workOrder: WorkOrderRow,
permissions: { depositFreeAmount: number }, permissions: { depositFreeAmount: number },
myShare?: WorkOrderShareRow | null, myShare?: WorkOrderShareRow | null,
workerNote = '',
) { ) {
const mapped = mapWorkOrderAdmin(workOrder) const mapped = mapWorkOrderAdmin(workOrder)
const { const {
@@ -429,6 +430,7 @@ export function mapWorkOrderForWorker(
return { return {
...visibleOrder, ...visibleOrder,
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
workerNote,
myShare: myShare ? mapWorkOrderShare(myShare) : null, myShare: myShare ? mapWorkOrderShare(myShare) : null,
} }
} }
@@ -48,6 +48,7 @@ import {
listWorkerFinanceRequests, listWorkerFinanceRequests,
listWorkerSharesByWorker, listWorkerSharesByWorker,
listWorkerWalletLedgers, listWorkerWalletLedgers,
listWorkerWorkOrderNotes,
listWorkCategories, listWorkCategories,
listWorkOrders, listWorkOrders,
listWorkProductRules, listWorkProductRules,
@@ -67,6 +68,7 @@ import {
updateWorkOrderDraftAcceptance, updateWorkOrderDraftAcceptance,
updateWorkOrderShareAcceptanceRecord, updateWorkOrderShareAcceptanceRecord,
updateWorkOrderShareDraftAcceptance, updateWorkOrderShareDraftAcceptance,
upsertWorkerWorkOrderNote,
updateWorkerPassword, updateWorkerPassword,
updateWorkerUser, updateWorkerUser,
upsertWorkCategory, upsertWorkCategory,
@@ -981,14 +983,69 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const myShareByOrderId = new Map( const myShareByOrderId = new Map(
myShares.map((share) => [Number(share.work_order_id), share]), myShares.map((share) => [Number(share.work_order_id), share]),
) )
const notes = await listWorkerWorkOrderNotes(
worker.id,
items.map((item) => Number(item.id)),
)
const noteByOrderId = new Map(
notes.map((item) => [Number(item.work_order_id), String(item.note || '')]),
)
return { return {
items: items.map((item) => items: items.map((item) =>
mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), mapWorkOrderForWorker(
item,
permissions,
myShareByOrderId.get(Number(item.id)),
noteByOrderId.get(Number(item.id)) || '',
),
), ),
pagination: { page, pageSize, total }, pagination: { page, pageSize, total },
} }
} }
export async function saveWorkerOrderNote(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const note = String(payload.note || '').trim()
if (note.length > 100) {
throw createHttpError('备注不能超过 100 个字符', {
statusCode: 400,
errorCode: 'work_order_note_too_long',
})
}
const worker = await getRequiredWorker(workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
const isOrderOwner =
Number(workOrder.assigned_worker_id || 0) === workerId ||
Number(workOrder.last_assigned_worker_id || 0) === workerId
if (!sharingShare && !isOrderOwner) {
throw createHttpError('只能修改自己的订单备注', {
statusCode: 403,
errorCode: 'work_order_note_owner_required',
})
}
const savedNote = await upsertWorkerWorkOrderNote({
workOrderId: Number(workOrder.id),
workerId,
note,
now: nowIso(),
})
return {
order: mapWorkOrderForWorker(
workOrder,
resolveWorkerPermissions(worker),
sharingShare,
savedNote,
),
}
}
export async function joinWorkerSharingOrder( export async function joinWorkerSharingOrder(
workOrderId: number | string, workOrderId: number | string,
payload: JsonObject = {}, payload: JsonObject = {},
@@ -1,5 +1,6 @@
import { import {
CopyOutlined, CopyOutlined,
EditOutlined,
EyeOutlined, EyeOutlined,
PictureOutlined, PictureOutlined,
ReloadOutlined, ReloadOutlined,
@@ -7,6 +8,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
AutoComplete,
App, App,
Button, Button,
Card, Card,
@@ -32,6 +34,7 @@ import {
cancelWorkerOrder, cancelWorkerOrder,
fetchWorkerMyOrders, fetchWorkerMyOrders,
saveWorkerAcceptanceDraft, saveWorkerAcceptanceDraft,
saveWorkerOrderNote,
submitWorkerAcceptance, submitWorkerAcceptance,
} from '@/services/worker' } from '@/services/worker'
import type { import type {
@@ -56,6 +59,10 @@ type AcceptanceFormValues = {
note?: string note?: string
} }
type WorkerNoteFormValues = {
note?: string
}
type DetailFieldItem = { type DetailFieldItem = {
key: string key: string
label: string label: string
@@ -73,8 +80,10 @@ export default function WorkerOrdersPage() {
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null) const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
const [editingOrder, setEditingOrder] = useState<WorkOrder | null>(null) const [editingOrder, setEditingOrder] = useState<WorkOrder | null>(null)
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([]) const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
const [form] = Form.useForm<AcceptanceFormValues>() const [form] = Form.useForm<AcceptanceFormValues>()
const [noteForm] = Form.useForm<WorkerNoteFormValues>()
const ordersQuery = useQuery({ const ordersQuery = useQuery({
queryKey: ['worker-my-orders', status, keyword, page, pageSize], queryKey: ['worker-my-orders', status, keyword, page, pageSize],
@@ -176,6 +185,24 @@ export default function WorkerOrdersPage() {
setDetailOrder(null) setDetailOrder(null)
} }
function openNoteModal(order: WorkOrder) {
setNoteOrder(order)
noteForm.setFieldsValue({ note: String(order.workerNote || '') })
}
async function saveOrderNote(values: WorkerNoteFormValues) {
if (!noteOrder) return
try {
await saveWorkerOrderNote(noteOrder.workOrderId, String(values.note || '').trim())
message.success('备注已保存')
setNoteOrder(null)
noteForm.resetFields()
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存备注失败')
}
}
async function submitAcceptance(values: AcceptanceFormValues) { async function submitAcceptance(values: AcceptanceFormValues) {
if (!editingOrder) return if (!editingOrder) return
if (acceptanceFiles.length === 0) { if (acceptanceFiles.length === 0) {
@@ -392,6 +419,23 @@ export default function WorkerOrdersPage() {
) )
}, },
}, },
{
title: '备注',
width: 190,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text
type={row.workerNote ? undefined : 'secondary'}
ellipsis={{ tooltip: row.workerNote || undefined }}
>
{row.workerNote || '未填写'}
</Typography.Text>
<Button size="small" icon={<EditOutlined />} onClick={() => openNoteModal(row)}>
</Button>
</div>
),
},
{ {
title: '操作', title: '操作',
width: 260, width: 260,
@@ -520,7 +564,7 @@ export default function WorkerOrdersPage() {
setPageSize(nextPageSize) setPageSize(nextPageSize)
}, },
}} }}
scroll={{ x: 1060 }} scroll={{ x: 1440 }}
/> />
</Space> </Space>
</Card> </Card>
@@ -672,6 +716,25 @@ export default function WorkerOrdersPage() {
) : null} ) : null}
</Drawer> </Drawer>
<Modal
title={`订单备注 · ${noteOrder?.workOrderNo || ''}`}
open={Boolean(noteOrder)}
onCancel={() => {
setNoteOrder(null)
noteForm.resetFields()
}}
onOk={() => noteForm.submit()}
destroyOnHidden
>
<Form form={noteForm} layout="vertical" onFinish={saveOrderNote}>
<Form.Item label="备注" name="note">
<AutoComplete options={[{ value: '已安排' }, { value: '未安排' }]}>
<Input allowClear maxLength={100} placeholder="输入备注或选择状态" />
</AutoComplete>
</Form.Item>
</Form>
</Modal>
<Modal <Modal
title={ title={
editingOrder editingOrder
+7
View File
@@ -126,6 +126,13 @@ 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)
} }
export function saveWorkerOrderNote(workOrderId: number, note: string) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/worker/orders/${workOrderId}/note`,
{ note },
)
}
export function submitWorkerAcceptance( export function submitWorkerAcceptance(
workOrderId: number, workOrderId: number,
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] },
@@ -248,6 +248,7 @@ export type WorkOrder = {
acceptance: WorkerAcceptance acceptance: WorkerAcceptance
/** 暂存的验收草稿(未提交时保存于此,刷新不丢失;提交后清空) */ /** 暂存的验收草稿(未提交时保存于此,刷新不丢失;提交后清空) */
draftAcceptance: WorkerAcceptance draftAcceptance: WorkerAcceptance
workerNote?: string
problemNote: string problemNote: string
worker: null | { worker: null | {
workerId: number workerId: number