Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6599646f | ||
|
|
513bbe2bb0 | ||
|
|
d910b24755 |
@@ -0,0 +1,11 @@
|
||||
# 头程下单交货方式调整
|
||||
|
||||
依据:当前对话 2026-10-19 用户确认“卡车自送排第一,我司提货排第二,快递自提排第三;快递自提增加非必填快递单号”。该确认优先于历史原型对应字段。
|
||||
|
||||
| 页面 | 操作 | 承载方式 | 布局 | 当前实现 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 头程下单 | 新增 | 现有独立页面 | 保持现有国内交货区布局 | 快递自送、我司提货、卡车自送;无快递单号输入 | 按确认顺序排列;第三项改为快递自提,下方条件显示选填快递单号 |
|
||||
| 头程订单 | 编辑草稿 | 现有居中弹框 | 复用新增表单 | 无快递单号输入 | 同步新增规则,回显已保存单号 |
|
||||
| 头程订单 | 详情 | 现有独立页面 | 保持现有信息区 | 已有快递单号只读展示 | 复用当前展示;说明客户可在快递自提草稿填写单号 |
|
||||
|
||||
自动检查与人工视觉验收分别记录;1920×1080、100% 缩放的页面效果待人工截图确认。
|
||||
@@ -139,6 +139,7 @@ export interface FirstLegOrderSaveRequest {
|
||||
privateDestinationAddressId?: number | string
|
||||
cargoes: FirstLegOrderCargoPayload[]
|
||||
pickupMethod: ApiPickupMethod
|
||||
expressTrackingNo?: string
|
||||
receivingWarehouseId: number | string
|
||||
receivingWarehouseName: string
|
||||
receivingWarehouseAddress?: string | null
|
||||
|
||||
@@ -8,6 +8,21 @@ const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url)
|
||||
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
||||
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('saves optional express tracking, trims it, and omits it for other pickup methods', () => {
|
||||
const functions = storeSource.slice(storeSource.indexOf('function apiTransport('), storeSource.indexOf('function prepareDraftForSave('))
|
||||
const javascript = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const save = runInNewContext(`${javascript}; savePayload`)
|
||||
const draft = { pickupMethod: 'SELF_EXPRESS', trackingNumber: ' SF123456789 ', destinationCountryCode: 'US', destinationType: 'OVERSEAS', items: [] }
|
||||
const warehouse = { id: '1', name: '收货仓' }
|
||||
assert.equal(save(draft, warehouse, '客户').expressTrackingNo, 'SF123456789')
|
||||
for (const trackingNumber of ['', ' ']) {
|
||||
assert.equal(save({ ...draft, trackingNumber }, warehouse, '客户').expressTrackingNo, undefined)
|
||||
}
|
||||
for (const pickupMethod of ['SELF_TRUCK', 'PICKUP']) {
|
||||
assert.equal(save({ ...draft, pickupMethod }, warehouse, '客户').expressTrackingNo, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('detail fetches authoritative progress on direct entry and refresh, and clears stale progress on failure', async () => {
|
||||
const functionSource = storeSource.slice(storeSource.indexOf(' async function loadDetail('), storeSource.indexOf(' async function loadWarehouseDetail('))
|
||||
const progressSource = readFileSync(new URL('../utils/orderProgress.ts', import.meta.url), 'utf8')
|
||||
|
||||
@@ -120,6 +120,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
|
||||
productImageFileId: item.productImageFileId || undefined,
|
||||
})),
|
||||
pickupMethod: draft.pickupMethod,
|
||||
expressTrackingNo: draft.pickupMethod === 'SELF_EXPRESS' ? draft.trackingNumber.trim() || undefined : undefined,
|
||||
receivingWarehouseId: warehouse.id,
|
||||
receivingWarehouseName: warehouse.name,
|
||||
channelCode: draft.channelCode,
|
||||
|
||||
@@ -183,6 +183,7 @@ export type FirstLegOrderDraft = Omit<Pick<
|
||||
| 'destinationWarehouseId'
|
||||
| 'destinationWarehouseName'
|
||||
| 'pickupMethod'
|
||||
| 'trackingNumber'
|
||||
| 'channelCode'
|
||||
| 'channelName'
|
||||
| 'pricingTaxMode'
|
||||
|
||||
@@ -549,9 +549,19 @@ test('shows fulfillment courier information as read-only client detail', () => {
|
||||
assert.match(source, /履约快递信息/)
|
||||
assert.match(source, /detailOrder\.expressCompanyName/)
|
||||
assert.match(source, /detailOrder\.trackingNumber/)
|
||||
assert.match(source, /客户端仅只读展示/)
|
||||
assert.match(source, /快递公司由平台维护/)
|
||||
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
|
||||
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
|
||||
})
|
||||
|
||||
test('orders pickup choices and offers an optional tracking number only for express pickup', () => {
|
||||
const group = source.match(/<el-radio-group v-model="form\.pickupMethod"[\s\S]*?<\/el-radio-group>/)[0]
|
||||
assert.deepEqual([...group.matchAll(/value="([A-Z_]+)"/g)].map(match => match[1]), ['SELF_TRUCK', 'PICKUP', 'SELF_EXPRESS'])
|
||||
assert.match(group, /value="SELF_EXPRESS">快递自提/)
|
||||
const field = source.match(/<el-form-item v-if="form\.pickupMethod === 'SELF_EXPRESS'"[^>]*label="快递单号"[\s\S]*?<\/el-form-item>/)[0]
|
||||
assert.match(field, /v-model="form\.trackingNumber" maxlength="128"/)
|
||||
assert.doesNotMatch(field, /\brequired\b|:rules=/)
|
||||
assert.match(source, /trackingNumber: order\.pickupMethod === 'SELF_EXPRESS' \? order\.trackingNumber : ''/)
|
||||
assert.match(source, /if \(form\.pickupMethod !== 'SELF_EXPRESS'\) form\.trackingNumber = ''/)
|
||||
})
|
||||
|
||||
test('shows the full customer-safe order detail without platform-only controls', () => {
|
||||
|
||||
@@ -372,9 +372,9 @@ function formatExportDateTime(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
const pickupMethodLabels: Record<FirstLegOrder['pickupMethod'], string> = {
|
||||
SELF_EXPRESS: '自行快递到仓',
|
||||
PICKUP: '平台上门提货',
|
||||
SELF_TRUCK: '自行送货到仓',
|
||||
SELF_TRUCK: '卡车自送',
|
||||
PICKUP: '我司提货',
|
||||
SELF_EXPRESS: '快递自提',
|
||||
}
|
||||
const destinationTypeLabels: Record<FirstLegOrder['destinationType'], string> = {
|
||||
FBA: 'FBA 仓',
|
||||
@@ -424,6 +424,7 @@ function emptyDraft(): FirstLegOrderDraft {
|
||||
destinationWarehouseId: '',
|
||||
destinationWarehouseName: '',
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
trackingNumber: '',
|
||||
channelCode: '',
|
||||
channelName: '',
|
||||
pricingTaxMode: 'TAX_INCLUDED',
|
||||
@@ -920,6 +921,7 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
||||
destinationWarehouseId: order.destinationWarehouseId,
|
||||
destinationWarehouseName: order.destinationWarehouseName,
|
||||
pickupMethod: order.pickupMethod,
|
||||
trackingNumber: order.pickupMethod === 'SELF_EXPRESS' ? order.trackingNumber : '',
|
||||
channelCode: order.channelCode,
|
||||
channelName: order.channelName,
|
||||
pricingTaxMode: order.pricingTaxMode,
|
||||
@@ -942,6 +944,7 @@ function copyDraftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
||||
return {
|
||||
...draft,
|
||||
inboundCode: '',
|
||||
trackingNumber: '',
|
||||
items: draft.items.map((item) => ({
|
||||
...item,
|
||||
id: `copied-cargo-${++cargoSequence}`,
|
||||
@@ -1134,6 +1137,7 @@ async function changeDestinationType() {
|
||||
}
|
||||
|
||||
function changePickupMethod() {
|
||||
if (form.pickupMethod !== 'SELF_EXPRESS') form.trackingNumber = ''
|
||||
if (form.pickupMethod === 'PICKUP') {
|
||||
form.shipperPartySnapshot ??= createPickupPartySnapshot()
|
||||
} else {
|
||||
@@ -2241,7 +2245,8 @@ async function searchOrders(model: QueryModel) {
|
||||
<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" @change="changeFulfillmentType"><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 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>
|
||||
<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_TRUCK">卡车自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_EXPRESS">快递自提</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="form.pickupMethod === 'SELF_EXPRESS'" label="快递单号" prop="trackingNumber" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.trackingNumber" maxlength="128" placeholder="请输入快递单号(选填)" /></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">
|
||||
<div class="address-book-control"><el-select v-model="form.senderAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的提货地址" @change="selectPickupAddress"><el-option v-for="address in pickupAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePickupAddress">保存当前地址</el-button><el-button v-if="form.senderAddressId" link type="danger" @click="removeOrderAddress('PICKUP')">停用</el-button></div>
|
||||
@@ -2474,7 +2479,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<section v-if="detailOrder.status !== 'CANCELLED' || detailOrder.expressCompanyName || detailOrder.trackingNumber" class="detail-section">
|
||||
<h3>履约快递信息</h3>
|
||||
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div></dl>
|
||||
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
|
||||
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>暂无快递信息</strong><span>快递自提可在下单或编辑草稿时选填快递单号,快递公司由平台维护。</span></div></div>
|
||||
</section>
|
||||
<section class="detail-section">
|
||||
<h3>货物明细</h3>
|
||||
|
||||
@@ -202,6 +202,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
|
||||
destinationWarehouseId: row.destinationWarehouseId,
|
||||
destinationWarehouseName: row.destinationWarehouseName,
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
trackingNumber: '',
|
||||
channelCode: channel.channelCode,
|
||||
channelName: channel.channelName,
|
||||
pricingTaxMode,
|
||||
|
||||
@@ -104,17 +104,6 @@ interface DeliveryOrderDetailResponse {
|
||||
}>
|
||||
}
|
||||
|
||||
export interface DeliveryTransferResult {
|
||||
dispatchNo: string
|
||||
outboundNo: string
|
||||
outboundStatus: string
|
||||
deliveryNo: string
|
||||
deliveryStatus: string
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
replayed: boolean
|
||||
}
|
||||
|
||||
export interface DeliveryQuery {
|
||||
page: number
|
||||
size: number
|
||||
@@ -140,26 +129,6 @@ export function normalizeDeliveryPage(data: PageResult<DeliveryRow>): PageResult
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeliveryTransferPayload {
|
||||
dispatchNo: string
|
||||
idempotencyKey: string
|
||||
plannedDeliveryAt: string
|
||||
deliveryMethod: string
|
||||
pickupMethod: string
|
||||
loadingType: string
|
||||
waybillNo?: string
|
||||
carrierName?: string
|
||||
vehicleType?: string
|
||||
vehicleSize?: string
|
||||
driverName?: string
|
||||
driverPhone?: string
|
||||
vehiclePlate?: string
|
||||
appointmentAccount?: string
|
||||
sellerName?: string
|
||||
remark?: string
|
||||
fees: DeliveryOrderFee[]
|
||||
}
|
||||
|
||||
export function listDeliveryOrders(params: DeliveryQuery) {
|
||||
return apiRequest<PageResult<DeliveryRow>>({
|
||||
method: 'GET',
|
||||
@@ -179,13 +148,3 @@ export function getDeliveryOrder(deliveryNo: string) {
|
||||
items: skus,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createDeliveryOrder(payload: DeliveryTransferPayload) {
|
||||
const { dispatchNo, ...request } = payload
|
||||
return apiRequest<DeliveryTransferResult>({
|
||||
method: 'POST',
|
||||
url: '/merchant/v1/last-leg/delivery-orders',
|
||||
params: { dispatchNo },
|
||||
data: request,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
@@ -8,7 +8,6 @@ const routesSource = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8
|
||||
const listSource = readFileSync(new URL('./views/DeliveryOrdersView.vue', import.meta.url), 'utf8')
|
||||
const detailSource = readFileSync(new URL('./views/DeliveryOrderDetailView.vue', import.meta.url), 'utf8')
|
||||
const deliveryStyles = readFileSync(new URL('./views/deliveryOrders.css', import.meta.url), 'utf8')
|
||||
const createSource = readFileSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url), 'utf8')
|
||||
const permissionSource = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||
|
||||
async function importTypeScriptModule(source) {
|
||||
@@ -33,22 +32,18 @@ for (const signedPackages of [null, 0, 3]) test(`preserves actual signed quantit
|
||||
test('uses only authenticated merchant delivery endpoints without customer scope or mock fallback', () => {
|
||||
assert.match(apiSource, /method: 'GET'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||
assert.match(apiSource, /delivery-orders\/\$\{encodeURIComponent\(deliveryNo\)\}/)
|
||||
assert.match(apiSource, /method: 'POST'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||
assert.match(apiSource, /const \{ dispatchNo, \.\.\.request \} = payload/)
|
||||
assert.match(apiSource, /params: \{ dispatchNo \}/)
|
||||
assert.doesNotMatch(apiSource, /method: '(?:POST|PUT|PATCH|DELETE)'|createDeliveryOrder|DeliveryTransferPayload|DeliveryTransferResult/)
|
||||
assert.match(apiSource, /apiRequest<DeliveryOrderDetailResponse>/)
|
||||
assert.match(apiSource, /\.then\(\(\{ order, skus, fees \}\) => \(\{[\s\S]*\.\.\.order,[\s\S]*fees,[\s\S]*items: skus/)
|
||||
assert.match(apiSource, /apiRequest<DeliveryTransferResult>/)
|
||||
assert.doesNotMatch(apiSource, /customerId|tenantId|Record<string, unknown>|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('keeps list, read-only detail and create permissions independent in active routes', () => {
|
||||
test('keeps customer delivery list and read-only detail active without a creation route', () => {
|
||||
const listRoute = routesSource.match(/path: 'orders\/delivery',[\s\S]*?\n\s*},/)?.[0] ?? ''
|
||||
assert.match(listRoute, /permission: 'merchant:warehouse:delivery:list'/)
|
||||
assert.match(listRoute, /navigation: \{ key: 'orders-delivery', icon: Truck \}/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/create'[\s\S]*DeliveryOrderCreateView\.vue[\s\S]*permission: 'merchant:warehouse:delivery:create'/)
|
||||
assert.doesNotMatch(routesSource, /orders\/delivery\/create|orders-delivery-create|DeliveryOrderCreateView/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/:deliveryNo'[\s\S]*DeliveryOrderDetailView\.vue[\s\S]*props: true[\s\S]*permission: 'merchant:warehouse:delivery:list'/)
|
||||
assert.match(routesSource, /orders-delivery-create[\s\S]*keepAlive: false/)
|
||||
assert.match(routesSource, /orders-delivery-detail[\s\S]*keepAlive: false/)
|
||||
})
|
||||
|
||||
@@ -70,10 +65,9 @@ test('renders the operation column first and covers loading, empty, error and pa
|
||||
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
|
||||
})
|
||||
|
||||
test('shows the create button only with the independent create permission', () => {
|
||||
assert.match(listSource, /auth\.hasPermission\('merchant:warehouse:delivery:create'\)/)
|
||||
assert.match(listSource, /<el-button v-if="canCreate" type="primary" @click="openCreate"/)
|
||||
assert.doesNotMatch(listSource, /hasPermission\('merchant:warehouse:delivery:list'\)[\s\S]{0,120}创建派送单/)
|
||||
test('does not expose customer delivery creation even to an account retaining an old permission snapshot', () => {
|
||||
assert.doesNotMatch(listSource, /delivery:create|canCreate|openCreate|orders-delivery-create|创建派送单/)
|
||||
assert.equal(existsSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url)), false)
|
||||
})
|
||||
|
||||
test('loads a typed detail route by delivery number and never exposes an internal id fallback', () => {
|
||||
@@ -126,33 +120,12 @@ test('renders a compact customer-readable delivery detail surface', () => {
|
||||
assert.match(deliveryStyles, /delivery-detail-surface \.delivery-detail-grid div\{display:grid;min-height:42px;/)
|
||||
})
|
||||
|
||||
test('submits the complete create contract with stable retry idempotency and recoverable failures', () => {
|
||||
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
|
||||
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
|
||||
}
|
||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = createIdempotencyKey\(\)/)
|
||||
assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/)
|
||||
assert.match(createSource, /fees: form\.fees\.map/)
|
||||
assert.match(createSource, /if \(form\.fees\.length === 0\)/)
|
||||
assert.match(createSource, /请至少添加一条费用明细/)
|
||||
for (const value of ['FBA_PICKUP', 'CUSTOMER_PICKUP', 'TRUCK_PALLET', 'TRUCK_FLOOR', 'EXPRESS', 'OUTSOURCED', 'DEDICATED_TRUCK', 'LTL']) {
|
||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
||||
}
|
||||
for (const value of ['ONE_PICKUP_ONE_DROP', 'ONE_PICKUP_MULTI_DROP', 'MULTI_PICKUP_ONE_DROP', 'MULTI_PICKUP_MULTI_DROP', 'WAREHOUSE_PICKUP', 'PALLET', 'FLOOR', 'MIXED']) {
|
||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
||||
}
|
||||
assert.doesNotMatch(createSource, /value="(?:TRUCK|COURIER|SELF_PICKUP|DOOR_PICKUP|LOOSE_CARTON)"/)
|
||||
assert.match(createSource, /submitError\.value = error instanceof Error/)
|
||||
assert.match(createSource, /useUnsavedWorkRegistration/)
|
||||
assert.doesNotMatch(createSource, /customerId|tenantId|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('maps both merchant delivery permissions independently', async () => {
|
||||
test('keeps merchant delivery reads and removes creation from the advertised permission map', async () => {
|
||||
assert.match(permissionSource, /'merchant:warehouse:delivery:list': \['merchant:warehouse:delivery:list'\]/)
|
||||
assert.match(permissionSource, /'merchant:warehouse:delivery:create': \['merchant:warehouse:delivery:create'\]/)
|
||||
assert.doesNotMatch(permissionSource, /merchant:warehouse:delivery:create/)
|
||||
const contracts = await importTypeScriptModule(permissionSource)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:list'])), true)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:create'])), false)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:create'])), true)
|
||||
assert.equal(Object.hasOwn(contracts.FRONTEND_TO_MERCHANT_PERMISSION_CODES, 'merchant:warehouse:delivery:create'), false)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:list'])), false)
|
||||
})
|
||||
|
||||
@@ -37,13 +37,6 @@ export const orderManagementModule = {
|
||||
meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' },
|
||||
navigation: { key: 'orders-delivery', icon: Truck },
|
||||
},
|
||||
{
|
||||
path: 'orders/delivery/create',
|
||||
name: 'orders-delivery-create',
|
||||
component: () => import('./views/DeliveryOrderCreateView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '创建派送单', workspaceTitle: '派送订单 · 新建', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false },
|
||||
},
|
||||
{
|
||||
path: 'orders/delivery/:deliveryNo',
|
||||
name: 'orders-delivery-detail',
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { useUnsavedWorkRegistration } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||
import { createDeliveryOrder, type DeliveryOrderFee, type DeliveryTransferPayload } from '../api/deliveryOrders'
|
||||
|
||||
type DeliveryCreateForm = Omit<DeliveryTransferPayload, 'idempotencyKey'>
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
const submitted = ref(false)
|
||||
let pendingIdempotencyKey = ''
|
||||
|
||||
const form = reactive<DeliveryCreateForm>({
|
||||
dispatchNo: '',
|
||||
plannedDeliveryAt: '',
|
||||
deliveryMethod: '',
|
||||
pickupMethod: '',
|
||||
loadingType: '',
|
||||
waybillNo: '',
|
||||
carrierName: '',
|
||||
vehicleType: '',
|
||||
vehicleSize: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
vehiclePlate: '',
|
||||
appointmentAccount: '',
|
||||
sellerName: '',
|
||||
remark: '',
|
||||
fees: [],
|
||||
})
|
||||
|
||||
const rules: FormRules<DeliveryCreateForm> = {
|
||||
dispatchNo: [{ required: true, message: '请输入排车单号', trigger: 'blur' }],
|
||||
plannedDeliveryAt: [{ required: true, message: '请选择计划派送时间', trigger: 'change' }],
|
||||
deliveryMethod: [{ required: true, message: '请选择派送方式', trigger: 'change' }],
|
||||
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
|
||||
loadingType: [{ required: true, message: '请选择装载类型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const dirty = computed(() => !submitted.value && Boolean(
|
||||
form.dispatchNo || form.plannedDeliveryAt || form.deliveryMethod || form.pickupMethod || form.loadingType
|
||||
|| form.waybillNo || form.carrierName || form.vehicleType || form.vehicleSize || form.driverName
|
||||
|| form.driverPhone || form.vehiclePlate || form.appointmentAccount || form.sellerName || form.remark || form.fees.length,
|
||||
))
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: 'delivery-order-create-form',
|
||||
routeKey: 'orders-delivery-create',
|
||||
label: '创建派送单',
|
||||
dirty,
|
||||
})
|
||||
|
||||
function optional(value: string | undefined) {
|
||||
const normalized = value?.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function addFee() {
|
||||
form.fees.push({ feeType: '', currency: 'USD', amount: 0, remark: '' })
|
||||
}
|
||||
|
||||
function removeFee(index: number) {
|
||||
form.fees.splice(index, 1)
|
||||
}
|
||||
|
||||
function validateFees() {
|
||||
if (form.fees.length === 0) {
|
||||
ElMessage.warning('请至少添加一条费用明细')
|
||||
return false
|
||||
}
|
||||
const invalidIndex = form.fees.findIndex((fee) => !fee.feeType.trim() || !fee.currency.trim() || fee.amount <= 0)
|
||||
if (invalidIndex < 0) return true
|
||||
ElMessage.warning(`请完整填写第 ${invalidIndex + 1} 条费用的类型、币种和大于 0 的金额`)
|
||||
return false
|
||||
}
|
||||
|
||||
function requestPayload(): DeliveryTransferPayload {
|
||||
return {
|
||||
dispatchNo: form.dispatchNo.trim(),
|
||||
idempotencyKey: pendingIdempotencyKey,
|
||||
plannedDeliveryAt: form.plannedDeliveryAt,
|
||||
deliveryMethod: form.deliveryMethod,
|
||||
pickupMethod: form.pickupMethod,
|
||||
loadingType: form.loadingType,
|
||||
waybillNo: optional(form.waybillNo),
|
||||
carrierName: optional(form.carrierName),
|
||||
vehicleType: optional(form.vehicleType),
|
||||
vehicleSize: optional(form.vehicleSize),
|
||||
driverName: optional(form.driverName),
|
||||
driverPhone: optional(form.driverPhone),
|
||||
vehiclePlate: optional(form.vehiclePlate),
|
||||
appointmentAccount: optional(form.appointmentAccount),
|
||||
sellerName: optional(form.sellerName),
|
||||
remark: optional(form.remark),
|
||||
fees: form.fees.map((fee): DeliveryOrderFee => ({
|
||||
feeType: fee.feeType.trim(),
|
||||
currency: fee.currency.trim().toUpperCase(),
|
||||
amount: fee.amount,
|
||||
remark: optional(fee.remark ?? ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
submitError.value = ''
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !validateFees()) return
|
||||
if (!pendingIdempotencyKey) pendingIdempotencyKey = createIdempotencyKey()
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await createDeliveryOrder(requestPayload())
|
||||
submitted.value = true
|
||||
pendingIdempotencyKey = ''
|
||||
ElMessage.success('派送单创建成功')
|
||||
if (auth.hasPermission('merchant:warehouse:delivery:list')) {
|
||||
await router.replace({ name: 'orders-delivery-detail', params: { deliveryNo: created.deliveryNo } })
|
||||
} else {
|
||||
await router.replace('/')
|
||||
}
|
||||
} catch (error) {
|
||||
submitError.value = error instanceof Error ? error.message : '派送单创建失败,请检查后重试'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (dirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前派送单尚未提交,离开后填写内容将丢失。', '放弃创建?', { type: 'warning', confirmButtonText: '放弃', cancelButtonText: '继续填写' })
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
submitted.value = true
|
||||
await router.push({ name: 'orders-delivery' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="delivery-page" aria-labelledby="delivery-create-title">
|
||||
<button type="button" class="delivery-back" @click="cancel"><ArrowLeft :size="16" />返回派送单列表</button>
|
||||
<div class="delivery-heading"><div><h2 id="delivery-create-title">创建派送单</h2><p>从已确认的排车单创建本企业海外仓派送任务。</p></div></div>
|
||||
|
||||
<div v-if="submitError" class="delivery-card delivery-state delivery-state--error" role="alert"><p>{{ submitError }}</p></div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="delivery-card delivery-form" @submit.prevent="submit">
|
||||
<section>
|
||||
<h3>基础信息</h3>
|
||||
<div class="delivery-form-grid">
|
||||
<el-form-item label="排车单号" prop="dispatchNo" required><el-input v-model="form.dispatchNo" maxlength="64" placeholder="请输入排车单号" /></el-form-item>
|
||||
<el-form-item label="计划派送时间" prop="plannedDeliveryAt" required><el-date-picker v-model="form.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划派送时间" /></el-form-item>
|
||||
<el-form-item label="派送方式" prop="deliveryMethod" required><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item>
|
||||
<el-form-item label="提货方式" prop="pickupMethod" required><el-select v-model="form.pickupMethod" placeholder="请选择提货方式"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item>
|
||||
<el-form-item label="装载类型" prop="loadingType" required><el-select v-model="form.loadingType" placeholder="请选择装载类型"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item>
|
||||
<el-form-item label="运单号"><el-input v-model="form.waybillNo" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>承运与车辆</h3>
|
||||
<div class="delivery-form-grid">
|
||||
<el-form-item label="承运商"><el-input v-model="form.carrierName" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车型"><el-input v-model="form.vehicleType" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车辆尺寸"><el-input v-model="form.vehicleSize" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车牌号"><el-input v-model="form.vehiclePlate" maxlength="32" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="司机姓名"><el-input v-model="form.driverName" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="司机电话"><el-input v-model="form.driverPhone" maxlength="32" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="预约账号"><el-input v-model="form.appointmentAccount" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="卖家名称"><el-input v-model="form.sellerName" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="备注" class="delivery-span-3"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="delivery-fee-heading"><h3>费用明细</h3><el-button @click="addFee"><Plus :size="16" />添加费用</el-button></div>
|
||||
<el-table v-if="form.fees.length" :data="form.fees" table-layout="fixed">
|
||||
<el-table-column label="操作" width="88"><template #default="{ $index }"><el-button link type="danger" aria-label="删除费用" @click="removeFee($index)"><Trash2 :size="16" />删除</el-button></template></el-table-column>
|
||||
<el-table-column label="费用类型" min-width="180"><template #default="{ row }"><el-input v-model="row.feeType" maxlength="64" placeholder="请输入费用类型" /></template></el-table-column>
|
||||
<el-table-column label="币种" width="140"><template #default="{ row }"><el-input v-model="row.currency" maxlength="3" placeholder="USD" /></template></el-table-column>
|
||||
<el-table-column label="金额" width="180"><template #default="{ row }"><el-input-number v-model="row.amount" :min="0" :precision="2" :step="1" controls-position="right" placeholder="请输入金额" /></template></el-table-column>
|
||||
<el-table-column label="备注" min-width="220"><template #default="{ row }"><el-input v-model="row.remark" maxlength="200" placeholder="选填" /></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="delivery-fee-empty">请至少添加一条费用明细后再提交。</div>
|
||||
</section>
|
||||
|
||||
<div class="delivery-form-actions"><el-button :disabled="submitting" @click="cancel">取消</el-button><el-button type="primary" native-type="submit" :loading="submitting">提交创建</el-button></div>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./deliveryOrders.css"></style>
|
||||
@@ -1,16 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { listDeliveryOrders, type DeliveryQuery, type DeliveryRow } from '../api/deliveryOrders'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const records = ref<DeliveryRow[]>([])
|
||||
@@ -18,7 +15,6 @@ const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = reactive({ documentNo: '', dispatchNo: '', waybillNo: '', status: '', deliveryMethod: '', plannedRange: [] as string[] })
|
||||
const canCreate = computed(() => auth.hasPermission('merchant:warehouse:delivery:create'))
|
||||
let requestSequence = 0
|
||||
|
||||
const deliveryMethodOptions = [
|
||||
@@ -142,9 +138,6 @@ function openDetail(row: DeliveryRow) {
|
||||
void router.push({ name: 'orders-delivery-detail', params: { deliveryNo: row.deliveryNo } })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
void router.push({ name: 'orders-delivery-create' })
|
||||
}
|
||||
function rowActions(candidate: object): TableAction[] {
|
||||
const row = candidate as DeliveryRow
|
||||
return [{ key: 'detail', label: '详情', disabled: !row.deliveryNo, run: () => openDetail(row) }]
|
||||
@@ -163,7 +156,6 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
|
||||
<div class="delivery-card delivery-table-card">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="deliveryNo" empty-text="暂无派送单">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />创建派送单</el-button></template>
|
||||
<template v-if="errorMessage" #error><div class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div></template>
|
||||
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #planned="{ row }">{{ formatDateTime(row.plannedDeliveryAt) }}</template>
|
||||
|
||||
@@ -57,7 +57,6 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
{ code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' },
|
||||
{ code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' },
|
||||
{ code: 'merchant:warehouse:delivery:list', label: '查看派送单', description: '分页查询本企业海外仓派送单' },
|
||||
{ code: 'merchant:warehouse:delivery:create', label: '创建派送单', description: '从排车单创建海外仓派送单' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -145,7 +144,6 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'order:first-leg:submit': ['merchant:order:submit'],
|
||||
'order:first-leg:cancel': ['merchant:order:cancel'],
|
||||
'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:overseas-warehouse:last-mile-order:list': ['merchant:overseas-warehouse:last-mile-order:list'],
|
||||
|
||||
Reference in New Issue
Block a user