This commit is contained in:
2026-08-30 16:40:03 +08:00
parent ea1d06448b
commit 9d3862b215
11 changed files with 130 additions and 28 deletions
@@ -12,7 +12,7 @@
明确不接入:
- 采购审核 `review`:后端存在,但当前页面原型与本专项范围排除。
- 采购出库:后端已移除 `/outbounds`,四期不承载采购出库或库存离仓。
- 采购出库不提供独立 `/outbounds` 端点;采购编辑通过出库明细维护出库量并派生待出库、部分出库、全部出库状态,不直接操作海外仓 PDA 可用库存。
- 卡车状态推进、收托盘和散板状态推进:后端存在,但当前原型没有对应操作承载方式。
- 海外出库、海外派送和 Merchant API。
@@ -15,7 +15,7 @@ const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const purchaseSummary = {
id: '9007199254740993', orderNo: 'OP-1', customerId: '10001', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995',
purchaseWarehouseName: '采购仓', inboundQuantity: 5, inventoryQuantity: 5, status: 'APPROVED', version: 4,
purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 0, inventoryQuantity: 5, status: 'PENDING_OUTBOUND', version: 4,
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
}
const truckSummary = {
@@ -22,6 +22,7 @@ describe('overseas business real contract pages', () => {
expect(detail).not.toContain('outbounds')
expect(edit).toContain('estimatedArrivalAt: form.estimatedArrivalAt || null')
expect(edit).toContain('purchaseWarehouseId: form.purchaseWarehouseId || null')
expect(edit).not.toContain('入库量不能小于 0')
expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/)
})
@@ -33,7 +33,7 @@ function memoryStorage(): Storage { const values = new Map<string, string>(); re
const purchase: OverseasPurchaseDetail = {
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, inventoryQuantity: 10, status: 'APPROVED', version: 3,
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, outboundQuantity: 0, inventoryQuantity: 10, status: 'PENDING_OUTBOUND', version: 3,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', domesticCarrier: null, domesticTrackingNo: null,
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }],
@@ -58,7 +58,7 @@ describe('overseas business mounted pages', () => {
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '可选客户', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('已通过'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待出库'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
it('mounts procurement detail operation history', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑') })
@@ -15,11 +15,12 @@ const route = useRoute(); const router = useRouter()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
const outboundDetails = computed(() => detail.value?.outboundDetails || [])
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
@@ -52,7 +53,8 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
<section><h3>库存信息</h3><dl><div v-for="field in stockFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>订单信息</h3><dl><div v-for="field in orderFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>快递信息</h3><dl><div v-for="field in courierFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核和编辑变更;四期不承载采购出库。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
<section class="full"><div class="section-heading"><h3>出库明细</h3><span>共 {{ outboundDetails.length }} 条,出库量 {{ outboundDetails.reduce((sum, item) => sum + item.quantity, 0) }}</span></div><DataTable :data="outboundDetails" :columns="[{ key: 'quantity', label: '出库数量', width: 110 }, { key: 'carrier', label: '承运商', minWidth: 140 }, { key: 'trackingNo', label: '运单号', minWidth: 160 }, { key: 'shippedAt', label: '出库时间', minWidth: 155, formatter: (row: any) => formatDateTime(row.shippedAt) }, { key: 'remark', label: '备注', minWidth: 180 }]" row-key="id" empty-text="暂无出库明细" /></section>
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
</div>
</div>
</DetailPageLayout></AdminPage>
@@ -33,10 +33,9 @@ const form = reactive<PurchaseEditModel>(initial())
const rules: FormRules<PurchaseEditModel> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
inboundQuantity: [{ required: true, type: 'number', min: 0, message: '入库量不能小于 0', trigger: 'change' }],
}
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
const inboundEditable = computed(() => form.status === 'APPROVED')
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
const inboundEditable = computed(() => ['APPROVED', 'PENDING_OUTBOUND', 'PARTIALLY_OUTBOUND', 'OUTBOUNDED'].includes(form.status))
const outboundQuantity = computed(() => form.outboundDetails.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0))
function addOutbound() { form.outboundDetails.push({ id: `new-${Date.now()}-${form.outboundDetails.length}`, quantity: 0, carrier: null, trackingNo: null, shippedAt: null, remark: null }) }
function removeOutbound(index: number) { form.outboundDetails.splice(index, 1) }
@@ -53,7 +52,7 @@ async function load() {
loading.value = true
try {
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version, outboundDetails: detail.outboundDetails.map((item) => ({ ...item })) })
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version, outboundDetails: (detail.outboundDetails || []).map((item) => ({ ...item })) })
if (detail.purchaseWarehouseId && !warehouses.value.some((item) => item.warehouseId === detail.purchaseWarehouseId)) warehouses.value.push({ warehouseId: detail.purchaseWarehouseId, warehouseCode: '', warehouseName: detail.purchaseWarehouseName || '当前采购仓', countryCode: '' })
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
@@ -78,7 +77,7 @@ watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { im
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" /></el-form-item></div></FormSection>
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input :model-value="outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
<FormSection title="出库明细"><div class="outbound-head"><span>出库总量:<b>{{ outboundQuantity }}</b> / {{ form.inboundQuantity }}</span><el-button type="primary" plain size="small" @click="addOutbound"><Plus :size="14" />添加出库明细</el-button></div><div v-if="!form.outboundDetails.length" class="empty-outbound">暂无出库明细,可添加多条出库记录</div><div v-for="(item, index) in form.outboundDetails" :key="item.id" class="outbound-row"><el-form-item label="出库数量"><el-input-number v-model="item.quantity" :min="0" :max="form.inboundQuantity" controls-position="right" /></el-form-item><el-form-item label="承运商"><el-input v-model="item.carrier" maxlength="128" placeholder="快递/物流名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="item.trackingNo" maxlength="128" /></el-form-item><el-form-item label="出库时间"><el-date-picker v-model="item.shippedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item class="outbound-remark" label="备注"><el-input v-model="item.remark" maxlength="500" /></el-form-item><el-button class="remove-outbound" type="danger" link @click="removeOutbound(index)" :aria-label="`删除第 ${index + 1} 条出库明细`"><Trash2 :size="16" /></el-button></div></FormSection>
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
</DetailPageLayout></AdminPage>
@@ -35,11 +35,12 @@ const columns: DataTableColumn[] = [
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
]
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
function purchaseStatusLabel(status: OverseasPurchaseStatus) { return statusLabels[status] || status }
function formatAmount(value: string | null, currency: string) { if (value == null) return '—'; const amount = Number(value); return `${Number.isFinite(amount) ? amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value} ${currency}` }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
+3 -3
View File
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED'
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'PENDING_OUTBOUND' | 'PARTIALLY_OUTBOUND' | 'OUTBOUNDED'
export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
@@ -16,7 +16,7 @@ export interface OverseasPurchaseSummary {
purchaseCategory: string; productName: string; productSpecification: string | null
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
inboundQuantity: number; inventoryQuantity: number
inboundQuantity: number; outboundQuantity: number; inventoryQuantity: number
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
}
export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
@@ -24,7 +24,7 @@ export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
estimatedArrivalAt: string | null; remark: string | null; createdBy: string | null; reviewedBy: string | null
reviewRemark: string | null; reviewedAt: string | null; histories: OverseasBusinessHistory[]
outboundDetails: OverseasPurchaseOutboundDetail[]
outboundDetails?: OverseasPurchaseOutboundDetail[]
}
export interface OverseasPurchaseOutboundDetail { id: string; quantity: number; carrier: string | null; trackingNo: string | null; shippedAt: string | null; remark: string | null }
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
@@ -17,6 +17,6 @@ describe('delivery order API', () => {
it('loads an encoded detail in its explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1' }, skus: [], fees: [], operations: [] } })
await getDeliveryOrder('DEL/1', '1001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001' } })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
})
})
@@ -68,7 +68,7 @@ export async function listDeliveryOrders(query: DeliveryOrderQuery) {
export async function getDeliveryOrder(deliveryNo: string, customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<DeliveryOrderDetail>, ApiEnvelope<DeliveryOrderDetail>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, { params: { customerId } },
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, { params: { customerId, page: 1, size: 200 } },
)
return unwrapApiResponse(response)
}
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -10,16 +11,114 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
const route = useRoute(); const router = useRouter(); const detail = ref<DeliveryOrderDetail | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const activeTab = ref('order')
const deliveryNo = computed(() => String(route.params.deliveryNo || '')); const customerId = computed(() => String(route.query.customerId || ''))
const route = useRoute()
const router = useRouter()
const detail = ref<DeliveryOrderDetail | null>(null)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const activeTab = ref('order')
const deliveryNo = computed(() => String(route.params.deliveryNo || '').trim())
const customerId = computed(() => String(route.query.customerId || '').trim())
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' }
const methodLabels: Record<string, string> = { SELF_DELIVERY: '自送', EXPRESS: '快递', THIRD_PARTY: '第三方配送' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收' }
const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK: '卡车派送', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' }
const pickupLabels: Record<string, string> = { WAREHOUSE_PICKUP: '仓库提货', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '打托装车', FLOOR: '地板装车', CARTON: '散箱装车' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function quantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` }
function customerConfirmation() { const signedAt = detail.value?.order.signedAt; return signedAt ? `已签收 · ${formatOverseasDateTime(signedAt)}` : '—' }
function back() { void router.push({ name: 'overseas-delivery-orders' }) }
async function load() { if (!deliveryNo.value || !customerId.value) { error.value = { message: '缺少派送单客户范围,无法安全查询详情' }; return }; loading.value = true; error.value = null; try { detail.value = await getDeliveryOrder(deliveryNo.value, customerId.value) } catch (cause) { error.value = getApiErrorDetails(cause, '派送单详情加载失败') } finally { loading.value = false } }
load()
async function load() {
if (!deliveryNo.value || !customerId.value) { detail.value = null; error.value = { message: '缺少派送单客户范围,无法安全查询详情' }; return }
loading.value = true; error.value = null
try { detail.value = await getDeliveryOrder(deliveryNo.value, customerId.value) }
catch (cause) { detail.value = null; error.value = getApiErrorDetails(cause, '派送单详情加载失败') }
finally { loading.value = false }
}
watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</script>
<template><AdminPage><DetailPageLayout aria-label="派送单详情"><template #header><div class="detail-heading"><el-button @click="back"><ArrowLeft :size="16" />返回</el-button><div><h1>派送单详情 <StatusBadge v-if="detail" :status="statusLabels[detail.order.status] || detail.order.status" /></h1><p>派送单号 {{ deliveryNo }}</p></div></div></template><LoadErrorState v-if="error" title="派送单详情加载失败" :message="error.message" :loading="loading" @retry="load" /><section v-else v-loading="loading" class="surface-panel delivery-detail"><el-tabs v-model="activeTab"><el-tab-pane label="订单信息" name="order"><h3>订单信息</h3><el-descriptions v-if="detail" :column="2" border><el-descriptions-item label="派送单号">{{ show(detail.order.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[detail.order.status] || detail.order.status }}</el-descriptions-item><el-descriptions-item label="仓库">{{ show(detail.order.overseasWarehouseName || detail.order.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.order.waybillNo) }}</el-descriptions-item><el-descriptions-item label="派送人">{{ show(detail.order.carrierName) }}</el-descriptions-item><el-descriptions-item label="派送时间">{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="提货方式">{{ show(detail.order.pickupMethod) }}</el-descriptions-item><el-descriptions-item label="装车类型">{{ show(detail.order.loadingType) }}</el-descriptions-item><el-descriptions-item label="派送方式">{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</el-descriptions-item><el-descriptions-item label="司机/车队">{{ show(detail.order.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.order.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="总箱数">{{ detail.order.packageCount }}</el-descriptions-item><el-descriptions-item label="总重量(kg)">{{ show(detail.order.totalWeightKg) }}</el-descriptions-item><el-descriptions-item label="总体积(m³)">{{ show(detail.order.totalVolumeCbm) }}</el-descriptions-item><el-descriptions-item label="预计到达时间">{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.order.remark) }}</el-descriptions-item></el-descriptions></el-tab-pane><el-tab-pane label="SKU明细" name="sku"><h3>SKU明细</h3><el-table :data="detail?.skus || []" border stripe><el-table-column prop="inboundCode" label="入库码" min-width="150" /><el-table-column prop="customerName" label="客户" min-width="160" /><el-table-column prop="destinationWarehouseCode" label="目的仓编码" min-width="130" /><el-table-column prop="receivingAddress" label="收货地址" min-width="220" /><el-table-column prop="shipmentNo" label="货件编号" min-width="150" /><el-table-column prop="fbaTrackingNo" label="FBA追踪号" min-width="150" /><el-table-column prop="skuCode" label="SKU" min-width="130" /><el-table-column prop="packageCount" label="箱数" width="90" /><el-table-column prop="weightKg" label="重量" width="100" /><el-table-column prop="volumeCbm" label="体积" width="100" /></el-table></el-tab-pane><el-tab-pane label="费用明细" name="fees"><h3>派送单费用明细</h3><el-table :data="detail?.fees || []" border stripe><el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : '应收' }}</template></el-table-column><el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" /><el-table-column prop="amount" label="金额" width="140" /><el-table-column prop="remark" label="备注" min-width="220" /></el-table></el-tab-pane><el-tab-pane label="操作记录" name="operations"><h3>操作记录</h3><el-table :data="detail?.operations || []" border stripe><el-table-column label="操作时间" min-width="170"><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column><el-table-column label="操作节点" min-width="160"><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column><el-table-column prop="operatorName" label="操作人" min-width="140" /><el-table-column prop="result" label="处理结果" min-width="220" /></el-table></el-tab-pane></el-tabs></section></DetailPageLayout></AdminPage></template>
<style scoped>.detail-heading{display:flex;align-items:center;gap:14px}.detail-heading h1{display:flex;align-items:center;gap:10px;margin:0}.detail-heading p{margin:4px 0 0;color:var(--cc-muted)}.delivery-detail{padding:0 16px 20px}.delivery-detail h3{margin:12px 0}</style>
<template>
<AdminPage>
<DetailPageLayout aria-label="派送单详情">
<template #header>
<el-breadcrumb separator=">" class="detail-breadcrumb">
<el-breadcrumb-item>首页</el-breadcrumb-item><el-breadcrumb-item>海外派送</el-breadcrumb-item>
<el-breadcrumb-item :to="{ name: 'overseas-delivery-orders' }">派送单列表</el-breadcrumb-item><el-breadcrumb-item>详情</el-breadcrumb-item>
</el-breadcrumb>
<div class="detail-heading">
<el-button aria-label="返回派送单列表" @click="back"><ArrowLeft :size="16" />返回</el-button>
<div class="detail-title"><div class="title-line"><h1>派送单详情</h1><StatusBadge v-if="detail" :status="statusLabels[detail.order.status] || detail.order.status" /></div><p>派送单号 {{ deliveryNo }}</p></div>
</div>
</template>
<LoadErrorState v-if="error" title="派送单详情加载失败" :message="error.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<section v-else v-loading="loading" class="surface-panel delivery-detail">
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="订单信息" name="order">
<section class="tab-content" aria-labelledby="delivery-order-heading">
<h2 id="delivery-order-heading">订单信息</h2>
<dl v-if="detail" class="info-grid">
<div class="info-item"><dt>派送单号</dt><dd>{{ show(detail.order.deliveryNo) }}</dd></div><div class="info-item"><dt>状态</dt><dd>{{ statusLabels[detail.order.status] || detail.order.status }}</dd></div>
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName || detail.order.overseasWarehouseId) }}</dd></div><div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div>
<div class="info-item"><dt>派送人</dt><dd>{{ show(detail.order.carrierName) }}</dd></div><div class="info-item"><dt>派送时间</dt><dd>{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</dd></div>
<div class="info-item"><dt>提货方式</dt><dd>{{ pickupLabels[detail.order.pickupMethod || ''] || show(detail.order.pickupMethod) }}</dd></div><div class="info-item"><dt>装车类型</dt><dd>{{ loadingLabels[detail.order.loadingType || ''] || show(detail.order.loadingType) }}</dd></div>
<div class="info-item"><dt>派送方式</dt><dd>{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</dd></div><div class="info-item"><dt>司机/车队</dt><dd>{{ show(detail.order.driverName) }}</dd></div>
<div class="info-item"><dt>车牌号</dt><dd>{{ show(detail.order.vehiclePlate) }}</dd></div><div class="info-item"><dt>总箱数</dt><dd>{{ quantity(detail.order.packageCount, '箱') }}</dd></div>
<div class="info-item"><dt>总重量</dt><dd>{{ quantity(detail.order.totalWeightKg, 'kg') }}</dd></div><div class="info-item"><dt>总体积</dt><dd>{{ quantity(detail.order.totalVolumeCbm, 'm³') }}</dd></div>
<div class="info-item"><dt>预计到达时间</dt><dd>{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</dd></div><div class="info-item"><dt>客户确认</dt><dd>{{ customerConfirmation() }}</dd></div>
<div class="info-item info-item--wide"><dt>备注</dt><dd>{{ show(detail.order.remark) }}</dd></div>
</dl>
</section>
</el-tab-pane>
<el-tab-pane :label="`SKU明细 (${detail?.skus.length || 0})`" name="sku">
<section class="tab-content" aria-labelledby="delivery-sku-heading"><h2 id="delivery-sku-heading">SKU明细</h2>
<el-table :data="detail?.skus || []" border stripe empty-text="暂无SKU明细">
<el-table-column prop="inboundCode" label="入库码" min-width="160" /><el-table-column prop="customerName" label="客户" min-width="180" />
<el-table-column prop="destinationWarehouseCode" label="目的仓编码" min-width="140" /><el-table-column prop="receivingAddress" label="收货地址" min-width="240" />
<el-table-column prop="shipmentNo" label="货件编号" min-width="160" /><el-table-column prop="fbaTrackingNo" label="FBA追踪号" min-width="160" />
<el-table-column prop="skuCode" label="SKU" min-width="140" /><el-table-column prop="productName" label="品名" min-width="160" />
<el-table-column prop="packageCount" label="箱数" width="90" align="right" /><el-table-column prop="weightKg" label="重量(kg)" width="110" align="right" /><el-table-column prop="volumeCbm" label="体积(m³)" width="110" align="right" />
</el-table>
</section>
</el-tab-pane>
<el-tab-pane :label="`费用明细 (${detail?.fees.length || 0})`" name="fees">
<section class="tab-content" aria-labelledby="delivery-fee-heading"><h2 id="delivery-fee-heading">派送单费用明细</h2>
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column prop="remark" label="备注" min-width="240" />
</el-table>
</section>
</el-tab-pane>
<el-tab-pane :label="`操作记录 (${detail?.operations.length || 0})`" name="operations">
<section class="tab-content" aria-labelledby="delivery-operation-heading"><h2 id="delivery-operation-heading">操作记录</h2>
<el-table :data="detail?.operations || []" border stripe empty-text="暂无操作记录">
<el-table-column label="操作时间" min-width="180"><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column>
<el-table-column label="操作节点" min-width="180"><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="160" /><el-table-column label="处理结果" min-width="280"><template #default="scope">{{ resultLabels[scope.row.result] || scope.row.result }}</template></el-table-column>
</el-table>
</section>
</el-tab-pane>
</el-tabs>
</section>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.detail-breadcrumb { margin-bottom: 12px; }.detail-heading { display: flex; align-items: center; gap: 14px; }.detail-title { min-width: 0; }.title-line { display: flex; align-items: center; gap: 10px; }.title-line h1 { margin: 0; font-size: 20px; line-height: 28px; }.detail-title p { margin: 2px 0 0; color: var(--cc-muted); font-size: 13px; }
.delivery-detail { min-height: 360px; overflow: hidden; }.detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 18px; }.detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }.detail-tabs :deep(.el-tabs__item) { height: 48px; padding: 0 22px; }.tab-content { width: 100%; padding: 18px 20px 24px; box-sizing: border-box; }.tab-content h2 { margin: 0 0 14px; font-size: 15px; line-height: 22px; }
.info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--el-border-color-lighter); }.info-item { display: grid; grid-template-columns: 116px minmax(0, 1fr); min-height: 44px; align-items: center; border-bottom: 1px solid var(--el-border-color-lighter); }.info-item:nth-child(odd) { padding-right: 24px; }.info-item:nth-child(even) { padding-left: 24px; }.info-item--wide { grid-column: 1 / -1; padding-left: 0 !important; }.info-item dt { color: var(--cc-muted); font-size: 13px; }.info-item dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-weight: 500; }
@media (max-width: 980px) { .info-grid { grid-template-columns: 1fr; }.info-item, .info-item:nth-child(odd), .info-item:nth-child(even) { padding: 0; }.info-item--wide { grid-column: auto; } }
</style>