Merge branch 'dev' of http://8.163.30.128:10082/html/client-web into release/test
This commit is contained in:
@@ -52,9 +52,9 @@
|
||||
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
|
||||
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
|
||||
| 客户资金账户、授信与充值申请 | `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/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。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`;贴标申请先选国外中转仓和有库存历史入仓单,换标申请必须提交正数 CNY `chargeAmount`,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
|
||||
|
||||
## 租户、认证与数据安全
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export const FIRST_LEG_ORDER_STATUSES = [
|
||||
{ code: 'DRAFT', label: '草稿' },
|
||||
{ code: 'PENDING_REVIEW', label: '待审核' },
|
||||
{ code: 'WAIT_MEASURE', label: '待测量数据' },
|
||||
{ code: 'WAIT_PALLETIZE', label: '待组托' },
|
||||
{ code: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ code: 'LOADING', label: '装柜中' },
|
||||
{ code: 'LOADED', label: '装柜完成' },
|
||||
|
||||
Vendored
+2
@@ -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']
|
||||
|
||||
@@ -8,6 +8,23 @@ export interface PageResult<T> {
|
||||
pages: number
|
||||
}
|
||||
|
||||
interface PageResultWire<T> extends Omit<PageResult<T>, 'total' | 'page' | 'size' | 'pages'> {
|
||||
total: number | string
|
||||
page: number | string
|
||||
size: number | string
|
||||
pages: number | string
|
||||
}
|
||||
|
||||
function normalizePageResult<T>(result: PageResultWire<T>): PageResult<T> {
|
||||
return {
|
||||
...result,
|
||||
total: Number(result.total),
|
||||
page: Number(result.page),
|
||||
size: Number(result.size),
|
||||
pages: Number(result.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinanceAccount {
|
||||
currencyCode: 'CNY'
|
||||
actualBalance: string
|
||||
@@ -71,20 +88,22 @@ export function getFinanceAccount() {
|
||||
return apiRequest<FinanceAccount>({ method: 'GET', url: '/client/finance/v1/account' })
|
||||
}
|
||||
|
||||
export function getAccountLedger(page: number, size: number) {
|
||||
return apiRequest<PageResult<AccountLedgerEntry>>({
|
||||
export async function getAccountLedger(page: number, size: number) {
|
||||
const result = await apiRequest<PageResultWire<AccountLedgerEntry>>({
|
||||
method: 'GET',
|
||||
url: '/client/finance/v1/account/ledger',
|
||||
params: { page, size },
|
||||
})
|
||||
return normalizePageResult(result)
|
||||
}
|
||||
|
||||
export function getRechargeRequests(page: number, size: number) {
|
||||
return apiRequest<PageResult<RechargeRequest>>({
|
||||
export async function getRechargeRequests(page: number, size: number) {
|
||||
const result = await apiRequest<PageResultWire<RechargeRequest>>({
|
||||
method: 'GET',
|
||||
url: '/client/finance/v1/recharge-requests',
|
||||
params: { page, size },
|
||||
})
|
||||
return normalizePageResult(result)
|
||||
}
|
||||
|
||||
export function getRechargeChannels() {
|
||||
|
||||
@@ -39,6 +39,11 @@ test('renders server balances, ledger and recharge states without internal ident
|
||||
assert.match(viewSource, /v-loading="pageLoading"/)
|
||||
assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
|
||||
assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
|
||||
assert.match(viewSource, /:default-current-page="ledgerPage"/)
|
||||
assert.match(viewSource, /:default-current-page="rechargePage"/)
|
||||
assert.doesNotMatch(viewSource, /<el-pagination (?:v-model:current-page|:current-page)=/)
|
||||
assert.match(apiSource, /function normalizePageResult<[\s\S]*total: Number\(result\.total\)/)
|
||||
assert.match(apiSource, /page: Number\(result\.page\)/)
|
||||
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
|
||||
})
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ onMounted(() => { void loadAccountData() })
|
||||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="发生时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
|
||||
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :default-current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
|
||||
</section>
|
||||
|
||||
<section class="account-card">
|
||||
@@ -261,7 +261,7 @@ onMounted(() => { void loadAccountData() })
|
||||
<el-table-column label="提交时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="审核时间" width="170"><template #default="{ row }">{{ formatDateTime(row.reviewedAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
|
||||
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :default-current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ export interface MerchantFirstLegOrder {
|
||||
customerId: number | string | null
|
||||
customerName: string
|
||||
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null
|
||||
lxCode?: string | null
|
||||
status: ApiFirstLegStatus
|
||||
transportMode?: ApiTransportMode
|
||||
tradeTerm: string | null
|
||||
|
||||
@@ -35,3 +35,7 @@ test('keeps all backend Long identifiers as strings', () => {
|
||||
assert.doesNotMatch(typesSource, /(?:id|Id): number/)
|
||||
assert.doesNotMatch(typesSource, /internalNotes|statusHistory|files/)
|
||||
})
|
||||
|
||||
test('accepts the one-click dropship palletization process status', () => {
|
||||
assert.match(typesSource, /MerchantInboundProcessStatus[\s\S]*?'WAIT_PALLETIZE'[\s\S]*?'WAIT_SPLIT'/)
|
||||
})
|
||||
|
||||
@@ -4,8 +4,11 @@ import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./unifiedOrders.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('keeps the merchant unified-order boundary and phase-two WAIT_SPLIT status', () => {
|
||||
test('keeps the merchant unified-order boundary and one-click palletization status', () => {
|
||||
assert.match(source, /const unifiedOrderBaseUrl = '\/merchant\/v1\/orders'/)
|
||||
assert.match(source, /ApiUnifiedOrderMainStatus[\s\S]*?'WAIT_PALLETIZE'/)
|
||||
assert.match(source, /ApiUnifiedOrderMainStatus[\s\S]*?'WAIT_SPLIT'/)
|
||||
assert.match(source, /fulfillmentType\?: 'STANDARD' \| 'ONE_CLICK_DROPSHIP' \| null/)
|
||||
assert.match(source, /lxCode\?: string \| null/)
|
||||
assert.match(source, /mainStatus\?: ApiUnifiedOrderMainStatus/)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ApiContainerMode, ApiDestinationType, ApiFirstLegStatus, ApiPickup
|
||||
export type ApiUnifiedOrderMainStatus =
|
||||
| 'PENDING_REVIEW'
|
||||
| 'WAIT_MEASURE'
|
||||
| 'WAIT_PALLETIZE'
|
||||
| 'WAIT_SPLIT'
|
||||
| 'LOADING'
|
||||
| 'LOADED'
|
||||
@@ -54,6 +55,8 @@ export interface MerchantUnifiedOrderRow {
|
||||
orderId: number | string
|
||||
tenantId: number | string
|
||||
orderNo: string | null
|
||||
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null
|
||||
lxCode?: string | null
|
||||
mainStatus: ApiUnifiedOrderMainStatus
|
||||
nextAction: string | null
|
||||
internalOrderStatus: ApiFirstLegStatus | null
|
||||
|
||||
@@ -23,7 +23,7 @@ function stageState(index: number) {
|
||||
<div class="progress-heading">
|
||||
<div>
|
||||
<h3 id="order-progress-title">订单进度</h3>
|
||||
<span>国内装柜进度及后续履约同步</span>
|
||||
<span>国内入仓、组托、装柜及后续履约同步</span>
|
||||
</div>
|
||||
<el-tag :type="order.status === 'DELIVERED' ? 'success' : order.status === 'DRAFT' || order.status === 'PENDING_REVIEW' ? 'warning' : 'info'" effect="light">
|
||||
{{ getFirstLegStatusLabel(order.status) }}
|
||||
|
||||
@@ -80,6 +80,8 @@ test('declares the final C02 row and detail contract in the API adapter', () =>
|
||||
assert.match(unifiedApiSource, /orderId: number \| string/)
|
||||
assert.match(unifiedApiSource, /mainStatus: ApiUnifiedOrderMainStatus/)
|
||||
assert.match(unifiedApiSource, /nextAction: string \| null/)
|
||||
assert.match(unifiedApiSource, /fulfillmentType\?: 'STANDARD' \| 'ONE_CLICK_DROPSHIP' \| null/)
|
||||
assert.match(unifiedApiSource, /lxCode\?: string \| null/)
|
||||
assert.match(unifiedApiSource, /customerRemark: string \| null/)
|
||||
for (const field of ['pickupMethod', 'requestedPickupAt', 'containerMode', 'destinationType', 'destinationCode', 'skuNos', 'totalQuantity', 'totalNetWeightKg', 'chargeableVolumeCbm', 'inboundCompletedAt', 'shipmentId', 'referenceId']) {
|
||||
assert.match(unifiedApiSource, new RegExp(`${field}:`))
|
||||
@@ -117,6 +119,10 @@ test('maps C02 authoritative status, transport, cargo and pickup snapshots', ()
|
||||
assert.match(storeSource, /id: String\(summary\.orderId\)/)
|
||||
assert.match(storeSource, /transportMode: unifiedTransport\(summary\.containerMode\)/)
|
||||
assert.match(storeSource, /status: summary\.mainStatus/)
|
||||
assert.match(storeSource, /fulfillmentType: summary\.fulfillmentType \?\? 'STANDARD'/)
|
||||
assert.match(storeSource, /lxCode: summary\.lxCode \?\? ''/)
|
||||
assert.match(storeSource, /mapped\.fulfillmentType = detail\.summary\.fulfillmentType \?\? mapped\.fulfillmentType/)
|
||||
assert.match(storeSource, /mapped\.lxCode = detail\.summary\.lxCode \?\? mapped\.lxCode/)
|
||||
assert.match(storeSource, /requestedPickupAt: summary\.requestedPickupAt \?\? ''/)
|
||||
assert.match(storeSource, /chargeableVolumeCbm: summary\.chargeableVolumeCbm \?\? null/)
|
||||
assert.match(storeSource, /inboundedAt: summary\.inboundCompletedAt \?\? ''/)
|
||||
@@ -127,6 +133,7 @@ test('maps C02 authoritative status, transport, cargo and pickup snapshots', ()
|
||||
assert.match(storeSource, /summary\?\.loading\?\.pickingVersion/)
|
||||
assert.match(storeSource, /summary\?\.inboundId == null \? '' : String\(summary\.inboundId\)/)
|
||||
assert.match(storeSource, /customerRemark: summary\.customerRemark \?\? ''/)
|
||||
assert.match(storeSource, /PALLETIZE_DROPSHIP: '一件代发组托'/)
|
||||
})
|
||||
|
||||
test('sends a manually entered old-system inbound code with the draft payload', () => {
|
||||
|
||||
@@ -231,6 +231,7 @@ const unifiedActionLabels: Readonly<Record<string, string>> = {
|
||||
SUBMIT_ORDER: '订单已提交',
|
||||
APPROVE_ORDER: '订单审核通过',
|
||||
CONFIRM_MEASUREMENT: '测量数据已确认',
|
||||
PALLETIZE_DROPSHIP: '一件代发组托',
|
||||
SMART_SPLIT: '分柜方案已更新',
|
||||
CONFIRM_PRELOAD: '预装清单已确认',
|
||||
COMPLETE_LOADING: '装柜已完成',
|
||||
@@ -279,7 +280,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
|
||||
return {
|
||||
id: String(order.id ?? order.orderId ?? ''), orderNo: order.orderNo, referenceNo: order.referenceId ?? '',
|
||||
transportMode: order.transportMode ? clientTransport(order) : null,
|
||||
businessType: 'EXPORT', fulfillmentType: order.fulfillmentType ?? 'STANDARD', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode || '',
|
||||
businessType: 'EXPORT', fulfillmentType: order.fulfillmentType ?? 'STANDARD', lxCode: order.lxCode ?? '', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode || '',
|
||||
receivingWarehouseId: String(order.receivingWarehouseId), receivingWarehouseName: order.receivingWarehouseName,
|
||||
receivingWarehouseSnapshot: orderWarehouseSnapshot(order, warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
|
||||
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
|
||||
@@ -328,7 +329,7 @@ function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: Platfo
|
||||
const warehouse = warehouses.find((item) => item.id === String(summary.receivingWarehouseId))
|
||||
return {
|
||||
id: String(summary.orderId), orderNo: summary.orderNo, referenceNo: summary.referenceId ?? '', transportMode: unifiedTransport(summary.containerMode),
|
||||
businessType: 'EXPORT', origin: '', destination: summary.destinationWarehouseName ?? '',
|
||||
businessType: 'EXPORT', fulfillmentType: summary.fulfillmentType ?? 'STANDARD', lxCode: summary.lxCode ?? '', origin: '', destination: summary.destinationWarehouseName ?? '',
|
||||
receivingWarehouseId: summary.receivingWarehouseId == null ? '' : String(summary.receivingWarehouseId),
|
||||
receivingWarehouseName: summary.receivingWarehouseName ?? '', receivingWarehouseSnapshot: warehouseSnapshot(warehouse),
|
||||
expectedDepartureDate: summary.expectedShipDate ?? '', etd: '', eta: '', shipperPartyId: '', shipperPartySnapshot: null,
|
||||
@@ -381,6 +382,8 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P
|
||||
const mapped = mapOrder(detail.order.order, warehouses, detail.order)
|
||||
mapped.id = String(detail.summary.orderId)
|
||||
mapped.status = detail.summary.mainStatus
|
||||
mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType
|
||||
mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode
|
||||
mapped.businessSummary = businessSummary(detail.summary)
|
||||
mapped.timeline = unifiedTimeline(detail)
|
||||
const summary = mapUnifiedOrderRow(detail.summary, warehouses)
|
||||
|
||||
@@ -6,6 +6,7 @@ export type MerchantInboundProcessStatus =
|
||||
| 'WAIT_PRICING'
|
||||
| 'FEE_CONFIRMING'
|
||||
| 'INBOUNDING'
|
||||
| 'WAIT_PALLETIZE'
|
||||
| 'WAIT_SPLIT'
|
||||
| 'VOIDED'
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
canClientCancelFirstLegOrder,
|
||||
canClientEditFirstLegOrder,
|
||||
createEmptyCargoItem,
|
||||
FIRST_LEG_PROGRESS_STAGES,
|
||||
getFirstLegProgressIndex,
|
||||
getFirstLegStatusLabel,
|
||||
getFirstLegTransportModeLabel,
|
||||
summarizeFirstLegCargo,
|
||||
@@ -58,12 +60,16 @@ function validDraft() {
|
||||
|
||||
test('uses the confirmed status and transport labels', () => {
|
||||
assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据')
|
||||
assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
|
||||
assert.equal(getFirstLegStatusLabel('WAIT_PALLETIZE'), '待组托')
|
||||
assert.equal(getFirstLegStatusLabel('WAIT_SPLIT'), '待分柜')
|
||||
assert.equal(getFirstLegStatusLabel('LOADING'), '装柜中')
|
||||
assert.equal(getFirstLegStatusLabel('LOADED'), '装柜完成')
|
||||
assert.equal(getFirstLegStatusLabel('DELIVERED'), '派送完成')
|
||||
assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL')
|
||||
assert.deepEqual(FIRST_LEG_PROGRESS_STAGES.map((item) => item.code), [
|
||||
'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED',
|
||||
])
|
||||
assert.equal(getFirstLegProgressIndex('WAIT_PALLETIZE'), 2)
|
||||
})
|
||||
|
||||
test('only allows draft orders to be edited in the confirmed phase-one flow', () => {
|
||||
@@ -72,6 +78,7 @@ test('only allows draft orders to be edited in the confirmed phase-one flow', ()
|
||||
assert.equal(canClientCancelFirstLegOrder('DRAFT'), true)
|
||||
assert.equal(canClientCancelFirstLegOrder('PENDING_REVIEW'), true)
|
||||
assert.equal(canClientCancelFirstLegOrder('WAIT_MEASURE'), false)
|
||||
assert.equal(canClientCancelFirstLegOrder('WAIT_PALLETIZE'), false)
|
||||
assert.equal(canClientCancelFirstLegOrder('WAIT_SPLIT'), false)
|
||||
})
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ test('shows one unified progress line and C02 business summaries without detail
|
||||
assert.match(source, /summary\.expectedPickCount/)
|
||||
assert.match(source, /summary\.deliveryTrackingNo/)
|
||||
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
|
||||
assert.match(progressSource, /国内装柜进度及后续履约同步/)
|
||||
assert.match(progressSource, /国内入仓、组托、装柜及后续履约同步/)
|
||||
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
|
||||
assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
|
||||
assert.match(source, /船期摘要/)
|
||||
@@ -369,6 +369,12 @@ test('distills order creation into the confirmed prototype sections with server
|
||||
assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/)
|
||||
})
|
||||
|
||||
test('shows an optional LX inbound order number for one-click dropship orders', () => {
|
||||
assert.match(source, /v-if="form\.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode"/)
|
||||
assert.match(source, /v-model="form\.lxCode"[^>]*placeholder="请输入LX入库单号"/)
|
||||
assert.doesNotMatch(source, /label="LX入库单号"[^>]*required/)
|
||||
})
|
||||
|
||||
test('loads only active server channels for the selected domestic warehouse', () => {
|
||||
assert.match(source, /getMerchantPricingChannels\(warehouseId\)/)
|
||||
assert.match(source, /const channelOptions = ref<MerchantPricingChannel\[\]>\(\[\]\)/)
|
||||
|
||||
@@ -1898,7 +1898,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<div class="order-section-heading"><div><h3>客户下单</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
|
||||
<div class="prototype-form-flow shipping-form-flow">
|
||||
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
|
||||
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX 代码" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入领星入库单 LX 代码" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
|
||||
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
|
||||
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
|
||||
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
|
||||
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
|
||||
|
||||
@@ -30,10 +30,9 @@ 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\)/)
|
||||
assert.match(overseas, /function formatDateTime\(value\?: string \| null\)/)
|
||||
assert.match(overseas, /import SingleLineText from '@\/components\/common\/SingleLineText\.vue'/)
|
||||
assert.match(overseas, /<SingleLineText :value="row\[String\(index\)\]"/)
|
||||
assert.match(overseas, /<td><button class="warehouse-link"[^>]*>.*?查看<\/button><\/td><td v-for="\(_, index\) in config\.columns\.slice\(1\)"/s)
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<ReturnInventoryItem>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory', params })
|
||||
}
|
||||
|
||||
export function listReturnInventorySummary(params: Record<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<ReturnInventorySummaryItem>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/summary', params })
|
||||
}
|
||||
|
||||
export function getReturnInventoryCarton(cartonMark: string) {
|
||||
return overseasApiRequest<ReturnInventoryCartonDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}` })
|
||||
}
|
||||
|
||||
export function getReturnInventoryLabel(cartonMark: string) {
|
||||
return overseasApiRequest<ReturnInventoryLabel>({ 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<ReturnPackingPreview>({ 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
||||
import type { PageResult } from './warehouseServices'
|
||||
|
||||
export interface ReturnOrderSummary {
|
||||
returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string
|
||||
skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; 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; warehouseContactName: string; warehouseContactPhone: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
|
||||
items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }>
|
||||
}
|
||||
|
||||
export function listReturnOrders(params: Record<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<ReturnOrderSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params })
|
||||
}
|
||||
export function getReturnOrder(returnNo: string) {
|
||||
return overseasApiRequest<ReturnOrderDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` })
|
||||
}
|
||||
export function createReturnOrder(payload: ReturnOrderCreate) {
|
||||
return overseasApiRequest<ReturnOrderDetail>({ method: 'POST', url: '/merchant/v1/last-leg/return-orders', data: payload })
|
||||
}
|
||||
@@ -1,35 +1,49 @@
|
||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
||||
|
||||
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP'
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
|
||||
export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; status: string; estimatedArrivalAt: string; createdAt: string }
|
||||
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 TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; status: string; estimatedArrivalAt: string; createdAt: string }
|
||||
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; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: 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 }
|
||||
remark?: string; items: RelabelItemDetail[]
|
||||
history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }>
|
||||
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
|
||||
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string }
|
||||
}
|
||||
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
|
||||
operatingWarehouseId: string; fbaWarehouseCode?: string
|
||||
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string
|
||||
fbaShipmentNo: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
|
||||
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
|
||||
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
|
||||
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 }>
|
||||
customerOrderNo: string; operationWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount: number
|
||||
remark?: string; idempotencyKey: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean
|
||||
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,8 +64,25 @@ export function listRelabels(params: Record<string, unknown>) {
|
||||
export function getRelabel(relabelNo: string) {
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` })
|
||||
}
|
||||
|
||||
export function listRelabelInventoryCandidates(operationWarehouseId: string, inboundCode?: string) {
|
||||
return overseasApiRequest<RelabelInventoryCandidate[]>({
|
||||
method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/inventory-candidates',
|
||||
params: { operationWarehouseId, inboundCode: inboundCode || undefined },
|
||||
})
|
||||
}
|
||||
export function createRelabel(payload: RelabelCreate) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload })
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload })
|
||||
}
|
||||
export function uploadRelabelShippingLabel(relabelNo: string, file: File) {
|
||||
const data = new FormData(); data.append('file', file)
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/shipping-label`, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
}
|
||||
export function confirmRelabelCompletion(relabelNo: string, remark?: string) {
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, data: { idempotencyKey: crypto.randomUUID(), remark } })
|
||||
}
|
||||
export function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) {
|
||||
return overseasApiRequest<Blob>({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download`, responseType: 'blob' })
|
||||
}
|
||||
export function listDiscards(params: Record<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<DiscardSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards', params })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Printer, Search } from '@lucide/vue'
|
||||
import {
|
||||
getReturnInventoryCarton,
|
||||
getReturnInventoryLabel,
|
||||
listReturnInventory,
|
||||
listReturnInventorySummary,
|
||||
type ReturnInventoryCartonDetail,
|
||||
type ReturnInventoryItem,
|
||||
type ReturnInventorySummaryItem,
|
||||
} from '../api/returnInventory'
|
||||
|
||||
const activeTab = ref<'summary' | 'cartons'>('summary')
|
||||
const props = defineProps<{ cartonOnly?: boolean }>()
|
||||
if (props.cartonOnly) activeTab.value = 'cartons'
|
||||
const loading = ref(false)
|
||||
const summaryRows = ref<ReturnInventorySummaryItem[]>([])
|
||||
const cartonRows = ref<ReturnInventoryItem[]>([])
|
||||
const total = ref(0)
|
||||
const showCartonDetail = ref(false)
|
||||
const cartonDetail = ref<ReturnInventoryCartonDetail>()
|
||||
const query = reactive({ page: 1, size: 20, overseasWarehouseId: '', skuCode: '', cartonMark: '', status: 'AVAILABLE', returnNo: '' })
|
||||
|
||||
function params(includeCartonMark: boolean) {
|
||||
return {
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
overseasWarehouseId: query.overseasWarehouseId || undefined,
|
||||
skuCode: query.skuCode || undefined,
|
||||
cartonMark: includeCartonMark ? query.cartonMark || undefined : undefined,
|
||||
status: includeCartonMark ? query.status || undefined : undefined,
|
||||
returnNo: includeCartonMark ? query.returnNo || undefined : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'summary') {
|
||||
const page = await listReturnInventorySummary(params(false))
|
||||
summaryRows.value = page.records
|
||||
total.value = page.total
|
||||
return
|
||||
}
|
||||
const page = await listReturnInventory(params(true))
|
||||
cartonRows.value = page.records
|
||||
total.value = page.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function search() { query.page = 1; void load() }
|
||||
function changeTab() { query.page = 1; void load() }
|
||||
function openSkuCartons(skuCode: string) {
|
||||
query.skuCode = skuCode
|
||||
activeTab.value = 'cartons'
|
||||
query.page = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function openCarton(cartonMark: string) {
|
||||
try {
|
||||
cartonDetail.value = await getReturnInventoryCarton(cartonMark)
|
||||
showCartonDetail.value = true
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱内明细加载失败') }
|
||||
}
|
||||
|
||||
async function printLabel(cartonMark: string) {
|
||||
try {
|
||||
const label = await getReturnInventoryLabel(cartonMark)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
|
||||
printWindow.opener = null
|
||||
printWindow.document.open()
|
||||
printWindow.document.write(label.printableHtml)
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
printWindow.print()
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="return-inventory-page">
|
||||
<div class="page-heading"><div><h2>{{ props.cartonOnly ? '退货箱号管理' : '退货库存管理' }}</h2><p>{{ props.cartonOnly ? '查询合箱完成后的退货箱号、箱规和箱内 SKU 明细。' : '库存数量以仓库 PDA 实收并上架的数量为准。' }}</p></div></div>
|
||||
<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="filter-bar">
|
||||
<el-input v-model="query.overseasWarehouseId" clearable placeholder="海外仓 ID" @keyup.enter="search" />
|
||||
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" />
|
||||
<el-input v-if="activeTab === 'cartons'" v-model="query.cartonMark" clearable placeholder="箱号 / 箱唛" @keyup.enter="search" />
|
||||
<el-input v-if="activeTab === 'cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" />
|
||||
<el-select v-if="activeTab === 'cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select>
|
||||
<el-button type="primary" @click="search"><Search :size="16" />查询</el-button>
|
||||
</div>
|
||||
<el-table v-if="activeTab === 'summary'" v-loading="loading" :data="summaryRows">
|
||||
<el-table-column prop="overseasWarehouseId" label="海外仓" min-width="150" /><el-table-column prop="skuCode" label="SKU" min-width="180" />
|
||||
<el-table-column prop="productNameZh" label="中文品名" min-width="190" show-overflow-tooltip /><el-table-column prop="availableQuantity" label="可用库存" min-width="120" align="right" />
|
||||
<el-table-column prop="cartonQuantity" label="所在箱数" min-width="120" align="right" /><el-table-column prop="updatedAt" label="最近入库时间" min-width="180" />
|
||||
<el-table-column label="操作" width="110" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openSkuCartons(row.skuCode)">查看箱号</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-table v-else v-loading="loading" :data="cartonRows">
|
||||
<el-table-column prop="cartonMark" label="箱号" min-width="235"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">{{ row.cartonMark }}</el-button></template></el-table-column>
|
||||
<el-table-column prop="overseasWarehouseId" label="海外仓" min-width="130" /><el-table-column prop="skuCode" label="SKU" min-width="150" />
|
||||
<el-table-column prop="productNameZh" label="中文品名" min-width="170" show-overflow-tooltip /><el-table-column prop="quantity" label="本箱 SKU 数量" min-width="135" align="right" />
|
||||
<el-table-column prop="cartonSkuTypes" label="箱内 SKU 种类" min-width="130" align="right" /><el-table-column prop="cartonTotalQuantity" label="箱内总件数" min-width="115" align="right" />
|
||||
<el-table-column prop="status" label="箱状态" min-width="100" /><el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" /><el-table-column prop="receivedAt" label="签收时间" min-width="175" />
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
|
||||
<el-drawer v-model="showCartonDetail" title="退货箱明细" size="680px"><template v-if="cartonDetail">
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="箱号">{{ cartonDetail.cartonMark }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ cartonDetail.overseasWarehouseId }}</el-descriptions-item><el-descriptions-item label="来源退货单">{{ cartonDetail.sourceReturnNo || '—' }}</el-descriptions-item><el-descriptions-item label="签收批次">{{ cartonDetail.sourceBatchNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ cartonDetail.status }}</el-descriptions-item><el-descriptions-item label="SKU 种类">{{ cartonDetail.skuTypes }}</el-descriptions-item><el-descriptions-item label="箱内总件数">{{ cartonDetail.totalQuantity }}</el-descriptions-item><el-descriptions-item label="箱规">{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</el-descriptions-item><el-descriptions-item label="重量">{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</el-descriptions-item></el-descriptions>
|
||||
<div class="drawer-heading"><h3>箱内 SKU</h3><el-button type="primary" @click="printLabel(cartonDetail.cartonMark)"><Printer :size="16" />打印箱唛</el-button></div>
|
||||
<el-table :data="cartonDetail.items" size="small"><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="quantity" label="数量" min-width="100" align="right" /></el-table>
|
||||
</template></el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.return-inventory-page { padding: 20px; } .page-heading { margin-bottom: 14px; } h2 { margin: 0; font-size: 22px; } p { margin: 6px 0 0; color: var(--el-text-color-secondary); } .filter-bar { display: grid; grid-template-columns: repeat(3, minmax(150px, 1fr)) auto; gap: 10px; margin-bottom: 14px; } .el-pagination { margin-top: 16px; justify-content: flex-end; } .drawer-heading { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 12px; } .drawer-heading h3 { margin: 0; font-size: 16px; }
|
||||
@media (max-width: 760px) { .return-inventory-page { padding: 12px; } .filter-bar { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('新建退货预报选择中转仓后带出联系人和电话', () => {
|
||||
assert.match(view, /@change="selectOperationWarehouse"/)
|
||||
assert.match(view, /warehouseContactName = warehouse\?\.contactName \|\| ''/)
|
||||
assert.match(view, /warehouseContactPhone = warehouse\?\.contactPhone \|\| ''/)
|
||||
assert.match(view, /label="中转仓联系人"/)
|
||||
assert.match(view, /label="中转仓联系电话"/)
|
||||
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactName\)"/)
|
||||
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactPhone\)"/)
|
||||
assert.match(view, /该中转仓未维护,请手动填写/)
|
||||
assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/)
|
||||
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
|
||||
})
|
||||
|
||||
test('新建退货预报使用加宽的双列表单弹窗', () => {
|
||||
assert.match(view, /title="新建退货预报" width="1120px"/)
|
||||
assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/)
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Search } from '@lucide/vue'
|
||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/api/firstLeg'
|
||||
import { createReturnOrder, getReturnOrder, listReturnOrders, type ReturnOrderDetail, type ReturnOrderSummary } from '../api/returnOrders'
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const selected = ref<ReturnOrderDetail>()
|
||||
const rows = ref<ReturnOrderSummary[]>([])
|
||||
const total = ref(0)
|
||||
const operationWarehouses = ref<MerchantWarehouse[]>([])
|
||||
const operationWarehousesLoading = ref(false)
|
||||
const operationWarehousesError = ref('')
|
||||
const query = reactive({ page: 1, size: 20, returnNo: '', trackingNo: '', skuCode: '', status: '' })
|
||||
interface SkuDraft { id: number; skuCode: string; productNameZh: string; expectedQuantity: number }
|
||||
let skuSequence = 0
|
||||
function emptySku(): SkuDraft { return { id: ++skuSequence, skuCode: '', productNameZh: '', expectedQuantity: 1 } }
|
||||
const form = reactive({ overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] })
|
||||
const selectedOperationWarehouse = computed(() => operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId))
|
||||
function normalizedSku(value: string) { return value.trim().toUpperCase() }
|
||||
const duplicateSkuIds = computed(() => {
|
||||
const grouped = new Map<string, number[]>()
|
||||
for (const item of form.items) {
|
||||
const skuCode = normalizedSku(item.skuCode)
|
||||
if (skuCode) grouped.set(skuCode, [...(grouped.get(skuCode) ?? []), item.id])
|
||||
}
|
||||
return new Set([...grouped.values()].filter(ids => ids.length > 1).flat())
|
||||
})
|
||||
function resetForm() { Object.assign(form, { overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) }
|
||||
function warehouseLabel(warehouse: MerchantWarehouse) { return `${warehouse.warehouseName} · ${warehouse.warehouseCode}` }
|
||||
function selectOperationWarehouse() {
|
||||
const warehouse = operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId)
|
||||
form.warehouseContactName = warehouse?.contactName || ''
|
||||
form.warehouseContactPhone = warehouse?.contactPhone || ''
|
||||
}
|
||||
async function loadOperationWarehouses() {
|
||||
operationWarehousesLoading.value = true
|
||||
operationWarehousesError.value = ''
|
||||
try {
|
||||
const result = await getMerchantWarehouses({ warehouseType: 'OVERSEAS' })
|
||||
operationWarehouses.value = result.records
|
||||
if (!result.records.length) operationWarehousesError.value = '暂无可用的国外中转仓'
|
||||
} catch (error) {
|
||||
operationWarehouses.value = []
|
||||
operationWarehousesError.value = error instanceof Error ? error.message : '国外中转仓加载失败'
|
||||
ElMessage.error(operationWarehousesError.value)
|
||||
} finally { operationWarehousesLoading.value = false }
|
||||
}
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
showCreate.value = true
|
||||
void loadOperationWarehouses()
|
||||
}
|
||||
function addItem() { form.items.push(emptySku()) }
|
||||
function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { const response = await listReturnOrders({ ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }); rows.value = response.records; total.value = response.total }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function openDetail(returnNo: string) { selected.value = await getReturnOrder(returnNo); showDetail.value = true }
|
||||
async function submit() {
|
||||
const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity }))
|
||||
if (!form.overseasWarehouseId || items.some(item => !item.skuCode || item.expectedQuantity <= 0)) { ElMessage.warning('请填写操作仓、SKU 和预报数量'); return }
|
||||
if (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写中转仓联系人和联系电话'); return }
|
||||
if (duplicateSkuIds.value.size) { ElMessage.warning('同一退货单不能重复填写相同 SKU'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
const detail = await createReturnOrder({ overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: crypto.randomUUID(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) })
|
||||
ElMessage.success('退货预报已创建'); showCreate.value = false; resetForm(); await load(); await openDetail(detail.order.returnNo)
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
function statusType(status: string) { return status === 'COMPLETED' ? 'success' : status === 'PARTIALLY_RECEIVED' ? 'warning' : 'info' }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="return-orders-page">
|
||||
<div class="page-heading"><div><h2>退货签收</h2><p>预报退货 SKU 和数量,仓库 PDA 实收后自动更新签收状态。</p></div><el-button type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></div>
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="query.page=1; load()" />
|
||||
<el-input v-model="query.trackingNo" clearable placeholder="快递单号" @keyup.enter="query.page=1; load()" />
|
||||
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="query.page=1; load()" />
|
||||
<el-select v-model="query.status" clearable placeholder="签收状态"><el-option label="待签收" value="PENDING_RECEIPT" /><el-option label="部分签收" value="PARTIALLY_RECEIVED" /><el-option label="已完成" value="COMPLETED" /></el-select>
|
||||
<el-button @click="query.page=1; load()"><Search :size="16" />查询</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" @row-click="row => openDetail(row.returnNo)">
|
||||
<el-table-column prop="returnNo" label="退货单号" min-width="190" />
|
||||
<el-table-column prop="customerReferenceNo" label="客户参考号" min-width="150" />
|
||||
<el-table-column prop="overseasWarehouseId" label="操作仓" min-width="120" />
|
||||
<el-table-column label="换标" width="100"><template #default="{ row }"><el-tag v-if="row.needRelabel" type="warning">需要换标</el-tag><span v-else>否</span></template></el-table-column>
|
||||
<el-table-column prop="skuTypes" label="SKU种类" width="100" />
|
||||
<el-table-column prop="expectedQuantity" label="预报数量" width="110" />
|
||||
<el-table-column prop="receivedQuantity" label="实收数量" width="110" />
|
||||
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170" />
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row.returnNo)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
|
||||
|
||||
<el-dialog v-model="showCreate" title="新建退货预报" width="1120px" destroy-on-close>
|
||||
<el-form label-width="160px" class="return-create-form">
|
||||
<el-form-item label="海外操作仓(中转仓)" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择国外中转仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
||||
<el-form-item label="中转仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从中转仓资料带出' : '该中转仓未维护,请手动填写'" /></el-form-item>
|
||||
<el-form-item label="中转仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从中转仓资料带出' : '该中转仓未维护,请手动填写'" /></el-form-item>
|
||||
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
|
||||
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" placeholder="填写退货说明(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
|
||||
<div class="return-relabel-option"><span>换标</span><el-checkbox v-model="form.needRelabel">需要换标</el-checkbox></div>
|
||||
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
|
||||
<el-table :data="form.items" row-key="id" size="small">
|
||||
<el-table-column label="SKU" min-width="200"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
|
||||
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
|
||||
</el-dialog>
|
||||
<el-drawer v-model="showDetail" title="退货签收详情" size="760px"><template v-if="selected"><el-descriptions :column="2" border><el-descriptions-item label="退货单号">{{ selected.order.returnNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(selected.order.status)">{{ selected.order.status }}</el-tag></el-descriptions-item><el-descriptions-item label="操作仓">{{ selected.order.overseasWarehouseId }}</el-descriptions-item><el-descriptions-item label="实收 / 预报">{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</el-descriptions-item><el-descriptions-item label="中转仓联系人">{{ selected.order.warehouseContactName || '—' }}</el-descriptions-item><el-descriptions-item label="中转仓联系电话">{{ selected.order.warehouseContactPhone || '—' }}</el-descriptions-item></el-descriptions><p v-if="selected.remark" class="remark">备注:{{ selected.remark }}</p><h3>预报与实收</h3><el-table :data="selected.items" size="small"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="expectedQuantity" label="预报" /><el-table-column prop="receivedQuantity" label="实收" /></el-table><h3>签收批次</h3><el-table :data="selected.receipts" size="small"><el-table-column prop="receiptBatchNo" label="批次号" min-width="170" /><el-table-column prop="receiptNo" label="签收单号" min-width="170" /><el-table-column prop="cartonMark" label="箱号" min-width="190" /><el-table-column prop="trackingNo" label="快递单号"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column prop="startedBy" label="开始方式" /><el-table-column prop="receivedQuantity" label="实收" /><el-table-column prop="signedAt" label="签收时间" min-width="170" /></el-table></template></el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.return-orders-page { padding: 20px; } .page-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:18px; } h2 { margin:0; font-size:22px; } p { margin:6px 0 0; color:var(--el-text-color-secondary); } .filter-bar { display:grid; grid-template-columns:repeat(4,minmax(120px,1fr)) auto; gap:10px; margin-bottom:14px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } .el-pagination { margin-top:16px; justify-content:flex-end; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
|
||||
@media (max-width: 760px) { .return-orders-page { padding:12px; } .page-heading { align-items:stretch; flex-direction:column; } .filter-bar { grid-template-columns:1fr 1fr; } }
|
||||
</style>
|
||||
@@ -5,54 +5,121 @@ import { ArrowLeft, Download, Eye, Plus, Search, Trash2, Upload } from '@lucide/
|
||||
import SingleLineText from '@/components/common/SingleLineText.vue'
|
||||
import {
|
||||
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
|
||||
listDiscardCandidates, listDiscards, listLastlegOrders, listRelabels, listTransfers,
|
||||
confirmRelabelCompletion, downloadRelabelCompletionPhoto, uploadRelabelShippingLabel,
|
||||
listDiscardCandidates, listDiscards, listRelabelInventoryCandidates, listRelabels, listTransfers,
|
||||
type RelabelInventoryCandidate,
|
||||
type RelabelCreate,
|
||||
type RelabelDetail,
|
||||
} from '../api/warehouseServices'
|
||||
import {
|
||||
getMerchantWarehouseDetail,
|
||||
getMerchantWarehouses,
|
||||
type MerchantWarehouse,
|
||||
type MerchantWarehouseDetail,
|
||||
} 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' | 'carton_and_sku'
|
||||
type RelabelTemplateType = 'carton' | 'sku' | 'mixed'
|
||||
interface CargoRow { id: number; included?: boolean; 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; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
|
||||
interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string }
|
||||
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
|
||||
|
||||
const props = defineProps<{ service: ServiceType }>()
|
||||
const configs: Record<ServiceType, ServiceConfig> = {
|
||||
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '客户单号', '目的海外仓', '贴标要求', 'SKU 数', '总箱数', '状态', '创建时间'] },
|
||||
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '进仓编号', '发货箱数', '操作仓', 'FBA 目的仓', '派送方式', '是否换标', '状态', '创建时间'] },
|
||||
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'
|
||||
? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
|
||||
? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '待客户确认', value: 'CUSTOMER_CONFIRM_PENDING' }, { label: '待出库', value: 'READY_FOR_OUTBOUND' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
|
||||
: props.service === 'disposal'
|
||||
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '已拒绝', value: 'REJECTED' }]
|
||||
: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }])
|
||||
const mode = ref<PageMode>('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 transferHasFbaDestination = ref(false)
|
||||
const selectedOperationWarehouse = ref<MerchantWarehouseDetail | null>(null)
|
||||
const operationWarehouseDetailLoading = ref(false)
|
||||
const operationWarehouseAddress = computed(() => {
|
||||
const address = selectedOperationWarehouse.value?.addresses[0]
|
||||
if (!address) return ''
|
||||
return [address.addressLine1, address.addressLine2, address.city, address.stateProvince, address.postalCode, address.countryCode]
|
||||
.filter(Boolean).join(', ')
|
||||
})
|
||||
const cargoRows = ref<CargoRow[]>([])
|
||||
const labelingMode = ref<LabelingMode>('carton')
|
||||
const shippingLabelFile = ref<File | null>(null)
|
||||
const skuRelabelRows = ref<SkuRelabelRow[]>([])
|
||||
const importedRelabelBindings = ref<ImportedRelabelBinding[]>([])
|
||||
const cartonDetailRows = computed(() => cargoRows.value.filter((row) => row.included))
|
||||
const labelingModeHint = computed(() => labelingMode.value === 'carton'
|
||||
? '只替换外箱箱唛,不修改箱内商品 SKU 标签;按箱填写新的 FBA 箱唛和目的仓。'
|
||||
: labelingMode.value === 'sku'
|
||||
? '只替换箱内商品 SKU 标签,保留原箱唛;同一箱唛可以添加多条 SKU 明细。'
|
||||
: '同一箱同时替换商品 SKU 标签和外箱箱唛;同一箱唛的多条 SKU 使用同一个新 FBA 箱唛和目的仓。')
|
||||
const submittedRows = ref<Record<string, string>[]>([])
|
||||
const selectedDetail = ref<Record<string, string> | null>(null)
|
||||
const selectedRelabelDetail = ref<RelabelDetail | null>(null)
|
||||
const fbaWarehouses = ref<MerchantWarehouse[]>([])
|
||||
const operationWarehouses = ref<MerchantWarehouse[]>([])
|
||||
const fbaWarehousesLoading = ref(false)
|
||||
const fbaWarehousesError = ref('')
|
||||
const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([])
|
||||
const selectedDiscardOrderId = ref('')
|
||||
const discardCandidatesLoading = ref(false)
|
||||
const relabelCandidates = ref<RelabelInventoryCandidate[]>([])
|
||||
const selectedRelabelInboundOrderId = ref('')
|
||||
const relabelCandidatesLoading = ref(false)
|
||||
const relabelInboundOptions = computed(() => {
|
||||
const grouped = new Map<string, { value: string; inboundOrderNo: string; inboundCode: string; availablePackages: number }>()
|
||||
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<HTMLInputElement | null>(null)
|
||||
const cartonTemplateInput = ref<HTMLInputElement | null>(null)
|
||||
const skuTemplateInput = ref<HTMLInputElement | null>(null)
|
||||
const mixedTemplateInput = ref<HTMLInputElement | null>(null)
|
||||
let cargoSequence = 0
|
||||
let skuRelabelSequence = 0
|
||||
|
||||
watch(() => props.service, () => { mode.value = 'list'; resetFilters(); resetForm(); void load() })
|
||||
watch(labelingMode, (value) => { if (value !== 'carton' && !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 = []
|
||||
importedRelabelBindings.value = []
|
||||
discardCandidates.value = []
|
||||
selectedDiscardOrderId.value = ''
|
||||
relabelCandidates.value = []
|
||||
selectedRelabelInboundOrderId.value = ''
|
||||
transferHasFbaDestination.value = false
|
||||
selectedOperationWarehouse.value = null
|
||||
}
|
||||
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() }
|
||||
@@ -60,15 +127,117 @@ 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 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' })
|
||||
function addSkuRelabelRow() {
|
||||
const source = cargoRows.value[Math.min(skuRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))]
|
||||
skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: source?.id || null,
|
||||
cartonMark: '', originalSku: source?.sku || '', newSku: '', quantity: 1,
|
||||
fbaCartonMark: '', destinationWarehouseId: source?.destinationWarehouseId || '', remark: '' })
|
||||
}
|
||||
function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) }
|
||||
function applyExistingSkuBinding(row: SkuRelabelRow) {
|
||||
if (!row.originalSku.trim() || row.newSku.trim()) return
|
||||
const mapping = skuRelabelRows.value.find((item) => item.id !== row.id
|
||||
&& item.originalSku.trim().toUpperCase() === row.originalSku.trim().toUpperCase() && item.newSku.trim())
|
||||
if (mapping) row.newSku = mapping.newSku
|
||||
}
|
||||
function applyExistingCartonBinding(row: SkuRelabelRow) {
|
||||
if (!row.cartonMark.trim()) return
|
||||
const cartonMark = row.cartonMark.trim().toUpperCase()
|
||||
const mapping = cargoRows.value.find((item) => item.cartonMark.trim().toUpperCase() === cartonMark && item.fbaCartonMark.trim())
|
||||
|| skuRelabelRows.value.find((item) => item.id !== row.id
|
||||
&& item.cartonMark.trim().toUpperCase() === cartonMark && item.fbaCartonMark.trim())
|
||||
if (!mapping) return
|
||||
if (!row.fbaCartonMark.trim()) row.fbaCartonMark = mapping.fbaCartonMark
|
||||
if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId
|
||||
}
|
||||
function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null }
|
||||
function buildSkuRelabelItems(): RelabelCreate['items'] {
|
||||
const groups = new Map<string, SkuRelabelRow[]>()
|
||||
for (const row of skuRelabelRows.value) {
|
||||
const cartonMark = row.cartonMark.trim().toUpperCase()
|
||||
if (!cartonMark) throw new Error('请填写每个箱子的我方箱唛')
|
||||
const rows = groups.get(cartonMark) || []
|
||||
rows.push(row)
|
||||
groups.set(cartonMark, rows)
|
||||
}
|
||||
const anchors = cargoRows.value.flatMap((item) => Array.from(
|
||||
{ length: Number(item.availableQuantity || 0) }, () => item,
|
||||
))
|
||||
if (!anchors.length) throw new Error('请先选择有库存的历史入仓单')
|
||||
if (groups.size > anchors.length) {
|
||||
throw new Error('填写的箱唛数量超过当前入仓单可用库存箱数')
|
||||
}
|
||||
return [...groups.entries()].map(([_, rows], groupIndex) => {
|
||||
const source = cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex]
|
||||
if (!source?.sourceInventoryBalanceId || !source.sourceInventoryPositionId || !source.sourceInboundOrderId) {
|
||||
throw new Error('来源库存已变化,请重新选择有库存的历史入仓单')
|
||||
}
|
||||
const cartonMark = rows[0].cartonMark.trim()
|
||||
const changeCarton = labelingMode.value === 'carton_and_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: 1,
|
||||
notifiedPackageCount: 1,
|
||||
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 }
|
||||
const source = cargoRows.value.find((row) => !row.included)
|
||||
if (!source) { ElMessage.warning('所选入仓单没有更多可添加的库存箱'); return }
|
||||
source.included = true
|
||||
}
|
||||
function removeCargoRow(id: number) {
|
||||
const item = cargoRows.value.find((row) => row.id === id)
|
||||
if (props.service === 'labeling' && item?.sourceInventoryBalanceId) {
|
||||
Object.assign(item, { included: false, shipmentNo: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' })
|
||||
return
|
||||
}
|
||||
cargoRows.value = cargoRows.value.filter((row) => row.id !== id)
|
||||
}
|
||||
function clearRelabelRows() {
|
||||
for (const row of cargoRows.value) Object.assign(row, { included: false, shipmentNo: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' })
|
||||
}
|
||||
function csvCell(value: string | number | undefined) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
function downloadRelabelTemplate(templateType: RelabelTemplateType) {
|
||||
if (!cargoRows.value.length) { ElMessage.warning('请先选择有库存的历史入仓单'); return }
|
||||
const cartonTemplate = templateType === 'carton'
|
||||
const mixedTemplate = templateType === 'mixed'
|
||||
const header = cartonTemplate
|
||||
? ['明细行标识', '旧箱唛', '新FBA箱唛', 'FBA目的仓', 'FBA货件编号', '备注']
|
||||
: ['明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签', '换标数量(件)',
|
||||
...(mixedTemplate ? ['新FBA箱唛', 'FBA目的仓', 'FBA货件编号'] : []), '备注']
|
||||
const rows = cargoRows.value.map((item) => cartonTemplate
|
||||
? [item.id, item.cartonMark, item.fbaCartonMark, item.destinationWarehouseId, item.shipmentNo, item.remark]
|
||||
: [item.id, item.cartonMark, item.sku, '', 1,
|
||||
...(mixedTemplate ? [item.fbaCartonMark, item.destinationWarehouseId, item.shipmentNo] : []), 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)
|
||||
anchor.href = url
|
||||
anchor.download = cartonTemplate ? '贴标申请-箱号关联模板.csv'
|
||||
: mixedTemplate ? '贴标申请-SKU混箱换标模板.csv' : '贴标申请-SKU关联模板.csv'
|
||||
anchor.click(); URL.revokeObjectURL(url)
|
||||
}
|
||||
function parseCsvLine(line: string) {
|
||||
const values: string[] = []; let value = ''; let quoted = false
|
||||
@@ -80,25 +249,112 @@ function parseCsvLine(line: string) {
|
||||
}
|
||||
values.push(value.trim()); return values
|
||||
}
|
||||
async function importRelabelTemplate(event: Event) {
|
||||
async function importRelabelTemplate(event: Event, templateType: RelabelTemplateType) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
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 }
|
||||
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 headers = parseCsvLine(lines[0])
|
||||
if (templateType !== 'carton') {
|
||||
const mixedTemplate = templateType === 'mixed'
|
||||
const required = ['明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签', '换标数量(件)',
|
||||
...(mixedTemplate ? ['新FBA箱唛', 'FBA目的仓', 'FBA货件编号'] : [])]
|
||||
if (required.some((name) => !headers.includes(name))) {
|
||||
ElMessage.error('SKU 关联模板格式不正确,请重新下载 SKU 关联模板'); return
|
||||
}
|
||||
const imported = lines.slice(1).map(parseCsvLine)
|
||||
.map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || ''])))
|
||||
.filter((item) => item['旧箱唛'] || item['新SKU标签'] || item['新FBA箱唛'])
|
||||
if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) {
|
||||
ElMessage.error('旧箱唛、旧 SKU 标签、新 SKU 标签和换标数量必须填写完整'); return
|
||||
}
|
||||
const rows: ImportedRelabelBinding[] = []
|
||||
for (const item of imported) {
|
||||
const quantity = Number(item['换标数量(件)'])
|
||||
if (!Number.isInteger(quantity) || quantity < 1) {
|
||||
ElMessage.error(`箱唛 ${item['旧箱唛']} 的换标数量必须是大于 0 的整数`); return
|
||||
}
|
||||
const source = cargoRows.value.find((row) => String(row.id) === item['明细行标识'])
|
||||
if (!source) { ElMessage.error(`明细行 ${item['明细行标识']} 不属于当前库存,请重新下载模板`); return }
|
||||
rows.push({
|
||||
id: ++skuRelabelSequence,
|
||||
sourceRowId: source.id,
|
||||
cartonMark: item['旧箱唛'].trim(),
|
||||
originalSku: item['旧SKU标签'].trim(),
|
||||
newSku: item['新SKU标签'].trim(),
|
||||
quantity,
|
||||
fbaCartonMark: mixedTemplate ? item['新FBA箱唛'].trim() : '',
|
||||
destinationWarehouseId: mixedTemplate ? item['FBA目的仓'].trim() : '',
|
||||
remark: item['备注'] || '',
|
||||
templateType,
|
||||
selected: true,
|
||||
shipmentNo: mixedTemplate ? item['FBA货件编号'].trim() : '',
|
||||
})
|
||||
}
|
||||
importedRelabelBindings.value = rows
|
||||
ElMessage.success(`已读取 ${rows.length} 条绑定关系,请选择后导入到明细`)
|
||||
return
|
||||
}
|
||||
const required = ['明细行标识', '旧箱唛', '新FBA箱唛', '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) => item['旧箱唛'] || item['新FBA箱唛'])
|
||||
if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) {
|
||||
ElMessage.error('模板中的明细行、旧箱唛、新 FBA 箱唛、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 !== imported.length || [...importedByRow.keys()].some((id) => !cargoRows.value.some((row) => String(row.id) === id))) {
|
||||
ElMessage.error('模板存在重复或不属于当前库存的明细行,请重新下载模板'); return
|
||||
}
|
||||
const bindings: ImportedRelabelBinding[] = []
|
||||
for (const [rowId, item] of importedByRow) {
|
||||
const row = cargoRows.value.find((candidate) => String(candidate.id) === rowId)!
|
||||
bindings.push({ id: ++skuRelabelSequence, sourceRowId: row.id, cartonMark: item['旧箱唛'],
|
||||
originalSku: row.sku, newSku: '', quantity: 1, fbaCartonMark: item['新FBA箱唛'],
|
||||
destinationWarehouseId: item['FBA目的仓'], remark: item['备注'] || '', templateType,
|
||||
selected: true, shipmentNo: item['FBA货件编号'] })
|
||||
}
|
||||
importedRelabelBindings.value = bindings
|
||||
ElMessage.success(`已读取 ${bindings.length} 条箱号关联,请选择后导入到明细`)
|
||||
}
|
||||
function selectAllImportedBindings(value: boolean) {
|
||||
for (const item of importedRelabelBindings.value) item.selected = value
|
||||
}
|
||||
function importSelectedBindingsToDetails() {
|
||||
const selected = importedRelabelBindings.value.filter((item) => item.selected)
|
||||
if (!selected.length) { ElMessage.warning('请至少选择一条绑定关系'); return }
|
||||
const templateType = selected[0].templateType
|
||||
if (selected.some((item) => item.templateType !== templateType)) { ElMessage.warning('请重新上传同一种绑定模板'); return }
|
||||
if (templateType === 'carton') {
|
||||
for (const binding of selected) {
|
||||
const source = cargoRows.value.find((row) => row.id === binding.sourceRowId)
|
||||
if (!source) continue
|
||||
Object.assign(source, { included: true, quantity: 1, shipmentNo: binding.shipmentNo,
|
||||
cartonMark: binding.cartonMark, fbaCartonMark: binding.fbaCartonMark,
|
||||
destinationWarehouseId: binding.destinationWarehouseId, remark: binding.remark })
|
||||
}
|
||||
labelingMode.value = 'carton'
|
||||
} else {
|
||||
const importedRows = selected.map(({ templateType: _, selected: __, shipmentNo, ...item }) => item)
|
||||
for (const item of importedRows) {
|
||||
skuRelabelRows.value = skuRelabelRows.value.filter((row) => !(row.cartonMark.trim().toUpperCase() === item.cartonMark.trim().toUpperCase()
|
||||
&& row.originalSku.trim().toUpperCase() === item.originalSku.trim().toUpperCase()))
|
||||
skuRelabelRows.value.push(item)
|
||||
if (templateType === 'mixed') {
|
||||
const source = cargoRows.value.find((row) => row.id === item.sourceRowId)
|
||||
const shipmentNo = selected.find((binding) => binding.id === item.id)?.shipmentNo || ''
|
||||
if (source) Object.assign(source, { included: true, cartonMark: item.cartonMark,
|
||||
fbaCartonMark: item.fbaCartonMark, destinationWarehouseId: item.destinationWarehouseId, shipmentNo })
|
||||
}
|
||||
}
|
||||
labelingMode.value = templateType === 'mixed' ? 'carton_and_sku' : 'sku'
|
||||
}
|
||||
importedRelabelBindings.value = importedRelabelBindings.value.filter((item) => !item.selected)
|
||||
ElMessage.success(`已将 ${selected.length} 条绑定关系导入本次换标明细`)
|
||||
}
|
||||
function selectDiscardInbound(orderId: string) {
|
||||
const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
|
||||
@@ -115,22 +371,32 @@ function dateTime(value: string, end = false) { return value ? `${value}T${end ?
|
||||
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '—' : String(value)])) }
|
||||
function warehouseDisplay(value?: string) {
|
||||
if (!value) return '—'
|
||||
const labels: Record<string, string> = { 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('、')
|
||||
}
|
||||
function statusLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消',
|
||||
|
||||
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认',
|
||||
READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', CANCELLED: '已取消',
|
||||
PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配',
|
||||
ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货',
|
||||
|
||||
}
|
||||
return labels[value] || value || '—'
|
||||
}
|
||||
function deliveryMethodLabel(value: string) {
|
||||
const labels: Record<string, string> = { TRUCK: '卡车派送', EXPRESS: '快递派送' }
|
||||
const labels: Record<string, string> = {
|
||||
TRUCK: '卡车派送', EXPRESS: '快递派送', TRUCK_PICKUP: '卡车自提', EXPRESS_PICKUP: '快递自提',
|
||||
WAREHOUSE_PICKUP: '仓库上门提货', CUSTOMER_DELIVERY: '客户送货到仓',
|
||||
// Keep the legacy value readable for historical applications created before the four-way split.
|
||||
PICKUP: '仓库自提',
|
||||
}
|
||||
return labels[value] || value || '—'
|
||||
}
|
||||
function formatDateTime(value: string) {
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
async function load() {
|
||||
@@ -148,7 +414,7 @@ async function load() {
|
||||
} 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, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', statusLabel(item.status), formatDateTime(item.createdAt)])
|
||||
? row([item.transferNo, 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 : '列表加载失败') }
|
||||
@@ -157,34 +423,59 @@ async function load() {
|
||||
function query() { void load() }
|
||||
async function submit() {
|
||||
const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse
|
||||
const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.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 !== 'carton'
|
||||
? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1
|
||||
|| (labelingMode.value === 'carton_and_sku' && (!item.fbaCartonMark || !item.destinationWarehouseId)))
|
||||
: cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name
|
||||
|| (props.service !== 'labeling' && item.quantity < 1)))
|
||||
if ((props.service === 'relabel' && !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 !== 'sku' && !form.deliveryMethod) {
|
||||
ElMessage.warning('请选择换标完成后的派送方式'); return
|
||||
}
|
||||
if (props.service === 'labeling' && labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP')
|
||||
&& form.relabel && !shippingLabelFile.value) {
|
||||
ElMessage.warning('选择打印面单时必须上传面单文件'); return
|
||||
}
|
||||
if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.shipmentNo || !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' && cartonDetailRows.value.some((item) => !item.destinationWarehouseId || !item.sourceInventoryPositionId)) {
|
||||
ElMessage.warning('请选择有库存的历史入仓单,并为每项填写 FBA 目的仓'); return
|
||||
}
|
||||
if (props.service === 'disposal' && !form.reason) {
|
||||
ElMessage.warning('请选择废弃原因'); return
|
||||
}
|
||||
if ((props.service === 'transfer' || props.service === 'relabel') && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) {
|
||||
if (props.service === 'relabel' && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) {
|
||||
ElMessage.warning('请填写完整的派送信息、体积、预计到达时间和 FBA 货件号'); return
|
||||
}
|
||||
if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || form.volumeCbm <= 0)) {
|
||||
ElMessage.warning(form.deliveryMethod === 'WAREHOUSE_PICKUP' ? '请填写中转仓、提货地址和预计入仓时间' : '请选择中转仓并确认预计入仓时间'); return
|
||||
}
|
||||
if (props.service === 'transfer' && transferHasFbaDestination.value && (!form.destination || !form.fbaShipmentNo)) {
|
||||
ElMessage.warning('已选择有 FBA 去向,请填写 FBA 目的仓和货件编号'); return
|
||||
}
|
||||
try { await ElMessageBox.confirm(`确认提交${config.value.title}吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return }
|
||||
saving.value = true
|
||||
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'
|
||||
? cartonDetailRows.value.map((item) => ({ sourceInventoryBalanceId: item.sourceInventoryBalanceId!, sourceInventoryPositionId: item.sourceInventoryPositionId!, sourceInboundOrderId: item.sourceInboundOrderId!, skuCode: item.sku, productNameZh: item.name, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: undefined, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationFbaWarehouseCode: item.destinationWarehouseId, operationType: 'CARTON_LABEL' as const, skuItems: [], remark: item.remark || undefined }))
|
||||
: buildSkuRelabelItems()
|
||||
const created = 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,
|
||||
deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP',
|
||||
printShippingLabel: labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel })
|
||||
if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value)
|
||||
} 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)) {
|
||||
@@ -194,10 +485,8 @@ async function submit() {
|
||||
})
|
||||
await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined })
|
||||
} else {
|
||||
const sources = await listLastlegOrders(form.inboundNo)
|
||||
if (sources.records.length === 0) throw new Error('未找到该入仓码对应的尾程订单')
|
||||
const packageCount = cargoRows.value.reduce((sum, item) => sum + item.quantity, 0)
|
||||
await createTransfer({ inboundCode: form.inboundNo, sourceLastlegOrderIds: sources.records.map((item) => item.id), operatingWarehouseId: primaryWarehouse, fbaWarehouseCode: form.destination, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, fbaShipmentNo: form.fbaShipmentNo, trackingNo: form.trackingNo || undefined, remark: form.remark || undefined, needRelabel: props.service === 'relabel' || form.relabel, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: item.shippingMark || undefined })) })
|
||||
await createTransfer({ operatingWarehouseId: primaryWarehouse, fbaWarehouseCode: form.destination || undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, fbaShipmentNo: form.fbaShipmentNo || undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, remark: form.remark || undefined, needRelabel: props.service === 'relabel' || form.relabel, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity })) })
|
||||
}
|
||||
ElMessage.success(`${config.value.title}提交成功`); mode.value = 'list'; await load()
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') }
|
||||
@@ -211,6 +500,101 @@ async function openDetail(value: Record<string, string>) {
|
||||
mode.value = 'detail'
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') }
|
||||
}
|
||||
async function confirmRelabel() {
|
||||
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
|
||||
if (!relabelNo) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
|
||||
selectedRelabelDetail.value = await confirmRelabelCompletion(relabelNo)
|
||||
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
|
||||
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
|
||||
}
|
||||
async function previewCompletionPhoto(fileId: string) {
|
||||
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
|
||||
if (!relabelNo) return
|
||||
try {
|
||||
const blob = await downloadRelabelCompletionPhoto(relabelNo, fileId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} 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 selectTransferWarehouse() {
|
||||
selectedOperationWarehouse.value = null
|
||||
form.contact = ''
|
||||
form.phone = ''
|
||||
if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = ''
|
||||
if (!form.warehouse) return
|
||||
operationWarehouseDetailLoading.value = true
|
||||
try {
|
||||
const detail = await getMerchantWarehouseDetail(form.warehouse)
|
||||
selectedOperationWarehouse.value = detail
|
||||
form.contact = detail.warehouse.contactName || ''
|
||||
form.phone = detail.warehouse.contactPhone || ''
|
||||
if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = operationWarehouseAddress.value
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '中转仓联系信息加载失败')
|
||||
} finally { operationWarehouseDetailLoading.value = false }
|
||||
}
|
||||
function selectTransferInboundMethod(value: string | number | boolean | undefined) {
|
||||
form.address = value === 'CUSTOMER_DELIVERY' ? operationWarehouseAddress.value : ''
|
||||
}
|
||||
function selectTransferFbaAvailability(value: string | number | boolean | undefined) {
|
||||
if (value === true) return
|
||||
form.destination = ''
|
||||
form.fbaShipmentNo = ''
|
||||
}
|
||||
async function selectOperationWarehouse() {
|
||||
cargoRows.value = []
|
||||
skuRelabelRows.value = []
|
||||
importedRelabelBindings.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) {
|
||||
cargoRows.value = relabelCandidates.value
|
||||
.filter((candidate) => String(candidate.inboundOrderId) === inboundOrderId)
|
||||
.flatMap((candidate) => candidate.positions.flatMap((position) => Array.from({ length: Math.min(position.availablePackages, 500) }, () => ({
|
||||
id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物',
|
||||
included: false, quantity: 1, availableQuantity: 1,
|
||||
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: '', newSkuCode: '', shippingMark: '', cartonMark: '', fbaCartonMark: '',
|
||||
destinationWarehouseId: candidate.destinationFbaWarehouseCode || '', remark: '',
|
||||
})))).slice(0, 500)
|
||||
if (cargoRows.value.length) cargoRows.value[0].included = true
|
||||
skuRelabelRows.value = []
|
||||
importedRelabelBindings.value = []
|
||||
shippingLabelFile.value = null
|
||||
if (labelingMode.value !== 'carton') addSkuRelabelRow()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -240,34 +624,86 @@ async function openDetail(value: Record<string, string>) {
|
||||
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>新增</strong></div>
|
||||
<div class="warehouse-page-heading"><h2>{{ config.createTitle }}</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="warehouse-card warehouse-form-section"><h3>{{ props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础与派送信息' }}</h3>
|
||||
<div class="warehouse-card warehouse-form-section"><h3>{{ props.service === 'transfer' ? '入库与转运信息' : props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础信息' }}</h3>
|
||||
<div class="warehouse-form-grid">
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>客户单号</span><el-input v-model="form.customerNo" placeholder="请输入客户单号" /></label>
|
||||
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable :loading="discardCandidatesLoading" placeholder="请选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|${candidate.overseasWarehouseName || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示一件代发中仍有可用箱数的入库单。</small></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" placeholder="请选择"><el-option label="ONT8" value="ONT8" /><el-option label="ABE8" value="ABE8" /><el-option label="CLT2" value="CLT2" /></el-select></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label>
|
||||
<label v-else><span><i v-if="props.service !== 'labeling'">*</i>{{ props.service === 'labeling' ? '统一目的海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" clearable placeholder="留空则按货物明细目的仓"><el-option label="美西海外仓-ONT8" value="ONT8" /><el-option label="美东海外仓-ABE8" value="ABE8" /><el-option label="美中海外仓-MDW2" value="MDW2" /></el-select><small v-if="props.service === 'labeling'" class="warehouse-hint">填写后,未单独指定目的仓的货物将使用此仓库。</small></label>
|
||||
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库中转仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(fbaWarehousesError)" clearable placeholder="请选择国外中转仓" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="props.service === 'labeling'" class="warehouse-hint">选择后仅查询当前客户在该中转仓的可用库存。</small><small v-else-if="props.service === 'transfer'" class="warehouse-hint">本批货物将先进入该仓完成签收和入库。</small></label>
|
||||
<label v-if="props.service === 'labeling'" class="warehouse-span-2"><span><i>*</i>选择有库存的历史入仓单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前中转仓暂无已入库可用库存" placeholder="请选择曾经下单且当前仍有库存的入仓单" @change="selectRelabelInventory"><el-option v-for="option in relabelInboundOptions" :key="option.value" :label="`${option.inboundOrderNo}|入仓码 ${option.inboundCode}|可用 ${option.availablePackages} 箱`" :value="option.value" /></el-select><small class="warehouse-hint">仅显示当前客户在所选中转仓已经完成入库且仍有可用库存的历史入仓单。</small></label>
|
||||
<label v-if="props.service === 'disposal'"><span>操作仓</span><el-input :model-value="discardCandidates.find((item) => item.orderId === selectedDiscardOrderId)?.overseasWarehouseName || form.warehouse" readonly /></label>
|
||||
<label v-if="props.service === 'relabel' || props.service === 'transfer'"><span><i>*</i>目的 FBA 仓</span><el-input v-model="form.destination" placeholder="请输入仓代码" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择"><el-option label="卡车派送" value="卡车派送" /><el-option label="快递派送" value="快递派送" /></el-select></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>货物如何送到中转仓</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group><small>仓库上门提货:填写货物当前地址;客户送货到仓:下方自动展示中转仓收货信息。</small></div>
|
||||
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="warehouse-span-2"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="请输入仓库上门提货的详细地址" /></label>
|
||||
<div v-if="props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY'" v-loading="operationWarehouseDetailLoading" class="warehouse-receiving-card warehouse-span-2">
|
||||
<div><span>送货地址</span><strong>{{ operationWarehouseAddress || '该中转仓暂未维护收货地址' }}</strong></div>
|
||||
<div><span>收货联系人</span><strong>{{ form.contact || '未维护' }}</strong></div>
|
||||
<div><span>联系电话</span><strong>{{ form.phone || '未维护' }}</strong></div>
|
||||
</div>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>目的 FBA 仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择"><el-option label="卡车派送" value="卡车派送" /><el-option label="快递派送" value="快递派送" /></el-select></label>
|
||||
<label v-if="props.service === 'relabel' || props.service === 'disposal'"><span><i>*</i>{{ props.service === 'relabel' ? '退货原因' : '废弃原因' }}</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>联系人</span><el-input v-model="form.contact" placeholder="请输入联系人" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>联系电话</span><el-input v-model="form.phone" placeholder="请输入联系电话" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓签收联系人<em v-if="form.warehouse && !form.contact" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.contact" placeholder="选择海外操作仓后自动带出" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓联系电话<em v-if="form.warehouse && !form.phone" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.phone" placeholder="选择海外操作仓后自动带出" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标费用(CNY)</span><el-input-number v-model="form.chargeAmount" :min="0" :precision="2" controls-position="right" placeholder="请输入贴标费用" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
|
||||
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select><small class="warehouse-hint">卡车派送进入预排车;其余方式确认换标完成后进入换标出库。</small></label>
|
||||
<div v-if="props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP')" class="warehouse-checkbox-field"><span>是否打印面单</span><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></div>
|
||||
<label v-if="props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"><small class="warehouse-hint">支持 PDF 或图片,最大 20 MB。</small></label>
|
||||
<label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>总体积(CBM)</span><el-input-number v-model="form.volumeCbm" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入总体积" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>预计到达时间</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到达时间" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" placeholder="请输入贴标要求(选填)" /></label>
|
||||
<label v-else-if="props.service !== 'disposal'"><span>物流跟踪号</span><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>是否已有 FBA 去向</span><el-radio-group v-model="transferHasFbaDestination" @change="selectTransferFbaAvailability"><el-radio-button :value="false">暂未确定</el-radio-button><el-radio-button :value="true">已有 FBA 去向</el-radio-button></el-radio-group><small>暂未确定也可以提交,货物先正常入中转仓,后续再补充转运指令。</small></div>
|
||||
<label v-if="props.service === 'transfer' && transferHasFbaDestination"><span><i>*</i>目的 FBA 仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-if="props.service === 'transfer' && transferHasFbaDestination"><span><i>*</i>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号" /></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" placeholder="请输入贴标要求" /></label>
|
||||
<label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></label>
|
||||
<label class="warehouse-span-2"><span>订单备注</span><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注信息" /></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>是否需要换标</span><el-radio-group v-model="form.relabel"><el-radio-button :value="false">不需要换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group><small v-if="form.relabel">转运单将显示换标标识,完成入库后再绑定换标单。</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warehouse-card warehouse-form-section"><div class="warehouse-section-heading"><h3>货物明细</h3><div v-if="props.service !== 'disposal'"><template v-if="props.service === 'labeling'"><el-button size="small" @click="downloadRelabelTemplate"><Download :size="15" />下载关联模板</el-button><el-button size="small" @click="templateInput?.click()"><Upload :size="15" />上传关联面单</el-button><input ref="templateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate"></template><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
|
||||
<p v-if="props.service === 'labeling'" class="warehouse-attachment-tip">模板每行代表一箱;上传后直接回显到下方明细。明细目的仓优先,留空时使用顶部统一目的海外仓。</p>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-edit-table" :class="{ 'warehouse-label-table': props.service === 'labeling' }"><thead><tr><th>商品 SKU<span v-if="props.service !== 'labeling'"> *</span></th><th>中文品名 *</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-else>{{ props.service === 'labeling' ? 'FBA 货件编号 *' : 'FNSKU / 箱唛' }}</th><th v-if="props.service === 'labeling'">换标新 SKU *</th><th v-if="props.service !== 'labeling'">{{ props.service === 'disposal' ? '本次废弃箱数' : props.service === 'relabel' ? '退件数量' : '箱数' }} *</th><template v-if="props.service === 'labeling'"><th>我方箱唛</th><th>FBA 箱唛</th><th>目的仓</th><th>备注</th></template><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" placeholder="请输入商品 SKU(选填)" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" placeholder="请输入中文品名" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-else><el-input v-if="props.service === 'labeling'" v-model="row.shipmentNo" placeholder="请输入 FBA 货件编号" /><el-input v-else v-model="row.shippingMark" placeholder="请输入 FNSKU 或箱唛" /></td><td v-if="props.service === 'labeling'"><el-input v-model="row.newSkuCode" placeholder="请输入换标新 SKU" /></td><td v-if="props.service !== 'labeling'"><el-input-number v-model="row.quantity" :min="props.service === 'disposal' ? 0 : 1" :max="props.service === 'disposal' ? row.availableQuantity : undefined" controls-position="right" placeholder="请输入箱数" /></td><template v-if="props.service === 'labeling'"><td><el-input v-model="row.cartonMark" placeholder="请输入我方箱唛" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入 FBA 箱唛" /></td><td><el-select v-model="row.destinationWarehouseId" clearable placeholder="使用顶部统一目的仓"><el-option label="美西海外仓-ONT8" value="ONT8" /><el-option label="美东海外仓-ABE8" value="ABE8" /><el-option label="美中海外仓-MDW2" value="MDW2" /></el-select></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入明细备注" /></td></template><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" /></button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'labeling' ? 8 : 5"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
|
||||
<div class="warehouse-card warehouse-form-section">
|
||||
<template v-if="props.service === 'labeling'">
|
||||
<div class="warehouse-section-heading warehouse-labeling-heading">
|
||||
<h3>货物明细</h3>
|
||||
<div class="warehouse-template-actions">
|
||||
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('sku')"><Download :size="15" />下载 SKU 关联模板</el-button>
|
||||
<el-button size="small" :disabled="!cargoRows.length" @click="skuTemplateInput?.click()"><Upload :size="15" />上传 SKU 关联</el-button>
|
||||
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('carton')"><Download :size="15" />下载箱号关联模板</el-button>
|
||||
<el-button size="small" :disabled="!cargoRows.length" @click="cartonTemplateInput?.click()"><Upload :size="15" />上传箱号关联</el-button>
|
||||
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('mixed')"><Download :size="15" />下载 SKU 混箱模板</el-button>
|
||||
<el-button size="small" :disabled="!cargoRows.length" @click="mixedTemplateInput?.click()"><Upload :size="15" />上传 SKU 混箱</el-button>
|
||||
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')">
|
||||
<input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')">
|
||||
<input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')">
|
||||
</div>
|
||||
</div>
|
||||
<div class="warehouse-labeling-mode">
|
||||
<span>换标类型</span>
|
||||
<el-radio-group v-model="labelingMode" aria-label="换标类型">
|
||||
<el-radio-button value="carton">只换箱唛</el-radio-button>
|
||||
<el-radio-button value="sku">只换 SKU 标签</el-radio-button>
|
||||
<el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button>
|
||||
</el-radio-group>
|
||||
<p>{{ labelingModeHint }}</p>
|
||||
</div>
|
||||
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview">
|
||||
<div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>旧箱唛</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th><th>FBA 目的仓</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td><td>{{ warehouseDisplay(item.destinationWarehouseId) }}</td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div class="warehouse-section-heading warehouse-tab-actions">
|
||||
<div><strong>绑定明细</strong><p>上传后在下方回显,也可以手动添加;SKU 与箱号关联通过旧箱唛合并。系统只提供大概库存位置,不代表箱唛已绑定具体托盘。</p></div>
|
||||
<div v-if="labelingMode === 'carton'"><el-button type="primary" size="small" @click="addRelabelCargoRow"><Plus :size="15" />手动添加行</el-button><el-button size="small" @click="clearRelabelRows">清空</el-button></div>
|
||||
<div v-else><el-button type="primary" size="small" @click="addSkuRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="skuRelabelRows = []">清空</el-button></div>
|
||||
</div>
|
||||
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>旧箱唛 *</th><th>新 FBA 箱唛 *</th><th>FBA 目的仓 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="选填" /></td><td><el-input v-model="row.name" placeholder="请输入中文品名" /></td><td><el-input v-model="row.shipmentNo" placeholder="请输入FBA货件编号" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" placeholder="请输入旧箱唛" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入明细备注" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="9"><div class="warehouse-empty">暂无本次换标明细,请上传关系后导入或手动添加</div></td></tr></tbody></table></div>
|
||||
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>旧箱唛 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th v-if="labelingMode === 'carton_and_sku'">FBA 目的仓 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" placeholder="请输入旧箱唛" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" placeholder="请输入旧 SKU" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></td><td><el-input v-model="row.remark" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 8 : 6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="warehouse-section-heading"><h3>货物明细</h3><div v-if="props.service !== 'disposal'"><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU *</th><th>中文品名 *</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" placeholder="请输入商品 SKU" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" placeholder="请输入中文品名" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td><el-input-number v-model="row.quantity" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="warehouse-submit-bar"><el-button @click="resetForm">重置</el-button><el-button type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div>
|
||||
</form>
|
||||
@@ -278,10 +714,20 @@ async function openDetail(value: Record<string, string>) {
|
||||
<div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div>
|
||||
<div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
|
||||
<div v-if="props.service === 'labeling'" class="warehouse-card warehouse-form-section">
|
||||
<h3>箱唛换标明细</h3>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>贴标要求</th><th>备注</th></tr></thead><tbody>
|
||||
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td><strong>{{ item.cartonMark || '—' }}</strong></td><td><strong>{{ item.fbaCartonMark || '—' }}</strong></td><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.shipmentNo || '—' }}</td><td>{{ item.newSkuCode || '—' }}</td><td>{{ warehouseDisplay(item.destinationWarehouseId) }}</td><td>{{ item.labelRequirement || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
|
||||
<tr v-if="!selectedRelabelDetail?.items?.length"><td colspan="9"><div class="warehouse-empty">暂无箱唛换标明细</div></td></tr>
|
||||
<div class="warehouse-section-heading"><h3>换标完成与出库</h3><el-button v-if="selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" @click="confirmRelabel">换标完成确认</el-button></div>
|
||||
<div class="warehouse-detail-grid warehouse-fulfillment-grid">
|
||||
<div><span>派送方式</span><strong>{{ deliveryMethodLabel(selectedRelabelDetail?.application.deliveryMethod || '') || '无需出库' }}</strong></div>
|
||||
<div><span>履约状态</span><strong>{{ selectedRelabelDetail?.application.fulfillmentStatus || '—' }}</strong></div>
|
||||
<div><span>客户确认时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.customerConfirmedAt) }}</strong></div>
|
||||
</div>
|
||||
<div class="warehouse-evidence"><span>仓库完工照片</span><div v-if="selectedRelabelDetail?.completionEvidence?.photoFileIds?.length" class="warehouse-photo-actions"><el-button v-for="(fileId, index) in selectedRelabelDetail.completionEvidence.photoFileIds" :key="fileId" size="small" plain @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></div><strong v-else>仓库尚未上传完工照片</strong></div>
|
||||
<p v-if="selectedRelabelDetail?.completionEvidence?.completionRemark" class="warehouse-completion-remark">完工备注:{{ selectedRelabelDetail.completionEvidence.completionRemark }}</p>
|
||||
</div>
|
||||
<div v-if="props.service === 'labeling'" class="warehouse-card warehouse-form-section">
|
||||
<h3>换标任务明细</h3>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>换标模式</th><th>我方箱唛</th><th>新 FBA 箱唛</th><th>FBA 目的仓</th><th>任务箱数</th><th>商品 SKU</th><th>SKU 换标明细</th><th>FBA 货件编号</th><th>贴标要求</th><th>备注</th></tr></thead><tbody>
|
||||
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td>{{ item.operationType === 'CARTON_AND_SKU' ? '箱唛 + SKU 换标' : item.operationType === 'SKU_LABEL' ? 'SKU 换标' : '箱唛换标' }}</td><td><strong>{{ item.cartonMark || '—' }}</strong></td><td><strong>{{ item.fbaCartonMark || '—' }}</strong></td><td>{{ warehouseDisplay(item.destinationFbaWarehouseCode) }}</td><td>{{ item.totalPackageCount }} 箱</td><td>{{ item.skuCode || '—' }}</td><td><template v-if="item.skuItems?.length"><div v-for="skuItem in item.skuItems" :key="`${skuItem.skuCode}-${skuItem.newSkuCode}-${skuItem.quantity}`">{{ skuItem.skuCode }} → {{ skuItem.newSkuCode }} × {{ skuItem.quantity }} 件</div></template><span v-else>{{ item.newSkuCode || '—' }}</span></td><td>{{ item.shipmentNo || '—' }}</td><td>{{ item.labelRequirement || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
|
||||
<tr v-if="!selectedRelabelDetail?.items?.length"><td colspan="10"><div class="warehouse-empty">暂无换标任务明细</div></td></tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -290,6 +736,12 @@ async function openDetail(value: Record<string, string>) {
|
||||
|
||||
<style scoped>
|
||||
.warehouse-service-page{color:#25324a}.warehouse-breadcrumb{display:flex;align-items:center;gap:9px;margin:0 0 14px;color:#9aa3b2;font-size:12px}.warehouse-breadcrumb b{font-weight:400}.warehouse-breadcrumb strong{color:#606b7c}.warehouse-breadcrumb button{padding:0;border:0;background:transparent;color:#4376b8;cursor:pointer}.warehouse-page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.warehouse-page-heading h2{margin:0;color:#17233a;font-size:22px;letter-spacing:0}.warehouse-card{margin-bottom:12px;border:1px solid #e7ebf1;border-radius:4px;background:#fff;box-shadow:0 1px 3px rgba(25,42,70,.04)}.warehouse-filter-card{padding:20px 22px 16px}.warehouse-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:16px 20px}.warehouse-filter-grid label,.warehouse-form-grid label{display:flex;min-width:0;flex-direction:column;gap:7px}.warehouse-filter-grid label>span,.warehouse-form-grid label>span{color:#596579;font-size:12px}.warehouse-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}.warehouse-table-card{overflow:hidden}.warehouse-table-scroll{overflow-x:auto}table{width:100%;min-width:940px;border-collapse:collapse;font-size:12px}th{height:44px;padding:0 14px;border-bottom:1px solid #e8edf3;background:#f7f9fc;color:#4d5a6e;font-weight:650;text-align:left;white-space:nowrap}td{height:48px;padding:8px 14px;border-bottom:1px solid #eef1f5;color:#526176;white-space:nowrap}.warehouse-empty{display:grid;height:150px;color:#a0a9b7;place-items:center}.warehouse-link,.warehouse-delete{display:inline-flex;align-items:center;gap:5px;padding:4px;border:0;background:transparent;color:#2176d2;cursor:pointer}.warehouse-delete{color:#d64545}.warehouse-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 18px;color:#778398;font-size:12px}.warehouse-pagination span{margin-right:8px}.warehouse-page-number{width:28px;height:28px;border:1px solid #2176d2;border-radius:3px;background:#2176d2;color:#fff}.warehouse-form-section{padding:22px}.warehouse-form-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px;letter-spacing:0}.warehouse-form-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px 28px}.warehouse-form-grid i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-span-2{grid-column:1/-1}.warehouse-section-heading{display:flex;align-items:center;justify-content:space-between}.warehouse-section-heading h3{flex:1}.warehouse-edit-table td{padding:9px 10px}.warehouse-edit-table :deep(.el-input-number){width:130px}.warehouse-hint{margin:12px 0 0;color:#8792a2;font-size:12px}.warehouse-submit-bar{display:flex;justify-content:center;gap:10px;padding:12px 0 30px}.warehouse-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.warehouse-detail-grid div{display:flex;flex-direction:column;gap:7px}.warehouse-detail-grid span{color:#8a95a5;font-size:12px}.warehouse-detail-grid strong{color:#334157;font-size:13px}:deep(.el-button){border-radius:4px}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-select),:deep(.el-date-editor.el-input){width:100%}@media(max-width:1100px){.warehouse-filter-grid{grid-template-columns:repeat(2,1fr)}.warehouse-detail-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
.warehouse-hidden-input{display:none}.warehouse-label-table{min-width:1320px}.warehouse-attachment-tip{margin:10px 0 12px;color:#7d8797;font-size:12px}.warehouse-mark-mapping{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 8px;padding:12px;border:1px solid #dce7f5;border-radius:5px;background:#f7faff}.warehouse-mark-mapping strong{width:100%;color:#34445c}.warehouse-mark-mapping span{padding:5px 9px;border-radius:3px;background:#eaf2fc;color:#3e6593;font-size:12px}
|
||||
.warehouse-checkbox-field{display:flex;min-height:32px;align-items:center;justify-content:flex-start;gap:16px}.warehouse-checkbox-field>span{color:#596579;font-size:12px}.warehouse-checkbox-field :deep(.el-checkbox){margin-right:0}
|
||||
.warehouse-choice-field{display:grid;grid-template-columns:150px auto 1fr;align-items:center;gap:14px;min-height:40px;padding:12px 14px;border:1px solid #e1e8f2;border-radius:5px;background:#f8faff}.warehouse-choice-field>span{color:#34445c;font-size:13px;font-weight:650}.warehouse-choice-field>span i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-choice-field>small{color:#7a879a;font-size:12px}.warehouse-receiving-card{display:grid;grid-template-columns:2fr 1fr 1fr;gap:22px;padding:15px 16px;border:1px solid #cfe0f5;border-radius:5px;background:#f6faff}.warehouse-receiving-card div{display:flex;min-width:0;flex-direction:column;gap:6px}.warehouse-receiving-card span{color:#7c899c;font-size:12px}.warehouse-receiving-card strong{overflow-wrap:anywhere;color:#314561;font-size:13px;font-weight:600}
|
||||
.warehouse-hidden-input{display:none}.warehouse-label-table{min-width:1120px}.warehouse-attachment-tip{margin:10px 0 12px;color:#7d8797;font-size:12px}.warehouse-mark-mapping{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 8px;padding:12px;border:1px solid #dce7f5;border-radius:5px;background:#f7faff}.warehouse-mark-mapping strong{width:100%;color:#34445c}.warehouse-mark-mapping span{padding:5px 9px;border-radius:3px;background:#eaf2fc;color:#3e6593;font-size:12px}
|
||||
.warehouse-unmaintained{margin-left:8px;color:#e6a23c;font-size:12px;font-style:normal}
|
||||
.warehouse-labeling-tabs{margin-top:-8px}.warehouse-labeling-heading{align-items:flex-start}.warehouse-labeling-heading h3{margin-bottom:16px}.warehouse-template-actions{display:flex;gap:8px;padding-bottom:12px}.warehouse-tab-actions{align-items:center;margin:2px 0 14px}.warehouse-tab-actions p{margin:0;color:#7d8797;font-size:12px}.warehouse-tab-actions>div{display:flex;gap:8px}.warehouse-sku-notice{margin:0 0 14px;padding:10px 12px;border:1px solid #f2d39b;border-radius:4px;background:#fff8e8;color:#8a6116;font-size:12px}.warehouse-sku-label-table{min-width:980px}.warehouse-sku-label-table :deep(.el-input-number){width:160px}:deep(.warehouse-labeling-tabs>.el-tabs__header){margin-bottom:18px}:deep(.warehouse-labeling-tabs .el-tabs__item){height:42px;padding:0 26px;font-weight:600}.warehouse-labeling-mode{display:flex;align-items:center;gap:14px;margin:0 0 16px;padding:14px 16px;border:1px solid #dce5f5;border-radius:5px;background:#f7f9ff}.warehouse-labeling-mode>span{color:#34445c;font-size:13px;font-weight:650;white-space:nowrap}.warehouse-labeling-mode p{margin:0;color:#71809a;font-size:12px}.warehouse-labeling-mode :deep(.el-radio-group){display:flex;flex-shrink:0}.warehouse-labeling-mode :deep(.el-radio-button__inner){padding:8px 14px}
|
||||
.warehouse-relabel-detail-table{min-width:1420px}.warehouse-relabel-detail-table td strong{color:#253f67}
|
||||
.warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:center;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{color:#71809a;font-size:12px}.warehouse-evidence>strong{color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-actions{display:flex;flex-wrap:wrap;gap:8px}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px}
|
||||
.warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff}
|
||||
</style>
|
||||
|
||||
@@ -4,45 +4,146 @@ 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('上传关联面单后直接生成箱维度货物明细', () => {
|
||||
assert.match(view, /下载关联模板/)
|
||||
assert.match(view, /上传关联面单/)
|
||||
assert.match(view, /cargoRows\.value = imported\.map/)
|
||||
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>\*<\/i>客户单号/)
|
||||
assert.doesNotMatch(view, /<th>原入仓单<\/th>/)
|
||||
assert.doesNotMatch(view, /<th>托盘<\/th>/)
|
||||
assert.doesNotMatch(view, /<th>中转仓<\/th>/)
|
||||
assert.doesNotMatch(view, /已选托盘组成/)
|
||||
})
|
||||
|
||||
test('贴标申请保留关联模板下载和上传入口', () => {
|
||||
assert.match(view, /downloadRelabelTemplate/)
|
||||
assert.match(view, /下载 SKU 关联模板/)
|
||||
assert.match(view, /上传 SKU 关联/)
|
||||
assert.match(view, /下载箱号关联模板/)
|
||||
assert.match(view, /上传箱号关联/)
|
||||
assert.match(view, /下载 SKU 混箱模板/)
|
||||
assert.match(view, /上传 SKU 混箱/)
|
||||
assert.match(view, /skuTemplateInput\?\.click\(\)/)
|
||||
assert.match(view, /cartonTemplateInput\?\.click\(\)/)
|
||||
assert.match(view, /importRelabelTemplate/)
|
||||
assert.match(view, /明细行标识/)
|
||||
assert.match(view, /'明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签'/)
|
||||
assert.match(view, /'旧箱唛', '新FBA箱唛', 'FBA目的仓'/)
|
||||
assert.match(view, /SKU 与箱号关联通过旧箱唛合并/)
|
||||
assert.doesNotMatch(view, /库存位置ID/)
|
||||
})
|
||||
|
||||
test('贴标货物明细支持手动添加和编辑', () => {
|
||||
assert.match(view, /手动添加行/)
|
||||
assert.match(view, /addRelabelCargoRow/)
|
||||
assert.match(view, /所选入仓单没有更多可添加的库存箱/)
|
||||
assert.match(view, /<el-input v-model="row\.sku" :readonly="props\.service === 'disposal'"/)
|
||||
})
|
||||
|
||||
test('上传关系支持部分或全量选择后导入本次换标明细', () => {
|
||||
assert.match(view, /interface ImportedRelabelBinding/)
|
||||
assert.match(view, /待导入绑定关系/)
|
||||
assert.match(view, /全选/)
|
||||
assert.match(view, /导入到明细/)
|
||||
assert.match(view, /importSelectedBindingsToDetails/)
|
||||
assert.match(view, /item\.selected/)
|
||||
assert.match(view, /cartonDetailRows/)
|
||||
assert.match(view, /included: false/)
|
||||
assert.match(view, /cargoRows\.value\[0\]\.included = true/)
|
||||
})
|
||||
|
||||
test('箱唛换标一行固定代表一箱', () => {
|
||||
const cartonTable = view.match(/<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll">.+?(?=<div v-else class="warehouse-table-scroll">)/s)?.[0]
|
||||
assert.ok(cartonTable)
|
||||
assert.match(cartonTable, /<th>箱数<\/th>/)
|
||||
assert.match(cartonTable, /<td><strong>1 箱<\/strong><\/td>/)
|
||||
assert.doesNotMatch(cartonTable, /v-model="row\.quantity"/)
|
||||
assert.doesNotMatch(view, /'本次箱数'/)
|
||||
assert.match(view, /totalPackageCount: 1, notifiedPackageCount: 1/)
|
||||
assert.doesNotMatch(view, /applyCartonMarkBinding/)
|
||||
assert.doesNotMatch(view, /上传SKU 标签/)
|
||||
})
|
||||
|
||||
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, /<el-radio-group v-model="labelingMode" aria-label="换标类型">/)
|
||||
assert.match(view, /<el-radio-button value="carton">只换箱唛<\/el-radio-button>/)
|
||||
assert.match(view, /<el-radio-button value="sku">只换 SKU 标签<\/el-radio-button>/)
|
||||
assert.match(view, /<el-radio-button value="carton_and_sku">SKU 和箱唛都换<\/el-radio-button>/)
|
||||
assert.doesNotMatch(view, /el-tab-pane label="(?:箱唛换标|SKU 换标)"/)
|
||||
assert.match(view, /interface SkuRelabelRow/)
|
||||
assert.match(view, /旧 SKU \*/)
|
||||
assert.match(view, /旧箱唛 \*/)
|
||||
assert.match(view, /换标数量(件)\*/)
|
||||
assert.match(view, /旧SKU标签/)
|
||||
assert.match(view, /新SKU标签/)
|
||||
assert.match(view, /已读取 \$\{rows\.length\} 条绑定关系/)
|
||||
assert.match(view, /applyExistingSkuBinding/)
|
||||
assert.match(view, /applyExistingCartonBinding/)
|
||||
assert.match(view, /buildSkuRelabelItems/)
|
||||
assert.match(view, /CARTON_AND_SKU/)
|
||||
assert.match(view, /changeCarton/)
|
||||
assert.doesNotMatch(view, /SKU 换标提交待接口接入/)
|
||||
})
|
||||
|
||||
test('贴标明细保留箱唛绑定,不再单独填箱数', () => {
|
||||
assert.match(view, /<th>我方箱唛<\/th><th>FBA 箱唛/)
|
||||
assert.match(view, /v-model="row\.cartonMark"/)
|
||||
assert.match(view, /v-if="props\.service !== 'labeling'">\{\{ props\.service === 'disposal'/)
|
||||
test('箱唛相关换标必须选择四选一派送方式', () => {
|
||||
assert.match(view, /label="卡车派送" value="TRUCK"/)
|
||||
assert.match(view, /label="快递派送" value="EXPRESS"/)
|
||||
assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/)
|
||||
assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/)
|
||||
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/)
|
||||
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
|
||||
})
|
||||
|
||||
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 v-if="props\.service !== 'labeling'"> \*<\/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, /<th>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<RelabelDetail>/)
|
||||
assert.match(view, /<h3>箱唛换标明细<\/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/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
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')
|
||||
|
||||
test('仓储转运先选择入仓方式并按方式切换地址信息', () => {
|
||||
assert.match(view, /value="WAREHOUSE_PICKUP">仓库上门提货/)
|
||||
assert.match(view, /value="CUSTOMER_DELIVERY">客户送货到仓/)
|
||||
assert.match(view, /form\.deliveryMethod === 'WAREHOUSE_PICKUP'.*提货地址/s)
|
||||
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*送货地址.*收货联系人.*联系电话/s)
|
||||
assert.match(view, /getMerchantWarehouseDetail\(form\.warehouse\)/)
|
||||
})
|
||||
|
||||
test('仓储转运允许暂未确定 FBA 去向并显式选择是否换标', () => {
|
||||
assert.match(view, /是否已有 FBA 去向/)
|
||||
assert.match(view, /:value="false">暂未确定/)
|
||||
assert.match(view, /transferHasFbaDestination.*目的 FBA 仓/s)
|
||||
assert.match(view, /是否需要换标/)
|
||||
assert.match(view, /:value="false">不需要换标/)
|
||||
assert.match(view, /:value="true">需要换标/)
|
||||
assert.match(api, /fbaWarehouseCode\?: string/)
|
||||
assert.match(api, /fbaShipmentNo\?: string/)
|
||||
})
|
||||
|
||||
test('仓储转运不要求客户填写内部入仓号和箱唛', () => {
|
||||
const transferConfig = view.match(/transfer:\s*\{[^\n]+/)?.[0] || ''
|
||||
const transferSubmit = view.match(/await createTransfer\(\{[^\n]+/)?.[0] || ''
|
||||
const transferCargoTable = view.match(/<template v-else>[\s\S]*?<\/template>\s*<\/div>\s*<div class="warehouse-submit-bar"/)?.[0] || ''
|
||||
assert.doesNotMatch(transferConfig, /进仓编号/)
|
||||
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds|shippingMark/)
|
||||
assert.match(transferCargoTable, /预计箱数/)
|
||||
assert.doesNotMatch(transferCargoTable, /props\.service === 'transfer'.*FNSKU \/ 箱唛/)
|
||||
})
|
||||
|
||||
test('上门提货不提交物流号,需要换标仅提示入库后绑定', () => {
|
||||
assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
|
||||
assert.match(view, /完成入库后再绑定换标单/)
|
||||
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
|
||||
})
|
||||
@@ -106,6 +106,7 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'merchant:warehouse:delivery:list': ['merchant:warehouse:delivery:list'],
|
||||
'merchant:warehouse:delivery:create': ['merchant:warehouse:delivery:create'],
|
||||
'merchant:warehouse:inbound:list': ['merchant:warehouse:inbound:list'],
|
||||
'merchant:overseas-warehouse:return-inventory:list': ['merchant:overseas-warehouse:return-inventory:list'],
|
||||
'merchant:cross-border:procurement': ['merchant:cross-border:procurement'],
|
||||
'merchant:cross-border:domestic-trucking': ['merchant:cross-border:domestic-trucking'],
|
||||
'merchant:cross-border:pallet-shipping': ['merchant:cross-border:pallet-shipping'],
|
||||
|
||||
@@ -59,7 +59,7 @@ test('keeps only implemented navigation groups visible', () => {
|
||||
},
|
||||
{
|
||||
title: '海外仓',
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标', '退货库存', '退货箱号管理'],
|
||||
},
|
||||
{
|
||||
title: '订单管理',
|
||||
@@ -106,6 +106,8 @@ test('flattens visible leaves with unique paths', () => {
|
||||
'/overseas-warehouse/storage-transfer',
|
||||
'/overseas-warehouse/cargo-disposal',
|
||||
'/overseas-warehouse/return-relabel',
|
||||
'/overseas-warehouse/return-inventory',
|
||||
'/overseas-warehouse/return-cartons',
|
||||
'/orders/inbound',
|
||||
'/orders/delivery',
|
||||
'/finance/account',
|
||||
@@ -341,7 +343,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'],
|
||||
|
||||
Reference in New Issue
Block a user