feat: 增加打手工单备注
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user