From aaca62f461a0260902c1bf18921671de015b961a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 10 Sep 2026 15:33:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- .../overseas-warehouse/api/outbounds.ts | 7 +- .../overseas-warehouse/api/relabelOrders.ts | 15 ++ .../pages/OutboundDetailView.vue | 3 +- .../pages/OutboundsView.test.ts | 20 +- .../pages/OutboundsView.vue | 177 ++++++++++++++++-- .../pages/RelabelOrderDetailView.vue | 9 +- 7 files changed, 212 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6fd4b329..9615b979 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,10 +75,10 @@ | 尾程订单控制与柜级入仓计划转换 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询与修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;是否展示生成动作只消费 `allPlansReady/pendingPlanCount`,不得在浏览器拼柜或自行推导就绪。确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示托数、`readiness/blockers`,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`;前端不计算托数、不逐票建计划、不预占托盘,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划并编排真实托盘批量预留/确认,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 排车查询、智能预排与转派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 | -| 海外出库与派送 | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;一件代发出库、取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` | +| 海外出库与派送 | `pages/OutboundsView.vue`、`OutboundDetailView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` | | 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` | | 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` | -| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 | +| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;换标详情展示 PDA 完工照片与复核人,状态区分待客户确认和待出库;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 | | 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` | | 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` | | 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` | diff --git a/src/modules/overseas-warehouse/api/outbounds.ts b/src/modules/overseas-warehouse/api/outbounds.ts index 3d9c5206..484e495e 100644 --- a/src/modules/overseas-warehouse/api/outbounds.ts +++ b/src/modules/overseas-warehouse/api/outbounds.ts @@ -17,8 +17,9 @@ export interface OutboundPalletRow { status?: string | null } export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null } -export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string } +export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string; outboundType?: string } export interface ReturnOutboundCandidate { returnNo?: string | null; overseasWarehouseId?: string | null; cartonMark?: string | null; fbaCartonMark?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; totalQuantity?: number | null; status?: string | null } +export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number } export async function listOutbounds(query: OutboundQuery) { const response = await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/outbound-orders', { params: query }) return normalizeOverseasPage(unwrapApiResponse(response)) @@ -31,6 +32,10 @@ export async function createOneClickDropshipOutbound(customerId: string, payload const response = await overseasHttp.post, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } }) return unwrapApiResponse(response) } +export async function listOneClickOutboundCandidates(customerId?: string, query: { warehouseId?: string } = {}) { + const response = await overseasHttp.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates', { params: { customerId, ...query } }) + return unwrapApiResponse(response).map((item) => ({ ...item, inventoryPositionId: String(item.inventoryPositionId), availablePackages: Number(item.availablePackages || 0) })) +} export async function listReturnOutboundCandidates(customerId: string, warehouseId: string) { const response = await overseasHttp.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } }) return unwrapApiResponse(response) diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index d083b922..0e08dc89 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -71,6 +71,13 @@ export interface RelabelSummaryDto { chargeCurrency?: string | null walletStatus?: string | null walletErrorMessage?: string | null + deliveryMethod?: string | null + printShippingLabel?: boolean | null + shippingLabelFileId?: string | null + shippingLabelFileName?: string | null + fulfillmentStatus?: string | null + outboundOrderNo?: string | null + customerConfirmedAt?: string | null createdAt?: string | null } @@ -106,6 +113,7 @@ export interface RelabelDetailDto { remark?: string | null items: RelabelItemDto[] history: RelabelHistoryDto[] + completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string | null; reviewerName?: string | null; reviewRemark?: string | null; completedAt?: string | null } | null } export async function listRelabelOrders(query: RelabelOrderQuery) { @@ -165,3 +173,10 @@ export async function downloadRelabelPdf(relabelNo: string, fileId: string, scop { params: { scope, itemId }, responseType: 'blob' }, ) } + +export async function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) { + return overseasHttp.get( + `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download`, + { responseType: 'blob' }, + ) +} diff --git a/src/modules/overseas-warehouse/pages/OutboundDetailView.vue b/src/modules/overseas-warehouse/pages/OutboundDetailView.vue index f0a75945..567da71b 100644 --- a/src/modules/overseas-warehouse/pages/OutboundDetailView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundDetailView.vue @@ -34,6 +34,7 @@ const palletColumns: DataTableColumn[] = [ ] function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' } function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) } +function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) } const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED') async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } } async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } } @@ -46,7 +47,7 @@ watch(outboundNo, () => { void load() }, { immediate: true }) - + diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.test.ts index 426df26b..fff8459e 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundsView.test.ts @@ -39,15 +39,27 @@ describe('OutboundsView permissions and navigation', () => { canPermission.mockImplementation((code: string) => ['platform:overseas-warehouse:outbound:create', 'platform:overseas-warehouse:outbound:cancel'].includes(code)) const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(wrapper.text()).toContain('一件代发出库') + expect(wrapper.text()).toContain('新建出库单') expect(wrapper.text()).toContain('取消') wrapper.unmount() }) - it('requires an explicit customer selection before choosing customer inventory', async () => { + it('loads one-click candidates across customers and loads return inventory after customer selection', async () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') expect(source).toContain('v-model="selectedCustomerId"') - expect(source).toContain('availablePositionOptions') - expect(source).toContain('createOneClickDropshipOutbound(selectedCustomerId.value') + expect(source).toContain('listOneClickOutboundCandidates(selectedCustomerId.value || undefined') + expect(source).toContain('if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates()') + expect(source).toContain("createOneClickDropshipOutbound([...customerIds][0] || ''") + }) + + it('applies batched return filters only through explicit search and exposes page sizes', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') + expect(source).toContain('returnSkuQueryInput') + expect(source).toContain('returnCartonQueryInput') + expect(source).toContain("item.replace(/\\s+/g, '')") + expect(source).toContain('@click="searchReturnSkus"') + expect(source).toContain('@click="searchReturnCartons"') + expect(source).toContain(':page-sizes="[5, 10, 20, 50]"') + expect(source).toContain('width="min(1320px, calc(100vw - 32px))"') }) }) diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.vue b/src/modules/overseas-warehouse/pages/OutboundsView.vue index 244a6685..b19e95a3 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundsView.vue @@ -14,31 +14,95 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listReturnOutboundCandidates, listOutbounds, type OutboundRow, type ReturnOutboundCandidate } from '../api/outbounds' -import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory' +import { cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listOneClickOutboundCandidates, listReturnOutboundCandidates, listOutbounds, type OneClickOutboundCandidate, type OutboundRow, type ReturnOutboundCandidate } from '../api/outbounds' +import { type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory' import { formatOverseasDateTime } from '../lastMilePresentation' import { overseasWarehousePermissions as permission } from '../permissions' +import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam/api/platformIam' +import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' +import { getReturnCarton, listReturnCartons, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnStockRow } from '../api/returnInventory' const router = useRouter(); const access = useAccessPreviewStore() const rows = ref([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref(null); const mutationError = ref(null) -const filters = ref({ documentNo: '', lxCode: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref(); const cartonMarks = ref(''); const selectedReturnMarks = ref([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref>([]); const returnCandidates = ref([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false) +const filters = ref({ documentNo: '', lxCode: '', warehouseId: '', status: '', outboundType: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref(); const cartonMarks = ref(''); const selectedReturnMarks = ref([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref>([]); const dropshipCandidates = ref([]); const selectedDropshipIds = ref([]); const returnCandidates = ref([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false); const dropshipQueryInput = ref(''); const dropshipQuery = ref(''); const dropshipPage = ref(1); const dropshipPageSize = ref(5) +const tenantOptions = ref([]) +const warehouseOptionsSource = ref([]) +const returnStockRows = ref([]) +const returnCartonRows = ref([]) +const selectedReturnSkus = ref([]) +const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('') +const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('') +const returnSkuPage = ref(1); const returnCartonPage = ref(1) +const returnSkuPageSize = ref(5); const returnCartonPageSize = ref(5) +const returnCartonDetails = ref>({}) const customerOptions = computed(() => { const customers = new Map() + tenantOptions.value.forEach((item) => { + if (item.status !== 'ACTIVE') return + const name = item.tenantName?.trim() || '客户名称待补充' + const phone = item.contactPhone?.trim() + customers.set(String(item.id), phone ? `${name}-${phone}` : name) + }) positionOptions.value.forEach(({ balance }) => customers.set(balance.customerId, balance.customerName?.trim() || '客户名称待补充')) return [...customers].map(([value, label]) => ({ value, label })) }) const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value)) -const warehouseOptions = computed(() => [...new Map(positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value).map(({ balance }) => [balance.overseasWarehouseId, balance.overseasWarehouseName || balance.overseasWarehouseId])).entries()].map(([value, label]) => ({ value, label: label || value }))) +const warehouseOptions = computed(() => warehouseOptionsSource.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ value: String(item.id), label: item.warehouseName || String(item.id) }))) +const selectedReturnStock = computed(() => returnStockRows.value.filter((item) => Number(item.availableQuantity || 0) > 0)) +const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE')) +const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '').toLowerCase()).filter(Boolean) +const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) } +const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value))) +const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => { + const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || '' + return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value) +})) +const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value)) +const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value)) +const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[]) const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } }) +const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase()))) +const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value)) const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : []) -const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', 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: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }] +const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', 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: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }] -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, lxCode: String(filters.value.lxCode || '').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; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedPositionId.value = ''; selectedWarehouseId.value = ''; selectedReturnNo.value = ''; returnCandidates.value = []; selectedReturnMarks.value = []; quantity.value = undefined; cartonMarks.value = ''; recipient.value = ''; remark.value = ''; 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 } } -function selectCustomer() { selectedPositionId.value = ''; quantity.value = undefined; selectedWarehouseId.value = ''; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = [] } -async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { returnCandidates.value = await listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '退货箱唛加载失败').message) } finally { inventoryLoading.value = false } } -async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedCustomerId.value || !recipient.value.trim()) { ElMessage.warning('请选择客户并填写收件信息'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!selectedWarehouseId.value || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请选择海外仓、退货单和至少一个箱唛'); return }; await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }); ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务') } else { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; if (marks.length && marks.length !== quantity.value) { ElMessage.warning('指定箱唛数量必须等于出库数量'); return }; await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined, cartonMarks: marks.length ? marks : 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 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, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, outboundType: String(filters.value.outboundType || '').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 } } +function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' } +async function loadInventoryOptions() { + inventoryLoading.value = true + try { + const [tenants, warehouses] = await Promise.allSettled([ + listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }), + listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }), + ]) + if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value.records + else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message) + if (warehouses.status === 'fulfilled') { + warehouseOptionsSource.value = warehouses.value.records + if (!selectedWarehouseId.value) { + const lax = warehouses.value.records.find((item) => item.warehouseCode === 'US-LAX-01' || item.warehouseName?.includes('洛杉矶')) + selectedWarehouseId.value = String(lax?.id || warehouses.value.records[0]?.id || '') + } + } else ElMessage.error(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败').message) + } finally { inventoryLoading.value = false } +} +async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; dropshipCandidates.value = []; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() } +async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } } +async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() } +async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() } +async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; dropshipCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates() } +async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } } +function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` } +function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) } +function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) } +function searchDropshipCandidates() { dropshipQuery.value = dropshipQueryInput.value.trim(); dropshipPage.value = 1 } +function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1 } +function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') } +function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 } +function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 } +function toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter((value) => value !== sku) } +async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }; if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!recipient.value.trim() || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请填写收件信息并选择退货单和箱唛'); return }; await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }); ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务') } else { const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))); if (!selected.length) { ElMessage.warning('请至少选择一个已完成入库的一件代发订单'); return }; const customerIds = new Set(selected.map(item => item.customerId).filter(Boolean)); if (customerIds.size !== 1) { ElMessage.warning('一张出库单只能选择同一个客户的订单'); return }; const warehouses = new Set(selected.map(item => item.overseasWarehouseId).filter(Boolean)); if (warehouses.size > 1 || (warehouses.size === 1 && !warehouses.has(selectedWarehouseId.value))) { ElMessage.warning('所选订单必须属于当前海外仓'); return }; const items = selected.map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode || '', cartonMarks: [] as string[] })); await createOneClickDropshipOutbound([...customerIds][0] || '', { recipient: '一件代发订单直出', items, remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`) } createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false } } function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) } function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' } async function cancel(row: OutboundRow) { if (!canCancel(row)) return; 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 }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } } @@ -54,12 +118,99 @@ onMounted(load)
+
- 一件代发出库退货出库 + + + + + 一件代发出库 + 退货出库 + + + + + + + + + + + + diff --git a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue index c6d9de08..81d1d012 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue @@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' -import { downloadRelabelPdf, getRelabelOrder, uploadRelabelPdf, type RelabelDetailDto } from '../api/relabelOrders' +import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, uploadRelabelPdf, type RelabelDetailDto } from '../api/relabelOrders' import { formatOverseasDateTime } from '../lastMilePresentation' import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue' import AppDialog from '@/shared/components/AppDialog.vue' @@ -29,7 +29,7 @@ const groups = [ ] function text(value: unknown) { return String(value ?? '').trim() || '—' } function status(value: unknown) { - const labels: Record = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } + const labels: Record = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } return labels[String(value || '')] || text(value) } function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record)[String(value || '')] || text(value) } @@ -80,12 +80,17 @@ async function previewPdf(fileId: string | null | undefined, name: string, scope try { const blob = await downloadRelabelPdf(orderId.value, fileId, scope, itemId); if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = name; previewOpen.value = true } catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF预览加载失败').message) } } +async function previewCompletionPhoto(fileId: string) { + try { const blob = await downloadRelabelCompletionPhoto(orderId.value, fileId); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) } + catch (error) { ElMessage.error(getApiErrorDetails(error, '完工照片加载失败').message) } +}