feat: edit collaboration remarks across order loading

This commit is contained in:
2026-09-14 16:56:11 +08:00
parent 33520e8fd3
commit 9e28e10e5f
16 changed files with 125 additions and 78 deletions
@@ -3,7 +3,6 @@ import { http } from '@/shared/api/http'
import {
getPlatformTenantOrderStatistics,
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
approvePlatformFirstLegOrder,
cancelPlatformFirstLegOrder,
completePlatformFirstLegOrder,
@@ -85,7 +84,6 @@ describe('platform first-leg order API', () => {
await startPlatformFirstLegOrder('10001', '90001')
await completePlatformFirstLegOrder('10001', '90001')
await cancelPlatformFirstLegOrder('10001', '90001', '客户取消')
await addPlatformFirstLegOrderNote('10001', '90001', '已联系客户确认')
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001', { params: { tenantId: '10001' } })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '10001' } })
@@ -93,7 +91,6 @@ describe('platform first-leg order API', () => {
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/orders/first-leg/90001/start', undefined, { params: { tenantId: '10001' } })
expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/orders/first-leg/90001/complete', undefined, { params: { tenantId: '10001' } })
expect(http.post).toHaveBeenNthCalledWith(5, '/platform/v1/orders/first-leg/90001/cancel', { reason: '客户取消' }, { params: { tenantId: '10001' } })
expect(http.post).toHaveBeenNthCalledWith(6, '/platform/v1/orders/first-leg/90001/internal-notes', { content: '已联系客户确认' }, { params: { tenantId: '10001' } })
})
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
@@ -192,10 +192,6 @@ export function cancelPlatformFirstLegOrder(tenantId: string, orderId: string, r
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/cancel`, { reason }, { tenantId })
}
export function addPlatformFirstLegOrderNote(tenantId: string, orderId: string, content: string) {
return postData<number | string>(`/platform/v1/orders/first-leg/${orderId}/internal-notes`, { content }, { tenantId })
}
export function updatePlatformFirstLegOrderExpress(
tenantId: string,
orderId: string,
@@ -9,7 +9,7 @@ import {
preparePalletLabels,
printPalletLabels,
updatePreloadTransport,
updateLoadingItemWarehouseRemark,
updateLoadingItemRemarks,
} from './platformLoadingLists'
const preloadSummary = {
@@ -53,7 +53,7 @@ const detail = {
customerName: '测试客户', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true, inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null,
goodsImageUrl: null, unitType: 'SEALED_PALLET', palletId: 9007199254740993n, palletNo: 'PLT-001', indivisible: true, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null,
internalRemark: null, customerRemark: null, warehouseRemark: null, customerServiceRemark: null, loadSequence: 1, frozenAt: null,
}],
histories: [{ historyId: 21, itemId: 11, operationId: 'transport-21', actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }],
}
@@ -108,18 +108,20 @@ describe('platform loading lists API', () => {
expect(loaded.summary.status).toBe('COMPLETED')
})
it('updates one item warehouse remark with both optimistic-lock versions and operation id', async () => {
const patch = vi.spyOn(http, 'patch').mockResolvedValue({ code: '200', message: 'ok', data: { id: 100, version: 4, items: [{ id: 11, warehouseRemark: '装柜时注意外箱', version: 5 }] } })
const result = await updateLoadingItemWarehouseRemark('100', '11', {
it('updates one item editable remarks with both optimistic-lock versions and operation id', async () => {
const patch = vi.spyOn(http, 'patch').mockResolvedValue({ code: '200', message: 'ok', data: { id: 100, version: 4, items: [{ id: 11, internalRemark: '内部', warehouseRemark: '装柜时注意外箱', customerServiceRemark: '客服', version: 5 }] } })
const result = await updateLoadingItemRemarks('100', '11', {
internalRemark: '内部',
warehouseRemark: '装柜时注意外箱',
customerServiceRemark: '客服',
expectedPlanVersion: 3,
expectedItemVersion: 4,
operationId: 'warehouse-remark-1',
})
expect(patch).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/100/items/11/warehouse-remark',
{ warehouseRemark: '装柜时注意外箱', expectedPlanVersion: 3, expectedItemVersion: 4, operationId: 'warehouse-remark-1' },
'/platform/v1/first-leg/smart-loading/plans/100/items/11/remarks',
{ internalRemark: '内部', warehouseRemark: '装柜时注意外箱', customerServiceRemark: '客服', expectedPlanVersion: 3, expectedItemVersion: 4, operationId: 'warehouse-remark-1' },
)
expect(result).toMatchObject({ id: '100', version: 4, items: [{ id: '11', version: 5, warehouseRemark: '装柜时注意外箱' }] })
})
@@ -111,6 +111,7 @@ export interface LoadingItem {
internalRemark: string | null
customerRemark: string | null
warehouseRemark: string | null
customerServiceRemark: string | null
loadSequence: number
frozenAt: string | null
}
@@ -161,17 +162,19 @@ export interface TransportUpdateRequest {
operationReason: string | null
}
export interface WarehouseRemarkUpdateRequest {
export interface LoadingItemRemarksUpdateRequest {
internalRemark: string | null
warehouseRemark: string | null
customerServiceRemark: string | null
expectedPlanVersion: number
expectedItemVersion: number
operationId: string
}
export interface WarehouseRemarkUpdateResponse {
export interface LoadingItemRemarksUpdateResponse {
id: string
version: number
items: Array<{ id: string; version: number; warehouseRemark: string | null }>
items: Array<{ id: string; version: number; internalRemark: string | null; warehouseRemark: string | null; customerServiceRemark: string | null }>
}
export interface PreloadConfirmationResponse {
@@ -342,9 +345,9 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
}
}
export async function updateLoadingItemWarehouseRemark(planId: string, itemId: string, request: WarehouseRemarkUpdateRequest) {
const response = await http.patch<ApiEnvelope<WarehouseRemarkUpdateResponse>, ApiEnvelope<WarehouseRemarkUpdateResponse>>(
`${basePath}/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}/warehouse-remark`,
export async function updateLoadingItemRemarks(planId: string, itemId: string, request: LoadingItemRemarksUpdateRequest) {
const response = await http.patch<ApiEnvelope<LoadingItemRemarksUpdateResponse>, ApiEnvelope<LoadingItemRemarksUpdateResponse>>(
`${basePath}/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}/remarks`,
request,
)
const result = unwrapApiResponse(response)
@@ -24,7 +24,7 @@ const props = defineProps<{
confirmSaving?: boolean
}>()
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: [] }>()
const emit = defineEmits<{ back: []; retry: []; edit: []; editRemarks: [item: LoadingItem]; confirmPreload: [] }>()
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
const baseItemColumns: DataTableColumn[] = [
@@ -54,6 +54,7 @@ const baseItemColumns: DataTableColumn[] = [
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 190, slotName: 'customerRemark' },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 190, slotName: 'internalRemark' },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 210, slotName: 'warehouseRemark' },
{ key: 'customerServiceRemark', prop: 'customerServiceRemark', label: '备注(客服)', minWidth: 210, slotName: 'customerServiceRemark' },
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
]
@@ -203,7 +204,7 @@ function formatNumber(value?: number | null, digits = 3) {
>
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
<template #actions="{ row }">
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的备注`" @click="emit('editRemarks', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
</template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag><small v-if="row.indivisible">整托不可拆</small></template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
@@ -214,6 +215,7 @@ function formatNumber(value?: number | null, digits = 3) {
<template #customerRemark="{ row }"><div class="remark-cell"><span>{{ row.customerRemark || '—' }}</span><small>来源:客户下单</small></div></template>
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
<template #customerServiceRemark="{ row }"><div class="remark-cell"><span>{{ row.customerServiceRemark || '—' }}</span><small>{{ mode === 'preload' ? '由客服维护' : '装柜完成时冻结' }}</small></div></template>
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
</DataTable>
</section>
@@ -169,6 +169,7 @@ describe('LoadingListsView', () => {
'customerRemark',
'internalRemark',
'warehouseRemark',
'customerServiceRemark',
]
let previous = detailSource.indexOf("key: 'customerName'")
for (const field of fields) {
@@ -180,14 +181,14 @@ describe('LoadingListsView', () => {
expect(detailSource).not.toContain("label: '退税资料'")
})
it('keeps customer and internal remarks read-only while exposing per-item warehouse remark editing', () => {
it('keeps customer remarks read-only while exposing three editable collaboration remarks', () => {
expect(detailSource).toContain('来源:客户下单')
expect(detailSource).toContain('来源:订单内部备注')
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).toContain("emit('editWarehouseRemark', row as LoadingItem)")
expect(detailSource).toContain("emit('editRemarks', row as LoadingItem)")
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
expect(detailSource).not.toContain("emit('editCustomerRemark'")
expect(detailSource).not.toContain("emit('editInternalRemark'")
expect(detailSource).toContain("key: 'customerServiceRemark'")
})
it('uses one white detail surface, a readable four-column summary and dense item table', () => {
@@ -17,7 +17,7 @@ vi.mock('../api/platformLoadingLists', () => ({
confirmPreloadPlan: vi.fn(),
getConfirmedLoadingList: vi.fn(),
getPreloadPlan: vi.fn(),
updateLoadingItemWarehouseRemark: vi.fn(),
updateLoadingItemRemarks: vi.fn(),
updatePreloadTransport: vi.fn(),
}))
@@ -39,20 +39,22 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).not.toContain('saveMfNote')
})
it('edits only the selected item warehouse remark with optimistic locking', () => {
expect(source).toContain('@edit-warehouse-remark="openWarehouseRemark"')
expect(source).toContain('updateLoadingItemWarehouseRemark(current.summary.planId, target.itemId')
it('edits the selected item internal, warehouse and customer-service remarks with optimistic locking', () => {
expect(source).toContain('@edit-remarks="openRemarks"')
expect(source).toContain('updateLoadingItemRemarks(current.summary.planId, target.itemId')
expect(source).toContain('expectedPlanVersion: current.summary.version')
expect(source).toContain('expectedItemVersion: target.itemVersion')
expect(source).toContain('operationId: remarkOperationId.value')
expect(source).toContain('maxlength="1000"')
expect(source).toContain("warehouseRemark.value.trim() || null")
expect(source).toContain("remarkForm.internalRemark.trim() || null")
expect(source).toContain("remarkForm.warehouseRemark.trim() || null")
expect(source).toContain("remarkForm.customerServiceRemark.trim() || null")
expect(source).toContain('summary: { ...current.summary, version: result.version }')
expect(source).toContain('updatedItem?.version')
expect(source).not.toContain('plan.remark')
})
it('preserves warehouse remark input and refreshes current data on conflicts', () => {
it('preserves remark input and refreshes current data on conflicts', () => {
expect(source).toContain("details.code === 'A0409'")
expect(source).toContain('await loadDetail()')
expect(source).toContain("detail.value?.items.find((item) => item.itemId === target.itemId) || target")
@@ -12,7 +12,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemRemarks, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -34,7 +34,7 @@ const remarkOpen = ref(false)
const remarkSaving = ref(false)
const remarkError = ref<ApiErrorDetails | null>(null)
const remarkTarget = ref<LoadingItem | null>(null)
const warehouseRemark = ref('')
const remarkForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
const remarkOperationId = ref('')
let loadSequence = 0
const planId = computed(() => String(route.params.planId || ''))
@@ -163,28 +163,32 @@ async function saveTransport() {
}
}
function openWarehouseRemark(item: LoadingItem) {
function openRemarks(item: LoadingItem) {
if (!canEdit.value || !detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
remarkTarget.value = item
warehouseRemark.value = item.warehouseRemark || ''
remarkForm.internalRemark = item.internalRemark || ''
remarkForm.warehouseRemark = item.warehouseRemark || ''
remarkForm.customerServiceRemark = item.customerServiceRemark || ''
remarkError.value = null
remarkOperationId.value = `admin-preload-warehouse-remark-${detail.value.summary.planId}-${item.itemId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
remarkOperationId.value = `admin-preload-remarks-${detail.value.summary.planId}-${item.itemId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
remarkOpen.value = true
}
async function saveWarehouseRemark() {
async function saveRemarks() {
const target = remarkTarget.value
const current = detail.value
if (!target || !current || !canEdit.value || remarkSaving.value) return
if (warehouseRemark.value.length > 1000) {
remarkError.value = { code: 'WAREHOUSE_REMARK_TOO_LONG', message: '仓库备注不能超过 1000 个字符' }
if (Object.values(remarkForm).some((value) => value.length > 1000)) {
remarkError.value = { code: 'REMARK_TOO_LONG', message: '每项备注不能超过 1000 个字符' }
return
}
remarkSaving.value = true
remarkError.value = null
try {
const result = await updateLoadingItemWarehouseRemark(current.summary.planId, target.itemId, {
warehouseRemark: warehouseRemark.value.trim() || null,
const result = await updateLoadingItemRemarks(current.summary.planId, target.itemId, {
internalRemark: remarkForm.internalRemark.trim() || null,
warehouseRemark: remarkForm.warehouseRemark.trim() || null,
customerServiceRemark: remarkForm.customerServiceRemark.trim() || null,
expectedPlanVersion: current.summary.version,
expectedItemVersion: target.itemVersion,
operationId: remarkOperationId.value,
@@ -194,15 +198,18 @@ async function saveWarehouseRemark() {
...current,
summary: { ...current.summary, version: result.version },
items: current.items.map((item) => item.itemId === target.itemId
? { ...item, itemVersion: updatedItem?.version ?? item.itemVersion, warehouseRemark: updatedItem?.warehouseRemark ?? null }
? { ...item, itemVersion: updatedItem?.version ?? item.itemVersion,
internalRemark: updatedItem?.internalRemark ?? null,
warehouseRemark: updatedItem?.warehouseRemark ?? null,
customerServiceRemark: updatedItem?.customerServiceRemark ?? null }
: item),
}
remarkOpen.value = false
remarkTarget.value = null
remarkOperationId.value = ''
ElMessage.success('仓库备注已更新')
ElMessage.success('备注已更新')
} catch (cause) {
const details = getApiErrorDetails(cause, '仓库备注保存失败,请稍后重试')
const details = getApiErrorDetails(cause, '备注保存失败,请稍后重试')
if (details.code === 'A0409' || /版本|并发|状态.*变化/.test(details.message)) {
await loadDetail()
remarkTarget.value = detail.value?.items.find((item) => item.itemId === target.itemId) || target
@@ -247,7 +254,7 @@ watch([planId, warehouseId], () => void loadDetail())
<AdminPage>
<div class="preload-detail-page admin-density">
<OperationErrorToast :details="remarkError || editError || confirmError" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload" />
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
@@ -258,14 +265,15 @@ watch([planId, warehouseId], () => void loadDetail())
</el-form>
</AppDialog>
<AppDialog v-model="remarkOpen" class="warehouse-remark-dialog admin-density-dialog" title="编辑仓库备注" width="min(560px, calc(100vw - 28px))" confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveWarehouseRemark">
<el-alert type="info" :closable="false" show-icon title="仓库备注将随装柜完成资料下发海外仓" />
<el-form label-position="top" class="warehouse-remark-form">
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog" title="编辑备注" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
<el-alert type="info" :closable="false" show-icon title="客户备注来自客户下单且不可修改;其余备注可维护" />
<el-form label-position="top" class="remarks-form">
<el-form-item label="客户备注(只读)"><el-input :model-value="remarkTarget?.customerRemark || '—'" disabled /><small>来源:客户下单</small></el-form-item>
<el-form-item label="内部备注(只读)"><el-input :model-value="remarkTarget?.internalRemark || '—'" disabled /><small>来源:订单内部备注</small></el-form-item>
<el-form-item label="仓库备注"><el-input v-model="warehouseRemark" type="textarea" :rows="5" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
<el-form-item label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
<el-form-item label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
<el-form-item label="客服备注"><el-input v-model="remarkForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item>
</el-form>
<LoadErrorState v-if="remarkError" compact title="仓库备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
@@ -286,8 +294,8 @@ watch([planId, warehouseId], () => void loadDetail())
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: 12px; }
.warehouse-remark-form { margin-top: 14px; }
.warehouse-remark-form small { display: block; margin-top: 4px; color: var(--cc-muted); }
.remarks-form { margin-top: 14px; }
.remarks-form small { display: block; margin-top: 4px; color: var(--cc-muted); }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@media (min-width: 1920px) { .preload-detail-page { width: 100%; max-width: none; gap: 12px; } }
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
@@ -4,7 +4,7 @@ import {
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
getPlatformUnifiedOrderBatchApprovalPreview,
addPlatformUnifiedOrderNote,
updatePlatformUnifiedOrderRemarks,
getPlatformUnifiedOrder,
downloadPlatformProductImage,
listPlatformUnifiedOrders,
@@ -172,10 +172,11 @@ describe('unified orders API contract', () => {
)
})
it('writes platform internal notes through the unified order boundary', async () => {
vi.mocked(http.post).mockResolvedValue({ data: '91001' })
await expect(addPlatformUnifiedOrderNote('208', '90001', '已确认船期')).resolves.toBe('91001')
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } })
it('updates the three editable remarks while keeping customer remark outside the request', async () => {
const request = { expectedVersion: 7, internalRemark: '内部', warehouseRemark: '仓库', customerServiceRemark: '客服' }
vi.mocked(http.put).mockResolvedValue({ data: { ...request, version: 8, customerRemark: '客户只读' } })
await expect(updatePlatformUnifiedOrderRemarks('208', '90001', request)).resolves.toMatchObject({ version: 8 })
expect(http.put).toHaveBeenCalledWith('/platform/v1/orders/90001/remarks', request, { params: { tenantId: '208' } })
})
it('updates customer declaration fields through the versioned tenant-scoped contract', async () => {
@@ -160,6 +160,8 @@ export interface UnifiedOrderResource {
customerRemark?: string | null
latestInternalNote?: string | null
internalRemark?: string | null
warehouseRemark?: string | null
customerServiceRemark?: string | null
createdBy?: string | null
}
@@ -599,10 +601,25 @@ export function batchApprovePlatformUnifiedOrders(tenantId: string, items: Unifi
).then(unwrapApiResponse)
}
export function addPlatformUnifiedOrderNote(tenantId: string, orderId: string, content: string) {
return http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(
`${unifiedOrdersApiPath}/${orderId}/internal-notes`,
{ content },
export interface UnifiedOrderRemarksUpdateRequest {
expectedVersion: number
internalRemark: string | null
warehouseRemark: string | null
customerServiceRemark: string | null
}
export interface UnifiedOrderRemarksUpdateResponse {
version: number
customerRemark: string | null
internalRemark: string | null
warehouseRemark: string | null
customerServiceRemark: string | null
}
export function updatePlatformUnifiedOrderRemarks(tenantId: string, orderId: string, request: UnifiedOrderRemarksUpdateRequest) {
return http.put<ApiEnvelope<UnifiedOrderRemarksUpdateResponse>, ApiEnvelope<UnifiedOrderRemarksUpdateResponse>>(
`${unifiedOrdersApiPath}/${orderId}/remarks`,
request,
{ params: { tenantId } },
).then(unwrapApiResponse)
}
@@ -13,7 +13,7 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mock
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [{ value: 'WAIT_SPLIT', label: '待分柜' }],
}))
@@ -26,7 +26,7 @@ vi.mock('@/shared/api/inboundCodeRelations', () => ({
getLoadingAssociationSummary: vi.fn(), getOverseasInboundCodeRelationship: vi.fn(),
}))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
downloadPlatformProductImage: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [],
@@ -24,7 +24,7 @@ vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
getPlatformCoLoadGroupByInboundCode: vi.fn(),
}))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(),
updatePlatformUnifiedOrderRemarks: vi.fn(),
approvePlatformUnifiedOrder: vi.fn(),
getPlatformUnifiedOrder: vi.fn(),
listPlatformUnifiedOrderFeeDetails: vi.fn(),
@@ -375,6 +375,16 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("value.replaceAll('_', ' ')")
})
it('keeps customer remarks read-only and edits the three collaboration remarks together', () => {
expect(source).toContain('title="编辑订单备注"')
expect(source).toContain('label="客户备注(只读)"')
expect(source).toContain(':model-value="noteOrder?.customerRemark')
expect(source).toContain('v-model="noteForm.internalRemark"')
expect(source).toContain('v-model="noteForm.warehouseRemark"')
expect(source).toContain('v-model="noteForm.customerServiceRemark"')
expect(source).toContain('await updatePlatformUnifiedOrderRemarks')
})
it('keeps the customer order package count independent from measured inbound totals', () => {
expect(source).toContain("{ source: '客户下单数据', count: customerTotals.count")
expect(source).not.toContain("{ source: '客户下单数据', count: summary?.totalPackageCount")
@@ -9,7 +9,7 @@ import {
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
getPlatformUnifiedOrderBatchApprovalPreview,
addPlatformUnifiedOrderNote,
updatePlatformUnifiedOrderRemarks,
listPlatformUnifiedOrders,
updatePlatformUnifiedOrderPlanning,
unifiedOrderStatusOptions,
@@ -179,7 +179,7 @@ const coLoadError = ref<ApiErrorDetails | null>(null)
const approvalOrder = ref<UnifiedOrderResource | null>(null)
const noteOpen = ref(false)
const noteSaving = ref(false)
const noteContent = ref('')
const noteForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
const noteOrder = ref<UnifiedOrderResource | null>(null)
const holdDetail = ref<InboundHoldDetail | null>(null)
const holdLoading = ref(false)
@@ -1360,27 +1360,30 @@ function openNoteDialog(order?: UnifiedOrderResource) {
const target = order || detail.value?.summary || null
if (!target) return
noteOrder.value = target
noteContent.value = ''
noteForm.internalRemark = target.latestInternalNote || target.internalRemark || ''
noteForm.warehouseRemark = target.warehouseRemark || ''
noteForm.customerServiceRemark = target.customerServiceRemark || ''
noteOpen.value = true
}
async function saveNote() {
const content = noteContent.value.trim()
if (!noteOrder.value || !content) {
ElMessage.warning('请填写内部备注')
return
}
if (!noteOrder.value) return
noteSaving.value = true
try {
const target = noteOrder.value
await addPlatformUnifiedOrderNote(String(target.tenantId), String(target.orderId), content)
await updatePlatformUnifiedOrderRemarks(String(target.tenantId), String(target.orderId), {
expectedVersion: Number(target.orderVersion || 0),
internalRemark: noteForm.internalRemark.trim() || null,
warehouseRemark: noteForm.warehouseRemark.trim() || null,
customerServiceRemark: noteForm.customerServiceRemark.trim() || null,
})
noteOpen.value = false
await loadOrders()
if (detail.value?.summary.orderId === target.orderId) await loadDetail(true)
noteOrder.value = null
ElMessage.success('内部备注已保存')
ElMessage.success('订单备注已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败,请稍后重试').message)
ElMessage.error(getApiErrorDetails(error, '订单备注保存失败,请稍后重试').message)
} finally {
noteSaving.value = false
}
@@ -2725,8 +2728,13 @@ onBeforeUnmount(() => {
/>
</AppDrawer>
<AppDialog class="admin-density-dialog" v-model="noteOpen" title="添加内部备注" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
<AppDialog class="admin-density-dialog" v-model="noteOpen" title="编辑订单备注" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
<el-form label-position="top" class="order-remarks-form">
<el-form-item label="客户备注(只读)"><el-input :model-value="noteOrder?.customerRemark || '—'" disabled /></el-form-item>
<el-form-item label="内部备注"><el-input v-model="noteForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
<el-form-item label="仓库备注"><el-input v-model="noteForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
<el-form-item label="客服备注"><el-input v-model="noteForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">