Merge test into dev
This commit is contained in:
@@ -39,6 +39,8 @@
|
||||
|
||||
## 模块快速查询
|
||||
|
||||
退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”,正式路由 `return-relabel` 已使用独立页面,不再由 `WarehouseServiceView.vue` 承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。
|
||||
|
||||
先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。
|
||||
|
||||
| 业务 | 模块入口 | 联查模块或服务端 |
|
||||
@@ -51,13 +53,15 @@
|
||||
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
|
||||
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
|
||||
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
|
||||
| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
|
||||
| 客户资金账户、授信与充值申请 | `src/modules/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`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标申请必须提交正数 CNY `chargeAmount`,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
|
||||
|
||||
## 租户、认证与数据安全
|
||||
|
||||
- 换标申请必须提交正数 CNY `chargeAmount`;入口为 `src/modules/overseas-warehouse/views/WarehouseServiceView.vue` 与 `api/warehouseServices.ts`,金额由服务端再次校验,资金状态以服务端返回为准,不在客户端模拟冻结成功。
|
||||
|
||||
- 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。
|
||||
- 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。
|
||||
- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `contactPhone`,个人资料或成员管理中的登录手机号由 IAM/Auth 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
const source = await readFile(new URL('./types.ts', import.meta.url), 'utf8')
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
const { isApiFailure, isApiEnvelope } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
|
||||
|
||||
test('recognizes a security failure without data and preserves diagnostics', () => {
|
||||
const failure = { code: 'A0403', message: 'Access denied', requestId: 'request-1', traceId: 'trace-1' }
|
||||
assert.equal(isApiFailure(failure), true)
|
||||
assert.equal(isApiEnvelope(failure), false)
|
||||
assert.equal(failure.requestId, 'request-1')
|
||||
assert.equal(failure.traceId, 'trace-1')
|
||||
})
|
||||
|
||||
test('does not accept malformed or data-less successful responses', () => {
|
||||
for (const value of [null, {}, { code: 403, message: 'Denied' }, { code: 'A0403' },
|
||||
{ code: 'A0403', message: 'Denied', traceId: {} }, { code: '200', message: 'OK' }]) {
|
||||
assert.equal(isApiFailure(value), false)
|
||||
}
|
||||
assert.equal(isApiEnvelope({ code: '200', message: 'OK' }), false)
|
||||
assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true)
|
||||
})
|
||||
|
||||
test('overseas transport handles business-code and HTTP security failures', async () => {
|
||||
const transport = await readFile(new URL('./overseasHttp.ts', import.meta.url), 'utf8')
|
||||
assert.match(transport, /if \(isApiFailure\(response.data\)\)/)
|
||||
assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
|
||||
import { ApiError, isApiEnvelope, isApiFailure } from '@/api/types'
|
||||
import { getOverseasAccessToken } from '@/api/session'
|
||||
import { refreshAuthenticatedSession } from '@/api/http'
|
||||
|
||||
@@ -37,7 +37,11 @@ transport.interceptors.response.use((response) => response, async (error) => {
|
||||
|
||||
export async function overseasApiRequest<T>(config: AxiosRequestConfig) {
|
||||
try {
|
||||
const response = await transport.request<ApiEnvelope<T>>(config)
|
||||
const response = await transport.request<unknown>(config)
|
||||
if (isApiFailure(response.data)) {
|
||||
throw new ApiError(response.data.message, response.data.code, response.status, undefined,
|
||||
response.data.traceId, response.data.requestId)
|
||||
}
|
||||
if (!isApiEnvelope(response.data) || response.data.code !== '200') {
|
||||
throw new ApiError(
|
||||
isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误',
|
||||
@@ -48,12 +52,12 @@ export async function overseasApiRequest<T>(config: AxiosRequestConfig) {
|
||||
isApiEnvelope(response.data) ? response.data.requestId : null,
|
||||
)
|
||||
}
|
||||
return response.data.data
|
||||
return response.data.data as T
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
if (axios.isAxiosError(error)) {
|
||||
const body = error.response?.data
|
||||
if (isApiEnvelope(body)) {
|
||||
if (isApiEnvelope(body) || isApiFailure(body)) {
|
||||
throw new ApiError(body.message, body.code, error.response?.status, undefined, body.traceId, body.requestId)
|
||||
}
|
||||
throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
|
||||
|
||||
@@ -30,3 +30,13 @@ export function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
|
||||
const record = value as Record<string, unknown>
|
||||
return typeof record.code === 'string' && typeof record.message === 'string' && 'data' in record
|
||||
}
|
||||
|
||||
// Security failures have no business data, unlike successful API responses.
|
||||
export function isApiFailure(value: unknown): value is Omit<ApiEnvelope<never>, 'data'> {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return typeof record.code === 'string' && record.code !== '200'
|
||||
&& typeof record.message === 'string'
|
||||
&& (record.requestId == null || typeof record.requestId === 'string')
|
||||
&& (record.traceId == null || typeof record.traceId === 'string')
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ test('source contract: keeps route context and user identity in the compact head
|
||||
assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/)
|
||||
})
|
||||
|
||||
test('source contract: renders the complete route breadcrumb before page content', () => {
|
||||
assert.match(layoutSource, /import \{ getFirstAccessiblePath, getRouteBreadcrumbs \} from '@\/navigation\/clientNavigation'/)
|
||||
assert.match(layoutSource, /const breadcrumbs = computed\(\(\) => getRouteBreadcrumbs\(route\.path, String\(pageTitle\.value\)\)\)/)
|
||||
assert.match(layoutSource, /<nav class="client-breadcrumb" aria-label="页面路径">[\s\S]*?v-for="\(item, index\) in breadcrumbs"[\s\S]*?<RouterLink v-if="item\.path" :to="item\.path">[\s\S]*?<strong v-else aria-current="page">/)
|
||||
assert.match(shellStyles, /\.client-breadcrumb \{[^}]*margin: 0 0 14px;/)
|
||||
})
|
||||
|
||||
test('source contract: aligns the light top bar beside the prototype-inspired dark sidebar and bounds the mobile header', () => {
|
||||
assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 250px;[^}]*max-width: calc\(100vw - 250px\);[^}]*padding: 0 24px;/)
|
||||
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.96\)/)
|
||||
|
||||
@@ -8,7 +8,7 @@ import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
|
||||
import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { useNotificationsStore } from '@/modules/notifications/public'
|
||||
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
|
||||
import { getFirstAccessiblePath, getRouteBreadcrumbs } from '@/navigation/clientNavigation'
|
||||
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import {
|
||||
MAX_CACHED_WORKSPACE_VIEWS,
|
||||
@@ -25,6 +25,7 @@ const workspace = useWorkspaceTabsStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const pageTitle = computed(() => route.meta.title || '业务总览')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const breadcrumbs = computed(() => getRouteBreadcrumbs(route.path, String(pageTitle.value)))
|
||||
const mobileMenuOpen = ref(false)
|
||||
const desktopViewport = ref(true)
|
||||
const menuButtonRef = ref<HTMLButtonElement>()
|
||||
@@ -259,6 +260,13 @@ async function handleLogout() {
|
||||
:inert="mobileMenuOpen"
|
||||
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
||||
>
|
||||
<nav class="client-breadcrumb" aria-label="页面路径">
|
||||
<template v-for="(item, index) in breadcrumbs" :key="`${item.title}-${index}`">
|
||||
<span v-if="index" aria-hidden="true">/</span>
|
||||
<RouterLink v-if="item.path" :to="item.path">{{ item.title }}</RouterLink>
|
||||
<strong v-else aria-current="page">{{ item.title }}</strong>
|
||||
</template>
|
||||
</nav>
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
|
||||
<component
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface RegisterMerchantRequest {
|
||||
password: string
|
||||
displayName: string
|
||||
companyName: string
|
||||
legalPerson: string
|
||||
legalPerson?: string
|
||||
phone: string
|
||||
contactEmail?: string
|
||||
countryCode?: string
|
||||
|
||||
@@ -67,6 +67,15 @@ test('implements every backend registration field and real submission', () => {
|
||||
assert.match(registerSource, /registerMerchant\(/)
|
||||
assert.match(registerSource, /4–64 位/)
|
||||
assert.match(registerSource, /8–72 位/)
|
||||
assert.match(registerSource, /label="法定代表人(选填)" prop="legalPerson"/)
|
||||
assert.doesNotMatch(registerSource, /label="法定代表人[^\"]*" prop="legalPerson" required/)
|
||||
assert.doesNotMatch(registerSource, /required: true, message: '请输入法定代表人'/)
|
||||
assert.match(authApiSource, /legalPerson\?: string/)
|
||||
})
|
||||
|
||||
test('shows an explicit registration entry directly below the login button', () => {
|
||||
assert.match(loginSource, /<el-button[^>]*class="auth-submit"[\s\S]*?<p class="login-register-entry">还没有企业账号?<RouterLink to="\/register">立即注册<\/RouterLink><\/p>/)
|
||||
assert.match(stylesSource, /\.login-register-entry \{[^}]*text-align: center;/)
|
||||
})
|
||||
|
||||
test('shows immediate login after registration and separates company review from account activation', () => {
|
||||
|
||||
@@ -119,6 +119,7 @@ function showResetHint() {
|
||||
<button type="button" @click="showResetHint">忘记密码?</button>
|
||||
</div>
|
||||
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
||||
<p class="login-register-entry">还没有企业账号?<RouterLink to="/register">立即注册</RouterLink></p>
|
||||
</el-form>
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
||||
@@ -40,7 +40,6 @@ const rules: FormRules<RegisterForm> = {
|
||||
{ max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
legalPerson: [
|
||||
{ required: true, message: '请输入法定代表人', trigger: 'blur' },
|
||||
{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
contactEmail: [
|
||||
@@ -68,12 +67,13 @@ async function submit() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const legalPerson = form.legalPerson.trim()
|
||||
await registerMerchant({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
displayName: form.displayName.trim(),
|
||||
companyName: form.companyName.trim(),
|
||||
legalPerson: form.legalPerson.trim(),
|
||||
...(legalPerson ? { legalPerson } : {}),
|
||||
phone: form.phone.trim(),
|
||||
contactEmail: form.contactEmail.trim() || undefined,
|
||||
countryCode: form.countryCode,
|
||||
@@ -122,7 +122,7 @@ async function submit() {
|
||||
<h2 id="company-section-title">企业信息</h2>
|
||||
<div class="register-form-grid">
|
||||
<el-form-item label="企业名称" prop="companyName" class="register-form-full" required><el-input v-model="form.companyName" maxlength="100" placeholder="请输入营业执照上的企业名称" /></el-form-item>
|
||||
<el-form-item label="法定代表人" prop="legalPerson" required><el-input v-model="form.legalPerson" maxlength="64" placeholder="请输入营业执照法定代表人" /></el-form-item>
|
||||
<el-form-item label="法定代表人(选填)" prop="legalPerson"><el-input v-model="form.legalPerson" maxlength="64" placeholder="可在企业资料中后续补充" /></el-form-item>
|
||||
<el-form-item label="国家/地区" prop="countryCode"><el-select v-model="form.countryCode" class="register-select"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -36,4 +36,17 @@ test('covers list detail create loading error empty pagination and actions-first
|
||||
assert.match(view, /empty-text="暂无订单"/)
|
||||
assert.match(view, /<el-pagination/)
|
||||
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
|
||||
assert.match(view, /PENDING_OUTBOUND: '待出库'/)
|
||||
assert.match(view, /PARTIALLY_OUTBOUND: '部分出库'/)
|
||||
assert.match(view, /OUTBOUNDED: '全部出库'/)
|
||||
assert.match(view, /\['入库量', read\(summary, 'inboundQuantity'\)\]/)
|
||||
assert.match(view, /\['审核备注', current\.reviewRemark\]/)
|
||||
})
|
||||
|
||||
test('keeps procurement filters, details and forms readable at PDA width', () => {
|
||||
assert.match(view, /@media \(max-width: 767px\)/)
|
||||
assert.match(view, /\.filter-card :deep\(\.el-form\) \{ display: grid; grid-template-columns: 1fr; \}/)
|
||||
assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/)
|
||||
assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/)
|
||||
assert.match(view, /\.budget-form-item :deep\(\.el-form-item__content\) \{ display: flex; flex-wrap: nowrap; \}/)
|
||||
})
|
||||
|
||||
@@ -61,6 +61,9 @@ const configuration = computed(() => ({
|
||||
{ key: 'productSpecification', label: '商品规格', width: 160 },
|
||||
{ key: 'purchaseQuantity', label: '采购数量', width: 100 },
|
||||
{ key: 'budgetUnitPrice', label: '预算单价', width: 110 },
|
||||
{ key: 'inboundQuantity', label: '入库量', width: 90 },
|
||||
{ key: 'outboundQuantity', label: '出库量', width: 90 },
|
||||
{ key: 'inventoryQuantity', label: '库存量', width: 90 },
|
||||
{ key: 'status', label: '状态', width: 120 },
|
||||
{ key: 'createdAt', label: '创建时间', width: 170 },
|
||||
] satisfies ColumnDefinition[],
|
||||
@@ -125,11 +128,13 @@ const detailFields = computed(() => {
|
||||
['订单号', summary.orderNo], ['采购品类', read(summary, 'purchaseCategory')],
|
||||
['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')],
|
||||
['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))],
|
||||
['入库量', read(summary, 'inboundQuantity')], ['出库量', read(summary, 'outboundQuantity')],
|
||||
['库存量', read(summary, 'inventoryQuantity')], ['预计到达时间', dateTime(current.estimatedArrivalAt)],
|
||||
['联系人', current.contactName], ['联系电话', current.contactPhone],
|
||||
['收货地址', current.receivingAddress], ['商品链接', current.productUrl],
|
||||
['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)],
|
||||
['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)],
|
||||
['状态', statusLabel(summary.status)], ['备注', current.remark],
|
||||
['状态', statusLabel(summary.status)], ['审核备注', current.reviewRemark], ['备注', current.remark],
|
||||
]
|
||||
}
|
||||
const current = detail.value as TransportDetail
|
||||
@@ -166,7 +171,7 @@ function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO'
|
||||
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
|
||||
function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
|
||||
function statusLabel(value: unknown) {
|
||||
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
|
||||
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
|
||||
}
|
||||
function displayCell(candidate: unknown, key: string) {
|
||||
const row = candidate as BusinessRow
|
||||
@@ -330,7 +335,7 @@ onMounted(() => void load())
|
||||
<el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item>
|
||||
<el-form-item label="要求时间" :prop="kind === 'trucking' ? 'requestedDate' : undefined"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="kind === 'procurement' ? '预算单价' : '预算金额'"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item class="budget-form-item" :label="kind === 'procurement' ? '预算单价' : '预算金额'"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<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>
|
||||
@@ -355,4 +360,35 @@ onMounted(() => void load())
|
||||
.form-card :deep(.el-form) { max-width: 860px; }
|
||||
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
|
||||
.form-card .currency { width: 110px; margin-left: 12px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.business-orders { gap: 14px; }
|
||||
.page-head { align-items: flex-start; gap: 12px; }
|
||||
.page-head h2 { font-size: 24px; }
|
||||
.page-head > div { min-width: 0; }
|
||||
|
||||
.filter-card :deep(.el-form) { display: grid; grid-template-columns: 1fr; }
|
||||
.filter-card :deep(.el-form-item) { width: 100%; margin-right: 0; margin-bottom: 12px; }
|
||||
.filter-card :deep(.el-form-item:last-child) { margin-bottom: 0; }
|
||||
.filter-card :deep(.el-form-item__content),
|
||||
.filter-card :deep(.el-input) { width: 100%; }
|
||||
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.detail-grid div { padding: 14px 16px; border-right: 0; }
|
||||
.detail-grid dd { overflow-wrap: break-word; word-break: normal; }
|
||||
|
||||
.form-card :deep(.el-form-item) { display: block; }
|
||||
.form-card :deep(.el-form-item__label) {
|
||||
width: auto !important;
|
||||
height: auto;
|
||||
margin-bottom: 7px;
|
||||
line-height: 1.4;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.form-card :deep(.el-form-item__content) { width: 100%; margin-left: 0 !important; }
|
||||
.form-card :deep(.el-input-number) { width: 100%; }
|
||||
.form-card .budget-form-item :deep(.el-form-item__content) { display: flex; flex-wrap: nowrap; }
|
||||
.form-card .budget-form-item :deep(.el-input-number) { width: auto; min-width: 0; flex: 1 1 auto; }
|
||||
.form-card .currency { width: 96px; flex: 0 0 96px; margin-left: 8px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,16 @@ test('loads real dashboard data allowed by the current permissions', () => {
|
||||
assert.match(source, /auth\.hasPermission\('company:profile:view'\).*tenant\.loadCompanyContext\(\)/)
|
||||
assert.match(source, /auth\.hasPermission\('company:member:list'\).*tenant\.loadMembers\(\)/)
|
||||
assert.match(source, /auth\.hasPermission\('order:first-leg:list'\).*orders\.load\(\)/)
|
||||
assert.match(source, /onMounted\(\(\) => void loadDashboardData\(\)\)/)
|
||||
assert.match(source, /onMounted\(async \(\) => \{\s*await loadDashboardData\(\)\s*showQualificationReminderOnce\(\)\s*\}\)/)
|
||||
})
|
||||
|
||||
test('prompts once per session for incomplete company qualification and opens the upload dialog', () => {
|
||||
assert.match(source, /\['PROFILE_REQUIRED', 'REJECTED'\]\.includes\(tenant\.company\.auditStatus\)/)
|
||||
assert.match(source, /sessionStorage\.getItem\(reminderKey\)/)
|
||||
assert.match(source, /sessionStorage\.setItem\(reminderKey, 'shown'\)/)
|
||||
assert.match(source, /title="完善企业认证资料"/)
|
||||
assert.match(source, /立即上传认证资料/)
|
||||
assert.match(source, /router\.push\(\{ path: '\/company\/profile', query: \{ qualification: 'upload' \} \}\)/)
|
||||
})
|
||||
|
||||
test('isolates recoverable dashboard failures without hiding successful sections', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ const orders = useFirstLegOrdersStore()
|
||||
const tenant = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const qualificationReminderOpen = ref(false)
|
||||
type DashboardDataSource = 'company' | 'members' | 'orders'
|
||||
const loadErrors = ref<Partial<Record<DashboardDataSource, string>>>({})
|
||||
const dataSourceLabels: Record<DashboardDataSource, string> = {
|
||||
@@ -116,6 +117,20 @@ async function loadDashboardData() {
|
||||
}
|
||||
}
|
||||
|
||||
function showQualificationReminderOnce() {
|
||||
if (companyUnavailable.value || !auth.hasPermission('company:qualification:upload')) return
|
||||
if (!['PROFILE_REQUIRED', 'REJECTED'].includes(tenant.company.auditStatus)) return
|
||||
const reminderKey = `client-qualification-reminder:${auth.user?.id ?? 'current'}`
|
||||
if (sessionStorage.getItem(reminderKey)) return
|
||||
sessionStorage.setItem(reminderKey, 'shown')
|
||||
qualificationReminderOpen.value = true
|
||||
}
|
||||
|
||||
async function openQualificationUpload() {
|
||||
qualificationReminderOpen.value = false
|
||||
await router.push({ path: '/company/profile', query: { qualification: 'upload' } })
|
||||
}
|
||||
|
||||
function openBusinessOrder(business: typeof businessCards[number]) {
|
||||
if (auth.hasPermission(business.permission)) {
|
||||
void router.push(business.route)
|
||||
@@ -133,11 +148,18 @@ function showSupportMessage(type: 'service' | 'faq' | 'quote') {
|
||||
ElMessage.info(messages[type])
|
||||
}
|
||||
|
||||
onMounted(() => void loadDashboardData())
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
showQualificationReminderOnce()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="client-dashboard" :aria-busy="loading">
|
||||
<el-dialog v-model="qualificationReminderOpen" title="完善企业认证资料" width="min(520px, 92vw)" append-to-body>
|
||||
<p class="qualification-reminder-copy">请先上传营业执照和统一社会信用代码并提交审核;法人身份证及其他资质可选填。审核通过后即可提交头程订单。</p>
|
||||
<template #footer><el-button @click="qualificationReminderOpen = false">稍后处理</el-button><el-button type="primary" @click="openQualificationUpload">立即上传认证资料</el-button></template>
|
||||
</el-dialog>
|
||||
<div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert">
|
||||
<AlertTriangle :size="20" aria-hidden="true" />
|
||||
<div>
|
||||
@@ -237,6 +259,8 @@ onMounted(() => void loadDashboardData())
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qualification-reminder-copy { margin: 0; color: #475467; font-size: 14px; line-height: 1.75; }
|
||||
|
||||
.dashboard-load-error ul {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface FinanceAccount {
|
||||
creditUsed: string
|
||||
creditFrozen: string
|
||||
availableCredit: string
|
||||
creditExpiresOn: string | null
|
||||
creditActive: boolean
|
||||
version: number
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -51,6 +53,7 @@ export interface AccountLedgerEntry {
|
||||
creditLimitAfter: string
|
||||
creditUsedAfter: string
|
||||
availableCreditAfter: string
|
||||
creditExpiresOnAfter: string | null
|
||||
sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION' | 'CREDIT_LIMIT_ADJUSTMENT' | string
|
||||
sourceNo: string
|
||||
operatorName: string | null
|
||||
|
||||
@@ -225,7 +225,7 @@ onMounted(() => { void loadAccountData() })
|
||||
<article><span>实际余额</span><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
|
||||
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
|
||||
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
|
||||
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>平台配置的授信上限</small></article>
|
||||
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>{{ account?.creditExpiresOn ? `${account.creditExpiresOn} ${account.creditActive ? '到期' : '已到期'}` : '未设置有效期' }}</small></article>
|
||||
<article><span>已用授信</span><strong>¥ {{ formatAccountAmount(account?.creditUsed) }}</strong><small>已结算使用的授信</small></article>
|
||||
<article><span>可用授信</span><strong>¥ {{ formatAccountAmount(account?.availableCredit) }}</strong><small>扣除已用和冻结后的授信</small></article>
|
||||
</section>
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface FirstLegOrderCargoPayload {
|
||||
shipmentId?: string
|
||||
referenceId?: string
|
||||
productImageFileId?: number | string
|
||||
packingGroupNo?: string
|
||||
}
|
||||
|
||||
export interface ProductImageUploadResult {
|
||||
|
||||
@@ -25,7 +25,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
|
||||
})
|
||||
|
||||
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
|
||||
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
||||
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '对客单价', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
||||
assert.match(source, new RegExp(label))
|
||||
}
|
||||
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
|
||||
@@ -42,6 +42,7 @@ test('shows the published merchant inbound, measurement details and customer con
|
||||
assert.match(source, /latestFee\.rateCode/)
|
||||
assert.match(source, /latestFee\.chargeRuleVersion/)
|
||||
assert.match(source, /latestFee\.carrierRateVersion/)
|
||||
assert.doesNotMatch(source, /箱规规则|calculationRuleVersion/)
|
||||
})
|
||||
|
||||
test('stacks core information for 720 and 360 pixel layouts', () => {
|
||||
|
||||
@@ -227,6 +227,10 @@ async function confirmFee() {
|
||||
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>对客单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
|
||||
<div><dt>基础费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
<div><dt>附加费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.additionalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
<div><dt>费用合计</dt><dd>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
</dl>
|
||||
<el-table v-if="props.data?.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
|
||||
<el-table-column type="index" label="#" width="54" />
|
||||
@@ -237,7 +241,6 @@ async function confirmFee() {
|
||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||
<el-table-column prop="calculationRuleVersion" label="箱规规则" min-width="180" />
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
<el-alert
|
||||
@@ -248,11 +251,11 @@ async function confirmFee() {
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert v-else class="fee-confirmed-alert" title="请核对全部计费数据。点击确认后还会进行一次确认,完成后费用将锁定。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else class="fee-confirmed-alert" title="请核对计费量、对客单价和费用合计。确认后当前费用版本将锁定。" type="warning" :closable="false" show-icon />
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="feeDialogOpen = false">关闭</el-button>
|
||||
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认费用</el-button>
|
||||
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认计费量和费用</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useFirstLegOrdersStore } from './store/firstLegOrders'
|
||||
export { getMerchantWarehouses, type MerchantWarehouse } from './api/firstLeg'
|
||||
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
|
||||
export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue'
|
||||
|
||||
@@ -90,6 +90,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
|
||||
customsHsCode: item.customsHsCode || undefined,
|
||||
packageType: item.packageType,
|
||||
packageCount: item.packageCount,
|
||||
packingGroupNo: draft.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? item.packingGroupNo.trim() || undefined : undefined,
|
||||
grossWeightKg: item.grossWeightKg,
|
||||
volumeCbm: item.volumeCbm,
|
||||
lengthCm: item.lengthCm || undefined,
|
||||
@@ -296,7 +297,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
|
||||
productImageName: '', productImagePreviewUrl: '', productImageUploading: false, productImageError: '',
|
||||
customsHsCode: item.customsHsCode ?? '', lengthCm: item.lengthCm ?? 0, widthCm: item.widthCm ?? 0,
|
||||
heightCm: item.heightCm ?? 0, unitsPerPackage: item.unitsPerPackage ?? 1,
|
||||
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg,
|
||||
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, packingGroupNo: item.packingGroupNo ?? '', grossWeightKg: item.grossWeightKg,
|
||||
volumeCbm: item.volumeCbm, remark: item.cargoRemark ?? '',
|
||||
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
|
||||
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface FirstLegCargoItem {
|
||||
unitsPerPackage: number
|
||||
pieces: number
|
||||
packageCount: number
|
||||
packingGroupNo: string
|
||||
grossWeightKg: number
|
||||
volumeCbm: number
|
||||
netWeightKg: number
|
||||
|
||||
@@ -54,7 +54,7 @@ function validDraft() {
|
||||
destinationType: 'FBA',
|
||||
amazonWarehouseCode: 'ONT8',
|
||||
shipmentId: 'FBA-001',
|
||||
items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, shipmentId: 'FBA-001', referenceId: 'REF-001' }],
|
||||
items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', unitsPerPackage: 100, pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, shipmentId: 'FBA-001', referenceId: 'REF-001' }],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,17 @@ test('validates the confirmed minimum submission fields', () => {
|
||||
assert.ok(errors.includes('第 1 条货物毛重必须大于 0'))
|
||||
})
|
||||
|
||||
test('rejects a total quantity that does not equal units per package multiplied by package count', () => {
|
||||
const draft = validDraft()
|
||||
draft.items[0].unitsPerPackage = 24
|
||||
draft.items[0].packageCount = 5
|
||||
draft.items[0].pieces = 119
|
||||
|
||||
assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物总数量应等于每箱数量 × 箱数(120)'))
|
||||
draft.items[0].pieces = 120
|
||||
assert.ok(!validateFirstLegOrderDraft(draft).some((message) => message.includes('总数量应等于')))
|
||||
})
|
||||
|
||||
test('only requires sender for pickup and receiver for a private destination', () => {
|
||||
const publicDraft = validDraft()
|
||||
publicDraft.shipperPartySnapshot = null
|
||||
@@ -169,3 +180,20 @@ test('summarizes cargo values from line items', () => {
|
||||
volumeCbm: 4.75,
|
||||
})
|
||||
})
|
||||
|
||||
test('counts one physical carton series for SKU lines in the same mixed-carton group', () => {
|
||||
const first = { ...validDraft().items[0], packingGroupNo: 'A', packageCount: 10, pieces: 20, unitsPerPackage: 2 }
|
||||
const second = { ...first, id: 'cargo-2', sku: 'SKU-002', pieces: 30, unitsPerPackage: 3 }
|
||||
|
||||
assert.deepEqual(summarizeFirstLegCargo([first, second]), {
|
||||
pieces: 50,
|
||||
packageCount: 10,
|
||||
grossWeightKg: first.grossWeightKg,
|
||||
volumeCbm: first.volumeCbm,
|
||||
})
|
||||
const draft = { ...validDraft(), fulfillmentType: 'ONE_CLICK_DROPSHIP', items: [first, second] }
|
||||
assert.ok(!validateFirstLegOrderDraft(draft).some((message) => message.includes('同箱 SKU')))
|
||||
|
||||
second.packageCount = 9
|
||||
assert.ok(validateFirstLegOrderDraft(draft).includes('同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致'))
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI
|
||||
unitsPerPackage: 1,
|
||||
pieces: 1,
|
||||
packageCount: 1,
|
||||
packingGroupNo: '',
|
||||
grossWeightKg: 0,
|
||||
volumeCbm: 0,
|
||||
netWeightKg: 0,
|
||||
@@ -130,22 +131,51 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
|
||||
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
|
||||
if (!Number.isInteger(item.packageCount) || item.packageCount <= 0) errors.push(`${line}箱数必须为正整数`)
|
||||
if (!Number.isInteger(item.unitsPerPackage) || item.unitsPerPackage <= 0) errors.push(`${line}每箱数量必须为正整数`)
|
||||
if (Number.isInteger(item.unitsPerPackage) && item.unitsPerPackage > 0
|
||||
&& Number.isInteger(item.packageCount) && item.packageCount > 0
|
||||
&& Number.isInteger(item.pieces) && item.pieces > 0
|
||||
&& item.pieces !== item.unitsPerPackage * item.packageCount) {
|
||||
errors.push(`${line}总数量应等于每箱数量 × 箱数(${item.unitsPerPackage * item.packageCount})`)
|
||||
}
|
||||
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
|
||||
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
|
||||
if (item.woodenProduct && draft.customsInspection !== true) errors.push(`${line}为木制品,“是否商检”必须选择“是”`)
|
||||
})
|
||||
|
||||
if (draft.fulfillmentType === 'ONE_CLICK_DROPSHIP') {
|
||||
const groups = new Map<string, FirstLegCargoItem>()
|
||||
draft.items.forEach((item) => {
|
||||
const group = item.packingGroupNo.trim()
|
||||
if (!group) return
|
||||
const first = groups.get(group)
|
||||
if (!first) groups.set(group, item)
|
||||
else if (first.packageCount !== item.packageCount || first.packageType !== item.packageType
|
||||
|| first.lengthCm !== item.lengthCm || first.widthCm !== item.widthCm || first.heightCm !== item.heightCm
|
||||
|| first.grossWeightKg !== item.grossWeightKg || first.netWeightKg !== item.netWeightKg
|
||||
|| first.volumeCbm !== item.volumeCbm) {
|
||||
errors.push(`同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export function summarizeFirstLegCargo(items: FirstLegCargoItem[]) {
|
||||
const countedGroups = new Set<string>()
|
||||
return items.reduce(
|
||||
(summary, item) => ({
|
||||
(summary, item) => {
|
||||
const group = item.packingGroupNo.trim()
|
||||
const alreadyCounted = Boolean(group && countedGroups.has(group))
|
||||
const packageCount = alreadyCounted ? 0 : item.packageCount
|
||||
if (group) countedGroups.add(group)
|
||||
return ({
|
||||
pieces: summary.pieces + item.pieces,
|
||||
packageCount: summary.packageCount + item.packageCount,
|
||||
grossWeightKg: summary.grossWeightKg + item.grossWeightKg,
|
||||
volumeCbm: summary.volumeCbm + item.volumeCbm,
|
||||
}),
|
||||
packageCount: summary.packageCount + packageCount,
|
||||
grossWeightKg: summary.grossWeightKg + (alreadyCounted ? 0 : item.grossWeightKg),
|
||||
volumeCbm: summary.volumeCbm + (alreadyCounted ? 0 : item.volumeCbm),
|
||||
})
|
||||
},
|
||||
{ pieces: 0, packageCount: 0, grossWeightKg: 0, volumeCbm: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -281,6 +281,11 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
|
||||
})
|
||||
|
||||
test('selects platform destinations and uses customer-scoped server address books', () => {
|
||||
assert.match(source, /v-if="form\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="FBA"/)
|
||||
assert.match(source, /<el-radio-button value="OVERSEAS">海外仓<\/el-radio-button>/)
|
||||
assert.match(source, /v-if="form\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="PRIVATE"/)
|
||||
assert.match(source, /@change="changeFulfillmentType"/)
|
||||
assert.match(source, /form\.destinationType = 'OVERSEAS'\s*\n\s*await changeDestinationType\(\)/)
|
||||
assert.match(source, /warehouse\.warehouseType === form\.destinationType/)
|
||||
assert.match(source, /await store\.loadWarehouseType\(type\)/)
|
||||
assert.match(source, /await loadDestinationWarehouses\(form\.destinationType\)/)
|
||||
@@ -357,6 +362,13 @@ test('distills order creation into the confirmed prototype sections with server
|
||||
assert.doesNotMatch(source, /未填写行标识/)
|
||||
assert.match(source, /batchImportError[\s\S]*requestId[\s\S]*traceId/)
|
||||
assert.match(source, /添加行[\s\S]*删除所选行/)
|
||||
assert.match(source, /添加 SKU/)
|
||||
assert.match(source, /addSkuToPackingGroup/)
|
||||
assert.match(source, /shared-carton-value">同箱/)
|
||||
assert.match(source, /syncSkuQuantity/)
|
||||
assert.match(source, /每个箱组默认装一个 SKU/)
|
||||
assert.doesNotMatch(source, /v-model="item\.packingGroupNo"/)
|
||||
assert.doesNotMatch(cargoTableSource, /<th[^>]*>混箱组合/)
|
||||
assert.doesNotMatch(source, /货物明细导入模板\.xls/)
|
||||
for (const heading of ['SKU NO.', '中文品名', '箱规长 cm', '总毛重', '报关 HS CODE', '货件编号', '跟踪编号', 'UPC NO.']) {
|
||||
assert.ok(cargoTableSource.includes(heading))
|
||||
@@ -430,6 +442,8 @@ test('shows field-level validation and concise guidance for required order input
|
||||
assert.match(source, /prop="consigneePartySnapshot\.address" required/)
|
||||
assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/)
|
||||
assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/)
|
||||
assert.match(source, /<span class="required-field-mark" aria-hidden="true">\*<\/span>总毛重/)
|
||||
assert.match(source, /<span class="required-field-mark" aria-hidden="true">\*<\/span>总体积/)
|
||||
assert.match(source, /:prop="`items\.\$\{index\}\.upc`">\s*<el-input v-model="item\.upc"[^>]*placeholder="UPC NO\.(选填)"/)
|
||||
assert.doesNotMatch(source, /:prop="`items\.\$\{index\}\.upc`"[^>]*(?:required|:rules)/)
|
||||
assert.match(source, /UPC NO\. 选填/)
|
||||
|
||||
@@ -215,6 +215,7 @@ const destinationWarehousesLoading = ref(false)
|
||||
const pageDataLoading = computed(() => supportingDataLoading.value || (!isCreatePage.value && store.loading))
|
||||
const editorBaseline = ref('')
|
||||
let cargoSequence = 0
|
||||
let packingGroupSequence = 0
|
||||
let inboundBillingRequestSequence = 0
|
||||
let channelRequestSequence = 0
|
||||
let estimateRequestSequence = 0
|
||||
@@ -1332,6 +1333,77 @@ function addCargoItem() {
|
||||
resetEditorValidation()
|
||||
}
|
||||
|
||||
async function changeFulfillmentType() {
|
||||
if (form.fulfillmentType !== 'ONE_CLICK_DROPSHIP' || form.destinationType === 'OVERSEAS') return
|
||||
form.destinationType = 'OVERSEAS'
|
||||
await changeDestinationType()
|
||||
}
|
||||
|
||||
const sharedPackingFields = [
|
||||
'packageType', 'packageCount', 'lengthCm', 'widthCm', 'heightCm',
|
||||
'grossWeightKg', 'netWeightKg', 'volumeCbm',
|
||||
] as const
|
||||
|
||||
function packingGroupFor(item: FirstLegCargoItem) {
|
||||
return item.packingGroupNo.trim()
|
||||
}
|
||||
|
||||
function mixedSkuCount(item: FirstLegCargoItem) {
|
||||
const group = packingGroupFor(item)
|
||||
return group ? form.items.filter((candidate) => packingGroupFor(candidate) === group).length : 1
|
||||
}
|
||||
|
||||
function isAdditionalMixedSku(item: FirstLegCargoItem, index: number) {
|
||||
const group = packingGroupFor(item)
|
||||
return Boolean(group && form.items.slice(0, index).some((candidate) => packingGroupFor(candidate) === group))
|
||||
}
|
||||
|
||||
function syncSkuQuantity(item: FirstLegCargoItem) {
|
||||
if (Number.isInteger(item.unitsPerPackage) && item.unitsPerPackage > 0
|
||||
&& Number.isInteger(item.packageCount) && item.packageCount > 0) {
|
||||
item.pieces = item.unitsPerPackage * item.packageCount
|
||||
}
|
||||
}
|
||||
|
||||
function syncPackingGroupFields(source: FirstLegCargoItem) {
|
||||
if (form.fulfillmentType !== 'ONE_CLICK_DROPSHIP') return
|
||||
syncSkuQuantity(source)
|
||||
const group = packingGroupFor(source)
|
||||
if (!group) return
|
||||
form.items.forEach((item) => {
|
||||
if (item === source || packingGroupFor(item) !== group) return
|
||||
sharedPackingFields.forEach((field) => { item[field] = source[field] as never })
|
||||
syncSkuQuantity(item)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSingleSkuGroups() {
|
||||
const counts = new Map<string, number>()
|
||||
form.items.forEach((item) => {
|
||||
const group = packingGroupFor(item)
|
||||
if (group) counts.set(group, (counts.get(group) ?? 0) + 1)
|
||||
})
|
||||
form.items.forEach((item) => {
|
||||
const group = packingGroupFor(item)
|
||||
if (group && counts.get(group) === 1) item.packingGroupNo = ''
|
||||
})
|
||||
}
|
||||
|
||||
function addSkuToPackingGroup(index: number) {
|
||||
const source = form.items[index]
|
||||
if (!source) return
|
||||
const group = packingGroupFor(source) || `PG-${Date.now().toString(36)}-${++packingGroupSequence}`
|
||||
source.packingGroupNo = group
|
||||
const added = createEmptyCargoItem(`new-cargo-${++cargoSequence}`)
|
||||
added.packingGroupNo = group
|
||||
sharedPackingFields.forEach((field) => { added[field] = source[field] as never })
|
||||
syncSkuQuantity(added)
|
||||
let insertAt = index + 1
|
||||
while (insertAt < form.items.length && packingGroupFor(form.items[insertAt]!) === group) insertAt++
|
||||
form.items.splice(insertAt, 0, added)
|
||||
resetEditorValidation()
|
||||
}
|
||||
|
||||
function removeCargoItem(index: number) {
|
||||
if (form.items.length === 1) {
|
||||
ElMessage.warning('订单至少需要保留一条货物明细')
|
||||
@@ -1339,6 +1411,7 @@ function removeCargoItem(index: number) {
|
||||
}
|
||||
const [removed] = form.items.splice(index, 1)
|
||||
if (removed) releaseProductImagePreviews([removed])
|
||||
normalizeSingleSkuGroups()
|
||||
resetEditorValidation()
|
||||
}
|
||||
|
||||
@@ -1503,6 +1576,7 @@ function removeSelectedCargoItems() {
|
||||
const selectedIds = new Set(selectedCargoIds.value)
|
||||
const removedItems = form.items.filter((item) => selectedIds.has(item.id))
|
||||
form.items.splice(0, form.items.length, ...form.items.filter((item) => !selectedIds.has(item.id)))
|
||||
normalizeSingleSkuGroups()
|
||||
releaseProductImagePreviews(removedItems)
|
||||
selectedCargoIds.value = []
|
||||
resetEditorValidation()
|
||||
@@ -1897,7 +1971,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<section class="order-form-section prototype-section">
|
||||
<div class="order-section-heading"><div><h3>客户下单</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
|
||||
<div class="prototype-form-flow shipping-form-flow">
|
||||
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
|
||||
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
|
||||
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
|
||||
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
|
||||
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
|
||||
@@ -1937,7 +2011,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<section class="order-form-section prototype-section destination-section">
|
||||
<div class="order-section-heading destination-section-heading"><div><h3>目的地信息</h3></div></div>
|
||||
<div class="prototype-form-flow destination-form-flow">
|
||||
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
|
||||
|
||||
<el-form-item v-if="form.destinationType === 'PRIVATE'" label="私人地址簿" class="prototype-form-row prototype-form-row--wide address-book-row">
|
||||
<div class="address-book-control"><el-select v-model="form.privateDestinationAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的私人仓地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="form.privateDestinationAddressId" link type="danger" @click="removeOrderAddress('PRIVATE_DESTINATION')">停用</el-button></div>
|
||||
@@ -1969,31 +2043,31 @@ async function searchOrders(model: QueryModel) {
|
||||
<span v-else class="cargo-table-hint">可逐行维护货物明细。</span>
|
||||
<div class="cargo-table-actions"><el-button @click="addCargoItem"><Plus :size="16" />添加行</el-button><el-button type="danger" plain :disabled="!selectedCargoIds.length" @click="removeSelectedCargoItems"><Trash2 :size="15" />删除所选行</el-button></div>
|
||||
</div>
|
||||
<div class="cargo-table-wrap">
|
||||
<div class="cargo-table-wrap" :class="{ 'is-dropship': form.fulfillmentType === 'ONE_CLICK_DROPSHIP' }">
|
||||
<table class="cargo-edit-table">
|
||||
<thead><tr>
|
||||
<th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th>
|
||||
<th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th><th>材质</th><th class="cargo-switch-column">危险品</th><th class="cargo-switch-column">木制品<small>勾选后须商检</small></th>
|
||||
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column">总毛重</th><th class="cargo-number-column">总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
|
||||
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总毛重</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
|
||||
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
|
||||
</tr></thead>
|
||||
<tbody><tr v-for="(item, index) in form.items" :key="item.id">
|
||||
<tbody><tr v-for="(item, index) in form.items" :key="item.id" :class="{ 'mixed-sku-detail-row': isAdditionalMixedSku(item, index) }">
|
||||
<td class="cargo-check-cell"><el-checkbox :model-value="selectedCargoIds.includes(item.id)" :aria-label="`选择货物 ${index + 1}`" @change="toggleCargoItem(item.id, $event)" /></td><td class="cargo-index-cell">{{ index + 1 }}</td>
|
||||
<td><el-form-item :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入 SKU NO.') : undefined"><el-input v-model="item.sku" maxlength="64" placeholder="SKU编号" /></el-form-item></td>
|
||||
<td><el-form-item :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA' || form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" :rules="form.destinationType === 'FBA' || form.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? requiredTextRules('请输入 SKU NO.') : undefined"><div class="cargo-sku-input"><el-input v-model="item.sku" maxlength="64" placeholder="SKU编号" /><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP' && mixedSkuCount(item) > 1" class="mixed-sku-badge">同箱</small></div></el-form-item></td>
|
||||
<td><el-form-item :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" maxlength="128" placeholder="中文品名" /></el-form-item></td>
|
||||
<td class="cargo-image-column"><div class="cargo-table-image"><img v-if="item.productImagePreviewUrl" :src="item.productImagePreviewUrl" :alt="`${item.cargoNameCn || `货物 ${index + 1}`}产品图片`" /><el-upload v-else :show-file-list="false" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :disabled="item.productImageUploading" :http-request="(options: UploadRequestOptions) => uploadCargoProductImage(item, options)"><el-button text :loading="item.productImageUploading" aria-label="上传图片"><ImagePlus :size="17" /></el-button></el-upload><el-button v-if="item.productImagePreviewUrl" class="cargo-image-remove" text type="danger" aria-label="移除图片" @click="removeCargoProductImage(item)"><Trash2 :size="12" /></el-button></div></td>
|
||||
<td><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="English Name" /></td><td><el-input v-model="item.hsCode" maxlength="20" placeholder="HS Code" /></td><td><el-input v-model="item.customsHsCode" maxlength="20" placeholder="HS Code" /></td>
|
||||
<td><el-form-item :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
|
||||
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType" @change="syncPackingGroupFields(item)"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
|
||||
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct && form.customsInspection !== true">第 {{ index + 1 }} 条须商检</small></div></td>
|
||||
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></td>
|
||||
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" /></td>
|
||||
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" @change="syncPackingGroupFields(item)" /></td>
|
||||
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" @change="syncSkuQuantity(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="derived-sku-value">{{ item.pieces }}</span><el-form-item v-else :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-input-number v-else v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" @change="syncPackingGroupFields(item)" /></td>
|
||||
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" placeholder="申报单价" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" placeholder="申报总价" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
|
||||
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></div></td>
|
||||
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
|
||||
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" link type="primary" :aria-label="`为货物 ${index + 1} 添加同箱 SKU`" @click="addSkuToPackingGroup(index)"><Plus :size="15" />添加 SKU</el-button><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cargo-table-hint"><span>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
|
||||
<div class="cargo-table-hint"><span v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'">每个箱组默认装一个 SKU;同一箱内有多个 SKU 时点击“添加 SKU”,箱数、箱规、重量和体积只填写一次。</span><span v-else>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
|
||||
</section>
|
||||
|
||||
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
|
||||
@@ -2138,6 +2212,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<el-table :data="pagedDetailCargoItems" border stripe class="cargo-detail-table" row-key="id">
|
||||
<el-table-column type="index" label="#" width="54" align="center" :index="(index: number) => (detailCargoPage - 1) * detailCargoPageSize + index + 1" />
|
||||
<el-table-column label="SKU NO." width="150" show-overflow-tooltip><template #default="{ row }"><span class="table-code table-ellipsis">{{ row.sku || '-' }}</span></template></el-table-column>
|
||||
<el-table-column v-if="detailOrder.fulfillmentType === 'ONE_CLICK_DROPSHIP'" prop="packingGroupNo" label="混箱组合" width="100" align="center"><template #default="{ row }">{{ row.packingGroupNo || '-' }}</template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.cargoNameCn || '-' }}</template></el-table-column>
|
||||
<el-table-column label="图片" width="66" align="center"><template #default="{ row }"><div class="cargo-detail-thumbnail"><img v-if="row.productImagePreviewUrl" :src="row.productImagePreviewUrl" :alt="`${row.cargoNameCn || '货物'}产品图片`" /><span v-else>-</span></div></template></el-table-column>
|
||||
<el-table-column label="英文品名" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.cargoNameEn || '-' }}</template></el-table-column>
|
||||
@@ -2425,8 +2500,15 @@ async function searchOrders(model: QueryModel) {
|
||||
.cargo-check-cell, .cargo-index-cell { width: 38px !important; text-align: center !important; vertical-align: middle !important; }
|
||||
.cargo-image-column { width: 62px !important; text-align: center !important; }
|
||||
.cargo-number-column { width: 92px !important; }
|
||||
.required-field-mark { margin-right: 2px; color: var(--el-color-danger); }
|
||||
.cargo-dimensions-column { width: 240px !important; }
|
||||
.cargo-operation-column { width: 76px !important; text-align: center !important; }
|
||||
.cargo-table-wrap.is-dropship .cargo-operation-column { width: 148px !important; }
|
||||
.cargo-sku-input { position: relative; width: 100%; }
|
||||
.mixed-sku-badge { position: absolute; top: -10px; right: 2px; padding: 1px 5px; border-radius: 999px; background: #eaf6f2; color: var(--primary); font-size: 9px; font-weight: 650; line-height: 16px; white-space: nowrap; }
|
||||
.mixed-sku-detail-row { background: #f8fbff; }
|
||||
.shared-carton-value { display: inline-flex; min-height: 30px; align-items: center; color: #8a97a8; font-size: 12px; white-space: nowrap; }
|
||||
.derived-sku-value { display: inline-flex; min-height: 30px; align-items: center; color: #172b4d; font-weight: 650; }
|
||||
.cargo-switch-column { width: 112px !important; text-align: center !important; vertical-align: middle !important; }
|
||||
.cargo-switch-cell { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 4px; }
|
||||
.cargo-switch-cell small { color: #b54708; font-size: 9px; line-height: 1.25; white-space: normal; }
|
||||
|
||||
@@ -13,6 +13,15 @@ export interface ReturnInventorySummaryItem {
|
||||
|
||||
export interface ReturnInventoryCartonItem { skuCode: string; productNameZh?: string; quantity: number }
|
||||
|
||||
export interface ReturnInventoryCartonSummary {
|
||||
cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string
|
||||
skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string
|
||||
}
|
||||
|
||||
export function listReturnInventoryCartons(params: Record<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<ReturnInventoryCartonSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/cartons', params })
|
||||
}
|
||||
|
||||
export interface ReturnInventoryCartonDetail {
|
||||
cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string
|
||||
skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; receivedAt: string; items: ReturnInventoryCartonItem[]
|
||||
|
||||
@@ -38,7 +38,7 @@ export const overseasWarehouseModule = {
|
||||
{
|
||||
path: 'overseas-warehouse/return-cartons', name: 'overseas-return-cartons',
|
||||
component: () => import('./views/ReturnInventoryView.vue'), props: { cartonOnly: true }, release: 'active',
|
||||
meta: { title: '退货箱号管理', description: '查询合箱后的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
|
||||
meta: { title: '退货箱号管理', description: '查询签收及合箱生成的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
|
||||
navigation: { key: 'overseas-return-cartons', icon: Boxes },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const inventory = readFileSync(new URL('./ReturnInventoryView.vue', import.meta.url), 'utf8')
|
||||
const orders = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('desktop filters reserve six slots and inventory time and statuses have readable presentation', () => {
|
||||
assert.match(inventory, /grid-template-columns: repeat\(4, minmax\(150px, 1fr\)\) 130px auto/)
|
||||
assert.match(inventory, /white-space: nowrap/)
|
||||
assert.match(inventory, /cartonStatusLabel\(cartonDetail.status\)/)
|
||||
assert.match(inventory, /formatInventoryTime\(row.createdAt\)/)
|
||||
assert.match(inventory, /formatInventoryTime\(row.updatedAt\)/)
|
||||
})
|
||||
|
||||
test('inventory failures are visible and are not presented as empty inventory', () => {
|
||||
assert.match(inventory, /catch \(error\)/)
|
||||
assert.match(inventory, /<el-alert v-if="loadError"/)
|
||||
assert.equal((inventory.match(/:empty-text="loadError \?/g) || []).length, 3)
|
||||
assert.match(inventory, /当前账号无权访问退货库存/)
|
||||
assert.match(inventory, /error\.requestId/)
|
||||
assert.match(inventory, /error\.traceId/)
|
||||
assert.match(inventory, /summaryRows\.value = \[\]/)
|
||||
assert.match(inventory, /cartonRows\.value = \[\]/)
|
||||
})
|
||||
|
||||
test('return pagination receives numeric totals from the wire format', () => {
|
||||
assert.equal((inventory.match(/total\.value = Number\(page.total\)/g) || []).length, 3)
|
||||
assert.match(orders, /total\.value = Number\(response.total\)/)
|
||||
})
|
||||
|
||||
test('inventory actions lead both tables and timestamps describe inventory changes', () => {
|
||||
const tables = inventory.match(/<el-table\b[^>]*>[\s\S]*?<\/el-table>/g).slice(0, 3)
|
||||
for (const table of tables) {
|
||||
assert.match(table, /<el-table\b[^>]*>\s*<el-table-column label="操作"[^>]*fixed="left"/)
|
||||
}
|
||||
assert.match(inventory, /prop="updatedAt" label="库存更新时间"/)
|
||||
assert.doesNotMatch(inventory, /实收并上架|最近入库时间/)
|
||||
})
|
||||
|
||||
test('carton management uses server carton pagination rather than deduplicating SKU pages', () => {
|
||||
assert.match(inventory, /if \(props.cartonOnly\) \{\s*const page = await listReturnInventoryCartons/)
|
||||
assert.match(inventory, /:data="cartonSummaryRows" row-key="cartonMark"/)
|
||||
assert.match(inventory, /cartonSummaryRows.value = \[\]/)
|
||||
})
|
||||
|
||||
test('printing opens its window before awaiting the label and closes on failure', () => {
|
||||
const print = inventory.slice(inventory.indexOf('async function printLabel'), inventory.indexOf('onMounted('))
|
||||
assert.ok(print.indexOf('window.open(') < print.indexOf('await getReturnInventoryLabel('))
|
||||
assert.match(print, /if \(printWindow.closed\) return/)
|
||||
assert.match(print, /catch \(error\) \{\s*printWindow.close\(\)/)
|
||||
})
|
||||
|
||||
test('warehouse selectors and displays use the catalog instead of internal IDs', () => {
|
||||
assert.match(inventory, /from '@\/modules\/first-leg\/public'/)
|
||||
assert.match(inventory, /warehouseType: 'OVERSEAS', page, size: 200/)
|
||||
assert.match(inventory, /warehouseLabel\(cartonDetail.overseasWarehouseId\)/)
|
||||
assert.match(inventory, /warehouse\.warehouseName/)
|
||||
assert.match(inventory, /warehouse\.warehouseCode/)
|
||||
assert.doesNotMatch(inventory, /海外仓 ID|\{\{ cartonDetail.overseasWarehouseId \}\}|prop="overseasWarehouseId"/)
|
||||
})
|
||||
@@ -2,11 +2,15 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Printer, Search } from '@lucide/vue'
|
||||
import { ApiError } from '@/api/types'
|
||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
|
||||
import {
|
||||
getReturnInventoryCarton,
|
||||
getReturnInventoryLabel,
|
||||
listReturnInventory,
|
||||
listReturnInventorySummary,
|
||||
listReturnInventoryCartons,
|
||||
type ReturnInventoryCartonSummary,
|
||||
type ReturnInventoryCartonDetail,
|
||||
type ReturnInventoryItem,
|
||||
type ReturnInventorySummaryItem,
|
||||
@@ -16,8 +20,35 @@ const activeTab = ref<'summary' | 'cartons'>('summary')
|
||||
const props = defineProps<{ cartonOnly?: boolean }>()
|
||||
if (props.cartonOnly) activeTab.value = 'cartons'
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const warehouses = ref<MerchantWarehouse[]>([])
|
||||
const warehouseError = ref('')
|
||||
const warehousesLoading = ref(false)
|
||||
function formatInventoryTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
|
||||
function cartonStatusLabel(value: string) { return ({ AVAILABLE: '可用', MERGED: '已合箱', OUTBOUNDED: '已出库' } as Record<string, string>)[value] || '—' }
|
||||
function warehouseLabel(warehouseId: string) {
|
||||
const warehouse = warehouses.value.find(item => String(item.id) === String(warehouseId))
|
||||
return warehouse ? `${warehouse.warehouseName} · ${warehouse.warehouseCode}` : '—'
|
||||
}
|
||||
async function loadWarehouses() {
|
||||
warehousesLoading.value = true
|
||||
warehouseError.value = ''
|
||||
try {
|
||||
const result: MerchantWarehouse[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await getMerchantWarehouses({ warehouseType: 'OVERSEAS', page, size: 200 })
|
||||
result.push(...response.records)
|
||||
if (!response.records.length || result.length >= Number(response.total)) break
|
||||
}
|
||||
warehouses.value = result
|
||||
} catch (error) {
|
||||
warehouses.value = []
|
||||
warehouseError.value = error instanceof Error ? error.message : '海外仓目录加载失败,请重试'
|
||||
} finally { warehousesLoading.value = false }
|
||||
}
|
||||
const summaryRows = ref<ReturnInventorySummaryItem[]>([])
|
||||
const cartonRows = ref<ReturnInventoryItem[]>([])
|
||||
const cartonSummaryRows = ref<ReturnInventoryCartonSummary[]>([])
|
||||
const total = ref(0)
|
||||
const showCartonDetail = ref(false)
|
||||
const cartonDetail = ref<ReturnInventoryCartonDetail>()
|
||||
@@ -37,16 +68,35 @@ function params(includeCartonMark: boolean) {
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
if (props.cartonOnly) {
|
||||
const page = await listReturnInventoryCartons(params(true))
|
||||
cartonSummaryRows.value = page.records
|
||||
total.value = Number(page.total)
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'summary') {
|
||||
const page = await listReturnInventorySummary(params(false))
|
||||
summaryRows.value = page.records
|
||||
total.value = page.total
|
||||
total.value = Number(page.total)
|
||||
return
|
||||
}
|
||||
const page = await listReturnInventory(params(true))
|
||||
cartonRows.value = page.records
|
||||
total.value = page.total
|
||||
total.value = Number(page.total)
|
||||
} catch (error) {
|
||||
summaryRows.value = []
|
||||
cartonRows.value = []
|
||||
cartonSummaryRows.value = []
|
||||
total.value = 0
|
||||
const message = error instanceof ApiError && (error.code === 'A0403' || error.status === 403)
|
||||
? '当前账号无权访问退货库存,请联系管理员核对海外库存权限。'
|
||||
: error instanceof Error ? error.message : '退货库存加载失败,请重试'
|
||||
const diagnostics = error instanceof ApiError
|
||||
? [error.code, error.requestId && `requestId: ${error.requestId}`, error.traceId && `traceId: ${error.traceId}`].filter(Boolean).join(' · ')
|
||||
: ''
|
||||
loadError.value = diagnostics ? `${message}(${diagnostics})` : message
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -67,51 +117,71 @@ async function openCarton(cartonMark: string) {
|
||||
}
|
||||
|
||||
async function printLabel(cartonMark: string) {
|
||||
try {
|
||||
const label = await getReturnInventoryLabel(cartonMark)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
|
||||
printWindow.opener = null
|
||||
printWindow.document.title = '正在加载退货箱唛'
|
||||
printWindow.document.body.textContent = '正在加载退货箱唛,请稍候…'
|
||||
try {
|
||||
const label = await getReturnInventoryLabel(cartonMark)
|
||||
if (printWindow.closed) return
|
||||
printWindow.document.open()
|
||||
printWindow.document.write(label.printableHtml)
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
printWindow.print()
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') }
|
||||
} catch (error) {
|
||||
printWindow.close()
|
||||
ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => { void load(); void loadWarehouses() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="return-inventory-page">
|
||||
<div class="page-heading"><div><h2>{{ props.cartonOnly ? '退货箱号管理' : '退货库存管理' }}</h2><p>{{ props.cartonOnly ? '查询合箱完成后的退货箱号、箱规和箱内 SKU 明细。' : '库存数量以仓库 PDA 实收并上架的数量为准。' }}</p></div></div>
|
||||
<div class="page-heading"><div><h2>{{ props.cartonOnly ? '退货箱号管理' : '退货库存管理' }}</h2><p>{{ props.cartonOnly ? '查询签收及合箱生成的退货箱号、箱规和箱内 SKU 明细。' : '库存数量以仓库 PDA 确认实收后的入账数量为准。' }}</p></div></div>
|
||||
<el-tabs v-if="!props.cartonOnly" v-model="activeTab" @tab-change="changeTab"><el-tab-pane label="SKU 汇总库存" name="summary" /><el-tab-pane label="箱号明细" name="cartons" /></el-tabs>
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="query.overseasWarehouseId" clearable placeholder="海外仓 ID" @keyup.enter="search" />
|
||||
<div class="filter-bar" :class="{ 'carton-filters': activeTab === 'cartons' }">
|
||||
<el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="海外仓" :loading="warehousesLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select>
|
||||
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" />
|
||||
<el-input v-if="activeTab === 'cartons'" v-model="query.cartonMark" clearable placeholder="箱号 / 箱唛" @keyup.enter="search" />
|
||||
<el-input v-if="activeTab === 'cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" />
|
||||
<el-select v-if="activeTab === 'cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select>
|
||||
<el-button type="primary" @click="search"><Search :size="16" />查询</el-button>
|
||||
</div>
|
||||
<el-table v-if="activeTab === 'summary'" v-loading="loading" :data="summaryRows">
|
||||
<el-table-column prop="overseasWarehouseId" label="海外仓" min-width="150" /><el-table-column prop="skuCode" label="SKU" min-width="180" />
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" />
|
||||
<el-alert v-if="warehouseError" :title="warehouseError" type="warning" show-icon :closable="false"><el-button link type="primary" @click="loadWarehouses">重试加载海外仓</el-button></el-alert>
|
||||
<el-table v-if="activeTab === 'summary'" v-loading="loading" :data="summaryRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
|
||||
<el-table-column label="操作" width="110" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openSkuCartons(row.skuCode)">查看箱号</el-button></template></el-table-column>
|
||||
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="180" />
|
||||
<el-table-column prop="productNameZh" label="中文品名" min-width="190" show-overflow-tooltip /><el-table-column prop="availableQuantity" label="可用库存" min-width="120" align="right" />
|
||||
<el-table-column prop="cartonQuantity" label="所在箱数" min-width="120" align="right" /><el-table-column prop="updatedAt" label="最近入库时间" min-width="180" />
|
||||
<el-table-column label="操作" width="110" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openSkuCartons(row.skuCode)">查看箱号</el-button></template></el-table-column>
|
||||
<el-table-column prop="cartonQuantity" label="所在箱数" min-width="120" align="right" /><el-table-column prop="updatedAt" label="库存更新时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.updatedAt) }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
<el-table v-else v-loading="loading" :data="cartonRows">
|
||||
<el-table v-else-if="props.cartonOnly" v-loading="loading" :data="cartonSummaryRows" row-key="cartonMark" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
|
||||
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
|
||||
<el-table-column prop="cartonMark" label="箱号" min-width="235" />
|
||||
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
|
||||
<el-table-column prop="skuTypes" label="箱内 SKU 种类" min-width="130" align="right" />
|
||||
<el-table-column prop="totalQuantity" label="箱内总件数" min-width="115" align="right" />
|
||||
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="235" />
|
||||
<el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" />
|
||||
<el-table-column label="箱规" min-width="160"><template #default="{ row }">{{ row.length && row.width && row.height ? `${row.length} × ${row.width} × ${row.height} cm` : '—' }}</template></el-table-column>
|
||||
<el-table-column label="重量" min-width="100"><template #default="{ row }">{{ row.weight ? `${row.weight} kg` : '—' }}</template></el-table-column>
|
||||
<el-table-column prop="createdAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.createdAt) }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
<el-table v-else v-loading="loading" :data="cartonRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
|
||||
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
|
||||
<el-table-column prop="cartonMark" label="箱号" min-width="235"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">{{ row.cartonMark }}</el-button></template></el-table-column>
|
||||
<el-table-column prop="overseasWarehouseId" label="海外仓" min-width="130" /><el-table-column prop="skuCode" label="SKU" min-width="150" />
|
||||
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" />
|
||||
<el-table-column prop="productNameZh" label="中文品名" min-width="170" show-overflow-tooltip /><el-table-column prop="quantity" label="本箱 SKU 数量" min-width="135" align="right" />
|
||||
<el-table-column prop="cartonSkuTypes" label="箱内 SKU 种类" min-width="130" align="right" /><el-table-column prop="cartonTotalQuantity" label="箱内总件数" min-width="115" align="right" />
|
||||
<el-table-column prop="status" label="箱状态" min-width="100" /><el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" /><el-table-column prop="receivedAt" label="签收时间" min-width="175" />
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
|
||||
<el-table-column prop="status" label="箱状态" min-width="100"><template #default="{ row }">{{ cartonStatusLabel(row.status) }}</template></el-table-column><el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" /><el-table-column prop="receivedAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.receivedAt) }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
|
||||
<el-drawer v-model="showCartonDetail" title="退货箱明细" size="680px"><template v-if="cartonDetail">
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="箱号">{{ cartonDetail.cartonMark }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ cartonDetail.overseasWarehouseId }}</el-descriptions-item><el-descriptions-item label="来源退货单">{{ cartonDetail.sourceReturnNo || '—' }}</el-descriptions-item><el-descriptions-item label="签收批次">{{ cartonDetail.sourceBatchNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ cartonDetail.status }}</el-descriptions-item><el-descriptions-item label="SKU 种类">{{ cartonDetail.skuTypes }}</el-descriptions-item><el-descriptions-item label="箱内总件数">{{ cartonDetail.totalQuantity }}</el-descriptions-item><el-descriptions-item label="箱规">{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</el-descriptions-item><el-descriptions-item label="重量">{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</el-descriptions-item></el-descriptions>
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="箱号">{{ cartonDetail.cartonMark }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel(cartonDetail.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="来源退货单">{{ cartonDetail.sourceReturnNo || '—' }}</el-descriptions-item><el-descriptions-item label="签收批次">{{ cartonDetail.sourceBatchNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ cartonStatusLabel(cartonDetail.status) }}</el-descriptions-item><el-descriptions-item label="SKU 种类">{{ cartonDetail.skuTypes }}</el-descriptions-item><el-descriptions-item label="箱内总件数">{{ cartonDetail.totalQuantity }}</el-descriptions-item><el-descriptions-item label="箱规">{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</el-descriptions-item><el-descriptions-item label="重量">{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</el-descriptions-item></el-descriptions>
|
||||
<div class="drawer-heading"><h3>箱内 SKU</h3><el-button type="primary" @click="printLabel(cartonDetail.cartonMark)"><Printer :size="16" />打印箱唛</el-button></div>
|
||||
<el-table :data="cartonDetail.items" size="small"><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="quantity" label="数量" min-width="100" align="right" /></el-table>
|
||||
</template></el-drawer>
|
||||
@@ -119,6 +189,6 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.return-inventory-page { padding: 20px; } .page-heading { margin-bottom: 14px; } h2 { margin: 0; font-size: 22px; } p { margin: 6px 0 0; color: var(--el-text-color-secondary); } .filter-bar { display: grid; grid-template-columns: repeat(3, minmax(150px, 1fr)) auto; gap: 10px; margin-bottom: 14px; } .el-pagination { margin-top: 16px; justify-content: flex-end; } .drawer-heading { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 12px; } .drawer-heading h3 { margin: 0; font-size: 16px; }
|
||||
@media (max-width: 760px) { .return-inventory-page { padding: 12px; } .filter-bar { grid-template-columns: 1fr 1fr; } }
|
||||
.return-inventory-page { padding: 20px; } .page-heading { margin-bottom: 14px; } h2 { margin: 0; font-size: 22px; } p { margin: 6px 0 0; color: var(--el-text-color-secondary); } .filter-bar { display: grid; grid-template-columns: repeat(2, minmax(180px, 320px)) auto; justify-content: start; gap: 10px; margin-bottom: 14px; } .filter-bar.carton-filters { grid-template-columns: repeat(4, minmax(150px, 1fr)) 130px auto; } .filter-bar > * { min-width: 0; } .inventory-time { white-space: nowrap; } .el-pagination { margin-top: 16px; justify-content: flex-end; } .drawer-heading { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 12px; } .drawer-heading h3 { margin: 0; font-size: 16px; }
|
||||
@media (max-width: 760px) { .return-inventory-page { padding: 12px; } .filter-bar, .filter-bar.carton-filters { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -19,5 +19,34 @@ test('新建退货预报选择海外仓后带出联系人和电话', () => {
|
||||
|
||||
test('新建退货预报使用加宽的双列表单弹窗', () => {
|
||||
assert.match(view, /title="新建退货预报" width="1120px"/)
|
||||
assert.match(view, /<el-form label-width="190px" class="return-create-form">/)
|
||||
assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/)
|
||||
})
|
||||
|
||||
test('退货列表和详情使用客户可读仓库、状态与时间', () => {
|
||||
assert.match(view, /function operationWarehouseLabel\(warehouseId: string\)/)
|
||||
assert.match(view, /return warehouse \? warehouseLabel\(warehouse\) : '未关联'/)
|
||||
assert.match(view, /PENDING_RECEIPT: '待签收'/)
|
||||
assert.match(view, /PARTIALLY_RECEIVED: '部分签收'/)
|
||||
assert.match(view, /COMPLETED: '已完成'/)
|
||||
assert.match(view, /function formatDateTime\(value\?: string \| null\)/)
|
||||
assert.match(view, /\{\{ operationWarehouseLabel\(row\.overseasWarehouseId\) \}\}/)
|
||||
assert.match(view, /\{\{ statusLabel\(row\.status\) \}\}/)
|
||||
assert.match(view, /\{\{ formatDateTime\(row\.createdAt\) \}\}/)
|
||||
assert.doesNotMatch(view, /<el-table-column prop="overseasWarehouseId"/)
|
||||
assert.doesNotMatch(view, /\{\{ selected\.order\.overseasWarehouseId \}\}/)
|
||||
})
|
||||
|
||||
test('可操作退货表格把操作列固定在最左侧', () => {
|
||||
const mainTable = view.slice(view.indexOf('<el-table v-loading="loading"'), view.indexOf('<el-pagination'))
|
||||
const createTable = view.slice(view.indexOf('<el-table :data="form.items"'), view.indexOf('</el-table>', view.indexOf('<el-table :data="form.items"')))
|
||||
assert.match(mainTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="90" fixed="left">/)
|
||||
assert.match(createTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="80" fixed="left">/)
|
||||
})
|
||||
|
||||
test('分页初始化不会重复请求退货列表', () => {
|
||||
assert.match(view, /function changePage\(page: number\) \{\s*if \(page === query\.page\) return/)
|
||||
assert.match(view, /:current-page="query\.page" :page-size="query\.size"/)
|
||||
assert.match(view, /@current-change="changePage"/)
|
||||
assert.doesNotMatch(view, /@current-change="load"/)
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Search } from '@lucide/vue'
|
||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/api/firstLeg'
|
||||
import { createReturnOrder, getReturnOrder, listReturnOrders, type ReturnOrderDetail, type ReturnOrderSummary } from '../api/returnOrders'
|
||||
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 creating = ref(false)
|
||||
@@ -32,6 +32,10 @@ const duplicateSkuIds = computed(() => {
|
||||
})
|
||||
function resetForm() { Object.assign(form, { overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) }
|
||||
function warehouseLabel(warehouse: MerchantWarehouse) { return `${warehouse.warehouseName} · ${warehouse.warehouseCode}` }
|
||||
function operationWarehouseLabel(warehouseId: string) {
|
||||
const warehouse = operationWarehouses.value.find(item => String(item.id) === String(warehouseId))
|
||||
return warehouse ? warehouseLabel(warehouse) : '未关联'
|
||||
}
|
||||
function selectOperationWarehouse() {
|
||||
const warehouse = operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId)
|
||||
form.warehouseContactName = warehouse?.contactName || ''
|
||||
@@ -57,15 +61,20 @@ async function loadOperationWarehouses() {
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
showCreate.value = true
|
||||
void loadOperationWarehouses()
|
||||
if (!operationWarehouses.value.length && !operationWarehousesLoading.value) void loadOperationWarehouses()
|
||||
}
|
||||
function addItem() { form.items.push(emptySku()) }
|
||||
function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { const response = await listReturnOrders({ ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }); rows.value = response.records; total.value = response.total }
|
||||
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) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function changePage(page: number) {
|
||||
if (page === query.page) return
|
||||
query.page = page
|
||||
void load()
|
||||
}
|
||||
async function openDetail(returnNo: string) { selected.value = await getReturnOrder(returnNo); showDetail.value = true }
|
||||
async function submit() {
|
||||
const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity }))
|
||||
@@ -79,7 +88,17 @@ async function submit() {
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
function statusType(status: string) { return status === 'COMPLETED' ? 'success' : status === 'PARTIALLY_RECEIVED' ? 'warning' : 'info' }
|
||||
onMounted(load)
|
||||
function statusLabel(status: string) {
|
||||
return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '部分签收', COMPLETED: '已完成' } as Record<string, string>)[status] ?? '未知状态'
|
||||
}
|
||||
function startedByLabel(startedBy: ReturnReceipt['startedBy']) { return startedBy === 'SKU' ? 'SKU 查询' : '退货单号' }
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
|
||||
}
|
||||
onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,21 +112,21 @@ onMounted(load)
|
||||
<el-button @click="query.page=1; load()"><Search :size="16" />查询</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" @row-click="row => openDetail(row.returnNo)">
|
||||
<el-table-column 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" />
|
||||
<el-table-column prop="overseasWarehouseId" label="操作仓" min-width="120" />
|
||||
<el-table-column label="操作仓" min-width="190"><template #default="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
|
||||
<el-table-column label="换标" width="100"><template #default="{ row }"><el-tag v-if="row.needRelabel" type="warning">需要换标</el-tag><span v-else>否</span></template></el-table-column>
|
||||
<el-table-column prop="skuTypes" label="SKU种类" width="100" />
|
||||
<el-table-column prop="expectedQuantity" label="预报数量" width="110" />
|
||||
<el-table-column prop="receivedQuantity" label="实收数量" width="110" />
|
||||
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170" />
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row.returnNo)">详情</el-button></template></el-table-column>
|
||||
<el-table-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 v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
|
||||
<el-pagination :current-page="query.page" :page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="changePage" />
|
||||
|
||||
<el-dialog v-model="showCreate" title="新建退货预报" width="1120px" destroy-on-close>
|
||||
<el-form label-width="160px" class="return-create-form">
|
||||
<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>
|
||||
@@ -118,14 +137,14 @@ onMounted(load)
|
||||
<div class="return-relabel-option"><span>换标</span><el-checkbox v-model="form.needRelabel">需要换标</el-checkbox></div>
|
||||
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
|
||||
<el-table :data="form.items" row-key="id" size="small">
|
||||
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
||||
<el-table-column label="SKU" min-width="200"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
|
||||
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
|
||||
</el-dialog>
|
||||
<el-drawer v-model="showDetail" title="退货签收详情" size="760px"><template v-if="selected"><el-descriptions :column="2" border><el-descriptions-item label="退货单号">{{ selected.order.returnNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(selected.order.status)">{{ selected.order.status }}</el-tag></el-descriptions-item><el-descriptions-item label="海外仓">{{ selected.order.overseasWarehouseId }}</el-descriptions-item><el-descriptions-item label="实收 / 预报">{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</el-descriptions-item><el-descriptions-item label="海外仓联系人">{{ selected.order.warehouseContactName || '—' }}</el-descriptions-item><el-descriptions-item label="海外仓联系电话">{{ selected.order.warehouseContactPhone || '—' }}</el-descriptions-item></el-descriptions><p v-if="selected.remark" class="remark">备注:{{ selected.remark }}</p><h3>预报与实收</h3><el-table :data="selected.items" size="small"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="expectedQuantity" label="预报" /><el-table-column prop="receivedQuantity" label="实收" /></el-table><h3>签收批次</h3><el-table :data="selected.receipts" size="small"><el-table-column prop="receiptBatchNo" label="批次号" min-width="170" /><el-table-column prop="receiptNo" label="签收单号" min-width="170" /><el-table-column prop="cartonMark" label="箱号" min-width="190" /><el-table-column prop="trackingNo" label="快递单号"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column prop="startedBy" label="开始方式" /><el-table-column prop="receivedQuantity" label="实收" /><el-table-column prop="signedAt" label="签收时间" min-width="170" /></el-table></template></el-drawer>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ const labelingModeHint = computed(() => labelingMode.value === 'carton'
|
||||
? '只替换箱内商品 SKU 标签,保留原箱唛;同一箱唛可以添加多条 SKU 明细。'
|
||||
: '同一箱同时替换商品 SKU 标签和外箱箱唛;同一箱唛的多条 SKU 使用同一个新 FBA 箱唛和目的仓。')
|
||||
const submittedRows = ref<Record<string, string>[]>([])
|
||||
const selectedDetail = ref<Record<string, any> | null>(null)
|
||||
interface ServiceDetail extends Record<string, unknown> { transferNo?: string; status?: string; estimatedShipAt?: string; actualShipAt?: string; cartons?: TransferCarton[] }
|
||||
const selectedDetail = ref<ServiceDetail | null>(null)
|
||||
const selectedRelabelDetail = ref<RelabelDetail | null>(null)
|
||||
const fbaWarehouses = ref<MerchantWarehouse[]>([])
|
||||
const operationWarehouses = ref<MerchantWarehouse[]>([])
|
||||
@@ -569,7 +570,7 @@ async function submit() {
|
||||
async function openDetail(value: Record<string, string>) {
|
||||
try {
|
||||
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
|
||||
selectedDetail.value = { ...value, ...(detail as Record<string, unknown>) as Record<string, string> }
|
||||
selectedDetail.value = { ...value, ...(detail as ServiceDetail) }
|
||||
selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null
|
||||
mode.value = 'detail'
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') }
|
||||
@@ -578,7 +579,7 @@ async function confirmTransferMeasurements() {
|
||||
const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '')
|
||||
if (!transferNo) return
|
||||
try { await ElMessageBox.confirm('确认 PDA 回传的箱数、尺寸、重量和打托数据吗?确认后将按实测数据入库。', '确认实测数据', { confirmButtonText: '确认入库', cancelButtonText: '取消' }) } catch { return }
|
||||
try { selectedDetail.value = await confirmTransferMeasurement(transferNo) as Record<string, string>; ElMessage.success('实测数据已确认并入库') }
|
||||
try { selectedDetail.value = await confirmTransferMeasurement(transferNo) as ServiceDetail; ElMessage.success('实测数据已确认并入库') }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : '确认失败') }
|
||||
}
|
||||
async function printTransferCartons() {
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface MerchantQualification {
|
||||
qualificationType: MerchantQualificationType
|
||||
certificateNo: string | null
|
||||
documentUrl?: string | null
|
||||
originalFileName?: string | null
|
||||
updatedAt?: string | null
|
||||
validTo: string | null
|
||||
status: QualificationStatus
|
||||
reviewComment: string | null
|
||||
@@ -119,6 +121,11 @@ export interface MerchantPersonalProfile {
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface QualificationDeletionResult {
|
||||
qualificationId: number | string
|
||||
deletedDocumentUrl: string | null
|
||||
}
|
||||
|
||||
export interface ChangeMerchantLoginIdentifiersRequest {
|
||||
username: string
|
||||
phone: string
|
||||
@@ -157,6 +164,13 @@ export function getTenantQualifications() {
|
||||
return apiRequest<MerchantQualification[]>({ method: 'GET', url: '/merchant/v1/iam/tenant/qualifications' })
|
||||
}
|
||||
|
||||
export function deleteTenantQualification(qualificationId: number | string) {
|
||||
return apiRequest<QualificationDeletionResult>({
|
||||
method: 'DELETE',
|
||||
url: `/merchant/v1/iam/tenant/qualifications/${qualificationId}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function getMerchantPermissionResources() {
|
||||
return apiRequest<MerchantPermissionResource[]>({ method: 'GET', url: '/merchant/v1/iam/permissions' })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
changeMerchantRoleStatus,
|
||||
createMerchantMember,
|
||||
createMerchantRole,
|
||||
deleteTenantQualification,
|
||||
deleteMerchantRole,
|
||||
getMerchantMembers,
|
||||
getMerchantPermissionResources,
|
||||
@@ -91,6 +92,13 @@ function formatLastLoginAt(value: string | null) {
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatQualificationUpdatedAt(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const emptyCompany: TenantCompanyProfile = {
|
||||
companyName: '',
|
||||
creditCode: '',
|
||||
@@ -145,10 +153,10 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书',
|
||||
BANK_ACCOUNT_LICENSE: '银行开户许可证',
|
||||
} as const)[item.qualificationType] || '企业资质',
|
||||
fileName: documentUrl.split('/').pop() || '资质文件',
|
||||
fileName: item.originalFileName?.trim() || '—',
|
||||
status: normalizeQualificationStatus(item.status),
|
||||
expiresAt: item.validTo || '长期有效',
|
||||
updatedAt: '-',
|
||||
updatedAt: formatQualificationUpdatedAt(item.updatedAt),
|
||||
documentUrl,
|
||||
}
|
||||
})
|
||||
@@ -198,6 +206,11 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
roles.value = roleRecords.map(mapMerchantRole)
|
||||
}
|
||||
|
||||
async function deleteQualification(id: string) {
|
||||
await deleteTenantQualification(id)
|
||||
qualifications.value = qualifications.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
members.value = []
|
||||
members.value = (await getMerchantMembers()).map(mapMerchantMember)
|
||||
@@ -335,6 +348,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
roles,
|
||||
activeMemberCount,
|
||||
loadCompanyContext,
|
||||
deleteQualification,
|
||||
loadRoles,
|
||||
loadMembers,
|
||||
updateCompany,
|
||||
|
||||
@@ -2,10 +2,12 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { formatQualificationFileSize, qualificationUploadErrorMessage, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
|
||||
|
||||
test('accepts the backend-confirmed PDF, JPG and PNG formats up to 5MB', () => {
|
||||
test('accepts PDF, JPG, PNG and BMP formats up to 5MB', () => {
|
||||
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
|
||||
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
|
||||
assert.equal(validateQualificationFile({ name: '货代资质.pdf', size: 2048, type: 'application/pdf' }), '')
|
||||
assert.equal(validateQualificationFile({ name: '法人身份证.bmp', size: 2048, type: 'image/bmp' }), '')
|
||||
assert.equal(validateQualificationFile({ name: '营业执照.bmp', size: 2048, type: 'image/x-ms-bmp' }), '')
|
||||
})
|
||||
|
||||
test('rejects unsupported, empty, mismatched, and oversized files', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'pdf'])
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'application/pdf'])
|
||||
const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'bmp', 'pdf'])
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'image/bmp', 'image/x-ms-bmp', 'application/pdf'])
|
||||
|
||||
export interface QualificationFileLike {
|
||||
name: string
|
||||
@@ -11,7 +11,7 @@ export interface QualificationFileLike {
|
||||
|
||||
export function validateQualificationFile(file: QualificationFileLike) {
|
||||
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
|
||||
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 PDF、JPG 或 PNG 文件'
|
||||
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 PDF、JPG、PNG 或 BMP 文件'
|
||||
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
|
||||
if (file.size <= 0) return '文件内容为空,请重新选择'
|
||||
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
|
||||
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
|
||||
const store = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
|
||||
const auditStatusCopy = computed(() => ({
|
||||
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照并提交审核后,才能提交头程订单;法人身份证可按需补充。' },
|
||||
@@ -41,6 +43,7 @@ const qualificationPage = ref(1)
|
||||
const qualificationPageSize = ref(10)
|
||||
const qualificationSubmitting = ref(false)
|
||||
const reviewSubmitting = ref(false)
|
||||
const deletingQualificationId = ref('')
|
||||
const profileSubmitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
@@ -60,7 +63,7 @@ type QualificationDraftItem = QualificationTypeDefinition & {
|
||||
|
||||
const qualificationTypes: QualificationTypeDefinition[] = [
|
||||
{ value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。', required: true },
|
||||
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '选填;如上传,正反面请合并为一个 PDF、JPG 或 PNG 文件。', required: false },
|
||||
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '选填;如上传,正反面请合并为一个 PDF、JPG、PNG 或 BMP 文件。', required: false },
|
||||
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '如有,可上传当前有效的货代资质证明。', required: false },
|
||||
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false },
|
||||
]
|
||||
@@ -154,7 +157,7 @@ const profileFormRules: FormRules<TenantCompanyProfile> = {
|
||||
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
|
||||
}
|
||||
const qualificationColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'type', prop: 'type', label: '文件类型', minWidth: 150 },
|
||||
{ key: 'fileName', prop: 'fileName', label: '文件名称', minWidth: 220, showOverflowTooltip: true },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
@@ -184,7 +187,13 @@ async function loadCompanyContext() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => void loadCompanyContext())
|
||||
onMounted(async () => {
|
||||
await loadCompanyContext()
|
||||
if (route.query.qualification === 'upload' && canUploadQualification.value) {
|
||||
activeTab.value = 'qualification'
|
||||
openQualificationDialog()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview))
|
||||
|
||||
function startEdit() {
|
||||
@@ -299,7 +308,7 @@ function handleQualificationFileChange(type: MerchantQualificationType, file: Up
|
||||
item.fileName = raw.name
|
||||
item.fileSize = raw.size
|
||||
item.fileType = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
|
||||
if (raw.type.startsWith('image/') || /\.(jpe?g|png)$/i.test(raw.name)) item.previewUrl = URL.createObjectURL(raw)
|
||||
if (raw.type.startsWith('image/') || /\.(jpe?g|png|bmp)$/i.test(raw.name)) item.previewUrl = URL.createObjectURL(raw)
|
||||
}
|
||||
|
||||
function removeSelectedFile(type: MerchantQualificationType) {
|
||||
@@ -329,19 +338,19 @@ function resetQualificationDraft() {
|
||||
qualificationCertificateNo.value = store.company.creditCode
|
||||
}
|
||||
|
||||
async function submitQualification() {
|
||||
if (qualificationSubmitting.value) return
|
||||
async function submitQualification(closeDialog = true) {
|
||||
if (qualificationSubmitting.value) return false
|
||||
if (!canUploadQualification.value) {
|
||||
qualificationOpen.value = false
|
||||
await loadCompanyContext()
|
||||
ElMessage.warning(qualificationUploadDisabledReason.value)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!requiredPackageComplete.value) {
|
||||
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
|
||||
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
|
||||
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
const optionalItems = qualificationDrafts.filter((item) => !item.required
|
||||
&& item.file
|
||||
@@ -418,20 +427,32 @@ async function submitQualification() {
|
||||
if (!canUploadQualification.value) qualificationOpen.value = false
|
||||
const uploadedCopy = uploadedCount ? `已保存 ${uploadedCount} 项;` : ''
|
||||
ElMessage.error(`${uploadedCopy}${failures[0]}`)
|
||||
return
|
||||
return false
|
||||
}
|
||||
qualificationOpen.value = false
|
||||
if (closeDialog) qualificationOpen.value = false
|
||||
if (refreshed) ElMessage.success(includedLegalPersonId
|
||||
? (requiredPackageChangeSelected.value
|
||||
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
|
||||
: '法人身份证已保存,尚未提交审核')
|
||||
: '资质文件已保存,尚未提交审核')
|
||||
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
|
||||
return refreshed
|
||||
} finally {
|
||||
qualificationSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQualificationAndReview() {
|
||||
if (qualificationSubmitting.value || reviewSubmitting.value) return
|
||||
if (selectedQualificationCount.value > 0 && !await submitQualification(false)) return
|
||||
if (!canSubmitQualificationReview.value) {
|
||||
ElMessage.warning(qualificationReviewDisabledReason.value)
|
||||
return
|
||||
}
|
||||
await submitQualificationReview()
|
||||
if (store.company.auditStatus === 'PENDING') qualificationOpen.value = false
|
||||
}
|
||||
|
||||
async function submitQualificationReview() {
|
||||
if (!canSubmitQualificationReview.value || reviewSubmitting.value) return
|
||||
reviewSubmitting.value = true
|
||||
@@ -450,6 +471,34 @@ async function submitQualificationReview() {
|
||||
}
|
||||
}
|
||||
|
||||
function qualificationDeleteDisabled(record: TenantQualification) {
|
||||
return deletingQualificationId.value === record.id
|
||||
|| !auth.hasPermission('company:qualification:upload')
|
||||
|| !canUploadQualification.value
|
||||
}
|
||||
|
||||
async function deleteQualification(record: TenantQualification) {
|
||||
if (qualificationDeleteDisabled(record)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除资质文件“${record.fileName}”?删除后如需送审必须重新上传。`,
|
||||
'删除资质文件',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
deletingQualificationId.value = record.id
|
||||
try {
|
||||
await store.deleteQualification(record.id)
|
||||
ElMessage.success('资质文件已删除')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '资质文件删除失败')
|
||||
} finally {
|
||||
deletingQualificationId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function previewQualification(record: TenantQualification) {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewRecord.value = record
|
||||
@@ -548,7 +597,10 @@ function closeQualificationPreview() {
|
||||
:total="store.qualifications.length"
|
||||
row-key="id"
|
||||
>
|
||||
<template #actions="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button>
|
||||
<el-button link type="danger" :loading="deletingQualificationId === (row as TenantQualification).id" :disabled="qualificationDeleteDisabled(row as TenantQualification)" @click="deleteQualification(row as TenantQualification)">删除</el-button>
|
||||
</template>
|
||||
<template #status="{ row }"><el-tag :type="qualificationStatusMeta(row.status).type" effect="light">{{ qualificationStatusMeta(row.status).label }}</el-tag></template>
|
||||
</DataTable>
|
||||
<div class="mobile-qualification-list">
|
||||
@@ -556,7 +608,10 @@ function closeQualificationPreview() {
|
||||
<header><strong>{{ record.type }}</strong><el-tag :type="qualificationStatusMeta(record.status).type" effect="light">{{ qualificationStatusMeta(record.status).label }}</el-tag></header>
|
||||
<div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div>
|
||||
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
|
||||
<div class="mobile-qualification-actions">
|
||||
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
|
||||
<el-button link type="danger" :loading="deletingQualificationId === record.id" :disabled="qualificationDeleteDisabled(record)" @click="deleteQualification(record)">删除</el-button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div>
|
||||
</div>
|
||||
@@ -612,11 +667,11 @@ function closeQualificationPreview() {
|
||||
:key="`${item.value}-${item.inputVersion}`"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.bmp,application/pdf,image/jpeg,image/png,image/bmp,image/x-ms-bmp"
|
||||
@change="handleQualificationFileChange(item.value, $event)"
|
||||
>
|
||||
<el-button :disabled="qualificationSubmitting"><Upload :size="16" />{{ qualificationRecord(item.value) ? '替换文件' : '选择文件' }}</el-button>
|
||||
<template #tip><div class="upload-tip">支持 PDF、JPG、PNG,单个文件不超过 5MB。</div></template>
|
||||
<template #tip><div class="upload-tip">支持 PDF、JPG、PNG、BMP,单个文件不超过 5MB。</div></template>
|
||||
</el-upload>
|
||||
<div v-if="item.fileName" class="selected-qualification-file" aria-live="polite">
|
||||
<a v-if="item.previewUrl" class="selected-file-preview" :href="item.previewUrl" target="_blank" rel="noopener" :aria-label="`查看${item.label}图片预览`">
|
||||
@@ -634,7 +689,7 @@ function closeQualificationPreview() {
|
||||
<small class="qualification-credit-code-hint">{{ companyCreditCodeValid ? '已登记的统一社会信用代码不可直接修改。' : '与营业执照一致,保存后不可直接修改。' }}</small>
|
||||
</el-form-item>
|
||||
<p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</p>
|
||||
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch" @click="submitQualification">保存全部资质</el-button></template>
|
||||
<template #footer><el-button :disabled="qualificationSubmitting || reviewSubmitting" @click="qualificationOpen = false">取消</el-button><el-button :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch || reviewSubmitting" @click="submitQualification()">保存草稿</el-button><el-button type="primary" :loading="qualificationSubmitting || reviewSubmitting" :disabled="selectedQualificationCount > 0 ? !canSaveQualificationBatch : !canSubmitQualificationReview" @click="submitQualificationAndReview">提交审核</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
|
||||
@@ -692,6 +747,7 @@ function closeQualificationPreview() {
|
||||
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.form-grid-two .full-row { grid-column: 1 / -1; }
|
||||
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.mobile-qualification-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.qualification-dialog-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; padding: 13px 15px; background: #f4f7f7; }
|
||||
.qualification-dialog-intro strong { color: var(--navy); font-size: 13px; }
|
||||
.qualification-dialog-intro span { max-width: 420px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: right; }
|
||||
|
||||
@@ -8,6 +8,7 @@ const membersSource = fs.readFileSync(new URL('./MembersView.vue', import.meta.u
|
||||
const rolesSource = fs.readFileSync(new URL('./RolesView.vue', import.meta.url), 'utf8')
|
||||
const permissionsSource = fs.readFileSync(new URL('./PermissionsView.vue', import.meta.url), 'utf8')
|
||||
const storeSource = fs.readFileSync(new URL('../store/tenantAdministration.ts', import.meta.url), 'utf8')
|
||||
const apiSource = fs.readFileSync(new URL('../api/merchantIam.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('routes tenant administration modules to implemented views instead of the placeholder', () => {
|
||||
assert.match(routerSource, /path: 'company\/profile'[\s\S]*?CompanyProfileView\.vue/)
|
||||
@@ -23,7 +24,7 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /联系信息/)
|
||||
assert.match(profileSource, /资质文件/)
|
||||
assert.match(profileSource, /async function saveProfile\(\)/)
|
||||
assert.match(profileSource, /function submitQualification\(\)/)
|
||||
assert.match(profileSource, /function submitQualification\(closeDialog = true\)/)
|
||||
assert.match(profileSource, /validateQualificationFile/)
|
||||
assert.match(profileSource, /function removeSelectedFile\(type: MerchantQualificationType\)/)
|
||||
assert.match(profileSource, /qualificationSubmitting/)
|
||||
@@ -32,7 +33,9 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /:key="`\$\{item\.value\}-\$\{item\.inputVersion\}`"/)
|
||||
assert.match(profileSource, /class="qualification-upload-grid"/)
|
||||
assert.match(profileSource, /v-for="item in qualificationDrafts"/)
|
||||
assert.match(profileSource, /保存全部资质/)
|
||||
assert.match(profileSource, /保存草稿/)
|
||||
assert.match(profileSource, /提交审核/)
|
||||
assert.match(profileSource, /async function submitQualificationAndReview\(\)/)
|
||||
assert.match(profileSource, /uploadMerchantRequiredQualificationPackage/)
|
||||
assert.match(profileSource, /const requiredPackageFilesSelected = computed/)
|
||||
assert.match(profileSource, /item\.uploadState = 'uploading'/)
|
||||
@@ -69,6 +72,8 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /const requiredQualificationTypes = qualificationTypes\.filter\(\(type\) => type\.required\)/)
|
||||
assert.match(profileSource, /货代资质证书'.*required: false/)
|
||||
assert.match(profileSource, /法人身份证'.*required: false/)
|
||||
assert.match(profileSource, /accept="\.pdf,\.jpg,\.jpeg,\.png,\.bmp,application\/pdf,image\/jpeg,image\/png,image\/bmp,image\/x-ms-bmp"/)
|
||||
assert.match(profileSource, /支持 PDF、JPG、PNG、BMP/)
|
||||
assert.match(profileSource, /银行开户许可证'.*required: false/)
|
||||
assert.match(profileSource, /requiredQualificationTypes\.every/)
|
||||
assert.doesNotMatch(profileSource, /四类必填资质/)
|
||||
@@ -79,6 +84,17 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /法人身份证已保存,尚未提交审核/)
|
||||
assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
|
||||
assert.match(profileSource, /submitTenantQualificationReview/)
|
||||
assert.match(apiSource, /function deleteTenantQualification\(qualificationId: number \| string\)/)
|
||||
assert.match(apiSource, /method: 'DELETE'/)
|
||||
assert.match(apiSource, /`\/merchant\/v1\/iam\/tenant\/qualifications\/\$\{qualificationId\}`/)
|
||||
assert.match(profileSource, /function qualificationDeleteDisabled\(record: TenantQualification\)/)
|
||||
assert.match(profileSource, /!canUploadQualification\.value/)
|
||||
assert.match(profileSource, /store\.deleteQualification\(record\.id\)/)
|
||||
assert.match(profileSource, /资质文件已删除/)
|
||||
assert.match(profileSource, /:disabled="qualificationDeleteDisabled\(row as TenantQualification\)"/)
|
||||
assert.match(profileSource, /:disabled="qualificationDeleteDisabled\(record\)"/)
|
||||
assert.match(storeSource, /originalFileName\?\.trim\(\) \|\| '—'/)
|
||||
assert.match(storeSource, /formatQualificationUpdatedAt\(item\.updatedAt\)/)
|
||||
assert.match(profileSource, /提交企业审核/)
|
||||
assert.match(profileSource, /资质文件已保存,尚未提交审核/)
|
||||
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
|
||||
@@ -105,6 +121,8 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /pattern: \/\^\[0-9-\]\{4,32\}\$\//)
|
||||
assert.doesNotMatch(profileSource, /<dt>联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/)
|
||||
assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/)
|
||||
assert.match(storeSource, /fileName: item\.originalFileName\?\.trim\(\) \|\| '—'/)
|
||||
assert.match(storeSource, /updatedAt: formatQualificationUpdatedAt\(item\.updatedAt\)/)
|
||||
})
|
||||
|
||||
test('implements member list, role assignment, password reset and status control', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getFirstAccessiblePath,
|
||||
getNavigationLeaves,
|
||||
getNavigationParentKeys,
|
||||
getRouteBreadcrumbs,
|
||||
openNavigationParentKeys,
|
||||
} from './clientNavigation.ts'
|
||||
|
||||
@@ -207,6 +208,31 @@ test('returns navigation parent keys from outermost to innermost', () => {
|
||||
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/not-found'), [])
|
||||
})
|
||||
|
||||
test('builds complete breadcrumbs without repeating the current navigation page', () => {
|
||||
assert.deepEqual(getRouteBreadcrumbs('/', '业务总览'), [{ title: '首页' }])
|
||||
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg', '订单列表'), [
|
||||
{ title: '首页', path: '/' },
|
||||
{ title: '头程物流' },
|
||||
{ title: '订单列表' },
|
||||
])
|
||||
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg/create', '创建订单'), [
|
||||
{ title: '首页', path: '/' },
|
||||
{ title: '头程物流' },
|
||||
{ title: '订单列表', path: '/orders/first-leg' },
|
||||
{ title: '创建订单' },
|
||||
])
|
||||
assert.deepEqual(getRouteBreadcrumbs('/notifications/NOTICE-001', '消息详情'), [
|
||||
{ title: '首页', path: '/' },
|
||||
{ title: '消息中心' },
|
||||
{ title: '我的消息', path: '/notifications' },
|
||||
{ title: '消息详情' },
|
||||
])
|
||||
assert.deepEqual(getRouteBreadcrumbs('/forbidden', '无权访问'), [
|
||||
{ title: '首页', path: '/' },
|
||||
{ title: '无权访问' },
|
||||
])
|
||||
})
|
||||
|
||||
test('opens the company parent group for a company route', () => {
|
||||
const openedKeys = []
|
||||
|
||||
|
||||
@@ -62,6 +62,41 @@ export function getFirstAccessiblePath(permissions: readonly string[]) {
|
||||
return getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions))[0]?.path ?? '/forbidden'
|
||||
}
|
||||
|
||||
export interface ClientBreadcrumbItem {
|
||||
title: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
function findNavigationTrail(
|
||||
nodes: readonly ClientNavigationNode[],
|
||||
path: string,
|
||||
parents: readonly ClientBreadcrumbItem[] = [],
|
||||
): ClientBreadcrumbItem[] | undefined {
|
||||
for (const node of nodes) {
|
||||
if (isNavigationGroup(node)) {
|
||||
const result = findNavigationTrail(node.children, path, [...parents, { title: node.title }])
|
||||
if (result) return result
|
||||
continue
|
||||
}
|
||||
if (node.path === path) return [...parents, { title: node.title, path: node.path }]
|
||||
}
|
||||
}
|
||||
|
||||
export function getRouteBreadcrumbs(path: string, currentTitle: string): ClientBreadcrumbItem[] {
|
||||
const home: ClientBreadcrumbItem = { title: '首页', path: '/' }
|
||||
if (path === '/') return [{ title: '首页' }]
|
||||
|
||||
const leaves = getNavigationLeaves(clientNavigation)
|
||||
.filter((leaf) => leaf.path !== '/' && (path === leaf.path || path.startsWith(`${leaf.path}/`)))
|
||||
.sort((left, right) => right.path.length - left.path.length)
|
||||
const parentLeaf = leaves[0]
|
||||
const trail = parentLeaf ? findNavigationTrail(clientNavigation, parentLeaf.path) ?? [] : []
|
||||
const items = [home, ...trail]
|
||||
if (items.at(-1)?.title !== currentTitle) items.push({ title: currentTitle })
|
||||
if (items.length > 1) delete items.at(-1)!.path
|
||||
return items
|
||||
}
|
||||
|
||||
function findNavigationParentKeys(
|
||||
nodes: readonly ClientNavigationNode[],
|
||||
path: string,
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
.auth-form-options button:focus-visible { outline: 2px solid #155eaa; outline-offset: 3px; }
|
||||
.auth-submit { width: 100%; min-height: 46px; }
|
||||
.auth-submit span { display: flex; align-items: center; gap: 7px; }
|
||||
.login-register-entry { margin: 16px 0 0; color: #667085; font-size: 13px; text-align: center; }
|
||||
.login-register-entry a { margin-left: 5px; color: #155eaa; font-weight: 650; text-decoration: none; }
|
||||
.login-register-entry a:hover { text-decoration: underline; }
|
||||
.login-register-entry a:focus-visible { border-radius: 3px; outline: 2px solid #155eaa; outline-offset: 3px; }
|
||||
|
||||
.register-form { margin-top: 4px; }
|
||||
.register-section { padding: 22px 0 2px; border-top: 1px solid #e5eaec; }
|
||||
|
||||
@@ -61,3 +61,8 @@
|
||||
.client-sidebar-logout:hover { background: rgba(239, 68, 68, .22); color: #fff; }
|
||||
.client-sidebar-logout svg { flex: 0 0 18px; }
|
||||
.client-main { width: calc(100% - 250px); max-width: 100%; min-height: 100vh; margin-left: 250px; padding: 134px 20px 40px; }
|
||||
.client-breadcrumb { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0 0 14px; color: #98a2b3; font-size: 12px; line-height: 1.5; }
|
||||
.client-breadcrumb a { color: #667085; text-decoration: none; }
|
||||
.client-breadcrumb a:hover { color: #1565c0; }
|
||||
.client-breadcrumb a:focus-visible { border-radius: 3px; outline: 2px solid #6db9ff; outline-offset: 2px; }
|
||||
.client-breadcrumb strong { min-width: 0; overflow: hidden; color: #344054; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
Reference in New Issue
Block a user