Merge branch 'release/test' of http://8.163.30.128:10082/html/client-web into release/test

This commit is contained in:
2026-10-08 17:08:06 +08:00
4 changed files with 70 additions and 9 deletions
@@ -54,6 +54,11 @@ export interface ReturnPackingPreview {
skus: Array<{ skuCode: string; productNameZh?: string; availableQuantity: number }>
}
export interface ReturnPackingPlan {
planNo: string; customerId: string; overseasWarehouseId: string; returnNos: string[]; mode: string; status: string
boxes: Array<{ boxNo?: number; items: Array<{ skuCode: string; quantity: number }>; length: number; width: number; height: number; weight: number }>; remark?: string
}
export function getReturnPackingPreview(returnNo?: string) {
return apiRequest<ReturnPackingPreview>({ method: 'GET', url: '/pda/v1/last-leg/return-inventory/packing/orders', params: { returnNo: returnNo || undefined } })
}
@@ -66,3 +71,11 @@ export function mergeReturnInventory(payload: {
method: 'POST', url: '/pda/v1/last-leg/return-inventory/cartons/merge', data: payload,
})
}
export function listReturnPackingPlans(params: Record<string, unknown> = {}) {
return apiRequest<PageResult<ReturnPackingPlan>>({ method: 'GET', url: '/merchant/v1/last-leg/return-packing-plans', params })
}
export function createReturnPackingPlan(payload: { returnNos: string[]; mode: string; boxes: ReturnPackingPlan['boxes']; remark?: string; idempotencyKey: string }) {
return apiRequest<ReturnPackingPlan>({ method: 'POST', url: '/merchant/v1/last-leg/return-packing-plans', data: payload })
}
@@ -14,10 +14,12 @@ export interface ReturnReceipt {
receivedQuantity: number; operatorName?: string; signedAt: string; unitType: 'PALLET' | 'BOX' | 'PARCEL'; unitCode?: string; boxCount: number
items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }>; cartonMark?: string; printableHtml?: string
labels?: Array<{ cartonMark: string; printableHtml: string }>
labelRepairRequired?: boolean
}
export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> }
export interface ReturnPhoto { fileId: number; fileName: string; contentType: string; sizeBytes: number }
export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[] }
export interface ReturnPackingDetail { sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[] }
export interface ReturnOrderCreate {
overseasWarehouseId: string; warehouseContactName: string; warehouseContactPhone: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Printer } from '@lucide/vue'
import { ArrowLeft, Printer, Plus } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/types'
import { createIdempotencyKey } from '@/utils/idempotencyKey'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import {
getReturnInventoryCarton,
@@ -18,9 +19,12 @@ import {
type ReturnInventoryCartonDetail,
type ReturnInventoryItem,
type ReturnInventorySummaryItem,
createReturnPackingPlan,
listReturnPackingPlans,
type ReturnPackingPlan,
} from '../api/returnInventory'
const activeTab = ref<'summary' | 'cartons'>('summary')
const activeTab = ref<'summary' | 'cartons' | 'plans'>('summary')
const props = defineProps<{ cartonOnly?: boolean; detailPage?: boolean }>()
const route = useRoute()
const router = useRouter()
@@ -57,6 +61,11 @@ async function loadWarehouses() {
const summaryRows = ref<ReturnInventorySummaryItem[]>([])
const cartonRows = ref<ReturnInventoryItem[]>([])
const cartonSummaryRows = ref<ReturnInventoryCartonSummary[]>([])
const packingPlans = ref<ReturnPackingPlan[]>([])
const packingPlanTotal = ref(0)
const showPackingCreate = ref(false)
const packingSaving = ref(false)
const packingForm = reactive({ returnNos: '', selectedSkus: {} as Record<string, number>, length: 1, width: 1, height: 1, weight: 1, remark: '' })
const total = ref(0)
const cartonDetail = ref<ReturnInventoryCartonDetail>()
const query = reactive({ page: 1, size: 20, overseasWarehouseId: '', skuCode: '', cartonMark: '', status: 'AVAILABLE', returnNo: '' })
@@ -122,6 +131,10 @@ async function load() {
loading.value = true
loadError.value = ''
try {
if (activeTab.value === 'plans') {
const page = await listReturnPackingPlans({ page: query.page, size: query.size })
packingPlans.value = page.records; packingPlanTotal.value = Number(page.total); total.value = packingPlanTotal.value; return
}
if (props.cartonOnly) {
const page = await listReturnInventoryCartons(params(true))
cartonSummaryRows.value = page.records
@@ -155,6 +168,18 @@ async function load() {
function search() { if (query.page === 1) void load(); else query.page = 1 }
function resetSearch() { query.status = 'AVAILABLE'; search() }
function changeTab() { query.page = 1; void load() }
function openPackingCreate() { packingForm.returnNos = ''; packingForm.selectedSkus = {}; packingForm.length = 1; packingForm.width = 1; packingForm.height = 1; packingForm.weight = 1; packingForm.remark = ''; showPackingCreate.value = true }
function togglePackingSku(skuCode: string, checked: boolean) { if (checked) packingForm.selectedSkus[skuCode] = 1; else delete packingForm.selectedSkus[skuCode] }
async function submitPackingPlan() {
const returnNos = packingForm.returnNos.split(/[\s,,]+/).map(item => item.trim()).filter(Boolean)
const items = Object.entries(packingForm.selectedSkus).filter(([, quantity]) => Number(quantity) > 0).map(([skuCode, quantity]) => ({ skuCode, quantity: Number(quantity) }))
if (!returnNos.length || !items.length) { ElMessage.warning('请填写已完成退货单号并至少选择一个 SKU'); return }
packingSaving.value = true
try {
await createReturnPackingPlan({ returnNos, mode: 'MERGE', boxes: [{ items, length: Number(packingForm.length), width: Number(packingForm.width), height: Number(packingForm.height), weight: Number(packingForm.weight) }], remark: packingForm.remark || undefined, idempotencyKey: createIdempotencyKey() })
ElMessage.success('合箱计划已提交审核'); showPackingCreate.value = false; activeTab.value = 'plans'; query.page = 1; await load()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '合箱计划创建失败') } finally { packingSaving.value = false }
}
function openSkuCartons(skuCode: string) {
query.skuCode = skuCode
activeTab.value = 'cartons'
@@ -217,11 +242,19 @@ onMounted(() => { if (props.detailPage) void Promise.all([loadCartonDetail(), lo
<template>
<section class="return-inventory-page">
<template v-if="!props.detailPage">
<el-tabs v-if="!props.cartonOnly" v-model="activeTab" @tab-change="changeTab"><el-tab-pane label="SKU 汇总库存" name="summary" /><el-tab-pane label="箱号明细" name="cartons" /></el-tabs>
<div class="inventory-toolbar"><el-tabs v-if="!props.cartonOnly" v-model="activeTab" @tab-change="changeTab"><el-tab-pane label="SKU 汇总库存" name="summary" /><el-tab-pane label="箱号明细" name="cartons" /><el-tab-pane label="合箱计划" name="plans" /></el-tabs><el-button type="primary" @click="openPackingCreate"><Plus :size="15" />创建合箱计划</el-button></div>
<QueryToolbar v-model="queryModel" layout="grid" :ariaLabel="`${props.cartonOnly ? '退货箱号' : '退货库存'}筛选`" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetSearch" />
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" />
<el-alert v-if="warehouseError" :title="warehouseError" type="warning" show-icon :closable="false"><el-button link type="primary" @click="loadWarehouses">重试加载海外仓</el-button></el-alert>
<div class="return-inventory-table">
<div v-if="activeTab === 'plans'" class="return-inventory-table">
<el-table :data="packingPlans" v-loading="loading" size="small">
<el-table-column prop="planNo" label="计划号" min-width="220" />
<el-table-column prop="status" label="状态" width="150"><template #default="{ row }"><el-tag>{{ row.status === 'PENDING_REVIEW' ? '等待平台审核' : row.status === 'READY' ? '待作业' : row.status === 'COMPLETED' ? '已完成' : row.status === 'CANCELLED' ? '已取消' : row.status }}</el-tag></template></el-table-column>
<el-table-column prop="returnNos" label="退货单" min-width="300"><template #default="{ row }">{{ row.returnNos.join('、') }}</template></el-table-column>
</el-table>
<div class="plan-pagination"><el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="packingPlanTotal" layout="total, sizes, prev, pager, next" :page-sizes="[20, 50, 100]" /></div>
</div>
<div v-else class="return-inventory-table">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="currentRows" :columns="currentColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<template #warehouse="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template>
<template #status="{ row }"><el-tag effect="light">{{ cartonStatusLabel(row.status) }}</el-tag></template>
@@ -230,6 +263,7 @@ onMounted(() => { if (props.detailPage) void Promise.all([loadCartonDetail(), lo
<template #time="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.updatedAt || row.createdAt || row.receivedAt) }}</span></template>
</DataTable>
</div>
<el-dialog v-model="showPackingCreate" title="创建合箱计划" width="720px" destroy-on-close><el-form label-width="110px"><el-form-item label="已完成退货单"><el-input v-model="packingForm.returnNos" type="textarea" :rows="2" placeholder="输入退货单号,多个用空格或逗号分隔" /></el-form-item><el-form-item label="合箱 SKU"><div class="packing-sku-grid"><div v-for="item in summaryRows" :key="item.skuCode" class="packing-sku-row"><el-checkbox :model-value="packingForm.selectedSkus[item.skuCode] !== undefined" @change="togglePackingSku(item.skuCode, Boolean($event))">{{ item.skuCode }}</el-checkbox><span>可用 {{ item.availableQuantity }}</span><el-input-number v-if="packingForm.selectedSkus[item.skuCode] !== undefined" v-model="packingForm.selectedSkus[item.skuCode]" :min="1" :max="item.availableQuantity" :controls="false" placeholder="数量" /></div></div></el-form-item><el-form-item label="箱规"><div class="packing-dimensions"><el-input-number v-model="packingForm.length" :min="1" placeholder="长 cm" /><el-input-number v-model="packingForm.width" :min="1" placeholder="宽 cm" /><el-input-number v-model="packingForm.height" :min="1" placeholder="高 cm" /><el-input-number v-model="packingForm.weight" :min="1" placeholder="重量 kg" /></div></el-form-item><el-form-item label="备注"><el-input v-model="packingForm.remark" placeholder="可填写合箱说明" /></el-form-item></el-form><template #footer><el-button @click="showPackingCreate = false">取消</el-button><el-button type="primary" :loading="packingSaving" @click="submitPackingPlan">提交审核</el-button></template></el-dialog>
</template>
<article v-else v-loading="loading" class="carton-detail-surface">
<template v-if="cartonDetail">
@@ -243,7 +277,7 @@ onMounted(() => { if (props.detailPage) void Promise.all([loadCartonDetail(), lo
</template>
<style scoped>
.return-inventory-page { display: grid; gap: 12px; } .return-inventory-page :deep(.el-tabs__header) { margin: 0; } .return-inventory-table { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; } .inventory-time { white-space: nowrap; }
.return-inventory-page { display: grid; gap: 12px; } .return-inventory-page :deep(.el-tabs__header) { margin: 0; } .inventory-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.return-inventory-table { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; } .inventory-time { white-space: nowrap; }.plan-pagination{display:flex;justify-content:flex-end;padding:10px}.packing-sku-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%}.packing-sku-row{display:grid;grid-template-columns:minmax(0,1fr) auto 92px;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--client-border);border-radius:6px}.packing-sku-row>span{color:var(--client-muted);font-size:12px}.packing-dimensions{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;width:100%}
.carton-detail-surface{overflow:hidden;min-height:240px;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.carton-detail-header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;min-height:64px;padding:10px 16px;border-bottom:1px solid var(--client-border)}.carton-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.carton-detail-header>div:nth-child(2){display:flex;min-width:0;flex-direction:column;gap:3px}.carton-detail-header strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.carton-detail-header span{color:#7c899c;font-size:12px}.carton-detail-actions{display:flex;align-items:center;gap:8px}.carton-detail-section+.carton-detail-section{border-top:1px solid var(--client-border)}.carton-detail-section>h3{display:flex;min-height:40px;align-items:center;margin:0;padding:0 16px;border-bottom:1px solid var(--client-border);background:var(--client-header-bg);font-size:14px}.carton-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0}.carton-detail-grid div{display:grid;min-height:42px;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:8px;padding:5px 12px;border-right:1px solid var(--client-border);border-bottom:1px solid var(--client-border)}.carton-detail-grid dt{color:var(--client-muted);font-size:12px}.carton-detail-grid dd{margin:0;overflow:hidden;color:var(--client-text);font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}@media(max-width:1100px){.carton-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.carton-detail-header{grid-template-columns:1fr auto}.carton-detail-back{grid-column:1/-1}.carton-detail-grid{grid-template-columns:1fr}}
.carton-detail-error{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;color:#b42318}
</style>
@@ -176,6 +176,7 @@ function previewPhoto(fileId: string | number) {
if (url) window.open(url, '_blank', 'noopener')
}
async function printReturnLabel(receipt: ReturnReceipt) {
if (receipt.labelRepairRequired) { ElMessage.warning('签收箱数与当前箱记录不一致,请联系仓库核实并修复后打印'); return }
if (!receipt.cartonMark) return
const popup = window.open('', '_blank')
if (!popup) { ElMessage.warning('请允许浏览器打开打印窗口'); return }
@@ -184,13 +185,14 @@ async function printReturnLabel(receipt: ReturnReceipt) {
const labels = receipt.labels?.length
? receipt.labels
: [{ cartonMark: receipt.cartonMark, printableHtml: receipt.printableHtml || (await getReturnInventoryLabel(receipt.cartonMark)).printableHtml }]
const printStyle = '<style>@page{size:100mm 100mm;margin:8mm}*{box-sizing:border-box}html,body{margin:0;background:#fff;color:#111;font-family:Arial,\'Microsoft YaHei\',sans-serif}.label{width:84mm;height:84mm;padding:8mm;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;page-break-after:always}.label:last-child{page-break-after:auto}.mark{font:800 18px Arial,sans-serif;line-height:1.15;word-break:break-all;margin:0 0 6mm}.barcode{width:84mm;margin:0 0 5mm}.barcode svg{display:block;width:100%;height:auto}.line{display:flex;justify-content:center;align-items:baseline;width:100%;padding:2mm 0;font-size:15px}.sku{max-width:84mm;overflow-wrap:anywhere;font-weight:700}</style>'
const printableHtml = `<!doctype html><html><head><meta charset="utf-8"><title>退货箱唛</title>${printStyle}</head><body>${labels.map((label) => {
const printStyles = [...new Set(labels.flatMap(label => label.printableHtml.match(/<style\b[^>]*>[\s\S]*?<\/style>/gi) || []))].join('')
const printableHtml = receipt.printableHtml || `<!doctype html><html><head><meta charset="utf-8"><title>退货箱唛</title>${printStyles}<style>.return-label{break-after:page}.return-label:last-child{break-after:auto}.return-label .label{page-break-after:auto}</style></head><body>${labels.map((label) => {
const html = label.printableHtml || ''
const bodyStart = html.indexOf('<body')
const contentStart = bodyStart < 0 ? 0 : html.indexOf('>', bodyStart) + 1
const bodyEnd = html.lastIndexOf('</body>')
return contentStart > 0 && bodyEnd > contentStart ? html.slice(contentStart, bodyEnd) : html
const content = contentStart > 0 && bodyEnd > contentStart ? html.slice(contentStart, bodyEnd) : html
return `<section class="return-label">${content}</section>`
}).join('')}</body></html>`
popup.document.open()
popup.document.write(printableHtml)
@@ -355,6 +357,16 @@ onBeforeUnmount(clearPhotoUrls)
<h3>预报与实收</h3>
<DataTable :data="selected.items" :columns="itemColumns" row-key="skuCode" empty-text="暂无 SKU 明细" />
</section>
<section v-if="selected.packingDetails?.length" class="return-detail-section">
<h3>合箱明细</h3>
<el-table :data="selected.packingDetails" size="small">
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="160" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="160" />
<el-table-column prop="skuCode" label="SKU" min-width="140" />
<el-table-column prop="quantity" label="本单装入数量" width="120" />
<el-table-column prop="createdAt" label="合箱时间" min-width="160" />
</el-table>
</section>
<section class="return-detail-section">
<h3 class="section-heading-with-action"><span>实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)</span><span v-if="selected.receipts?.some(item => item.unitType !== 'PARCEL' && item.cartonMark)" class="return-label-actions"><el-button v-for="receipt in selected.receipts.filter(item => item.unitType !== 'PARCEL' && item.cartonMark)" :key="receipt.receiptNo" type="primary" size="small" @click="printReturnLabel(receipt)">打印全部箱唛({{ receipt.labels?.length || receipt.boxCount || 1 }} 箱)</el-button></span></h3>
<el-collapse v-if="selected.pallets?.length" class="return-pallet-list">