diff --git a/src/modules/order-management/api/deliveryOrders.ts b/src/modules/order-management/api/deliveryOrders.ts index b7ea3dc..ab5515d 100644 --- a/src/modules/order-management/api/deliveryOrders.ts +++ b/src/modules/order-management/api/deliveryOrders.ts @@ -1,17 +1,125 @@ import { overseasApiRequest } from '@/api/overseasHttp' -export interface PageResult { records: T[]; page: number; size: number; total: number; pages: number } -export interface DeliveryRow { id: string; deliveryNo: string; outboundNo: string; dispatchNo: string; overseasWarehouseName: string; status: string; waybillNo: string; deliveryMethod: string; carrierName: string; vehiclePlate: string; driverName: string; palletCount: number; packageCount: number; totalWeightKg: number; totalVolumeCbm: number; plannedDeliveryAt: string; expectedArrivalAt: string; signedAt: string; createdAt: string } -export interface DeliveryQuery { page: number; size: number; documentNo?: string; warehouseId?: string; status?: string; dispatchNo?: string; waybillNo?: string; vehiclePlate?: string; driverName?: string; deliveryMethod?: string; plannedFrom?: string; plannedTo?: string } -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: Array<{ feeType: string; currency: string; amount: number; remark?: string }> } +export interface PageResult { + records: T[] + page: number + size: number + total: number + pages: number +} + +export interface DeliveryRow { + id: string + deliveryNo: string + outboundNo: string | null + dispatchNo: string + overseasWarehouseName: string | null + status: string + waybillNo: string | null + deliveryMethod: string + carrierName: string | null + vehiclePlate: string | null + driverName: string | null + palletCount: number + packageCount: number + totalWeightKg: number | null + totalVolumeCbm: number | null + plannedDeliveryAt: string + expectedArrivalAt: string | null + signedAt: string | null + createdAt: string +} + +export interface DeliveryOrderFee { + id?: string + feeType: string + currency: string + amount: number + remark?: string | null +} + +export interface DeliveryOrderItem { + id?: string + palletNo?: string | null + inboundCode?: string | null + skuCode?: string | null + productName?: string | null + packageCount?: number | null + weightKg?: number | null + volumeCbm?: number | null +} + +export interface DeliveryOrderDetail extends DeliveryRow { + pickupMethod: string + loadingType: string + vehicleType: string | null + vehicleSize: string | null + driverPhone: string | null + appointmentAccount: string | null + sellerName: string | null + remark: string | null + fees: DeliveryOrderFee[] + items?: DeliveryOrderItem[] | PageResult + updatedAt?: string | null +} + +export interface DeliveryQuery { + page: number + size: number + documentNo?: string + warehouseId?: string + status?: string + dispatchNo?: string + waybillNo?: string + vehiclePlate?: string + driverName?: string + deliveryMethod?: string + plannedFrom?: string + plannedTo?: string +} + +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 overseasApiRequest>({ method: 'GET', url: '/merchant/v1/last-leg/delivery-orders', params }) + return overseasApiRequest>({ + method: 'GET', + url: '/merchant/v1/last-leg/delivery-orders', + params, + }) } + export function getDeliveryOrder(deliveryNo: string) { - return overseasApiRequest>({ method: 'GET', url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, params: { page: 1, size: 200 } }) + return overseasApiRequest({ + method: 'GET', + url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, + params: { page: 1, size: 200 }, + }) } + export function createDeliveryOrder(payload: DeliveryTransferPayload) { const { dispatchNo, ...request } = payload - return overseasApiRequest>({ method: 'POST', url: '/merchant/v1/last-leg/delivery-orders', params: { dispatchNo }, data: request }) + return overseasApiRequest({ + method: 'POST', + url: '/merchant/v1/last-leg/delivery-orders', + params: { dispatchNo }, + data: request, + }) } diff --git a/src/modules/order-management/deliveryOrders.test.mjs b/src/modules/order-management/deliveryOrders.test.mjs new file mode 100644 index 0000000..fb925f0 --- /dev/null +++ b/src/modules/order-management/deliveryOrders.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import ts from 'typescript' + +const apiSource = readFileSync(new URL('./api/deliveryOrders.ts', import.meta.url), 'utf8') +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 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) { + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText + return import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`) +} + +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.match(apiSource, /overseasApiRequest/) + assert.doesNotMatch(apiSource, /customerId|tenantId|Record|mock|fallback/i) +}) + +test('keeps list, read-only detail and create permissions independent in active routes', () => { + assert.match(routesSource, /path: 'orders\/delivery'[\s\S]*permission: 'merchant:warehouse:delivery:list'[\s\S]*navigation:/) + assert.match(routesSource, /path: 'orders\/delivery\/create'[\s\S]*DeliveryOrderCreateView\.vue[\s\S]*permission: 'merchant:warehouse:delivery:create'/) + 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/) +}) + +test('renders the operation column first and covers loading, empty, error and pagination states', () => { + const operationIndex = listSource.indexOf('label="操作"') + const deliveryNumberIndex = listSource.indexOf('label="派送单号"') + assert.ok(operationIndex >= 0 && operationIndex < deliveryNumberIndex) + assert.match(listSource, /v-loading="loading"/) + assert.match(listSource, /empty-text="暂无派送单"/) + assert.match(listSource, /delivery-state--error/) + assert.match(listSource, /@click="loadOrders">重试/) + assert.match(listSource, /listDeliveryOrders\(buildQuery\(\)\)/) + assert.match(listSource, / { + assert.match(listSource, /auth\.hasPermission\('merchant:warehouse:delivery:create'\)/) + assert.match(listSource, / { + assert.match(detailSource, /defineProps<\{ deliveryNo: string \}>/) + assert.match(detailSource, /getDeliveryOrder\(props\.deliveryNo\)/) + assert.match(detailSource, /派送单详情加载失败/) + for (const heading of ['订单信息', '运输与预约', '货物明细', '费用明细']) assert.match(detailSource, new RegExp(heading)) + assert.doesNotMatch(detailSource, /detail\?\.id|deliveryNo \|\| detail\?\.id|customerId|tenantId/) +}) + +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 = crypto\.randomUUID\(\)/) + assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/) + assert.match(createSource, /fees: form\.fees\.map/) + 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 () => { + assert.match(permissionSource, /'merchant:warehouse:delivery:list': \['merchant:warehouse:delivery:list'\]/) + assert.match(permissionSource, /'merchant:warehouse:delivery:create': \['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(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:list'])), false) +}) diff --git a/src/modules/order-management/routes.ts b/src/modules/order-management/routes.ts index 99dcf2c..c3bb1e9 100644 --- a/src/modules/order-management/routes.ts +++ b/src/modules/order-management/routes.ts @@ -29,5 +29,20 @@ 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: '创建派送单', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false }, + }, + { + path: 'orders/delivery/:deliveryNo', + name: 'orders-delivery-detail', + component: () => import('./views/DeliveryOrderDetailView.vue'), + props: true, + release: 'active', + meta: { title: '派送单详情', description: '查看本企业海外仓派送单', permission: 'merchant:warehouse:delivery:list', keepAlive: false }, + }, ], } satisfies ClientModuleManifest diff --git a/src/modules/order-management/views/DeliveryOrderCreateView.vue b/src/modules/order-management/views/DeliveryOrderCreateView.vue new file mode 100644 index 0000000..b93d56e --- /dev/null +++ b/src/modules/order-management/views/DeliveryOrderCreateView.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/src/modules/order-management/views/DeliveryOrderDetailView.vue b/src/modules/order-management/views/DeliveryOrderDetailView.vue new file mode 100644 index 0000000..4fc2c60 --- /dev/null +++ b/src/modules/order-management/views/DeliveryOrderDetailView.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/modules/order-management/views/DeliveryOrdersView.vue b/src/modules/order-management/views/DeliveryOrdersView.vue index e69de29..cbb2be2 100644 --- a/src/modules/order-management/views/DeliveryOrdersView.vue +++ b/src/modules/order-management/views/DeliveryOrdersView.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/modules/order-management/views/deliveryOrders.css b/src/modules/order-management/views/deliveryOrders.css new file mode 100644 index 0000000..81183d7 --- /dev/null +++ b/src/modules/order-management/views/deliveryOrders.css @@ -0,0 +1 @@ +.delivery-page{color:#25324a}.delivery-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:16px}.delivery-heading h2{margin:0;color:#17233a;font-size:22px}.delivery-heading p{margin:6px 0 0;color:#7a8596;font-size:13px}.delivery-card{margin-bottom:14px;border:1px solid #e5eaf1;border-radius:6px;background:#fff;box-shadow:0 1px 3px rgba(25,42,70,.04)}.delivery-filter{padding:18px 20px}.delivery-filter-grid{display:grid;grid-template-columns:repeat(3,minmax(210px,1fr));gap:14px}.delivery-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.delivery-table-card{overflow:hidden}.delivery-pagination{display:flex;align-items:center;justify-content:flex-end;gap:16px;padding:14px 18px;color:#778398;font-size:12px}.delivery-state{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;flex-direction:column;color:#7a8596}.delivery-state p{margin:0}.delivery-state--error{margin:20px;border:1px solid #f5c2bd;border-radius:6px;background:#fff8f7;color:#b42318}.delivery-section{padding:20px}.delivery-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px}.delivery-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 28px}.delivery-detail-grid div{display:flex;min-width:0;flex-direction:column;gap:7px}.delivery-detail-grid span{color:#8792a2;font-size:12px}.delivery-detail-grid strong{overflow-wrap:anywhere;color:#334157;font-size:13px;font-weight:550}.delivery-span-all{grid-column:1/-1}.delivery-back{display:inline-flex;align-items:center;gap:6px;margin-bottom:14px;padding:0;border:0;background:none;color:#2176d2;cursor:pointer}.delivery-form{padding:22px}.delivery-form-grid{display:grid;grid-template-columns:repeat(3,minmax(210px,1fr));gap:4px 24px}.delivery-form-grid .delivery-span-2{grid-column:span 2}.delivery-form-grid .delivery-span-3{grid-column:1/-1}.delivery-form-actions{display:flex;justify-content:center;gap:10px;padding-top:8px}.delivery-fee-heading{display:flex;align-items:center;justify-content:space-between;margin:10px 0 12px}.delivery-fee-heading h3{margin:0;font-size:15px}.delivery-fee-empty{padding:20px;color:#8792a2;text-align:center}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-date-editor){width:100%}@media(max-width:1200px){.delivery-filter-grid,.delivery-form-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}.delivery-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.delivery-form-grid .delivery-span-3{grid-column:1/-1}} diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index fe985dd..0884cdf 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -99,6 +99,8 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly { title: '海外仓', children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'], }, + { + title: '订单管理', + children: ['派送订单列表'], + }, { title: '财务结算', children: ['客户账单'], @@ -95,6 +99,7 @@ test('flattens visible leaves with unique paths', () => { '/overseas-warehouse/storage-transfer', '/overseas-warehouse/cargo-disposal', '/overseas-warehouse/return-relabel', + '/orders/delivery', '/finance/bills', '/company/profile', '/organization/members', @@ -335,7 +340,7 @@ test('keeps prototype views cataloged while allowing implemented order and overs ] for (const [path, view] of routes) { - if (path === 'first-leg/quick-order' || path.startsWith('overseas-warehouse/')) { + if (path === 'first-leg/quick-order' || path.startsWith('overseas-warehouse/') || path === 'orders/delivery') { assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`)) assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true) continue