Merge branch 'release/test' of http://8.163.30.128:10082/html/client-web into release/test
This commit is contained in:
@@ -56,7 +56,7 @@
|
||||
| 客户资金账户、授信与充值申请 | `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;采购创建的 `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`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标申请必须提交正数 CNY `chargeAmount`,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
|
||||
| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
|
||||
|
||||
## 租户、认证与数据安全
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-9002}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
|
||||
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
|
||||
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
|
||||
OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"
|
||||
|
||||
@@ -65,3 +65,27 @@ export async function overseasApiRequest<T>(config: AxiosRequestConfig) {
|
||||
throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
export async function overseasBlobRequest(config: AxiosRequestConfig) {
|
||||
try {
|
||||
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
if (axios.isAxiosError(error)) {
|
||||
const body = error.response?.data
|
||||
if (body instanceof Blob && body.type.includes('json')) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await body.text())
|
||||
if (isApiEnvelope(parsed) || isApiFailure(parsed)) {
|
||||
throw new ApiError(parsed.message, parsed.code, error.response?.status, undefined, parsed.traceId, parsed.requestId)
|
||||
}
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof ApiError) throw parseError
|
||||
}
|
||||
}
|
||||
throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
|
||||
}
|
||||
throw new ApiError(error instanceof Error ? error.message : '海外文件下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -33,6 +33,7 @@ declare module 'vue' {
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
|
||||
@@ -22,8 +22,10 @@ test('does not cache the SPA entry while keeping hashed assets immutable', () =>
|
||||
})
|
||||
|
||||
test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
|
||||
assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/)
|
||||
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/)
|
||||
assert.match(testCompose, /- "host\.docker\.internal:host-gateway"/)
|
||||
assert.match(testCompose, /API_UPSTREAM: "\$\{API_UPSTREAM:-http:\/\/host\.docker\.internal:9000\}"/)
|
||||
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "\$\{OVERSEAS_API_UPSTREAM:-http:\/\/host\.docker\.internal:9300\}"/)
|
||||
assert.doesNotMatch(testCompose, /http:\/\/test-api-(?:cn|global)\.buggoglobal\.com/)
|
||||
assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/)
|
||||
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/)
|
||||
assert.match(config, /proxy_set_header Host \$proxy_host;/)
|
||||
|
||||
@@ -8,13 +8,17 @@ const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.
|
||||
|
||||
test('activates all three customer cross-border pages with frozen permissions', () => {
|
||||
for (const [path, permission] of [
|
||||
['cross-border/procurement', 'merchant:cross-border:procurement'],
|
||||
['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking'],
|
||||
['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping'],
|
||||
['cross-border/procurement', 'merchant:cross-border:procurement:list'],
|
||||
['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking:list'],
|
||||
['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping:list'],
|
||||
]) {
|
||||
assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`))
|
||||
}
|
||||
assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i)
|
||||
assert.match(view, /merchant:cross-border:procurement:create/)
|
||||
assert.match(view, /merchant:cross-border:domestic-trucking:create/)
|
||||
assert.match(view, /merchant:cross-border:pallet-shipping:create/)
|
||||
assert.match(view, /v-else-if="canCreate"/)
|
||||
})
|
||||
|
||||
test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => {
|
||||
|
||||
@@ -17,6 +17,11 @@ import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSp
|
||||
|
||||
const props = defineProps<{ kind: CrossBorderBusinessKind }>()
|
||||
const auth = useAuthStore()
|
||||
const canCreate = computed(() => auth.hasPermission(({
|
||||
procurement: 'merchant:cross-border:procurement:create',
|
||||
trucking: 'merchant:cross-border:domestic-trucking:create',
|
||||
looseboard: 'merchant:cross-border:pallet-shipping:create',
|
||||
} as Record<CrossBorderBusinessKind, string>)[props.kind]))
|
||||
|
||||
type BusinessRow = ProcurementSummary | TransportSummary
|
||||
type BusinessDetail = ProcurementDetail | TransportDetail
|
||||
@@ -277,7 +282,7 @@ onMounted(() => void load())
|
||||
<p>{{ mode === 'list' ? configuration.description : mode === 'create' ? '请填写订单信息,带 * 项为必填项' : `订单号:${detail?.summary.orderNo || '—'}` }}</p>
|
||||
</div>
|
||||
<el-button v-if="mode !== 'list'" @click="mode = 'list'">返回列表</el-button>
|
||||
<el-button v-else type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button>
|
||||
<el-button v-else-if="canCreate" type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button>
|
||||
</header>
|
||||
|
||||
<template v-if="mode === 'list'">
|
||||
@@ -340,7 +345,7 @@ onMounted(() => void load())
|
||||
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
|
||||
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
|
||||
<el-form-item><el-button @click="mode = 'list'">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
|
||||
<el-form-item><el-button @click="mode = 'list'">取消</el-button><el-button v-if="canCreate" type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const crossBorderModule = {
|
||||
name: 'cross-border-procurement',
|
||||
component: () => import('./views/ProcurementView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' },
|
||||
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement:list' },
|
||||
navigation: { key: 'cross-border-procurement', icon: Package },
|
||||
},
|
||||
{
|
||||
@@ -18,7 +18,7 @@ export const crossBorderModule = {
|
||||
name: 'cross-border-domestic-trucking',
|
||||
component: () => import('./views/DomesticTruckingView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' },
|
||||
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking:list' },
|
||||
navigation: { key: 'cross-border-domestic-trucking', icon: Truck },
|
||||
},
|
||||
{
|
||||
@@ -26,7 +26,7 @@ export const crossBorderModule = {
|
||||
name: 'cross-border-pallet-shipping',
|
||||
component: () => import('./views/PalletShippingView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' },
|
||||
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping:list' },
|
||||
navigation: { key: 'cross-border-pallet-shipping', icon: Ship },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -68,7 +68,7 @@ const businessCards = [
|
||||
icon: ShoppingCart,
|
||||
tone: 'blue',
|
||||
route: '/cross-border/procurement',
|
||||
permission: 'merchant:cross-border:procurement',
|
||||
permission: 'merchant:cross-border:procurement:list',
|
||||
},
|
||||
{
|
||||
title: '美国本土卡',
|
||||
@@ -76,7 +76,7 @@ const businessCards = [
|
||||
icon: Truck,
|
||||
tone: 'green',
|
||||
route: '/cross-border/domestic-trucking',
|
||||
permission: 'merchant:cross-border:domestic-trucking',
|
||||
permission: 'merchant:cross-border:domestic-trucking:list',
|
||||
},
|
||||
{
|
||||
title: '美本土散板',
|
||||
@@ -84,7 +84,7 @@ const businessCards = [
|
||||
icon: PanelsTopLeft,
|
||||
tone: 'teal',
|
||||
route: '/cross-border/pallet-shipping',
|
||||
permission: 'merchant:cross-border:pallet-shipping',
|
||||
permission: 'merchant:cross-border:pallet-shipping:list',
|
||||
},
|
||||
] as const
|
||||
|
||||
|
||||
@@ -457,15 +457,32 @@ export interface MerchantPriceEstimate {
|
||||
}
|
||||
|
||||
export function getMerchantWarehouses(params: MerchantWarehouseQuery = {}) {
|
||||
return apiRequest<PageResult<MerchantWarehouse>>({
|
||||
const request = apiRequest<PageResult<MerchantWarehouse>>({
|
||||
method: 'GET',
|
||||
url: '/merchant/v1/warehouses',
|
||||
params: { page: 1, size: 200, status: 'ACTIVE', ...params },
|
||||
})
|
||||
if (params.warehouseType !== 'OVERSEAS') return request
|
||||
return request.then(async (result) => {
|
||||
const usableRecords = result.records.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS')
|
||||
if (usableRecords.length > 0) return { ...result, records: usableRecords }
|
||||
const fallback = await apiRequest<PageResult<MerchantWarehouse>>({
|
||||
method: 'GET',
|
||||
url: '/platform/v1/warehouse-admin',
|
||||
params: { page: params.page || 1, size: params.size || 200, tenantId: 0, warehouseType: 'OVERSEAS', status: 'ACTIVE' },
|
||||
})
|
||||
return {
|
||||
...fallback,
|
||||
records: fallback.records.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getMerchantWarehouseDetail(id: number | string) {
|
||||
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
|
||||
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` }).catch(async () => {
|
||||
const fallback = await apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/platform/v1/warehouse-admin/${encodeURIComponent(id)}`, params: { tenantId: 0 } })
|
||||
return fallback
|
||||
})
|
||||
}
|
||||
|
||||
export function getMerchantPricingChannels(originWarehouseId: number | string) {
|
||||
|
||||
@@ -35,5 +35,5 @@ test('keeps overseas warehouse operations first and renders readable list values
|
||||
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)
|
||||
assert.match(overseas, /<td><div class="warehouse-list-actions"><button class="warehouse-link"[^>]*>.*?查看<\/button>.*?<\/div><\/td><td v-for="\(_, index\) in config\.columns\.slice\(1\)"/s)
|
||||
})
|
||||
|
||||
@@ -55,9 +55,10 @@ test('preserves overseas API diagnostic metadata without rendering it as a busin
|
||||
|
||||
test('renders inbound workflow statuses in Chinese and keeps desktop values on one line', () => {
|
||||
for (const [status, label] of [
|
||||
['PENDING_ASSIGNMENT', '待分配'], ['PDA_PENDING', '待收货'], ['ASSIGNED', '已分配'],
|
||||
['PENDING_ASSIGNMENT', '待分配'], ['PDA_PENDING', '待入库'], ['ASSIGNED', '已分配'],
|
||||
['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'],
|
||||
]) assert.match(view, new RegExp(`${status}: '${label}'`))
|
||||
assert.match(view, /COMPLETED: '全部入库'/)
|
||||
assert.match(view, /label="状态" width="120" show-overflow-tooltip/)
|
||||
assert.match(view, /\.detail-grid div \{ min-width: 0;/)
|
||||
assert.match(view, /\.detail-grid dd \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
|
||||
|
||||
@@ -12,11 +12,11 @@ const total = ref(0)
|
||||
const inboundStatusOptions = [
|
||||
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
|
||||
{ label: '已分配', value: 'ASSIGNED' },
|
||||
{ label: '待收货', value: 'PDA_PENDING' },
|
||||
{ label: '待入库', value: 'PDA_PENDING' },
|
||||
{ label: '已签收', value: 'SIGNED' },
|
||||
{ label: '拣货中', value: 'PICKING' },
|
||||
{ label: '部分入库', value: 'PARTIAL_INBOUND' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '全部入库', value: 'COMPLETED' },
|
||||
]
|
||||
const query = reactive({
|
||||
page: 1, size: 20, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '',
|
||||
@@ -33,9 +33,9 @@ function destinationWarehouseLabel(...values: unknown[]) {
|
||||
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function statusLabel(value: unknown) {
|
||||
return ({
|
||||
PENDING: '待处理', PROCESSING: '处理中', PENDING_ASSIGNMENT: '待分配', PDA_PENDING: '待收货',
|
||||
PENDING: '待处理', PROCESSING: '处理中', PENDING_ASSIGNMENT: '待分配', PDA_PENDING: '待入库',
|
||||
ASSIGNED: '已分配', SIGNED: '已签收', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库',
|
||||
COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功',
|
||||
COMPLETED: '全部入库', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功',
|
||||
FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车',
|
||||
} as Record<string, string>)[String(value)] || text(value)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
||||
import { overseasApiRequest, overseasBlobRequest } 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; estimatedShipAt?: string; actualShipAt?: 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 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; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: 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
|
||||
@@ -31,20 +31,24 @@ export interface RelabelInventoryCandidate {
|
||||
destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string
|
||||
availablePackages: number; version: number
|
||||
positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }>
|
||||
sourceType?: 'INBOUND' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string
|
||||
skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>
|
||||
}
|
||||
|
||||
export interface TransferCreate {
|
||||
operatingWarehouseId: string; fbaWarehouseCode?: string
|
||||
operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
|
||||
destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string
|
||||
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
|
||||
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string
|
||||
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
|
||||
labelDeadline?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
|
||||
items: Array<{ skuCode: string; productNameZh: string; packageCount: number; shippingMark?: string }>
|
||||
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
|
||||
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
|
||||
items: Array<{ skuCode?: string; productNameZh?: string; packageCount: number; shippingMark?: string }>
|
||||
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
|
||||
}
|
||||
export interface RelabelCreate {
|
||||
customerOrderNo: string; operationWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount: number
|
||||
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
|
||||
items: Array<{ sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: 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 }>
|
||||
}
|
||||
|
||||
@@ -60,6 +64,12 @@ export function getTransfer(transferNo: string) {
|
||||
export function createTransfer(payload: TransferCreate) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/warehouse-transfers', data: payload })
|
||||
}
|
||||
export function updateTransfer(transferNo: string, payload: TransferCreate) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/save-draft`, data: payload })
|
||||
}
|
||||
export function submitTransfer(transferNo: string) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
|
||||
}
|
||||
export function listRelabels(params: Record<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
|
||||
}
|
||||
@@ -73,6 +83,9 @@ export interface TransferCartonLabel { transferNo: string; cartons: Array<{ cart
|
||||
export function getTransferCartonLabel(transferNo: string) {
|
||||
return overseasApiRequest<TransferCartonLabel>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/carton-label` })
|
||||
}
|
||||
export function downloadTransferMeasurementPhoto(transferNo: string, fileId: string | number) {
|
||||
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/measurement-photos/files/${encodeURIComponent(fileId)}/download` })
|
||||
}
|
||||
|
||||
export function listRelabelInventoryCandidates(operationWarehouseId: string, inboundCode?: string) {
|
||||
return overseasApiRequest<RelabelInventoryCandidate[]>({
|
||||
@@ -87,11 +100,15 @@ 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 uploadRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SKU_TOTAL' | 'ITEM', file: File, itemId?: string) {
|
||||
const data = new FormData(); data.append('file', file)
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope, itemId }, 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' })
|
||||
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download` })
|
||||
}
|
||||
export function listDiscards(params: Record<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<DiscardSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards', params })
|
||||
@@ -105,3 +122,6 @@ export function listDiscardCandidates(inboundCode?: string) {
|
||||
export function createDiscard(payload: { items: Array<{ orderId: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
|
||||
}
|
||||
export function cancelDiscard(discardNo: string) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||
const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
const returns = readFileSync(new URL('./views/ReturnOrdersView.vue', import.meta.url), 'utf8')
|
||||
const contracts = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('protects every overseas warehouse page with its independent list permission', () => {
|
||||
for (const permission of [
|
||||
'merchant:overseas-warehouse:relabel:list',
|
||||
'merchant:overseas-warehouse:transfer:list',
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'merchant:overseas-warehouse:return-inventory:list',
|
||||
]) assert.match(routes, new RegExp(`permission: '${permission}'`))
|
||||
assert.doesNotMatch(routes, /permission: 'dashboard:view'/)
|
||||
})
|
||||
|
||||
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
|
||||
for (const permission of [
|
||||
'merchant:overseas-warehouse:relabel:create',
|
||||
'merchant:overseas-warehouse:relabel:files',
|
||||
'merchant:overseas-warehouse:relabel:confirm',
|
||||
'merchant:overseas-warehouse:transfer:create',
|
||||
'merchant:overseas-warehouse:transfer:confirm',
|
||||
'merchant:overseas-warehouse:discard:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
]) assert.match(services, new RegExp(permission))
|
||||
assert.match(services, /v-if="canCreate" type="primary" @click="openCreate"/)
|
||||
assert.match(services, /canManageRelabelFiles && props\.service === 'labeling'/)
|
||||
assert.match(services, /canConfirmTransfer && props\.service === 'transfer'/)
|
||||
assert.match(services, /canCancelDiscard && props\.service === 'disposal'/)
|
||||
|
||||
assert.match(returns, /merchant:overseas-warehouse:return-order:create/)
|
||||
assert.match(returns, /v-if="canCreate" type="primary" @click="openCreate"/)
|
||||
})
|
||||
|
||||
test('publishes all overseas operation permissions in the enterprise role editor', () => {
|
||||
for (const permission of [
|
||||
'merchant:overseas-warehouse:last-mile-order:list',
|
||||
'merchant:overseas-warehouse:relabel:list',
|
||||
'merchant:overseas-warehouse:relabel:create',
|
||||
'merchant:overseas-warehouse:relabel:files',
|
||||
'merchant:overseas-warehouse:relabel:confirm',
|
||||
'merchant:overseas-warehouse:transfer:list',
|
||||
'merchant:overseas-warehouse:transfer:create',
|
||||
'merchant:overseas-warehouse:transfer:confirm',
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:discard:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'merchant:overseas-warehouse:return-order:create',
|
||||
'merchant:overseas-warehouse:return-order:relabel',
|
||||
'merchant:overseas-warehouse:return-inventory:list',
|
||||
]) assert.match(contracts, new RegExp(`code: '${permission}'`))
|
||||
})
|
||||
@@ -8,25 +8,25 @@ export const overseasWarehouseModule = {
|
||||
{
|
||||
path: 'overseas-warehouse/labeling', name: 'overseas-labeling',
|
||||
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling' }, release: 'active',
|
||||
meta: { title: '贴标申请', description: '查询并管理海外仓贴标申请', permission: 'dashboard:view' },
|
||||
meta: { title: '贴标申请', description: '查询并管理海外仓贴标申请', permission: 'merchant:overseas-warehouse:relabel:list' },
|
||||
navigation: { key: 'overseas-labeling', icon: Tags },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/storage-transfer', name: 'overseas-storage-transfer',
|
||||
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer' }, release: 'active',
|
||||
meta: { title: '仓储转运', description: '查询并管理海外仓转运订单', permission: 'dashboard:view' },
|
||||
meta: { title: '仓储转运', description: '查询并管理海外仓转运订单', permission: 'merchant:overseas-warehouse:transfer:list' },
|
||||
navigation: { key: 'overseas-storage-transfer', icon: Truck },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/cargo-disposal', name: 'overseas-cargo-disposal',
|
||||
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal' }, release: 'active',
|
||||
meta: { title: '货物废弃', description: '查询并管理海外仓货物废弃申请', permission: 'dashboard:view' },
|
||||
meta: { title: '货物废弃', description: '查询并管理海外仓货物废弃申请', permission: 'merchant:overseas-warehouse:discard:list' },
|
||||
navigation: { key: 'overseas-cargo-disposal', icon: PackageX },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/return-relabel', name: 'overseas-return-relabel',
|
||||
component: () => import('./views/ReturnOrdersView.vue'), release: 'active',
|
||||
meta: { title: '退货换标', description: '创建退货预报并查看仓库签收结果', permission: 'dashboard:view' },
|
||||
meta: { title: '退货换标', description: '创建退货预报并查看仓库签收结果', permission: 'merchant:overseas-warehouse:return-order:list' },
|
||||
navigation: { key: 'overseas-return-relabel', icon: RefreshCcw },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,15 +4,15 @@ import test from 'node:test'
|
||||
|
||||
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('新建退货预报选择中转仓后带出联系人和电话', () => {
|
||||
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, /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, /该海外仓未维护,请手动填写/)
|
||||
assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/)
|
||||
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
|
||||
})
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Search } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
|
||||
import { createReturnOrder, getReturnOrder, listReturnOrders, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
|
||||
|
||||
const loading = ref(false)
|
||||
const auth = useAuthStore()
|
||||
const canCreate = computed(() => auth.hasPermission('merchant:overseas-warehouse:return-order:create'))
|
||||
const creating = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const showDetail = ref(false)
|
||||
@@ -15,7 +18,7 @@ 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: '' })
|
||||
const query = reactive({ page: 1, size: 15, 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 } }
|
||||
@@ -45,12 +48,16 @@ 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 = '暂无可用的国外中转仓'
|
||||
// Managed transit warehouses are maintained as OVERSEAS_PARTNER in the warehouse master.
|
||||
// Query the active catalogue and accept both overseas warehouse types.
|
||||
const result = await getMerchantWarehouses({ page: 1, size: 200 })
|
||||
operationWarehouses.value = result.records.filter((warehouse) =>
|
||||
warehouse.status === 'ACTIVE' && ['OVERSEAS', 'OVERSEAS_PARTNER'].includes(warehouse.warehouseType),
|
||||
)
|
||||
if (!result.records.length) operationWarehousesError.value = '暂无可用的海外仓'
|
||||
} catch (error) {
|
||||
operationWarehouses.value = []
|
||||
operationWarehousesError.value = error instanceof Error ? error.message : '国外中转仓加载失败'
|
||||
operationWarehousesError.value = error instanceof Error ? error.message : '海外仓加载失败'
|
||||
ElMessage.error(operationWarehousesError.value)
|
||||
} finally { operationWarehousesLoading.value = false }
|
||||
}
|
||||
@@ -63,7 +70,7 @@ 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 = Number(response.total) }
|
||||
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 = Number(response.total); if (!Number.isFinite(total.value)) total.value = response.records.length }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function changePage(page: number) {
|
||||
@@ -75,7 +82,7 @@ async function openDetail(returnNo: string) { selected.value = await getReturnOr
|
||||
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 (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写海外仓联系人和联系电话'); return }
|
||||
if (duplicateSkuIds.value.size) { ElMessage.warning('同一退货单不能重复填写相同 SKU'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
@@ -99,7 +106,7 @@ onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
|
||||
|
||||
<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="page-heading"><div><h2>退货签收</h2><p>预报退货 SKU 和数量,仓库 PDA 实收后自动更新签收状态。</p></div><el-button v-if="canCreate" 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()" />
|
||||
@@ -107,7 +114,7 @@ onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
|
||||
<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 v-loading="loading" class="return-table-card" :data="rows" @row-click="row => openDetail(row.returnNo)">
|
||||
<el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row.returnNo)">详情</el-button></template></el-table-column>
|
||||
<el-table-column prop="returnNo" label="退货单号" min-width="190" />
|
||||
<el-table-column prop="customerReferenceNo" label="客户参考号" min-width="150" />
|
||||
@@ -119,13 +126,13 @@ onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
|
||||
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination :current-page="query.page" :page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="changePage" />
|
||||
<div class="return-pagination"><span class="return-pagination-total">共 {{ total }} 条</span><el-select v-model="query.size" class="return-page-size-select" aria-label="每页条数" @change="query.page=1; load()"><el-option v-for="size in [15, 30, 50, 100]" :key="size" :label="`${size}条/页`" :value="size" /></el-select><el-pagination :current-page="query.page" :page-size="query.size" :total="total" :hide-on-single-page="false" layout="prev, pager, next" @current-change="changePage" /></div>
|
||||
|
||||
<el-dialog v-model="showCreate" title="新建退货预报" width="1120px" destroy-on-close>
|
||||
<el-form label-width="190px" 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="海外仓" 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>
|
||||
@@ -138,13 +145,36 @@ onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
|
||||
<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>
|
||||
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
|
||||
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="showDetail" title="退货签收详情" width="min(980px, calc(100vw - 32px))" top="8vh" class="return-detail-dialog" destroy-on-close>
|
||||
<template v-if="selected">
|
||||
<div class="detail-summary"><div><span>退货单号</span><strong>{{ selected.order.returnNo }}</strong></div><div><span>状态</span><el-tag :type="statusType(selected.order.status)">{{ statusLabel(selected.order.status) }}</el-tag></div><div><span>海外仓</span><strong>{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }}</strong></div><div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div><div><span>海外仓联系人</span><strong>{{ selected.order.warehouseContactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div></div>
|
||||
<p v-if="selected.remark" class="remark detail-remark">备注:{{ selected.remark }}</p>
|
||||
<section class="detail-section"><h3>预报与实收</h3><el-table :data="selected.items" size="small" border><el-table-column prop="skuCode" label="SKU" min-width="180" /><el-table-column prop="productNameZh" label="中文品名" min-width="220" /><el-table-column prop="expectedQuantity" label="预报" width="100" /><el-table-column prop="receivedQuantity" label="实收" width="100" /></el-table></section>
|
||||
<section class="detail-section"><h3>签收批次</h3><el-table :data="selected.receipts" size="small" border><el-table-column prop="receiptBatchNo" label="批次号" min-width="160" /><el-table-column prop="receiptNo" label="签收单号" min-width="160" /><el-table-column prop="cartonMark" label="箱号" min-width="170" /><el-table-column prop="trackingNo" label="快递单号" min-width="150"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column label="开始方式" width="110"><template #default="{ row }">{{ startedByLabel(row.startedBy) }}</template></el-table-column><el-table-column prop="receivedQuantity" label="实收" width="80" /><el-table-column label="签收时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.signedAt) }}</template></el-table-column></el-table></section>
|
||||
</template>
|
||||
<template #footer><el-button type="primary" @click="showDetail = false">关闭</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)">{{ statusLabel(selected.order.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="操作仓">{{ operationWarehouseLabel(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 label="开始方式"><template #default="{ row }">{{ startedByLabel(row.startedBy) }}</template></el-table-column><el-table-column prop="receivedQuantity" label="实收" /><el-table-column label="签收时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.signedAt) }}</template></el-table-column></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%; }
|
||||
.return-table-card { overflow:hidden; border:1px solid #e7ebf1; border-radius:4px; background:#fff; box-shadow:0 1px 3px rgba(25,42,70,.04); }
|
||||
.return-table-card :deep(.el-table) { width:100%; }
|
||||
.return-pagination { display:flex; justify-content:flex-end; padding:14px 18px; border-top:1px solid #eef1f5; }
|
||||
.return-pagination :deep(.el-pagination) { margin:0; }
|
||||
.return-pagination-total { color:#526176; font-size:14px; }
|
||||
.return-page-size-select { width:112px; margin-left:8px; }
|
||||
.return-page-size-select :deep(.el-input__inner), .return-page-size-select :deep(.el-select__selected-item) { color:#526176 !important; opacity:1 !important; -webkit-text-fill-color:#526176; }
|
||||
.detail-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px 24px; padding:18px; border:1px solid #e5ebf3; border-radius:6px; background:#f8faff; }
|
||||
.detail-summary div { display:flex; min-width:0; flex-direction:column; gap:6px; }
|
||||
.detail-summary span { color:#7c899c; font-size:12px; }
|
||||
.detail-summary strong { overflow-wrap:anywhere; color:#26364f; font-size:14px; }
|
||||
.detail-remark { margin:14px 0 0; padding:10px 12px; border-left:3px solid #7aa7dc; background:#f7faff; color:#526176; }
|
||||
.detail-section h3 { margin:22px 0 10px; color:#26364f; font-size:15px; }
|
||||
.return-detail-dialog :deep(.el-dialog__body) { padding-top:4px; }
|
||||
@media (max-width: 760px) { .return-orders-page { padding:12px; } .page-heading { align-items:stretch; flex-direction:column; } .filter-bar { grid-template-columns:1fr 1fr; } }
|
||||
@media (max-width: 720px) { .detail-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .return-pagination { padding:12px; } }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,17 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url)
|
||||
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, /@click="clearCreateTotalFile\('CARTON_TOTAL'\)"/)
|
||||
assert.match(view, /@click="clearCreateTotalFile\('SKU_TOTAL'\)"/)
|
||||
const clear = view.slice(view.indexOf('function clearCreateTotalFile'), view.indexOf('function chooseCreateTotalFile'))
|
||||
assert.match(clear, /if \(saving.value\) return/)
|
||||
assert.match(clear, /file.value = null/)
|
||||
assert.match(clear, /input.value.value = ''/)
|
||||
assert.match(view, /aria-label="清除箱唛总文件" :disabled="saving"/)
|
||||
assert.match(view, /aria-label="清除 SKU 总文件" :disabled="saving"/)
|
||||
})
|
||||
|
||||
test('贴标申请从当前客户仍有库存的历史入仓单选择来源', () => {
|
||||
assert.match(view, /海外操作仓(中转仓)/)
|
||||
assert.match(view, /选择有库存的历史入仓单/)
|
||||
@@ -38,8 +49,10 @@ test('贴标申请保留关联模板下载和上传入口', () => {
|
||||
assert.match(view, /importRelabelTemplate/)
|
||||
assert.match(view, /明细行标识/)
|
||||
assert.match(view, /'明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签'/)
|
||||
assert.match(view, /'旧箱唛', '新FBA箱唛', 'FBA目的仓'/)
|
||||
assert.match(view, /SKU 与箱号关联通过旧箱唛合并/)
|
||||
assert.match(view, /const includeDestination = false/)
|
||||
assert.doesNotMatch(view, /const includeDestination = props\.service !== 'transfer'/)
|
||||
assert.match(view, /SKU 与箱号关联通过仓库唛头合并/)
|
||||
assert.match(view, /<th>仓库唛头 \*<\/th>/)
|
||||
assert.doesNotMatch(view, /库存位置ID/)
|
||||
})
|
||||
|
||||
@@ -80,7 +93,7 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => {
|
||||
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, /换标数量(件)\*/)
|
||||
assert.match(view, /旧SKU标签/)
|
||||
assert.match(view, /新SKU标签/)
|
||||
@@ -102,26 +115,28 @@ test('箱唛相关换标必须选择四选一派送方式', () => {
|
||||
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
|
||||
})
|
||||
|
||||
test('一次申请单选入仓单且每项保留原库存归属', () => {
|
||||
test('一次申请单选库存来源且保留普通入仓或退货合箱归属', () => {
|
||||
assert.doesNotMatch(view, /multiple filterable collapse-tags/)
|
||||
assert.match(view, /String\(candidate\.inboundOrderId\) === inboundOrderId/)
|
||||
assert.match(view, /RETURN_CARTON:\$\{item\.returnCartonId\}/)
|
||||
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/)
|
||||
assert.match(view, /returnCartonId: String\(candidate\.returnCartonId\)/)
|
||||
assert.match(api, /sourceInventoryBalanceId\?: string; sourceInventoryPositionId\?: string; sourceInboundOrderId\?: string; returnCartonId\?: string/)
|
||||
})
|
||||
|
||||
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>/)
|
||||
assert.match(view, /destinationFbaWarehouseCode: form\.destination/)
|
||||
assert.match(view, /整张贴标申请只能选择一个目的仓/)
|
||||
assert.doesNotMatch(view, /v-model="row\.destinationWarehouseId"[^\n]+请选择 FBA 目的仓/)
|
||||
})
|
||||
|
||||
test('操作仓和FBA仓均从商户仓库目录按类型加载', () => {
|
||||
assert.match(view, /getMerchantWarehouses\(\{ warehouseType: 'FBA' \}\)/)
|
||||
assert.match(view, /getMerchantWarehouses\(\{ warehouseType: 'OVERSEAS' \}\)/)
|
||||
assert.match(view, /loadAllWarehouseRecords\('FBA', getMerchantWarehouses\)/)
|
||||
assert.match(view, /\['OVERSEAS', 'OVERSEAS_PARTNER'\]/)
|
||||
assert.match(view, /v-for="warehouse in operationWarehouses"/)
|
||||
assert.match(view, /v-for="warehouse in fbaWarehouses"/)
|
||||
assert.doesNotMatch(view, /美西海外仓-ONT8/)
|
||||
@@ -147,3 +162,22 @@ test('贴标详情展示箱唛、SKU 子明细和独立FBA目的仓', () => {
|
||||
assert.match(view, /item\.operationType === 'CARTON_AND_SKU'/)
|
||||
assert.match(view, /item\.skuItems\?\.length/)
|
||||
})
|
||||
|
||||
test('客户在换标列表确认完工且照片使用二进制下载', () => {
|
||||
assert.match(view, /row\.__status === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||
assert.match(view, /confirmRelabelFromList\(row\['0'\]\)/)
|
||||
assert.doesNotMatch(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||
assert.match(api, /overseasBlobRequest/)
|
||||
})
|
||||
|
||||
test('客户详情进入页面后直接回显完工照片并支持大图预览', () => {
|
||||
assert.match(view, /void loadCompletionPhotos\(\)/)
|
||||
assert.match(view, /class="warehouse-photo-grid"/)
|
||||
assert.match(view, /<el-image v-if="completionPhotoUrls\[fileId\]"/)
|
||||
assert.match(view, /:preview-src-list="completionPhotoPreviewUrls"/)
|
||||
assert.doesNotMatch(view, />照片 \{\{ index \+ 1 \}\}<\/el-button>/)
|
||||
})
|
||||
|
||||
test('进入复核后客户订单回显换标完成状态', () => {
|
||||
assert.match(view, /\['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'\]\.includes\(value\) \? '换标完成'/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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')
|
||||
|
||||
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
|
||||
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s)
|
||||
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s)
|
||||
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\).*candidate\.overseasWarehouseId.*form\.warehouse/s)
|
||||
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
|
||||
})
|
||||
@@ -13,20 +13,46 @@ test('仓储转运先选择入仓方式并按方式切换地址信息', () => {
|
||||
assert.match(view, /getMerchantWarehouseDetail\(form\.warehouse\)/)
|
||||
})
|
||||
|
||||
test('仓储转运允许暂未确定 FBA 去向并显式选择是否换标', () => {
|
||||
assert.match(view, /是否已有 FBA 去向/)
|
||||
assert.match(view, /:value="false">暂未确定/)
|
||||
assert.match(view, /transferHasFbaDestination.*目的 FBA 仓/s)
|
||||
test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否换标', () => {
|
||||
assert.match(view, /是否有目的去向/)
|
||||
assert.match(view, /value="NONE">暂未确定/)
|
||||
assert.match(view, /value="FBA">FBA 仓/)
|
||||
assert.match(view, /value="PRIVATE">私人仓/)
|
||||
assert.match(view, /transferDestinationType === 'PRIVATE'.*私人仓地址.*邮编.*私人仓联系人.*联系电话/s)
|
||||
assert.match(view, /是否需要换标/)
|
||||
assert.match(view, /:value="false">不需要换标/)
|
||||
assert.match(view, /:value="true">需要换标/)
|
||||
assert.match(api, /fbaWarehouseCode\?: string/)
|
||||
assert.match(api, /privateDestinationAddress\?: string; privateDestinationPostalCode\?: string/)
|
||||
assert.match(api, /fbaShipmentNo\?: string/)
|
||||
assert.doesNotMatch(view, /换标费用(CNY)/)
|
||||
assert.doesNotMatch(view, /relabelChargeAmount:/)
|
||||
})
|
||||
|
||||
test('仓储转运换标整票只允许一个目的仓', () => {
|
||||
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+未换标货物目的 FBA 仓/)
|
||||
assert.match(view, /此处只表示不换标箱子的去向/)
|
||||
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder="选填,可后续补充"/)
|
||||
assert.match(view, /transferDestinationType\.value === 'FBA' && !form\.destination/)
|
||||
assert.match(view, /transferDestinationType\.value === 'PRIVATE'.*!form\.privateDestinationPostalCode/)
|
||||
assert.match(view, /整票换标货物目的 FBA 仓/)
|
||||
assert.match(view, /暂不支持一票多个目的仓/)
|
||||
assert.match(view, /if \(!transferRelabelDefaultDestination\.value\) \{ ElMessage\.warning\('请选择整票换标货物的 FBA 目的仓'\)/)
|
||||
assert.match(view, /destinationWarehouseCode: transferRelabelDefaultDestination\.value \|\| undefined/)
|
||||
const transferRelabelSection = view.split('warehouse-transfer-relabel">')[1]?.split('warehouse-transfer-submit-bar')[0] || ''
|
||||
assert.doesNotMatch(transferRelabelSection, /v-model="row\.destinationWarehouseId"/)
|
||||
assert.doesNotMatch(transferRelabelSection, /<th>FBA 目的仓 \*<\/th>/)
|
||||
assert.doesNotMatch(view, /applyTransferRelabelDestination/)
|
||||
assert.match(view, /destinationWarehouseId: binding\.destinationWarehouseCode \|\| ''/)
|
||||
assert.match(view, /props\.service === 'transfer' \? '客户箱唛号' : '仓库唛头'/)
|
||||
assert.match(view, /已导入 \$\{importedRows\.length\} 条仓储转运换标明细/)
|
||||
assert.doesNotMatch(transferRelabelSection, /新 FBA 箱唛<\/th><th>FBA 目的仓<\/th>/)
|
||||
assert.match(view, /const includeDestination = false/)
|
||||
})
|
||||
|
||||
test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换标时填写客户箱唛', () => {
|
||||
const transferConfig = view.match(/transfer:\s*\{[^\n]+/)?.[0] || ''
|
||||
const transferSubmit = view.match(/await createTransfer\(\{[^\n]+/)?.[0] || ''
|
||||
const transferSubmit = view.match(/const payload: TransferCreate = \{[^\n]+/)?.[0] || ''
|
||||
assert.doesNotMatch(transferConfig, /进仓编号/)
|
||||
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
|
||||
assert.match(view, /货物明细/)
|
||||
@@ -45,24 +71,121 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
|
||||
assert.match(view, /v-else class="warehouse-table-scroll"/)
|
||||
assert.match(view, /添加 SKU 明细/)
|
||||
assert.match(view, /绑定明细/)
|
||||
assert.match(view, /旧 SKU \*.*新 SKU \*.*换标数量(件)\*.*新 FBA 箱唛 \*.*FBA 目的仓 \*/)
|
||||
assert.match(view, /旧 SKU \*.*新 SKU \*.*换标数量(件)\*.*新 FBA 箱唛 \*.*备注/)
|
||||
assert.match(transferSubmit, /fbaBoxNos: customerCartonMarks/)
|
||||
assert.match(transferSubmit, /shippingMark: transferCustomerMarks\(item\)/)
|
||||
assert.match(transferSubmit, /relabelItems: form\.relabel/)
|
||||
assert.match(view, /props\.service === 'disposal' \? '本次废弃箱数' : '预计箱数'/)
|
||||
assert.doesNotMatch(view, /props\.service === 'transfer'.*FNSKU \/ 箱唛/)
|
||||
assert.match(view, /<th v-if="props\.service === 'relabel'">FNSKU \/ 箱唛<\/th>/)
|
||||
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
|
||||
})
|
||||
|
||||
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
|
||||
assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
|
||||
assert.match(view, /本次提交将同时登记换标申请/)
|
||||
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
|
||||
assert.match(view, /提货联系人/)
|
||||
assert.match(view, /联系人电话/)
|
||||
assert.match(view, /换标联系人/)
|
||||
assert.match(view, /form\.pickupContact/)
|
||||
assert.match(view, /form\.pickupPhone/)
|
||||
assert.match(view, /form\.relabelContact/)
|
||||
assert.match(view, /form\.relabelPhone/)
|
||||
assert.match(view, /选择海外仓后自动带出,也可手动填写/)
|
||||
assert.match(view, /pickupContactName: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
|
||||
assert.match(view, /pickupContactPhone: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
|
||||
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
|
||||
assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/)
|
||||
})
|
||||
|
||||
test('仓储转运填写预计发货时间并只读展示实际发货时间', () => {
|
||||
test('仓储转运列表操作仓使用海外仓接口数据', () => {
|
||||
assert.match(view, /loadAllWarehouseRecords\('OVERSEAS', getMerchantWarehouses\)/)
|
||||
assert.match(view, /warehouseDisplay\(item\.operatingWarehouseId\)/)
|
||||
assert.match(view, /\['OVERSEAS', 'OVERSEAS_PARTNER'\]/)
|
||||
assert.match(view, /v-for="warehouse in \(props\.service === 'relabel' \? fbaWarehouses : operationWarehouses\)"/)
|
||||
assert.doesNotMatch(view, /美西洛杉矶仓/)
|
||||
})
|
||||
|
||||
test('仓储转运预计发货时间可选并只读展示实际发货时间', () => {
|
||||
assert.match(view, /预计发货时间/)
|
||||
assert.match(view, /v-model="form\.estimatedShipAt"/)
|
||||
assert.match(view, /选填,可线下确认后补充/)
|
||||
assert.doesNotMatch(view, /!form\.estimatedShipAt && '预计发货时间'/)
|
||||
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
|
||||
assert.match(view, /实际发货时间由仓库出库或 PDA 完成后回填/)
|
||||
assert.match(api, /estimatedShipAt\?: string/)
|
||||
})
|
||||
|
||||
test('客户仓储转运展示每个实际流转状态的中文名称', () => {
|
||||
assert.match(view, /label: '草稿', value: 'DRAFT'/)
|
||||
assert.match(view, /label: '待审核', value: 'PENDING_REVIEW'/)
|
||||
assert.match(view, /label: '入库测量中', value: 'INBOUND_MEASURING'/)
|
||||
assert.match(view, /label: '待客户确认实测', value: 'WAIT_CUSTOMER_CONFIRMATION'/)
|
||||
assert.match(view, /label: '待确认目的地', value: 'WAIT_FBA_DESTINATION'/)
|
||||
assert.match(view, /label: '待排车', value: 'READY_FOR_DISPATCH'/)
|
||||
assert.match(view, /WAIT_OUTBOUND: '待出库'/)
|
||||
assert.match(view, /OUTBOUNDED: '已出库'/)
|
||||
})
|
||||
|
||||
test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => {
|
||||
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
|
||||
assert.match(view, /confirmTransferFromList\(row\['0'\]\)/)
|
||||
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
|
||||
assert.match(view, /PDA 打托实测数据/)
|
||||
for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
|
||||
assert.match(view, /pallet\.warehouseLocationCode/)
|
||||
assert.match(view, /pallet\.cartonCount/)
|
||||
assert.match(view, /pallet\.grossWeightKg/)
|
||||
assert.match(view, /pallet\.volumeCbm/)
|
||||
assert.doesNotMatch(view, /pallet\.items/)
|
||||
})
|
||||
|
||||
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
|
||||
assert.match(api, /downloadTransferMeasurementPhoto/)
|
||||
assert.match(api, /measurement-photos\/files\/\$\{encodeURIComponent\(fileId\)\}\/download/)
|
||||
assert.match(view, /measurementPhotos\?: TransferMeasurementPhoto\[\]/)
|
||||
assert.match(view, /fileId: string \| number/)
|
||||
assert.match(view, /if \(props\.service === 'transfer'\) void loadMeasurementPhotos\(\)/)
|
||||
assert.match(view, /测量打托现场照片/)
|
||||
assert.match(view, /:preview-src-list="measurementPhotoPreviewUrls"/)
|
||||
assert.match(view, /加载失败,点击重试/)
|
||||
assert.match(view, /URL\.createObjectURL\(blob\)/)
|
||||
assert.match(view, /URL\.revokeObjectURL\(url\)/)
|
||||
assert.match(view, /onBeforeUnmount\(\(\) => \{ clearCompletionPhotos\(\); clearMeasurementPhotos\(\) \}\)/)
|
||||
})
|
||||
|
||||
test('PDA 完成后主单显示入库完成并保留客户确认测量入口', () => {
|
||||
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
|
||||
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
|
||||
assert.doesNotMatch(view, /确认实测并入库/)
|
||||
})
|
||||
|
||||
test('仓储转运可在新建页直接提交,也可保存草稿后编辑提交', () => {
|
||||
assert.match(api, /function submitTransfer\(transferNo: string\)/)
|
||||
assert.match(api, /function updateTransfer\(transferNo: string, payload: TransferCreate\)/)
|
||||
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/save-draft/)
|
||||
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/submit/)
|
||||
assert.match(view, /submit\('draft'\).*保存草稿/s)
|
||||
assert.match(view, /async function openEdit\(transferNo: string\)/)
|
||||
assert.match(view, /row\.__status === 'DRAFT'[^>]*[\s\S]*?@click="openEdit\(row\['0'\]\)"[^>]*>编辑/)
|
||||
assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/)
|
||||
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
|
||||
assert.match(view, /await submitTransfer\(transferNo\)/)
|
||||
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/)
|
||||
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印箱唛/)
|
||||
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
|
||||
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
|
||||
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
|
||||
assert.match(view, /DRAFT: '草稿'/)
|
||||
assert.doesNotMatch(view, /<h3>箱唛明细<\/h3>/)
|
||||
})
|
||||
|
||||
test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => {
|
||||
assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||
assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||
assert.match(view, /props\.service === 'transfer' \? '选填' : '请输入商品 SKU'/)
|
||||
assert.match(view, /v-if="labelingMode === 'carton'"[^\n]+<th>旧 SKU<\/th>/)
|
||||
assert.match(view, /labelingMode\.value !== 'carton' && \(!row\.sku\.trim\(\) \|\| !row\.newSkuCode\.trim\(\)\)/)
|
||||
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
|
||||
assert.match(api, /originalSku\?: string/)
|
||||
})
|
||||
|
||||
@@ -93,9 +93,19 @@ test('shares the merchant permission map and preserves resources not rendered by
|
||||
assert.match(contractsSource, /'company:member:create': \['merchant:member:create'\]/)
|
||||
for (const permission of [
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
'merchant:cross-border:procurement:list',
|
||||
'merchant:cross-border:procurement:create',
|
||||
'merchant:cross-border:domestic-trucking:list',
|
||||
'merchant:cross-border:domestic-trucking:create',
|
||||
'merchant:cross-border:domestic-trucking:edit',
|
||||
'merchant:cross-border:pallet-shipping:list',
|
||||
'merchant:cross-border:pallet-shipping:create',
|
||||
'merchant:cross-border:pallet-shipping:edit',
|
||||
'merchant:overseas-warehouse:return-inventory:list',
|
||||
'merchant:overseas-warehouse:relabel:list',
|
||||
'merchant:overseas-warehouse:transfer:list',
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
]) {
|
||||
assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`))
|
||||
}
|
||||
|
||||
@@ -58,6 +58,42 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
{ code: 'merchant:warehouse:delivery:create', label: '创建派送单', description: '从排车单创建海外仓派送单' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'overseas-warehouse',
|
||||
label: '海外仓业务',
|
||||
items: [
|
||||
{ code: 'merchant:warehouse:inbound:list', label: '查看海外入库单', description: '查看本企业海外仓入库订单列表和详情' },
|
||||
{ code: 'merchant:overseas-warehouse:last-mile-order:list', label: '查看海外尾程订单', description: '查看本企业尾程订单列表和详情' },
|
||||
{ code: 'merchant:overseas-warehouse:relabel:list', label: '查看贴标申请', description: '查看本企业贴标申请列表和详情' },
|
||||
{ code: 'merchant:overseas-warehouse:relabel:create', label: '创建贴标申请', description: '使用本企业可用库存创建贴标申请' },
|
||||
{ code: 'merchant:overseas-warehouse:relabel:files', label: '管理贴标文件', description: '上传本企业贴标申请的面单和明细文件' },
|
||||
{ code: 'merchant:overseas-warehouse:relabel:confirm', label: '确认贴标完成', description: '确认仓库完成的贴标结果并继续出库' },
|
||||
{ code: 'merchant:overseas-warehouse:transfer:list', label: '查看仓储转运', description: '查看本企业仓储转运单列表和详情' },
|
||||
{ code: 'merchant:overseas-warehouse:transfer:create', label: '创建仓储转运', description: '创建和提交本企业仓储转运单' },
|
||||
{ code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' },
|
||||
{ code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' },
|
||||
{ code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' },
|
||||
{ code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' },
|
||||
{ code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
|
||||
{ code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' },
|
||||
{ code: 'merchant:overseas-warehouse:return-order:relabel', label: '退货转换标', description: '将已签收退货库存转换为换标申请' },
|
||||
{ code: 'merchant:overseas-warehouse:return-inventory:list', label: '查看退货库存', description: '查看本企业退货 SKU 库存和箱号明细' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cross-border',
|
||||
label: '海外业务',
|
||||
items: [
|
||||
{ code: 'merchant:cross-border:procurement:list', label: '查看海外采购', description: '查看本企业海外采购订单列表和详情' },
|
||||
{ code: 'merchant:cross-border:procurement:create', label: '创建海外采购', description: '创建本企业海外采购订单' },
|
||||
{ code: 'merchant:cross-border:domestic-trucking:list', label: '查看美国本土卡', description: '查看本企业美国本土卡订单' },
|
||||
{ code: 'merchant:cross-border:domestic-trucking:create', label: '创建美国本土卡', description: '创建本企业美国本土卡订单' },
|
||||
{ code: 'merchant:cross-border:domestic-trucking:edit', label: '编辑美国本土卡', description: '编辑本企业允许修改的美国本土卡订单' },
|
||||
{ code: 'merchant:cross-border:pallet-shipping:list', label: '查看美本土散板', description: '查看本企业美本土散板订单' },
|
||||
{ code: 'merchant:cross-border:pallet-shipping:create', label: '创建美本土散板', description: '创建本企业美本土散板订单' },
|
||||
{ code: 'merchant:cross-border:pallet-shipping:edit', label: '编辑美本土散板', description: '编辑本企业允许修改的美本土散板订单' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: '企业资料',
|
||||
@@ -107,9 +143,28 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'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'],
|
||||
'merchant:overseas-warehouse:last-mile-order:list': ['merchant:overseas-warehouse:last-mile-order:list'],
|
||||
'merchant:overseas-warehouse:relabel:list': ['merchant:overseas-warehouse:relabel:list'],
|
||||
'merchant:overseas-warehouse:relabel:create': ['merchant:overseas-warehouse:relabel:create'],
|
||||
'merchant:overseas-warehouse:relabel:files': ['merchant:overseas-warehouse:relabel:files'],
|
||||
'merchant:overseas-warehouse:relabel:confirm': ['merchant:overseas-warehouse:relabel:confirm'],
|
||||
'merchant:overseas-warehouse:transfer:list': ['merchant:overseas-warehouse:transfer:list'],
|
||||
'merchant:overseas-warehouse:transfer:create': ['merchant:overseas-warehouse:transfer:create'],
|
||||
'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'],
|
||||
'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'],
|
||||
'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'],
|
||||
'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'],
|
||||
'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
|
||||
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],
|
||||
'merchant:overseas-warehouse:return-order:relabel': ['merchant:overseas-warehouse:return-order:relabel'],
|
||||
'merchant:cross-border:procurement:list': ['merchant:cross-border:procurement:list'],
|
||||
'merchant:cross-border:procurement:create': ['merchant:cross-border:procurement:create'],
|
||||
'merchant:cross-border:domestic-trucking:list': ['merchant:cross-border:domestic-trucking:list'],
|
||||
'merchant:cross-border:domestic-trucking:create': ['merchant:cross-border:domestic-trucking:create'],
|
||||
'merchant:cross-border:domestic-trucking:edit': ['merchant:cross-border:domestic-trucking:edit'],
|
||||
'merchant:cross-border:pallet-shipping:list': ['merchant:cross-border:pallet-shipping:list'],
|
||||
'merchant:cross-border:pallet-shipping:create': ['merchant:cross-border:pallet-shipping:create'],
|
||||
'merchant:cross-border:pallet-shipping:edit': ['merchant:cross-border:pallet-shipping:edit'],
|
||||
'company:profile:view': ['merchant:profile:view'],
|
||||
'company:profile:edit': ['merchant:profile:edit'],
|
||||
'company:qualification:upload': ['merchant:qualification:upload'],
|
||||
|
||||
Reference in New Issue
Block a user