diff --git a/AGENTS.md b/AGENTS.md index e454460..3100764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ | 客户资金账户与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际、冻结、可用余额和不可变流水;充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | | 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | | 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | -| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` | +| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;贴标申请先从共享仓库主数据选择国外中转仓,并自动带出海外仓签收联系人和联系电话,再从当前客户已经完成入库且仍有可用库存的历史入仓单中单选。货物明细支持箱唛换标、SKU 换标和同箱组合换标:SKU 明细按来源库存箱聚合为一个箱唛任务,整箱只冻结一次,SKU 数量按件保存为子明细;PDA 扫我方箱唛定位任务,返回新 FBA 箱唛和 SKU 子明细,照片完工并复核通过后释放原库存,后续仍进入既有排车出库。客户页面不暴露托盘、库位和内部库存 ID;Backend `lastleg-service/` 的 `relabel/`、`relabel/pda/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` | ## 租户、认证与数据安全 diff --git a/src/components.d.ts b/src/components.d.ts index 723bdea..2240e47 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -23,6 +23,8 @@ declare module 'vue' { ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] @@ -34,6 +36,7 @@ declare module 'vue' { ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElOption: typeof import('element-plus/es')['ElOption'] + ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadio: typeof import('element-plus/es')['ElRadio'] diff --git a/src/modules/first-leg/views/businessIdentifiers.test.mjs b/src/modules/first-leg/views/businessIdentifiers.test.mjs index 300481a..4d94bf6 100644 --- a/src/modules/first-leg/views/businessIdentifiers.test.mjs +++ b/src/modules/first-leg/views/businessIdentifiers.test.mjs @@ -30,7 +30,6 @@ test('keeps overseas warehouse operations first and renders readable list values assert.match(overseas, /columns: \['操作', '贴标申请单号'/) assert.match(overseas, /columns: \['操作', '转运单号'/) assert.match(overseas, /columns: \['操作', '废弃申请单号'/) - assert.match(overseas, /columns: \['操作', '退货换标单号'/) assert.match(overseas, /function statusLabel\(value: string\)/) assert.match(overseas, /function deliveryMethodLabel\(value: string\)/) assert.match(overseas, /function formatDateTime\(value: string\)/) diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts new file mode 100644 index 0000000..560a504 --- /dev/null +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -0,0 +1,59 @@ +import { overseasApiRequest } from '@/api/overseasHttp' +import type { PageResult } from './warehouseServices' + +export interface ReturnInventoryItem { + overseasWarehouseId: string; cartonMark: string; sourceBatchNo?: string; status: string; skuCode: string + productNameZh?: string; quantity: number; cartonSkuTypes: number; cartonTotalQuantity: number; receivedAt: string +} + +export interface ReturnInventorySummaryItem { + overseasWarehouseId: string; skuCode: string; productNameZh?: string + availableQuantity: number; cartonQuantity: number; updatedAt: string +} + +export interface ReturnInventoryCartonItem { skuCode: string; productNameZh?: string; quantity: number } + +export interface ReturnInventoryCartonDetail { + cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string + skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; receivedAt: string; items: ReturnInventoryCartonItem[] +} + +export interface ReturnInventoryLabel { + cartonMark: string; overseasWarehouseId: string; customerId: string; sourceBatchNo?: string; sourceReturnNo?: string + totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; items: ReturnInventoryCartonItem[]; generatedAt: string; printableHtml: string +} + +export function listReturnInventory(params: Record) { + return overseasApiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory', params }) +} + +export function listReturnInventorySummary(params: Record) { + return overseasApiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/summary', params }) +} + +export function getReturnInventoryCarton(cartonMark: string) { + return overseasApiRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}` }) +} + +export function getReturnInventoryLabel(cartonMark: string) { + return overseasApiRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}/label` }) +} + +export interface ReturnPackingPreview { + orders: Array<{ returnNo: string; customerId: string; customerReferenceNo?: string; status: string }> + customerId?: string + skus: Array<{ skuCode: string; productNameZh?: string; availableQuantity: number }> +} + +export function getReturnPackingPreview(returnNo?: string) { + return overseasApiRequest({ method: 'GET', url: '/pda/v1/last-leg/return-inventory/packing/orders', params: { returnNo: returnNo || undefined } }) +} + +export function mergeReturnInventory(payload: { + returnNos: string[]; items: Array<{ skuCode: string; quantity: number }> + length: number; width: number; height: number; weight: number; idempotencyKey: string +}) { + return overseasApiRequest<{ cartonMark: string; skuCode: string; totalQuantity: number; label: ReturnInventoryLabel }>({ + method: 'POST', url: '/pda/v1/last-leg/return-inventory/cartons/merge', data: payload, + }) +} diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts new file mode 100644 index 0000000..70b354b --- /dev/null +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -0,0 +1,29 @@ +import { overseasApiRequest } from '@/api/overseasHttp' +import type { PageResult } from './warehouseServices' + +export interface ReturnOrderSummary { + returnNo: string; overseasWarehouseId: string; customerReferenceNo?: string; status: string + skuTypes: number; expectedQuantity: number; receivedQuantity: number; createdAt: string +} +export interface ReturnOrderItem { + skuCode: string; productNameZh?: string; expectedQuantity: number; receivedQuantity: number +} +export interface ReturnReceipt { + receiptNo: string; receiptBatchNo: string; cartonMark?: string; trackingNo?: string; startedBy: 'RETURN_NO' | 'SKU' + receivedQuantity: number; operatorName?: string; signedAt: string +} +export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[] } +export interface ReturnOrderCreate { + overseasWarehouseId: string; customerReferenceNo?: string; remark?: string; idempotencyKey: string + items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }> +} + +export function listReturnOrders(params: Record) { + return overseasApiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params }) +} +export function getReturnOrder(returnNo: string) { + return overseasApiRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` }) +} +export function createReturnOrder(payload: ReturnOrderCreate) { + return overseasApiRequest({ method: 'POST', url: '/merchant/v1/last-leg/return-orders', data: payload }) +} diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 6f1dba1..c070e90 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -6,9 +6,13 @@ export interface TransferSummary { id: string; transferNo: string; inboundCode: export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string } export interface RelabelItemDetail { id: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number - shipmentNo: string; trackingCode?: string; newSkuCode: string; labelRequirement?: string; remark?: string + shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string; remark?: string relabelFileId?: string; relabelFileName?: string; relabelFileUrl?: string - cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string + cartonMark: string; fbaCartonMark?: string; destinationFbaWarehouseCode?: string + operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' + skuItems: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }> + sourceInventoryBalanceId: string; sourceInventoryPositionId: string; sourceInboundOrderId: string + sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseId: string; sourceWarehouseCode?: string; sourceWarehouseName?: string } export interface RelabelDetail { application: RelabelSummary & { labelDeadline?: string; contactName?: string; contactPhone?: string; chargeAmount?: number; chargeCurrency?: string; walletStatus?: string; walletErrorMessage?: string } @@ -18,6 +22,13 @@ export interface RelabelDetail { } export interface DiscardSummary { id: string; discardNo: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string } export interface DiscardCandidate { orderId: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } +export interface RelabelInventoryCandidate { + inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string + overseasWarehouseId: string; overseasWarehouseCode?: string; overseasWarehouseName?: string + destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string + availablePackages: number; version: number + positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }> +} export interface TransferCreate { inboundCode: string; sourceLastlegOrderIds: string[]; operatingWarehouseId: string; fbaWarehouseCode: string @@ -26,10 +37,10 @@ export interface TransferCreate { items: Array<{ skuCode: string; productNameZh: string; packageCount: number; shippingMark?: string }> } export interface RelabelCreate { - customerOrderNo: string; overseasWarehouseId?: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount: number + customerOrderNo: string; operationWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount: number remark?: string; idempotencyKey: string - items: Array<{ skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number; shipmentNo: string; trackingCode?: string; newSkuCode: string; labelRequirement?: string - cartonMark: string; fbaCartonMark: string; destinationWarehouseId?: string; remark?: string }> + items: Array<{ sourceInventoryBalanceId: string; sourceInventoryPositionId: string; sourceInboundOrderId: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number; shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string + cartonMark: string; fbaCartonMark?: string; destinationFbaWarehouseCode?: string; operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>; remark?: string }> } export function listLastlegOrders(inboundCode: string) { @@ -50,6 +61,13 @@ export function listRelabels(params: Record) { export function getRelabel(relabelNo: string) { return overseasApiRequest({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` }) } + +export function listRelabelInventoryCandidates(operationWarehouseId: string, inboundCode?: string) { + return overseasApiRequest({ + method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/inventory-candidates', + params: { operationWarehouseId, inboundCode: inboundCode || undefined }, + }) +} export function createRelabel(payload: RelabelCreate) { return overseasApiRequest>({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload }) } diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts index 9bc50df..01ed1e6 100644 --- a/src/modules/overseas-warehouse/routes.ts +++ b/src/modules/overseas-warehouse/routes.ts @@ -1,4 +1,4 @@ -import { PackageX, RefreshCcw, Tags, Truck, Warehouse } from '@lucide/vue' +import { Boxes, PackageX, RefreshCcw, Tags, Truck, Warehouse } from '@lucide/vue' import type { ClientModuleManifest } from '../types.ts' export const overseasWarehouseModule = { @@ -25,9 +25,21 @@ export const overseasWarehouseModule = { }, { path: 'overseas-warehouse/return-relabel', name: 'overseas-return-relabel', - component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'relabel' }, release: 'active', - meta: { title: '退货换标', description: '查询并管理退货换标订单', permission: 'dashboard:view' }, + component: () => import('./views/ReturnOrdersView.vue'), release: 'active', + meta: { title: '退货签收', description: '创建退货预报并查看仓库签收结果', permission: 'dashboard:view' }, navigation: { key: 'overseas-return-relabel', icon: RefreshCcw }, }, + { + path: 'overseas-warehouse/return-inventory', name: 'overseas-return-inventory', + component: () => import('./views/ReturnInventoryView.vue'), release: 'active', + meta: { title: '退货库存', description: '按箱号和 SKU 查询海外仓退货实收库存', permission: 'merchant:overseas-warehouse:return-inventory:list' }, + navigation: { key: 'overseas-return-inventory', icon: Boxes }, + }, + { + path: 'overseas-warehouse/return-cartons', name: 'overseas-return-cartons', + component: () => import('./views/ReturnInventoryView.vue'), props: { cartonOnly: true }, release: 'active', + meta: { title: '退货箱号管理', description: '查询合箱后的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' }, + navigation: { key: 'overseas-return-cartons', icon: Boxes }, + }, ], } satisfies ClientModuleManifest diff --git a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue new file mode 100644 index 0000000..670b3af --- /dev/null +++ b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue new file mode 100644 index 0000000..443ac05 --- /dev/null +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 5807c11..8ca2ad9 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -4,21 +4,26 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { ArrowLeft, Download, Eye, Plus, Search, Trash2, Upload } from '@lucide/vue' import { createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, - listDiscardCandidates, listDiscards, listLastlegOrders, listRelabels, listTransfers, + listDiscardCandidates, listDiscards, listLastlegOrders, listRelabelInventoryCandidates, listRelabels, listTransfers, + type RelabelInventoryCandidate, + type RelabelCreate, type RelabelDetail, } from '../api/warehouseServices' +import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/api/firstLeg' type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'detail' -interface CargoRow { id: number; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +type LabelingMode = 'carton' | 'sku' +interface CargoRow { id: number; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; changeCarton: boolean; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] } const props = defineProps<{ service: ServiceType }>() const configs: Record = { - labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['贴标申请单号', '客户单号', '目的海外仓', '贴标要求', 'SKU 数', '总箱数', '状态', '创建时间', '操作'] }, - transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['转运单号', '进仓编号', '发货箱数', '操作仓', 'FBA 目的仓', '派送方式', '是否换标', '状态', '创建时间', '操作'] }, - disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['废弃申请单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '创建时间', '操作'] }, - relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间', '操作'] }, + labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '客户单号', '海外操作仓', '贴标要求', 'SKU 数', '总箱数', '状态', '创建时间'] }, + transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '进仓编号', '发货箱数', '操作仓', 'FBA 目的仓', '派送方式', '是否换标', '状态', '创建时间'] }, + disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '创建时间'] }, + relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] }, } const config = computed(() => configs[props.service]) const statusOptions = computed(() => props.service === 'labeling' @@ -30,28 +35,64 @@ const mode = ref('list') const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', address: '', contact: '', phone: '', deliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', fbaShipmentNo: '', volumeCbm: 0.01, chargeAmount: 0 }) const cargoRows = ref([]) +const labelingMode = ref('carton') +const skuRelabelRows = ref([]) +const cartonMarkOptions = computed(() => cargoRows.value.map((item) => ({ + value: item.id, + label: `${item.cartonMark || item.sku || '库存货物'}|可用 ${item.availableQuantity || item.quantity} 箱`, +}))) const submittedRows = ref[]>([]) const selectedDetail = ref | null>(null) const selectedRelabelDetail = ref(null) +const fbaWarehouses = ref([]) +const operationWarehouses = ref([]) +const fbaWarehousesLoading = ref(false) +const fbaWarehousesError = ref('') const discardCandidates = ref([]) const selectedDiscardOrderId = ref('') const discardCandidatesLoading = ref(false) +const relabelCandidates = ref([]) +const selectedRelabelInboundOrderId = ref('') +const relabelCandidatesLoading = ref(false) +const relabelInboundOptions = computed(() => { + const grouped = new Map() + for (const candidate of relabelCandidates.value) { + const value = String(candidate.inboundOrderId) + const current = grouped.get(value) + if (current) current.availablePackages += candidate.availablePackages + else grouped.set(value, { + value, + inboundOrderNo: candidate.inboundOrderNo || '—', + inboundCode: candidate.inboundCode || '—', + availablePackages: candidate.availablePackages, + }) + } + return [...grouped.values()] +}) const loading = ref(false) const saving = ref(false) const templateInput = ref(null) let cargoSequence = 0 +let skuRelabelSequence = 0 watch(() => props.service, () => { mode.value = 'list'; resetFilters(); resetForm(); void load() }) +watch(labelingMode, (value) => { if (value === 'sku' && !skuRelabelRows.value.length) addSkuRelabelRow() }) onMounted(() => { void load() }) function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) } function resetForm() { Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', address: '', contact: '', phone: '', deliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', fbaShipmentNo: '', volumeCbm: 0.01, chargeAmount: 0 }) cargoRows.value = [] + labelingMode.value = 'carton' + skuRelabelRows.value = [] discardCandidates.value = [] selectedDiscardOrderId.value = '' + relabelCandidates.value = [] + selectedRelabelInboundOrderId.value = '' } async function openCreate() { resetForm(); mode.value = 'create' + if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer') void loadWarehouses() + if (props.service === 'labeling') return if (props.service !== 'disposal') { addCargoRow(); return } discardCandidatesLoading.value = true try { discardCandidates.value = await listDiscardCandidates() } @@ -59,12 +100,78 @@ async function openCreate() { finally { discardCandidatesLoading.value = false } } function addCargoRow() { cargoRows.value.push({ id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' }) } -function removeCargoRow(id: number) { cargoRows.value = cargoRows.value.filter((row) => row.id !== id) } -function csvCell(value: string) { return `"${value.replaceAll('"', '""')}"` } +function addSkuRelabelRow() { skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: null, cartonMark: '', originalSku: '', newSku: '', quantity: 1, changeCarton: false, fbaCartonMark: '', destinationWarehouseId: '', remark: '' }) } +function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) } +function selectSkuSource(row: SkuRelabelRow) { + const source = cargoRows.value.find((item) => item.id === row.sourceRowId) + if (!source) return + row.cartonMark = source.cartonMark || '' + row.originalSku = row.originalSku || source.sku + row.destinationWarehouseId = row.destinationWarehouseId || source.destinationWarehouseId +} +function buildSkuRelabelItems(): RelabelCreate['items'] { + const groups = new Map() + for (const row of skuRelabelRows.value) { + if (row.sourceRowId == null) throw new Error('请选择每条 SKU 明细对应的来源库存箱') + const rows = groups.get(row.sourceRowId) || [] + rows.push(row) + groups.set(row.sourceRowId, rows) + } + return [...groups.entries()].map(([sourceRowId, rows]) => { + const source = cargoRows.value.find((item) => item.id === sourceRowId) + if (!source?.sourceInventoryBalanceId || !source.sourceInventoryPositionId || !source.sourceInboundOrderId) { + throw new Error('来源库存已变化,请重新选择有库存的历史入仓单') + } + const cartonMark = rows[0].cartonMark.trim() + if (!cartonMark || rows.some((row) => row.cartonMark.trim() !== cartonMark)) { + throw new Error('同一来源库存箱的 SKU 明细必须使用同一个我方箱唛') + } + const changeCarton = rows[0].changeCarton + if (rows.some((row) => row.changeCarton !== changeCarton)) { + throw new Error('同一我方箱唛的 SKU 明细必须统一是否同时更换箱唛') + } + const fbaCartonMark = rows[0].fbaCartonMark.trim() + const destinationWarehouseId = rows[0].destinationWarehouseId + if (changeCarton && (!fbaCartonMark || !destinationWarehouseId + || rows.some((row) => row.fbaCartonMark.trim() !== fbaCartonMark || row.destinationWarehouseId !== destinationWarehouseId))) { + throw new Error('同一我方箱唛的组合换标必须使用相同的新 FBA 箱唛和目的仓') + } + return { + sourceInventoryBalanceId: source.sourceInventoryBalanceId, + sourceInventoryPositionId: source.sourceInventoryPositionId, + sourceInboundOrderId: source.sourceInboundOrderId, + skuCode: source.sku || rows[0].originalSku, + productNameZh: source.name, + totalPackageCount: source.quantity, + notifiedPackageCount: source.quantity, + shipmentNo: source.shipmentNo || cartonMark, + trackingCode: form.trackingNo || undefined, + newSkuCode: rows[0].newSku, + labelRequirement: form.requirement || undefined, + cartonMark, + fbaCartonMark: changeCarton ? fbaCartonMark : undefined, + destinationFbaWarehouseCode: changeCarton ? destinationWarehouseId : undefined, + operationType: changeCarton ? 'CARTON_AND_SKU' as const : 'SKU_LABEL' as const, + skuItems: rows.map((row) => ({ skuCode: row.originalSku.trim(), newSkuCode: row.newSku.trim(), quantity: row.quantity, remark: row.remark || undefined })), + remark: undefined, + } + }) +} +function addRelabelCargoRow() { + if (!selectedRelabelInboundOrderId.value) { ElMessage.warning('请先选择有库存的历史入仓单'); return } + ElMessage.info('货物明细已按所选入仓单的可用库存带出,请直接编辑现有明细') +} +function removeCargoRow(id: number) { + cargoRows.value = cargoRows.value.filter((row) => row.id !== id) +} +function clearRelabelRows() { cargoRows.value = []; selectedRelabelInboundOrderId.value = '' } +function csvCell(value: string | number | undefined) { return `"${String(value ?? '').replaceAll('"', '""')}"` } function downloadRelabelTemplate() { - const header = ['商品SKU(选填)', '中文品名', 'FBA货件编号', '换标新SKU', '我方箱唛', 'FBA箱唛', '目的仓', '备注'] - const example = ['', '示例商品', 'FBA-SHIPMENT-001', 'LX1000-NEW', 'LX1000箱唛', 'FBA-LX1000箱唛', 'ONT8', ''] - const blob = new Blob([`\uFEFF${header.map(csvCell).join(',')}\r\n${example.map(csvCell).join(',')}\r\n`], { type: 'text/csv;charset=utf-8' }) + if (!cargoRows.value.length) { ElMessage.warning('请先选择有库存的历史入仓单'); return } + const header = ['明细行标识', '本次箱数', '商品SKU', '中文品名', 'FBA货件编号', '换标新SKU', '我方箱唛', 'FBA箱唛', 'FBA目的仓', '备注'] + const rows = cargoRows.value.map((item) => [item.id, item.quantity, item.sku, item.name, item.shipmentNo, item.newSkuCode, + item.cartonMark, item.fbaCartonMark, item.destinationWarehouseId, item.remark]) + const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url; anchor.download = '贴标申请-关联面单模板.csv'; anchor.click(); URL.revokeObjectURL(url) @@ -84,20 +191,37 @@ async function importRelabelTemplate(event: Event) { if (!file) return const lines = (await file.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim()) if (lines.length < 2) { ElMessage.error('模板中没有可导入的明细'); return } - const headers = parseCsvLine(lines[0]); const required = ['中文品名', 'FBA货件编号', '换标新SKU', '我方箱唛', 'FBA箱唛'] - const skuHeader = headers.includes('商品SKU(选填)') ? '商品SKU(选填)' : '商品SKU' - if (!headers.includes(skuHeader)) { ElMessage.error('模板格式不正确,请重新下载模板填写'); return } + const headers = parseCsvLine(lines[0]) + const required = ['明细行标识', '本次箱数', 'FBA货件编号', '换标新SKU', '我方箱唛', 'FBA箱唛', 'FBA目的仓'] if (required.some((name) => !headers.includes(name))) { ElMessage.error('模板格式不正确,请重新下载模板填写'); return } - const imported = lines.slice(1).map(parseCsvLine).map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || '']))).filter((item) => required.some((key) => item[key])) - if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) { ElMessage.error('模板中的商品、货件、换标 SKU 和箱唛信息必须填写完整'); return } - if (new Set(imported.map((item) => item['我方箱唛'])).size !== imported.length) { ElMessage.error('模板中的我方箱唛不能重复'); return } - cargoRows.value = imported.map((item) => ({ - id: ++cargoSequence, sku: item[skuHeader], name: item['中文品名'], quantity: 1, fnsku: '', - shipmentNo: item['FBA货件编号'], newSkuCode: item['换标新SKU'], shippingMark: '', - cartonMark: item['我方箱唛'], fbaCartonMark: item['FBA箱唛'], - destinationWarehouseId: item['目的仓'] || '', remark: item['备注'] || '', - })) - ElMessage.success(`已导入 ${cargoRows.value.length} 箱货物明细`) + const imported = lines.slice(1).map(parseCsvLine) + .map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || '']))) + .filter((item) => item['明细行标识']) + if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) { + ElMessage.error('模板中的明细行、箱数、货件、换标 SKU、箱唛和 FBA 目的仓必须填写完整'); return + } + if (new Set(imported.map((item) => item['明细行标识'])).size !== imported.length) { + ElMessage.error('模板中的明细行标识不能重复'); return + } + const importedByRow = new Map(imported.map((item) => [item['明细行标识'], item])) + if (importedByRow.size !== cargoRows.value.length || cargoRows.value.some((row) => !importedByRow.has(String(row.id)))) { + ElMessage.error('模板明细与当前已选入仓单库存不一致,请重新下载模板'); return + } + for (const row of cargoRows.value) { + const item = importedByRow.get(String(row.id))! + const quantity = Number(item['本次箱数']) + if (!Number.isInteger(quantity) || quantity < 1 || quantity > Number(row.availableQuantity || 0)) { + ElMessage.error(`明细行 ${row.id} 的本次箱数不正确或超过可用库存`); return + } + row.quantity = quantity + row.shipmentNo = item['FBA货件编号'] + row.newSkuCode = item['换标新SKU'] + row.cartonMark = item['我方箱唛'] + row.fbaCartonMark = item['FBA箱唛'] + row.destinationWarehouseId = item['FBA目的仓'] + row.remark = item['备注'] || '' + } + ElMessage.success(`已导入 ${imported.length} 条关联面单明细`) } function selectDiscardInbound(orderId: string) { const candidate = discardCandidates.value.find((item) => item.orderId === orderId) @@ -112,10 +236,21 @@ function selectDiscardInbound(orderId: string) { } function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined } function row(values: Array) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '—' : String(value)])) } +function statusLabel(value: string) { + return statusOptions.value.find((option) => option.value === value)?.label || value +} +function deliveryMethodLabel(value: string) { + return ({ TRUCK: '卡车派送', EXPRESS: '快递派送', '卡车派送': '卡车派送', '快递派送': '快递派送' } as Record)[value] || value +} +function formatDateTime(value: string) { + return value ? value.replace('T', ' ').slice(0, 19) : '—' +} function warehouseDisplay(value?: string) { if (!value) return '—' - const labels: Record = { ONT8: '美西海外仓-ONT8', ABE8: '美东海外仓-ABE8', MDW2: '美中海外仓-MDW2' } - return value.split(',').map((warehouse) => labels[warehouse] || warehouse).join('、') + return value.split(',').map((code) => { + const warehouse = [...operationWarehouses.value, ...fbaWarehouses.value].find((item) => String(item.id) === code || item.warehouseCode === code) + return warehouse ? warehouseLabel(warehouse) : code + }).join('、') } async function load() { loading.value = true @@ -124,16 +259,16 @@ async function load() { const result = await listRelabels({ page: 1, size: 20, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) submittedRows.value = result.records.map((item) => { const destinationWarehouses = warehouseDisplay(item.overseasWarehouseId) - return row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, item.status, item.createdAt]) + return row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, statusLabel(item.status), formatDateTime(item.createdAt)]) }) } else if (props.service === 'disposal') { const result = await listDiscards({ page: 1, size: 20, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) - submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt])) + submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.createdAt)])) } else { const result = await listTransfers({ page: 1, size: 20, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, needRelabel: props.service === 'relabel', createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) submittedRows.value = result.records.map((item) => props.service === 'transfer' - ? row([item.transferNo, item.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt]) - : row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt])) + ? row([item.transferNo, item.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', statusLabel(item.status), formatDateTime(item.createdAt)]) + : row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)])) } } catch (error) { ElMessage.error(error instanceof Error ? error.message : '列表加载失败') } finally { loading.value = false } @@ -141,21 +276,25 @@ async function load() { function query() { void load() } async function submit() { const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse + const relabelRows = labelingMode.value === 'sku' ? skuRelabelRows.value : cargoRows.value const invalidCargo = props.service === 'disposal' ? !selectedDiscardOrderId.value || cargoRows.value.every((item) => item.quantity < 1) || cargoRows.value.some((item) => item.quantity < 0 || item.quantity > Number(item.availableQuantity || 0)) - : cargoRows.value.length === 0 || cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || item.quantity < 1) - if ((!form.customerNo && !form.inboundNo) || (props.service !== 'labeling' && !primaryWarehouse) || invalidCargo) { + : relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value === 'sku' + ? skuRelabelRows.value.some((item) => !item.sourceRowId || !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1 + || (item.changeCarton && (!item.fbaCartonMark || !item.destinationWarehouseId))) + : cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || item.quantity < 1)) + if ((props.service !== 'labeling' && !form.customerNo && !form.inboundNo) || (props.service !== 'disposal' && !primaryWarehouse) || invalidCargo) { ElMessage.warning('请填写必填信息和完整的货物明细'); return } - if (props.service === 'labeling' && (!form.labelDeadline || !form.contact || !form.phone || form.chargeAmount <= 0 || cargoRows.value.some((item) => !item.shipmentNo || !item.newSkuCode))) { - ElMessage.warning('请填写贴标截止时间、联系人、贴标费用以及货物的货件编号和新 SKU'); return + if (props.service === 'labeling' && (!form.labelDeadline || !form.contact || !form.phone || form.chargeAmount <= 0)) { + ElMessage.warning('请填写贴标截止时间、联系人和贴标费用'); return } - if (props.service === 'labeling' && cargoRows.value.some((item) => !item.cartonMark || !item.fbaCartonMark)) { + if (props.service === 'labeling' && labelingMode.value === 'carton' && cargoRows.value.some((item) => !item.shipmentNo || !item.newSkuCode || !item.cartonMark || !item.fbaCartonMark)) { ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return } - if (props.service === 'labeling' && cargoRows.value.some((item) => !item.destinationWarehouseId && !form.warehouse)) { - ElMessage.warning('请填写统一目的海外仓,或为每箱货物填写目的仓'); return + if (props.service === 'labeling' && labelingMode.value === 'carton' && cargoRows.value.some((item) => !item.destinationWarehouseId || !item.sourceInventoryPositionId)) { + ElMessage.warning('请选择有库存的历史入仓单,并为每项填写 FBA 目的仓'); return } if (props.service === 'disposal' && !form.reason) { ElMessage.warning('请选择废弃原因'); return @@ -168,7 +307,11 @@ async function submit() { try { const idempotencyKey = crypto.randomUUID() if (props.service === 'labeling') { - await createRelabel({ customerOrderNo: form.customerNo, overseasWarehouseId: form.warehouse || undefined, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, chargeAmount: form.chargeAmount, remark: form.remark || undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: item.newSkuCode, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationWarehouseId: item.destinationWarehouseId || undefined, remark: item.remark || undefined })) }) + const sourceInboundOrders = [...new Set(cargoRows.value.map((item) => item.sourceInboundOrderNo || item.sourceInboundCode).filter((value): value is string => Boolean(value)))].join('、').slice(0, 128) + const items = labelingMode.value === 'carton' + ? cargoRows.value.map((item) => ({ sourceInventoryBalanceId: item.sourceInventoryBalanceId!, sourceInventoryPositionId: item.sourceInventoryPositionId!, sourceInboundOrderId: item.sourceInboundOrderId!, skuCode: item.sku, productNameZh: item.name, totalPackageCount: item.quantity, notifiedPackageCount: item.quantity, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: item.newSkuCode, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationFbaWarehouseCode: item.destinationWarehouseId, operationType: 'CARTON_LABEL' as const, skuItems: [], remark: item.remark || undefined })) + : buildSkuRelabelItems() + await createRelabel({ customerOrderNo: sourceInboundOrders || '贴标申请', operationWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, chargeAmount: form.chargeAmount, remark: form.remark || undefined, idempotencyKey, items }) } else if (props.service === 'disposal') { const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => { if (!item.sourceOrderId || item.sourceOrderId !== selectedDiscardOrderId.value || item.quantity > Number(item.availableQuantity || 0)) { @@ -195,6 +338,57 @@ async function openDetail(value: Record) { mode.value = 'detail' } catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') } } +async function loadWarehouses() { + fbaWarehousesLoading.value = true + fbaWarehousesError.value = '' + try { + const [fbaResult, operationResult] = await Promise.all([ + getMerchantWarehouses({ warehouseType: 'FBA' }), + getMerchantWarehouses({ warehouseType: 'OVERSEAS' }), + ]) + fbaWarehouses.value = fbaResult.records + operationWarehouses.value = operationResult.records + } catch (error) { + fbaWarehouses.value = [] + fbaWarehousesError.value = error instanceof Error ? error.message : 'FBA 目的仓加载失败' + ElMessage.error(fbaWarehousesError.value) + } finally { fbaWarehousesLoading.value = false } +} +function warehouseLabel(value: MerchantWarehouse) { return `${value.warehouseName} · ${value.warehouseCode}` } +async function selectOperationWarehouse() { + cargoRows.value = [] + skuRelabelRows.value = [] + selectedRelabelInboundOrderId.value = '' + relabelCandidates.value = [] + const warehouse = operationWarehouses.value.find((item) => String(item.id) === form.warehouse) + form.contact = warehouse?.contactName || '' + form.phone = warehouse?.contactPhone || '' + if (!form.warehouse) return + relabelCandidatesLoading.value = true + try { relabelCandidates.value = await listRelabelInventoryCandidates(form.warehouse) } + catch (error) { ElMessage.error(error instanceof Error ? error.message : '当前客户库存加载失败') } + finally { relabelCandidatesLoading.value = false } +} +function selectRelabelInventory(inboundOrderId: string) { + const existing = new Map(cargoRows.value.map((row) => [row.sourceInventoryPositionId, row])) + cargoRows.value = relabelCandidates.value + .filter((candidate) => String(candidate.inboundOrderId) === inboundOrderId) + .flatMap((candidate) => candidate.positions.map((position) => { + const previous = existing.get(String(position.positionId)) + return { + id: previous?.id || ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物', + quantity: Math.min(previous?.quantity || 1, position.availablePackages), availableQuantity: position.availablePackages, + sourceInventoryBalanceId: String(candidate.inventoryBalanceId), sourceInventoryPositionId: String(position.positionId), + sourceInboundOrderId: String(candidate.inboundOrderId), sourceInboundOrderNo: candidate.inboundOrderNo, + sourceInboundCode: candidate.inboundCode, sourceWarehouseName: candidate.overseasWarehouseName, + palletNo: position.palletNo, warehouseLocationCode: position.warehouseLocationCode, + fnsku: '', shipmentNo: previous?.shipmentNo || '', newSkuCode: previous?.newSkuCode || '', shippingMark: '', + cartonMark: previous?.cartonMark || '', fbaCartonMark: previous?.fbaCartonMark || '', + destinationWarehouseId: previous?.destinationWarehouseId || candidate.destinationFbaWarehouseCode || '', remark: previous?.remark || '', + } + })) + skuRelabelRows.value = [] +} @@ -226,32 +420,47 @@ async function openDetail(value: Record) {

{{ props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础与派送信息' }}

- - + - + + - + - - + + - + - + - - + +
-

货物明细

添加行清空
-

模板每行代表一箱;上传后直接回显到下方明细。明细目的仓优先,留空时使用顶部统一目的海外仓。

-
商品 SKU *中文品名 *可用箱数{{ props.service === 'labeling' ? 'FBA 货件编号 *' : 'FNSKU / 箱唛' }}换标新 SKU *{{ props.service === 'disposal' ? '本次废弃箱数' : props.service === 'relabel' ? '退件数量' : '箱数' }} *操作
{{ row.availableQuantity }} 箱
{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}
+
+ +
重置提交{{ config.title }}
@@ -260,12 +469,12 @@ async function openDetail(value: Record) { @@ -274,6 +483,8 @@ async function openDetail(value: Record) { diff --git a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs index c351535..243d8d9 100644 --- a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs +++ b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs @@ -4,45 +4,99 @@ import test from 'node:test' const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8') +const warehouseApi = readFileSync(new URL('../../first-leg/api/firstLeg.ts', import.meta.url), 'utf8') -test('上传关联面单后直接生成箱维度货物明细', () => { +test('贴标申请从当前客户仍有库存的历史入仓单选择来源', () => { + assert.match(view, /海外操作仓(中转仓)/) + assert.match(view, /选择有库存的历史入仓单/) + assert.match(view, /曾经下单且当前仍有库存的入仓单/) + assert.match(view, /当前中转仓暂无已入库可用库存/) + assert.match(view, /relabelInboundOptions/) + assert.match(view, /listRelabelInventoryCandidates\(form\.warehouse\)/) + assert.match(api, /relabel-applications\/inventory-candidates/) + assert.match(api, /params: \{ operationWarehouseId/) +}) + +test('客户贴标表单不展示客户单号、托盘和内部库存来源列', () => { + assert.doesNotMatch(view, /\*<\/i>客户单号/) + assert.doesNotMatch(view, /原入仓单<\/th>/) + assert.doesNotMatch(view, /托盘<\/th>/) + assert.doesNotMatch(view, /中转仓<\/th>/) + assert.doesNotMatch(view, /已选托盘组成/) +}) + +test('贴标申请保留关联模板下载和上传入口', () => { + assert.match(view, /downloadRelabelTemplate/) assert.match(view, /下载关联模板/) + assert.match(view, /templateInput\?\.click\(\)/) assert.match(view, /上传关联面单/) - assert.match(view, /cargoRows\.value = imported\.map/) - assert.match(view, /totalPackageCount: 1, notifiedPackageCount: 1/) - assert.doesNotMatch(view, /applyCartonMarkBinding/) - assert.doesNotMatch(view, /上传SKU 标签/) + assert.match(view, /importRelabelTemplate/) + assert.match(view, /明细行标识/) + assert.doesNotMatch(view, /库存位置ID/) }) -test('目的仓支持顶部兜底并随每箱明细提交', () => { - assert.match(api, /destinationWarehouseId\?: string/) - assert.match(view, /!item\.destinationWarehouseId && !form\.warehouse/) - assert.match(view, /destinationWarehouseId: item\.destinationWarehouseId \|\| undefined/) - assert.match(view, /cartonMark: item\.cartonMark/) - assert.match(view, /fbaCartonMark: item\.fbaCartonMark/) +test('贴标货物明细支持手动添加和编辑', () => { + assert.match(view, /手动添加行/) + assert.match(view, /addRelabelCargoRow/) + assert.match(view, /货物明细已按所选入仓单的可用库存带出/) + assert.match(view, / { - assert.match(view, /我方箱唛<\/th>FBA 箱唛/) - assert.match(view, /v-model="row\.cartonMark"/) - assert.match(view, /v-if="props\.service !== 'labeling'">\{\{ props\.service === 'disposal'/) +test('贴标货物明细拆分为箱唛换标和 SKU 换标页签', () => { + assert.match(view, /el-tab-pane label="箱唛换标" name="carton"/) + assert.match(view, /el-tab-pane label="SKU 换标" name="sku"/) + assert.match(view, /interface SkuRelabelRow/) + assert.match(view, /来源库存箱 \*/) + assert.match(view, /我方箱唛 \*/) + assert.match(view, /换标数量(件)\*/) + assert.match(view, /buildSkuRelabelItems/) + assert.match(view, /CARTON_AND_SKU/) + assert.match(view, /changeCarton/) + assert.doesNotMatch(view, /SKU 换标提交待接口接入/) }) -test('商品 SKU 在贴标模板和明细中为选填', () => { - assert.match(view, /const header = \['商品SKU(选填)'/) - assert.match(view, /const example = \['', '示例商品'/) - assert.match(view, /headers\.includes\('商品SKU(选填)'\)/) - assert.match(view, /const required = \['中文品名', 'FBA货件编号'/) - assert.match(view, /商品 SKU \*<\/span>/) - assert.match(api, /skuCode\?: string; productNameZh/) +test('一次申请单选入仓单且每项保留原库存归属', () => { + assert.doesNotMatch(view, /multiple filterable collapse-tags/) + assert.match(view, /String\(candidate\.inboundOrderId\) === inboundOrderId/) + assert.match(view, /sourceInventoryBalanceId: String\(candidate\.inventoryBalanceId\)/) + assert.match(view, /sourceInventoryPositionId: String\(position\.positionId\)/) + assert.match(view, /sourceInboundOrderId: String\(candidate\.inboundOrderId\)/) + assert.match(api, /sourceInventoryBalanceId: string; sourceInventoryPositionId: string; sourceInboundOrderId: string/) }) -test('贴标申请详情展示逐箱箱唛换标关系', () => { +test('海外操作仓和FBA目的仓使用独立字段', () => { + assert.match(api, /operationWarehouseId: string/) + assert.match(api, /destinationFbaWarehouseCode\?: string/) + assert.match(view, /operationWarehouseId: form\.warehouse/) + assert.match(view, /destinationFbaWarehouseCode: item\.destinationWarehouseId/) + assert.match(view, /FBA 目的仓<\/th>/) +}) + +test('操作仓和FBA仓均从商户仓库目录按类型加载', () => { + assert.match(view, /getMerchantWarehouses\(\{ warehouseType: 'FBA' \}\)/) + assert.match(view, /getMerchantWarehouses\(\{ warehouseType: 'OVERSEAS' \}\)/) + assert.match(view, /v-for="warehouse in operationWarehouses"/) + assert.match(view, /v-for="warehouse in fbaWarehouses"/) + assert.doesNotMatch(view, /美西海外仓-ONT8/) +}) + +test('选择海外操作仓后自动带出仓库签收联系方式', () => { + assert.match(warehouseApi, /contactName: string \| null/) + assert.match(warehouseApi, /contactPhone: string \| null/) + assert.match(view, /form\.contact = warehouse\?\.contactName \|\| ''/) + assert.match(view, /form\.phone = warehouse\?\.contactPhone \|\| ''/) + assert.match(view, /海外仓签收联系人/) + assert.match(view, /海外仓联系电话/) + assert.doesNotMatch(view, /ElMessage\.warning\('该海外仓主数据未完整维护/) + assert.match(view, /form\.warehouse && !form\.contact[^\n]+warehouse-unmaintained[^\n]+未维护/) + assert.match(view, /form\.warehouse && !form\.phone[^\n]+warehouse-unmaintained[^\n]+未维护/) +}) + +test('贴标详情展示箱唛、SKU 子明细和独立FBA目的仓', () => { assert.match(api, /export interface RelabelDetail/) - assert.match(api, /return overseasApiRequest/) - assert.match(view, /

箱唛换标明细<\/h3>/) assert.match(view, /item\.cartonMark \|\| '—'/) assert.match(view, /item\.fbaCartonMark \|\| '—'/) - assert.match(view, /item\.shipmentNo \|\| '—'/) - assert.match(view, /item\.newSkuCode \|\| '—'/) + assert.match(view, /item\.destinationFbaWarehouseCode/) + assert.match(view, /item\.operationType === 'CARTON_AND_SKU'/) + assert.match(view, /item\.skuItems\?\.length/) }) diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index b19e0dd..c59dfd1 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -106,6 +106,7 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly { }, { title: '海外仓', - children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'], + children: ['贴标申请', '仓储转运', '货物废弃', '退货签收', '退货库存'], }, { title: '订单管理', @@ -106,6 +106,7 @@ test('flattens visible leaves with unique paths', () => { '/overseas-warehouse/storage-transfer', '/overseas-warehouse/cargo-disposal', '/overseas-warehouse/return-relabel', + '/overseas-warehouse/return-inventory', '/orders/inbound', '/orders/delivery', '/finance/account', @@ -341,7 +342,8 @@ test('keeps only the unified my-orders placeholder cataloged while activating im ['overseas-warehouse/labeling', 'WarehouseServiceView.vue'], ['overseas-warehouse/storage-transfer', 'WarehouseServiceView.vue'], ['overseas-warehouse/cargo-disposal', 'WarehouseServiceView.vue'], - ['overseas-warehouse/return-relabel', 'WarehouseServiceView.vue'], + ['overseas-warehouse/return-relabel', 'ReturnOrdersView.vue'], + ['overseas-warehouse/return-inventory', 'ReturnInventoryView.vue'], ['orders/my', 'MyOrdersView.vue'], ['orders/inbound', 'InboundOrdersView.vue'], ['orders/delivery', 'DeliveryOrdersView.vue'],