发布已通过 TEST 回归的 2026-10-08 批次至灰度 #13
@@ -4,7 +4,7 @@
|
||||
|
||||
- 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。
|
||||
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
|
||||
- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/统一认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
|
||||
- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及统一 API、Token、会话撤销或可靠事件时按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
|
||||
- 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。
|
||||
|
||||
## 技术基线
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowRight, Forklift, RefreshCcw, Ship, ShoppingCart, Tags, Truck } from '@lucide/vue'
|
||||
import { ArrowRight, Forklift, PackageOpen, RefreshCcw, Ship, ShoppingCart, Tags, Truck, Warehouse } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { FirstLegOrdersView } from '@/modules/first-leg/public'
|
||||
|
||||
@@ -13,6 +13,8 @@ const primaryActions = computed(() => [
|
||||
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false },
|
||||
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false },
|
||||
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false },
|
||||
{ title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer', permission: 'merchant:overseas-warehouse:transfer:list', tone: 'teal', featured: false, actionLabel: '开始办理' },
|
||||
{ title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal', permission: 'merchant:overseas-warehouse:discard:list', tone: 'brown', featured: false, actionLabel: '开始办理' },
|
||||
].filter((action) => auth.hasPermission(action.permission)))
|
||||
|
||||
</script>
|
||||
@@ -28,7 +30,7 @@ const primaryActions = computed(() => [
|
||||
<RouterLink v-for="(action, index) in primaryActions" :key="action.route" :to="action.route" class="quick-action" :class="[`quick-action--${action.tone}`, { 'quick-action--featured': action.featured }]" :style="{ '--entry-index': index }">
|
||||
<span class="action-icon"><component :is="action.icon" :size="25" :stroke-width="2.2" aria-hidden="true" /></span>
|
||||
<span class="action-copy"><strong>{{ action.title }}</strong><small>{{ action.description }}</small></span>
|
||||
<span class="action-enter"><span>开始下单</span><ArrowRight :size="15" aria-hidden="true" /></span>
|
||||
<span class="action-enter"><span>{{ action.actionLabel ?? '开始下单' }}</span><ArrowRight :size="15" aria-hidden="true" /></span>
|
||||
</RouterLink>
|
||||
<p v-if="!primaryActions.length" class="no-actions">当前账号暂无常用业务权限,请通过已授权菜单办理业务,或联系企业管理员。</p>
|
||||
</section>
|
||||
@@ -46,7 +48,7 @@ const primaryActions = computed(() => [
|
||||
.business-intro { padding-top: 0; }
|
||||
.business-intro h1 { margin: 0; color: #172033; font-size: 27px; font-weight: 750; letter-spacing: -.5px; line-height: 1.3; }
|
||||
.business-intro p { margin: 5px 0 0; color: #7a8495; font-size: 13px; line-height: 1.55; }
|
||||
.quick-actions { display: grid; grid-template-columns: repeat(6, minmax(188px, 1fr)); gap: 10px; overflow-x: auto; padding: 0 0 6px; scroll-snap-type: x proximity; scrollbar-color: #bdc9dc transparent; scrollbar-width: thin; }
|
||||
.quick-actions { display: grid; grid-template-columns: repeat(8, minmax(150px, 1fr)); gap: 10px; overflow-x: auto; padding: 0 0 6px; scroll-snap-type: x proximity; scrollbar-color: #bdc9dc transparent; scrollbar-width: thin; }
|
||||
.quick-actions::-webkit-scrollbar { height: 7px; }
|
||||
.quick-actions::-webkit-scrollbar-thumb { border-radius: 999px; background: #bdc9dc; }
|
||||
.quick-actions::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -60,6 +62,8 @@ const primaryActions = computed(() => [
|
||||
.quick-action--cyan { --action-a: #0891b2; --action-b: #0e7490; --action-soft: rgba(8, 145, 178, .18); }
|
||||
.quick-action--orange { --action-a: #f59e0b; --action-b: #dc6b12; --action-soft: rgba(245, 158, 11, .2); }
|
||||
.quick-action--rose { --action-a: #e11d48; --action-b: #be185d; --action-soft: rgba(225, 29, 72, .17); }
|
||||
.quick-action--teal { --action-a: #0f766e; --action-b: #0e7490; --action-soft: rgba(15, 118, 110, .18); }
|
||||
.quick-action--brown { --action-a: #b45309; --action-b: #c2410c; --action-soft: rgba(180, 83, 9, .18); }
|
||||
.quick-action:hover { border-color: color-mix(in srgb, var(--action-a) 35%, transparent); box-shadow: 0 24px 54px rgba(15, 23, 42, .14), 0 0 0 1px color-mix(in srgb, var(--action-a) 16%, transparent); transform: translateY(-9px); }
|
||||
.quick-action:hover::before { transform: scale(1.25); }
|
||||
.quick-action:hover::after { right: 12%; left: 12%; opacity: 1; transform: scaleX(1); }
|
||||
@@ -93,8 +97,8 @@ const primaryActions = computed(() => [
|
||||
@keyframes action-enter { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes featured-aura { 0%, 100% { opacity: .86; transform: translate3d(0, 0, 0) scale(1); } 50% { opacity: 1; transform: translate3d(-10px, 9px, 0) scale(1.08); } }
|
||||
@keyframes featured-shine { 0%, 55% { left: -55%; opacity: 0; } 65% { opacity: 1; } 88%, 100% { left: 125%; opacity: 0; } }
|
||||
@container dashboard-content (max-width: 1180px) { .quick-actions { grid-template-columns: repeat(6, minmax(178px, 1fr)); } }
|
||||
@container dashboard-content (max-width: 1180px) { .quick-actions { grid-template-columns: repeat(8, minmax(178px, 1fr)); } }
|
||||
@media (max-width: 720px) { .client-dashboard { padding: 0; gap: 16px; } .business-intro h1 { font-size: 25px; } .quick-actions { grid-template-columns: repeat(2, minmax(150px, 1fr)); } }
|
||||
@media (max-width: 420px) { .quick-actions { grid-template-columns: repeat(6, minmax(168px, 1fr)); } }
|
||||
@media (max-width: 420px) { .quick-actions { grid-template-columns: repeat(8, minmax(168px, 1fr)); } }
|
||||
@media (prefers-reduced-motion: reduce) { .client-dashboard, .quick-action, .quick-action--featured::before, .quick-action--featured .action-enter::before { animation: none; } .quick-action, .quick-action::before, .quick-action::after, .action-icon, .action-enter svg { transition: none; } }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -74,13 +74,68 @@ test('退货签收详情使用独立路由和紧凑连续页面', () => {
|
||||
assert.doesNotMatch(view, /class="return-detail-dialog"/)
|
||||
})
|
||||
|
||||
test('退货箱唛打印复用统一的紧凑单页模板并按箱分页', () => {
|
||||
assert.match(view, /@page\{size:100mm 100mm;margin:8mm\}/)
|
||||
assert.match(view, /\.label\{width:84mm;height:84mm;padding:8mm/)
|
||||
assert.match(view, /html\.slice\(contentStart, bodyEnd\)/)
|
||||
// ReturnInventoryService supplies the 100 mm template; the client must preserve
|
||||
// its page margin (8 mm) and content padding (4 mm), including barcode markup.
|
||||
const labelStyle = '<style>@page{size:100mm 100mm;margin:8mm}.label{width:84mm;height:84mm;padding:4mm;page-break-after:always}.label:last-child{page-break-after:auto}</style>'
|
||||
const labelHtml = mark => `<!doctype html><html><head>${labelStyle}</head><body><div class="label"><p>${mark}</p><svg data-barcode="${mark}"></svg></div></body></html>`
|
||||
|
||||
function printHarness() {
|
||||
const source = view.slice(view.indexOf('async function printReturnLabel('), view.indexOf('\nfunction openDetail('))
|
||||
.replace('receipt: ReturnReceipt', 'receipt')
|
||||
const calls = { opened: 0, printed: 0, html: '', warnings: [], errors: [], fetched: [] }
|
||||
const popup = {
|
||||
document: { open() {}, write(html) { calls.html = html }, close() {} },
|
||||
focus() {}, print() { calls.printed += 1 }, close() {},
|
||||
}
|
||||
const print = new Function('window', 'ElMessage', 'getReturnInventoryLabel', `${source}; return printReturnLabel`)(
|
||||
{ open() { calls.opened += 1; return popup } },
|
||||
{ warning(message) { calls.warnings.push(message) }, error(message) { calls.errors.push(message) } },
|
||||
async mark => { calls.fetched.push(mark); return { printableHtml: labelHtml(mark) } },
|
||||
)
|
||||
return { print, calls }
|
||||
}
|
||||
|
||||
test('退货箱唛打印保留服务端整份模板,不另行覆盖纸张或条码', async () => {
|
||||
const { print, calls } = printHarness()
|
||||
const printableHtml = labelHtml('RETURN-TEST-1')
|
||||
await print({ cartonMark: 'RETURN-TEST-1', printableHtml })
|
||||
assert.equal(calls.html, printableHtml)
|
||||
assert.equal(calls.printed, 1)
|
||||
assert.deepEqual(calls.fetched, [])
|
||||
assert.deepEqual(calls.errors, [])
|
||||
})
|
||||
|
||||
test('退货多箱打印保留统一模板尺寸和每箱条码,并按箱分页', async () => {
|
||||
const { print, calls } = printHarness()
|
||||
const labels = ['RETURN-TEST-1', 'RETURN-TEST-2'].map(cartonMark => ({ cartonMark, printableHtml: labelHtml(cartonMark) }))
|
||||
await print({ cartonMark: labels[0].cartonMark, labels })
|
||||
assert.equal(calls.html.split(labelStyle).length - 1, 1)
|
||||
assert.equal((calls.html.match(/<html>/g) || []).length, 1)
|
||||
assert.equal((calls.html.match(/<section class="return-label">/g) || []).length, 2)
|
||||
for (const label of labels) assert.ok(calls.html.includes(`<svg data-barcode="${label.cartonMark}"></svg>`))
|
||||
assert.match(calls.html, /\.return-label\{break-after:page\}/)
|
||||
assert.match(calls.html, /\.return-label:last-child\{break-after:auto\}/)
|
||||
assert.match(calls.html, /\.return-label \.label\{page-break-after:auto\}/)
|
||||
assert.equal(calls.printed, 1)
|
||||
assert.deepEqual(calls.fetched, [])
|
||||
assert.deepEqual(calls.errors, [])
|
||||
assert.match(view, /打印全部箱唛(\{\{ receipt\.labels\?\.length \|\| receipt\.boxCount \|\| 1 \}\} 箱)/)
|
||||
})
|
||||
|
||||
test('缺少内嵌模板时读取鉴权箱唛模板,待修复箱记录禁止打印', async () => {
|
||||
const { print, calls } = printHarness()
|
||||
await print({ cartonMark: 'RETURN-TEST-1' })
|
||||
assert.deepEqual(calls.fetched, ['RETURN-TEST-1'])
|
||||
assert.ok(calls.html.includes(labelStyle))
|
||||
assert.equal(calls.printed, 1)
|
||||
await print({ cartonMark: 'RETURN-TEST-2', labelRepairRequired: true })
|
||||
assert.equal(calls.opened, 1)
|
||||
assert.equal(calls.printed, 1)
|
||||
assert.deepEqual(calls.fetched, ['RETURN-TEST-1'])
|
||||
assert.equal(calls.warnings.length, 1)
|
||||
assert.deepEqual(calls.errors, [])
|
||||
})
|
||||
|
||||
test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => {
|
||||
assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/)
|
||||
assert.match(view, /if \(editingReturnNo\.value\) await updateReturnOrder\(editingReturnNo\.value, payload\)/)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user