Compare commits

..
3 Commits
Author SHA1 Message Date
liuyihe 3525e434a4 优化 2026-08-28 14:44:53 +08:00
liuyihe 632fc0bdfa 解决海外仓前端提交冲突 2026-08-28 14:41:40 +08:00
liuyihe ae48f46c9f 优化 2026-08-28 14:33:57 +08:00
27 changed files with 308 additions and 35 deletions
+2
View File
@@ -1,6 +1,7 @@
import type { Component } from 'vue'
import {
Building2,
ClipboardCheck,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -128,6 +129,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'overseas-acceptance', title: '尾程流程验收', icon: ClipboardCheck, path: '/prototype/overseas-delivery/acceptance-workbench' },
],
},
{
@@ -5,7 +5,7 @@ import type {
LocalLooseboardQuery, LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery,
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail,
OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest,
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest,
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest, OverseasPurchaseOutboundDetail,
} from '../types'
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
@@ -19,7 +19,7 @@ interface ProcurementDetailPayload {
contactName?: string | null; contactPhone?: string | null; 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[]
histories?: OverseasBusinessHistory[]; outboundDetails?: OverseasPurchaseOutboundDetail[]
}
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
@@ -40,7 +40,7 @@ function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseS
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
}
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [] }
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [], outboundDetails: (value.outboundDetails || []).map((item) => ({ ...item, id: String(item.id), quantity: Number(item.quantity) })) }
}
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
@@ -58,20 +58,20 @@ 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('已通过') })
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('编辑') })
it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() })
it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') })
it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') })
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import { ArrowLeft, Save, Plus, Trash2 } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -13,13 +13,14 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getOverseasPurchase, listOverseasPurchaseWarehouseOptions, updateOverseasPurchase } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseStatus, OverseasPurchaseWarehouseOption, UpdateOverseasPurchaseRequest } from '../types'
import type { OverseasPurchaseOutboundDetail, OverseasPurchaseStatus, OverseasPurchaseWarehouseOption, UpdateOverseasPurchaseRequest } from '../types'
interface PurchaseEditModel {
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
inboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
estimatedArrivalAt: string | null; remark: string | null; version: number
outboundDetails: OverseasPurchaseOutboundDetail[]
}
const route = useRoute(); const router = useRouter()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
@@ -27,7 +28,7 @@ const customerId = computed(() => typeof route.query.customerId === 'string' ? r
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehousesLoading = ref(false); const warehousesError = ref('')
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0 })
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0, outboundDetails: [] })
const form = reactive<PurchaseEditModel>(initial())
const rules: FormRules<PurchaseEditModel> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
@@ -36,6 +37,9 @@ const rules: FormRules<PurchaseEditModel> = {
}
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
const inboundEditable = computed(() => form.status === 'APPROVED')
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) }
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
async function loadWarehouses() {
warehousesLoading.value = true; warehousesError.value = ''
@@ -49,15 +53,16 @@ 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 })
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 }
}
async function save() {
if (!await formRef.value?.validate().catch(() => false)) return
if (outboundQuantity.value > form.inboundQuantity) { ElMessage.error('出库总量不能超过入库量'); return }
saving.value = true; submitError.value = null
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: form.productSpecification?.trim() || null, productUrl: form.productUrl?.trim() || null, inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: form.purchaseWarehouseId || null, domesticCarrier: form.domesticCarrier?.trim() || null, domesticTrackingNo: form.domesticTrackingNo?.trim() || null, overseasCarrier: form.overseasCarrier?.trim() || null, overseasTrackingNo: form.overseasTrackingNo?.trim() || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark?.trim() || null }
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: form.productSpecification?.trim() || null, productUrl: form.productUrl?.trim() || null, inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: form.purchaseWarehouseId || null, domesticCarrier: form.domesticCarrier?.trim() || null, domesticTrackingNo: form.domesticTrackingNo?.trim() || null, overseasCarrier: form.overseasCarrier?.trim() || null, overseasTrackingNo: form.overseasTrackingNo?.trim() || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark?.trim() || null, outboundDetails: form.outboundDetails.map((item) => ({ ...item, quantity: Number(item.quantity) || 0, carrier: item.carrier?.trim() || null, trackingNo: item.trackingNo?.trim() || null, remark: item.remark?.trim() || null })) }
try {
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
@@ -74,10 +79,12 @@ watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { im
<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="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>
</template>
<style scoped>
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-grid .field-error { color:var(--el-color-danger); }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
.outbound-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; color:var(--cc-muted); font-size:13px; }.outbound-head b { color:var(--cc-text); }.empty-outbound { padding:18px; color:var(--cc-muted); text-align:center; background:var(--cc-surface-muted); }.outbound-row { display:grid; grid-template-columns:110px 1fr 1fr 190px 1fr 32px; gap:12px; align-items:start; padding:12px; margin-bottom:10px; border:1px solid var(--cc-border); border-radius:6px; }.outbound-row :deep(.el-form-item) { margin-bottom:0; }.outbound-row :deep(.el-input-number), .outbound-row :deep(.el-date-editor) { width:100%; }.remove-outbound { margin-top:30px; }
</style>
@@ -37,10 +37,12 @@ const columns: DataTableColumn[] = [
{ key: 'inboundQuantity', prop: 'inboundQuantity', 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: '已拒绝' }
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) : '—' }
async function load() {
loading.value = true; loadError.value = null
try {
@@ -40,6 +40,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
@@ -49,8 +50,10 @@ const looseboardColumns: DataTableColumn[] = [
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'budgetAmount', label: '预算总价', width: 115, align: 'right', formatter: (row: LocalLooseboardOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
]
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function load() {
rows.value = []; total.value = 0; loadError.value = null; loading.value = true
try {
+3
View File
@@ -24,7 +24,9 @@ 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[]
}
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 }
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
export interface UpdateOverseasPurchaseRequest {
@@ -32,6 +34,7 @@ export interface UpdateOverseasPurchaseRequest {
productUrl?: string | null; inboundQuantity?: number; purchaseWarehouseId?: string | null
domesticCarrier?: string | null; domesticTrackingNo?: string | null; overseasCarrier?: string | null
overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
outboundDetails?: OverseasPurchaseOutboundDetail[]
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
import { convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeRequired, transferLastMileOrders } from './lastMileOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
@@ -47,6 +47,22 @@ describe('last-mile order overseas API', () => {
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] }))
})
it('uses the order hold endpoint with the required operation remark', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: null })
await setLastMileOrderDetained('190000000000000099', { detained: true, remark: '等待质检确认' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099/detained', {
detained: true, remark: '等待质检确认',
})
})
it('marks an order for a PDA pallet-merge reminder', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: null })
await setLastMileOrderPalletMergeRequired('190000000000000099', { palletMergeRequired: true })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099/pallet-merge-required', {
palletMergeRequired: true,
})
})
it('uses the inbound-plan conversion endpoint without customerId', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099' })
@@ -152,6 +152,15 @@ export interface ConvertInboundPlanPayload {
orderId: string
}
export interface SetLastMileOrderDetainedPayload {
detained: boolean
remark: string
}
export interface SetLastMileOrderPalletMergePayload {
palletMergeRequired: boolean
}
export interface LastMileOrderDetailDto {
order: LastMileOrderDto
cargo: LastMileCargoDto[]
@@ -195,3 +204,17 @@ export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundP
)
return unwrapApiResponse(response)
}
export async function setLastMileOrderDetained(orderId: string, payload: SetLastMileOrderDetainedPayload) {
const response = await overseasHttp.put<ApiEnvelope<void>, ApiEnvelope<void>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/detained`, payload,
)
return unwrapApiResponse(response)
}
export async function setLastMileOrderPalletMergeRequired(orderId: string, payload: SetLastMileOrderPalletMergePayload) {
const response = await overseasHttp.put<ApiEnvelope<void>, ApiEnvelope<void>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/pallet-merge-required`, payload,
)
return unwrapApiResponse(response)
}
@@ -56,9 +56,9 @@ export async function listReceivingOverseasWarehouses() {
return { records, page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0 }
}
export async function listPallets(warehouseId: string, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
export async function listPallets(warehouseId: string | undefined, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<PalletDto>>, ApiEnvelope<OverseasPagePayload<PalletDto>>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`,
warehouseId ? `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets` : '/platform/v1/warehouse-admin/pallets',
{ params: { ...query } },
)
return normalizeOverseasPage(unwrapApiResponse(response))
@@ -6,6 +6,7 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface RelabelOrderQuery {
page: number
size: number
relabelNo?: string
skuCode?: string
status?: string
}
@@ -9,6 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number }
@@ -77,7 +78,7 @@ const containerWeightLb = (container: LoadingContainer) => container.palletIds.r
function palletId(item: DispatchPalletCandidateDto) { return String(item.planPalletId) }
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function number(value: unknown, digits = 0) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized.toFixed(digits) : '0' }
function dateTime(value: unknown) { const raw = String(value || '').trim(); return raw ? raw.replace('T', ' ').slice(0, 16) : '—' }
function dateTime(value: unknown) { return formatOverseasDateTime(value) }
function orderType(value: unknown) { return ({ FIRST_LEG: '头程订单', RELABEL_APPLICATION: '换标下单', RETURN_RELABEL: '退货换标', LOCAL_LOOSEBOARD: '本土散板', WAREHOUSE_TRANSFER: '仓储转运', ONE_CLICK_DROPSHIP: '一件代发', REJECTED: '拒收订单', EXPRESS: '快递订单' } as Record<string, string>)[String(value || '')] || text(value) }
function cartonMarks(item: DispatchPalletCandidateDto) { return String(item.cartonMarks || '').split(',').map((value) => value.trim()).filter(Boolean) }
function containerFor(id: string) { return containers.value.find((item) => item.palletIds.includes(id)) }
@@ -36,6 +36,7 @@ describe('overseas inventory presentation adapter', () => {
available: 18,
held: 2,
status: '存在暂扣',
updatedAt: '2026-08-18 17:54:11',
})
})
@@ -1,5 +1,6 @@
import type { InventoryBalanceDto } from './api/inventory'
import type { InventoryListItem } from './types'
import { formatOverseasDateTime } from './lastMilePresentation'
function text(value: unknown) {
const normalized = String(value ?? '').trim()
@@ -40,6 +41,6 @@ export function toInventoryListItem(dto: InventoryBalanceDto): InventoryListItem
inTransit: count(dto.inTransitPackages),
purchase: count(dto.procurementPackages),
status: inventoryStatusLabel(dto),
updatedAt: text(dto.updatedAt),
updatedAt: formatOverseasDateTime(dto.updatedAt),
}
}
@@ -1,15 +1,24 @@
import { describe, expect, it } from 'vitest'
import { toLastMileOrderListItem } from './lastMilePresentation'
import { formatOverseasDateTime, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
it('formats overseas timestamps with zero-padded seconds', () => {
expect(formatOverseasDateTime('2026-08-27T16:19:46')).toBe('2026-08-27 16:19:46')
expect(formatOverseasDateTime('2026-08-07T06:09:04')).toBe('2026-08-07 06:09:04')
expect(formatOverseasDateTime('2026-08-07')).toMatch(/^2026-08-07 /)
expect(formatOverseasDateTime(new Date(2026, 7, 7, 6, 9, 4))).toBe('2026-08-07 06:09:04')
expect(formatOverseasDateTime('')).toBe('—')
expect(formatOverseasDateTime('not-a-date')).toBe('not-a-date')
})
it('maps backend codes, associations and long ids without inventing data', () => {
const row = toLastMileOrderListItem({
id: '190000000000000099', customerId: '190000000000000001', orderNo: 'LL-001', orderType: 'ONE_CLICK_DROPSHIP', currentStatus: 'PENDING_ASSIGNMENT',
abnormal: true, detained: false, packageCount: 12, deliveryOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
abnormal: true, detained: false, packageCount: 12, palletMergeFlag: 'REQUIRED', deliveryOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
})
expect(row).toMatchObject({
id: '190000000000000099', customerId: '190000000000000001', orderType: '一件代发', status: '待分配',
warning: '异常', abnormal: '是', held: '否', cartons: 12, dispatchOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
warning: '异常', abnormal: '是', held: '否', cartons: 12, consolidated: '待合托', palletMergeFlag: 'REQUIRED', dispatchOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
})
expect(row.rejectedAt).toBe('—')
})
@@ -48,8 +48,22 @@ export function lastMileOrderTypeLabel(value: unknown) {
export function formatOverseasDateTime(value: unknown) {
const raw = String(value ?? '').trim()
if (!raw) return '—'
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? raw : date.toLocaleString('zh-CN', { hour12: false })
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw)
const date = value instanceof Date
? new Date(value.getTime())
: typeof value === 'number'
? new Date(value)
: dateOnly
? new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]))
: new Date(raw)
if (Number.isNaN(date.getTime())) return raw
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
export function palletMergeLabel(value: unknown) {
const code = String(value ?? '').trim()
return ({ REQUIRED: '待合托', MERGED: '已合托' } as Record<string, string>)[code] || (code ? text(code) : '未标记')
}
export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileOrderListItem {
@@ -69,6 +83,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
containerStatus: text(dto.containerStatus),
abnormal: yesNo(dto.abnormal),
held: yesNo(dto.detained),
detained: dto.detained === true,
pallets: count(dto.palletCount),
cartons: count(dto.packageCount),
volume: count(dto.volumeCbm),
@@ -82,7 +97,8 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
dispatchOrderNo: text(dto.deliveryOrderNo),
relabelOrderNo: text(dto.relabelOrderNo),
relabelStatus: lastMileStatusLabel(dto.relabelStatus),
consolidated: text(dto.palletMergeFlag),
consolidated: palletMergeLabel(dto.palletMergeFlag),
palletMergeFlag: String(dto.palletMergeFlag ?? '').trim(),
status: lastMileStatusLabel(dto.currentStatus),
internalRemark: text(dto.privateRemark),
customerRemark: text(dto.customerRemark),
@@ -118,10 +118,10 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
orderNos: text(dto.orderNos),
route: text(dto.route),
overseasWarehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseId),
departAt: text(dto.departAt),
departAt: formatOverseasDateTime(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: text(dto.vehicleAppointmentStatus),
estimatedArrivalAt: text(dto.estimatedArrivalAt),
estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt),
status: statusLabel(dto.status),
palletCount: number(dto.palletCount),
packageCount: number(dto.packageCount),
@@ -92,7 +92,7 @@ function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), type: typeLabel(dto.orderType), warehouse: text(dto.warehouseName || dto.warehouseCode),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: text(dto.appointmentAt), expectedLoadingAt: text(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
createdAt: formatOverseasDateTime(dto.createdAt),
}
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -12,7 +12,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { convertLastMileOrderToInboundPlan, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders'
import { convertLastMileOrderToInboundPlan, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeRequired, transferLastMileOrders } from '../api/lastMileOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation'
@@ -30,6 +30,9 @@ const selectedRows = ref<LastMileOrderListItem[]>([])
const actionLoading = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
const transferOpen = ref(false)
const detainedOpen = ref(false)
const detainedTarget = ref<LastMileOrderListItem | null>(null)
const detainedRemark = ref('')
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
@@ -58,7 +61,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'createdAt', type: 'daterange', label: '创建时间' },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
@@ -139,6 +142,42 @@ function openTransfer() {
actionError.value = null
transferOpen.value = true
}
function openDetained(row: LastMileOrderListItem) {
detainedTarget.value = row
detainedRemark.value = ''
actionError.value = null
detainedOpen.value = true
}
async function submitDetained() {
const target = detainedTarget.value
if (!target) return
if (!detainedRemark.value.trim()) { ElMessage.warning('请填写操作备注'); return }
actionLoading.value = true; actionError.value = null
try {
await setLastMileOrderDetained(target.id, { detained: !target.detained, remark: detainedRemark.value.trim() })
detainedOpen.value = false; detainedTarget.value = null
ElMessage.success(target.detained ? '已解除暂扣,库存已转为可用' : '已标记暂扣,后续入库将进入暂扣库存')
await load()
} catch (error) { actionError.value = getApiErrorDetails(error, target.detained ? '解除暂扣失败,请重试' : '暂扣失败,请重试') }
finally { actionLoading.value = false }
}
async function togglePalletMerge(row: LastMileOrderListItem) {
const required = row.palletMergeFlag !== 'REQUIRED'
try {
await ElMessageBox.confirm(
required ? `标记后,PDA扫描入仓码 ${row.inboundCode} 的箱唛时会显示合托提示。` : '确认取消该订单的PDA合托提示?',
required ? '合托标记' : '取消合托标记',
{ type: 'warning', confirmButtonText: required ? '确认标记' : '确认取消', cancelButtonText: '返回' },
)
} catch { return }
actionLoading.value = true; actionError.value = null
try {
await setLastMileOrderPalletMergeRequired(row.id, { palletMergeRequired: required })
ElMessage.success(required ? '已标记合托,PDA扫描箱唛时将显示提示' : '已取消合托标记')
await load()
} catch (error) { actionError.value = getApiErrorDetails(error, required ? '合托标记失败,请重试' : '取消合托标记失败,请重试') }
finally { actionLoading.value = false }
}
async function submitTransfer() {
if (!transferForm.warehouseId.trim() || !transferForm.warehouseCode.trim() || !transferForm.warehouseName.trim()) {
ElMessage.warning('请填写目的仓 ID、代码和名称')
@@ -185,9 +224,9 @@ onMounted(() => { void load() })
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" :row-class-name="({ row }: { row: LastMileOrderListItem }) => row.detained ? 'is-detained-row' : ''" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" size="small" plain :type="(row as LastMileOrderListItem).detained ? 'success' : 'danger'" :disabled="actionLoading" @click="openDetained(row as LastMileOrderListItem)">{{ (row as LastMileOrderListItem).detained ? '解除暂扣' : '暂扣' }}</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer) && (row as LastMileOrderListItem).palletMergeFlag !== 'MERGED'" size="small" plain :type="(row as LastMileOrderListItem).palletMergeFlag === 'REQUIRED' ? 'warning' : 'primary'" :disabled="actionLoading" @click="togglePalletMerge(row as LastMileOrderListItem)">{{ (row as LastMileOrderListItem).palletMergeFlag === 'REQUIRED' ? '取消合托' : '合托标记' }}</el-button></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
@@ -199,9 +238,14 @@ onMounted(() => { void load() })
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="detainedOpen" :title="detainedTarget?.detained ? '解除暂扣' : '暂扣订单'" :confirm-text="detainedTarget?.detained ? '确认解除' : '确认暂扣'" :confirm-loading="actionLoading" @confirm="submitDetained">
<el-form label-position="top"><el-form-item label="操作备注" required><el-input v-model="detainedRemark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写暂扣或解除原因" /></el-form-item></el-form>
</AppDialog>
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.table-panel :deep(.el-table .is-detained-row > td.el-table__cell) { background: #fff1f0 !important; color: #b42318; }
.table-panel :deep(.el-table .is-detained-row:hover > td.el-table__cell) { background: #ffe4e1 !important; }
</style>
@@ -12,13 +12,14 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound, createOneClickDropshipOutbound, listOutbounds, type OutboundRow } from '../api/outbounds'
import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation'
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
async function openCreate() { createOpen.value = true; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
async function submit() { const selected = positionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selected.balance.customerId, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } }
async function cancel(row: OutboundRow) { let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '取消出库单失败').message) } }
@@ -5,6 +5,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getPallet, listReceivingOverseasWarehouses, type PalletDto } from '../api/pallets'
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
const route = useRoute()
@@ -22,7 +23,7 @@ const groups = [
const values = computed(() => ({
托盘编码: detail.value?.palletCode || '—', 托盘类型: detail.value?.palletType || '—', 仓库: warehouseName.value,
'容积(m³)': detail.value?.capacityCbm ?? '—', 采购价格: detail.value?.unitPrice ?? '—', 所属FBA仓: detail.value?.fbaWarehouseCode || '—', 状态: detail.value?.status || '—',
库位ID: detail.value?.locationId || '—', 创建时间: detail.value?.createdAt || '—', 更新时间: detail.value?.updatedAt || '—', 备注: detail.value?.remark || '—',
库位ID: detail.value?.locationId || '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: detail.value?.remark || '—',
}))
async function load() {
@@ -76,7 +76,6 @@ async function loadWarehouses() {
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
const warehouseId = String(filters.value.warehouseId || '')
if (!warehouseId) { rows.value = []; total.value = 0; return }
loading.value = true; loadError.value = null; loadErrorKind.value = 'list'
try {
const result = await listPallets(warehouseId, { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '') || undefined, status: String(filters.value.status || '') || undefined })
@@ -122,4 +122,17 @@ describe('RelabelOrdersView', () => {
expect(labels).toEqual(expect.arrayContaining(['处理中', '已驳回', '已取消']))
expect(labels).not.toContain('已完成')
})
it('passes the relabel order number search to the backend', async () => {
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
const input = wrapper.find('input[placeholder="请输入换标单号"]')
expect(input.exists()).toBe(true)
await input.setValue('FBR202608272092853218068373506')
await wrapper.findAll('button').find((button) => button.text().includes('查询'))!.trigger('click')
await flushPromises()
expect(listRelabelOrders).toHaveBeenLastCalledWith(expect.objectContaining({
relabelNo: 'FBR202608272092853218068373506',
}))
})
})
@@ -30,8 +30,9 @@ const warehousesLoading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const filters = ref<QueryModel>({ sku: '', status: '' })
const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
])
@@ -155,7 +156,7 @@ async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true; loadError.value = null
try {
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), remark: '',
+2
View File
@@ -19,6 +19,7 @@ export interface LastMileOrderListItem {
containerStatus: string
abnormal: string
held: string
detained: boolean
pallets: number
cartons: number
volume: number
@@ -35,6 +36,7 @@ export interface LastMileOrderListItem {
relabelOrderNo: string
relabelStatus: string
consolidated: string
palletMergeFlag: string
status: string
internalRemark: string
customerRemark: string
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ClipboardCheck, PackageCheck, Truck, Warehouse, Zap } from '@lucide/vue'
import { ElMessage } from 'element-plus'
type BizType = '一件代发' | '海外目的仓' | 'FBA仓' | '换标下单' | '仓储转运' | '退货换标'
type FlowStatus = '待推送' | '待分配' | '待入库' | '入库完成' | '待排车' | '已排车' | '待出库' | '已出库'
interface Order {
id: number
no: string
type: BizType
container: string
sku: string
cartons: number
qty: number
status: FlowStatus
inbound: string
outbound: string
delivery: string
}
const typeOptions: BizType[] = ['一件代发', '海外目的仓', 'FBA仓', '换标下单', '仓储转运', '退货换标']
const statuses: FlowStatus[] = ['待推送', '待分配', '待入库', '入库完成', '待排车', '已排车', '待出库', '已出库']
type TagType = 'success' | 'warning' | 'primary' | 'info' | 'danger' | ''
const typeColor: Record<BizType, TagType> = { '一件代发': 'warning', '海外目的仓': 'success', 'FBA仓': 'primary', '换标下单': 'info', '仓储转运': '', '退货换标': 'danger' }
const statusColor: Record<FlowStatus, TagType> = { '待推送': 'info', '待分配': 'warning', '待入库': 'warning', '入库完成': 'success', '待排车': 'warning', '已排车': 'primary', '待出库': 'warning', '已出库': 'success' }
const orders = ref<Order[]>(Array.from({ length: 18 }, (_, i) => {
const type = typeOptions[i % typeOptions.length]
const status = statuses[Math.min(7, Math.floor(i / 3) + 1)]
return { id: i + 1, no: `LL20260827${String(i + 1).padStart(4, '0')}`, type, container: `MSCU${String(9023100 + i)}`, sku: `SKU-${type === 'FBA仓' ? 'FBA' : 'OW'}-${String(i + 11).padStart(3, '0')}`, cartons: 8 + (i % 7), qty: 96 + i * 12, status, inbound: `RK202608${String(i + 1).padStart(4, '0')}`, outbound: i > 8 ? `CK202608${String(i + 1).padStart(4, '0')}` : '-', delivery: i > 11 ? `PS202608${String(i + 1).padStart(4, '0')}` : '-' }
}))
const activeType = ref<'全部' | BizType>('全部')
const pushTypes = ref<BizType[]>([...typeOptions])
const keyword = ref('')
const selected = ref<Order[]>([])
const dialog = ref<'push' | 'inbound' | 'dispatch' | 'outbound' | 'other' | 'inventory' | null>(null)
const pdaMode = ref<'入库' | '出库'>('入库')
const generatedCount = ref(6)
const truckCount = ref(3)
const inventoryChecked = ref(false)
const filteredOrders = computed(() => orders.value.filter((item) => (activeType.value === '全部' || item.type === activeType.value) && (!keyword.value || `${item.no}${item.sku}${item.container}`.includes(keyword.value))))
const metrics = computed(() => ({ total: orders.value.length, waiting: orders.value.filter((o) => ['待分配', '待入库', '待排车', '待出库'].includes(o.status)).length, inbound: orders.value.filter((o) => o.status === '入库完成').length, outbound: orders.value.filter((o) => o.status === '已出库').length }))
function toggle(item: Order) { selected.value = selected.value.some((row) => row.id === item.id) ? selected.value.filter((row) => row.id !== item.id) : [...selected.value, item] }
function selectAll() { selected.value = selected.value.length === filteredOrders.value.length ? [] : [...filteredOrders.value] }
function open(flow: typeof dialog.value) { dialog.value = flow }
function pushOrders() {
const target = selected.value.length ? selected.value : orders.value.filter((o) => o.status === '待推送').slice(0, generatedCount.value)
target.forEach((o) => { o.status = '待分配' })
selected.value = []; dialog.value = null; ElMessage.success(`已推送 ${target.length} 条尾程订单,状态变更为“待分配”`)
}
function receiveInbound() {
const target = selected.value.length ? selected.value : orders.value.filter((o) => o.status === '待入库')
target.forEach((o) => { o.status = '入库完成' })
selected.value = []; dialog.value = null; ElMessage.success(`PDA 已完成 ${target.length} 条入库单扫码、上架`)
}
function smartDispatch() {
const target = selected.value.length ? selected.value : orders.value.filter((o) => o.status === '入库完成').slice(0, truckCount.value * 2)
target.forEach((o) => { o.status = '已排车'; o.delivery = `PS202608${String(o.id).padStart(4, '0')}` })
selected.value = []; dialog.value = null; ElMessage.success(`智能排车完成,生成 ${Math.max(1, Math.ceil(target.length / 2))} 张派送单`)
}
function outboundPick() {
const target = selected.value.length ? selected.value : orders.value.filter((o) => o.status === '已排车')
target.forEach((o) => { o.status = '已出库'; o.outbound = `CK202608${String(o.id).padStart(4, '0')}` })
selected.value = []; dialog.value = null; ElMessage.success(`PDA 已拣货出库 ${target.length} 条,库存已扣减`)
}
function completeOther() { dialog.value = null; ElMessage.success('海外换标、转仓、退货换标单据已批量生成并进入对应作业队列') }
function checkInventory() { inventoryChecked.value = true; dialog.value = null; ElMessage.success('库存验收通过:可用库存、占用库存与出库流水一致') }
</script>
<template>
<div class="business-page acceptance-page">
<section class="workbench-head surface-panel">
<div><div class="eyebrow">四期海外仓 · 验收模式</div><h2>尾程业务全流程验收工作台</h2><p>按装柜完成 → 推送 → 入库 → 排车 → 出库 → 库存核对逐步验收,所有单据均为中文枚举。</p></div>
<el-tag type="success" effect="dark">验收数据 · {{ orders.length }} 单</el-tag>
</section>
<section class="metric-grid">
<div class="metric surface-panel"><span>尾程订单</span><strong>{{ metrics.total }}</strong><small>覆盖 6 种业务类型</small></div>
<div class="metric surface-panel"><span>待处理节点</span><strong>{{ metrics.waiting }}</strong><small>待分配 / 入库 / 排车 / 出库</small></div>
<div class="metric surface-panel"><span>已入库</span><strong>{{ metrics.inbound }}</strong><small>含 PDA 扫码上架</small></div>
<div class="metric surface-panel"><span>已出库</span><strong>{{ metrics.outbound }}</strong><small>库存已扣减</small></div>
</section>
<section class="flow surface-panel">
<div class="flow-title"><div><h3>验收操作</h3><span>每个按钮打开独立对话框,可同时核对不同节点</span></div><el-button type="primary" plain @click="open('inventory')"><ClipboardCheck :size="16" />最终库存验收</el-button></div>
<div class="flow-actions">
<el-button @click="open('push')"><Zap :size="16" />1. 装柜完成 / 推送尾程</el-button>
<el-button @click="open('inbound')"><PackageCheck :size="16" />2. PDA 拣货入库</el-button>
<el-button @click="open('dispatch')"><Truck :size="16" />3. 智能排车</el-button>
<el-button @click="open('outbound')"><Warehouse :size="16" />4. PDA 拣货出库</el-button>
<el-button @click="open('other')">5. 其他海外业务单据</el-button>
</div>
</section>
<section class="surface-panel order-panel">
<div class="list-head"><div><h3>验收单据明细</h3><span>已选 {{ selected.length }} 条,可对选中单据执行节点操作</span></div><el-input v-model="keyword" clearable placeholder="搜索尾程单号 / SKU / 柜号" style="width: 270px" /></div>
<div class="type-tabs"><button :class="{active: activeType === '全部'}" @click="activeType = '全部'">全部</button><button v-for="type in typeOptions" :key="type" :class="{active: activeType === type}" @click="activeType = type">{{ type }}</button></div>
<div class="table-wrap"><table><thead><tr><th><input type="checkbox" :checked="selected.length === filteredOrders.length && filteredOrders.length > 0" @change="selectAll" /></th><th>尾程单号</th><th>业务类型</th><th>集装箱号</th><th>SKU</th><th>箱数</th><th>数量</th><th>入库单号</th><th>出库单号</th><th>派送单号</th><th>当前状态</th></tr></thead><tbody><tr v-for="item in filteredOrders" :key="item.id"><td><input type="checkbox" :checked="selected.some((row) => row.id === item.id)" @change="toggle(item)" /></td><td class="mono">{{ item.no }}</td><td><el-tag :type="typeColor[item.type] as any" effect="plain">{{ item.type }}</el-tag></td><td class="mono">{{ item.container }}</td><td class="mono">{{ item.sku }}</td><td>{{ item.cartons }}</td><td>{{ item.qty }}</td><td class="mono">{{ item.inbound }}</td><td class="mono">{{ item.outbound }}</td><td class="mono">{{ item.delivery }}</td><td><el-tag :type="statusColor[item.status] as any" effect="light">{{ item.status }}</el-tag></td></tr></tbody></table></div>
</section>
<el-dialog :model-value="Boolean(dialog)" @update:model-value="dialog = $event ? dialog : null" title="装柜完成 / 推送尾程订单" width="560px" destroy-on-close v-if="dialog === 'push'"><p class="dialog-tip">头程柜号 <b>MSCU9023100</b> 已装柜完成,按业务类型批量生成尾程订单并推送到“待分配”。</p><el-form label-position="top"><el-form-item label="生成数量"><el-input-number v-model="generatedCount" :min="1" :max="18" /></el-form-item><el-form-item label="业务类型"><el-checkbox-group v-model="pushTypes"><el-checkbox v-for="type in typeOptions" :key="type" :label="type">{{ type }}</el-checkbox></el-checkbox-group></el-form-item></el-form><template #footer><el-button @click="dialog = null">取消</el-button><el-button type="primary" @click="pushOrders">确认推送</el-button></template></el-dialog>
<el-dialog :model-value="Boolean(dialog)" @update:model-value="dialog = $event ? dialog : null" title="PDA 拣货入库" width="560px" destroy-on-close v-if="dialog === 'inbound'"><p class="dialog-tip">扫描柜号、箱码后完成收货、分配库位和上架。当前待入库 {{ orders.filter((o) => o.status === '待入库').length }} 条。</p><el-alert title="已启用 PDA 幂等校验与海外实际仓绑定" type="info" :closable="false" /><template #footer><el-button @click="dialog = null">取消</el-button><el-button type="primary" @click="receiveInbound">完成 PDA 入库</el-button></template></el-dialog>
<el-dialog :model-value="Boolean(dialog)" @update:model-value="dialog = $event ? dialog : null" title="智能排车 / 生成派送单" width="560px" destroy-on-close v-if="dialog === 'dispatch'"><p class="dialog-tip">系统按目的仓、预约时间和托盘体积随机预排,确认后生成派送单与出库单。</p><el-form label-position="top"><el-form-item label="车辆数"><el-input-number v-model="truckCount" :min="1" :max="10" /></el-form-item><el-form-item label="排车策略"><el-radio-group model-value="随机预排"><el-radio value="随机预排">随机预排</el-radio><el-radio value="按目的仓合并">按目的仓合并</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialog = null">取消</el-button><el-button type="primary" @click="smartDispatch">确认生成派送单</el-button></template></el-dialog>
<el-dialog :model-value="Boolean(dialog)" @update:model-value="dialog = $event ? dialog : null" title="PDA 拣货出库" width="560px" destroy-on-close v-if="dialog === 'outbound'"><p class="dialog-tip">根据推送的出库清单逐箱扫码拣货,完成复核后扣减可用库存并关闭出库单。</p><el-steps :active="2" finish-status="success" simple><el-step title="领取清单" /><el-step title="逐箱拣货" /><el-step title="复核出库" /></el-steps><template #footer><el-button @click="dialog = null">取消</el-button><el-button type="primary" @click="outboundPick">完成 PDA 出库</el-button></template></el-dialog>
<el-dialog :model-value="Boolean(dialog)" @update:model-value="dialog = $event ? dialog : null" title="其他海外业务单据" width="620px" destroy-on-close v-if="dialog === 'other'"><p class="dialog-tip">一次性生成并核对其他海外本土业务单据,单据状态使用中文枚举。</p><el-table :data="[{type:'换标下单', count:4, status:'待作业'}, {type:'仓储转运', count:3, status:'待分配'}, {type:'退货换标', count:2, status:'待收货'}, {type:'本土散板', count:3, status:'待排车'}]" size="small"><el-table-column prop="type" label="业务类型" /><el-table-column prop="count" label="单据数" /><el-table-column prop="status" label="当前状态" /></el-table><template #footer><el-button @click="dialog = null">取消</el-button><el-button type="primary" @click="completeOther">生成并进入作业队列</el-button></template></el-dialog>
<el-dialog :model-value="Boolean(dialog)" @update:model-value="dialog = $event ? dialog : null" title="最终库存验收" width="620px" destroy-on-close v-if="dialog === 'inventory'"><div class="inventory-check"><div><span>账面可用库存</span><b>{{ orders.reduce((sum, item) => sum + item.qty, 0) }}</b></div><div><span>出库扣减</span><b>{{ orders.filter((o) => o.status === '已出库').reduce((sum, item) => sum + item.qty, 0) }}</b></div><div><span>库存流水</span><b>一致</b></div></div><el-alert title="核对范围:入库上架、锁定库存、PDA 出库、派送单关联、异常单据" type="success" :closable="false" /><template #footer><el-button @click="dialog = null">关闭</el-button><el-button type="primary" @click="checkInventory">确认验收通过</el-button></template></el-dialog>
</div>
</template>
<style scoped>
.acceptance-page { display: grid; gap: 16px; }
.surface-panel { background: var(--cc-surface); border: 1px solid var(--cc-border); border-radius: var(--cc-radius-md); box-shadow: var(--cc-shadow-sm); }
.workbench-head { display:flex; justify-content:space-between; align-items:flex-start; padding:22px 24px; }.eyebrow { color:var(--cc-primary); font-size:12px; font-weight:700; letter-spacing:1px; }.workbench-head h2 { margin:6px 0; font-size:22px; color:var(--cc-text); }.workbench-head p,.flow-title span,.list-head span { margin:0; color:var(--cc-muted); font-size:13px; }
.metric-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; }.metric { padding:16px 18px; }.metric span,.metric small { display:block; color:var(--cc-muted); font-size:12px; }.metric strong { display:block; margin:4px 0; color:var(--cc-text); font-size:26px; }
.flow,.order-panel { padding:18px 20px; }.flow-title,.list-head { display:flex; justify-content:space-between; align-items:center; gap:16px; }.flow h3,.order-panel h3 { margin:0 0 4px; font-size:15px; }.flow-actions { display:flex; flex-wrap:wrap; gap:10px; margin-top:16px; }.flow-actions .el-button { min-height:40px; }
.type-tabs { display:flex; gap:4px; margin:16px 0 12px; border-bottom:1px solid var(--cc-border); }.type-tabs button { padding:9px 14px; border:0; background:transparent; color:var(--cc-muted); cursor:pointer; }.type-tabs button.active { color:var(--cc-primary); border-bottom:2px solid var(--cc-primary); font-weight:700; }
.table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; font-size:12px; white-space:nowrap; } th,td { padding:10px 9px; border-bottom:1px solid var(--cc-border); text-align:left; } th { background:var(--cc-surface-muted); color:var(--cc-muted); font-weight:600; } .mono { font-family:ui-monospace, SFMono-Regular, Consolas, monospace; color:var(--cc-text-secondary); }.dialog-tip { padding:12px 14px; margin:0 0 16px; background:var(--cc-surface-muted); color:var(--cc-text-secondary); line-height:1.6; }.inventory-check { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin-bottom:16px; }.inventory-check div { padding:14px; background:var(--cc-surface-muted); }.inventory-check span,.inventory-check b { display:block; }.inventory-check span { color:var(--cc-muted); font-size:12px; }.inventory-check b { margin-top:6px; color:var(--cc-text); font-size:20px; }
</style>
+8
View File
@@ -8,6 +8,7 @@ const moduleViews = {
customerManagement: () => import('@/prototype/modules/customer-management/ModuleView.vue'),
domesticWarehouse: () => import('@/prototype/modules/domestic-warehouse/ModuleView.vue'),
overseasDelivery: () => import('@/prototype/modules/overseas-delivery/ModuleView.vue'),
overseasAcceptance: () => import('@/prototype/modules/overseas-delivery/AcceptanceWorkbench.vue'),
overseasInventory: () => import('@/prototype/modules/overseas-inventory/ModuleView.vue'),
serviceCenter: () => import('@/prototype/modules/service-center/ModuleView.vue'),
supplierCenter: () => import('@/prototype/modules/supplier-center/ModuleView.vue'),
@@ -35,6 +36,13 @@ function createRoutes(view: ModuleView, routes: PrototypeRoute[]): RouteRecordRa
}
export const prototypeModuleRoutes: RouteRecordRaw[] = [
{
path: 'prototype/overseas-delivery/acceptance-workbench',
name: 'overseas-acceptance-workbench',
component: moduleViews.overseasAcceptance,
props: { title: '尾程业务全流程验收', description: '覆盖尾程订单、海外入库、排车、出库与库存验收' },
meta: { title: '尾程业务全流程验收', description: '覆盖尾程订单、海外入库、排车、出库与库存验收' },
},
...createRoutes(moduleViews.customerManagement, [
['prototype/customer-management/warehouse-tier-fee', 'warehouse-tier-fee', '海外仓阶梯费用'],
]),