Merge pull request 'Release/test' (#3) from release/test into main

Reviewed-on: https://git.buggoglobal.com/html/admin-web/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-09-18 18:03:16 +08:00
71 changed files with 1220 additions and 323 deletions
+1 -1
View File
@@ -55,7 +55,7 @@
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
Vendored
+65
View File
@@ -1,3 +1,37 @@
def sendProductionWeComNotification(String status, String color, String detail) {
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '—'
String content = """## 生产部署通知
> 项目:${env.JOB_NAME}
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
> 提交:${sourceVersion}
> 状态:<font color="${color}">${status}</font>
> 详情:${detail}
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
"""
String payloadFile = 'wecom-production-notification.json'
writeFile(
file: payloadFile,
text: groovy.json.JsonOutput.toJson([
msgtype: 'markdown',
markdown: [content: content]
]),
encoding: 'UTF-8'
)
sh '''
set -eu
payload_file='wecom-production-notification.json'
trap 'rm -f "$payload_file"' EXIT HUP INT TERM
response="$(curl --fail --silent --show-error \
-H 'Content-Type: application/json' \
--data-binary @"$payload_file" \
"$WECOM_WEBHOOK")"
printf '%s\n' "$response"
printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])'
'''
}
}
pipeline {
agent {
label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}"
@@ -161,6 +195,17 @@ pipeline {
}
post {
success {
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
sendProductionWeComNotification('部署成功', 'info', 'Admin 生产前端已完成发布和部署检查')
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
failure {
sh '''
if [ "${IS_PRODUCTION:-false}" = "true" ]; then
@@ -171,6 +216,26 @@ pipeline {
docker compose -f docker-compose.test.yml logs --tail=100 || true
fi
'''
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
sendProductionWeComNotification('部署失败', 'warning', '请打开 Jenkins 构建日志查看失败阶段')
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
aborted {
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
sendProductionWeComNotification('部署已取消', 'comment', '生产部署流程被人工取消或中止')
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
}
}
+4 -2
View File
@@ -11,9 +11,11 @@ services:
restart: unless-stopped
ports:
- "${HOST_PORT:-9001}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
# Runtime URL used by the secure customer-workbench handoff. Each
# environment may override it without rebuilding the Admin image.
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"
+5 -2
View File
@@ -39,8 +39,11 @@ describe('admin nginx deployment config', () => {
})
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"')
expect(testCompose).toContain('- "host.docker.internal:host-gateway"')
expect(testCompose).toContain('API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"')
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"')
expect(testCompose).not.toContain('http://test-api-cn.buggoglobal.com')
expect(testCompose).not.toContain('http://test-api-global.buggoglobal.com')
expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"')
expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.')
expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
@@ -21,7 +21,7 @@ const container = {
} satisfies ContainerBookingResource
const candidate = {
oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68,
oceanBookingId: '101', bookingNo: 'OB-101', billOfLadingNo: 'BL-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68,
maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1,
customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班',
}
@@ -36,7 +36,7 @@ describe('OceanBookingLinkDialog', () => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HQ × 1')
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
wrapper.unmount()
})
@@ -51,7 +51,7 @@ async function loadCandidates() {
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
label: `${candidate.bookingNo} · 提单号 ${candidate.billOfLadingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
}))
} catch (cause) {
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
@@ -374,7 +374,7 @@ describe('ContainersView', () => {
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(true)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
@@ -453,14 +453,16 @@ describe('ContainersView', () => {
it('renders container business actions as buttons without changing the transport gate', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')")
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500")
expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')")
expect(source).toContain('<RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)"')
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('货柜基础资料不提供柜号写入')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
expect(source).not.toContain('containerNo: optional(form.containerNo)')
expect(source).not.toContain('updateContainerTransport')
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
@@ -655,7 +657,7 @@ describe('ContainersView', () => {
it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => {
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', requirementId: '45315',
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', billOfLadingNo: 'BL-OPTIONAL-CUSTOMER', requirementId: '45315',
containerType: '40HQ', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡',
}], page: 1, size: 200, total: 1, pages: 1 } as never)
const router = createTestRouter()
@@ -667,7 +669,7 @@ describe('ContainersView', () => {
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
const label = `OB-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
const label = `OB-OPTIONAL-CUSTOMER · 提单号 BL-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
expect(document.body.textContent).toContain(label)
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label)
wrapper.unmount()
@@ -698,9 +700,9 @@ describe('ContainersView', () => {
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', billOfLadingNo: 'BL-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HQ × 1')
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 提单号 BL-CAPACITY-SNAPSHOT · 45HQ × 1')
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
wrapper.unmount()
})
@@ -729,9 +731,9 @@ describe('ContainersView', () => {
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', billOfLadingNo: 'BL-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', billOfLadingNo: 'BL-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
@@ -3,7 +3,6 @@ import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { useRouter } from 'vue-router'
import { MoreHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -17,6 +16,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -638,6 +638,30 @@ function handleMoreAction(command: string, source: object) {
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>); return }
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
}
function containerRowActions(row: ContainerWorkbenchRow): RowActionItem[] {
const processLabel = row.planStatus === 'EDITING' ? '装柜' : row.planStatus === 'LOADING' ? '预装清单' : row.planStatus === 'COMPLETED' ? '装柜清单' : ''
const canOpenProcess = row.planStatus === 'EDITING' ? canSmartLoading.value : row.planStatus === 'LOADING' ? canViewPreload.value : row.planStatus === 'COMPLETED' ? canViewLoadingList.value : false
return [
{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500, visible: canView.value },
{ key: 'edit', label: '编辑', priority: 450, visible: isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') },
{ key: 'process', label: processLabel, priority: 400, tone: 'primary', visible: isDomestic.value && Boolean(processLabel) && canOpenProcess },
{ key: 'empty-return', label: '确认还柜', priority: 350, tone: 'primary', visible: canDrayage(row, 'RECORD_EMPTY_RETURN') },
{ key: 'send-email', label: '发送邮件', priority: 300, tone: 'primary', visible: canDrayage(row, 'SEND_PICKUP_EMAIL'), loading: drayageSavingIds.value.includes(row.id) },
{ key: 'overview', label: '轨迹与日志', priority: 200, visible: canView.value },
{ key: 'email-history', label: '邮件记录', priority: 150, visible: canView.value && isOverseas.value },
{ key: 'drayage-history', label: '码头拖车记录', priority: 140, visible: canView.value && isOverseas.value },
{ key: 'documents', label: '清关 / DO / PU', priority: 130, visible: canView.value && isOverseas.value && canOverseasDocumentEdit.value && hasAction(row, 'MANAGE_DESTINATION_DOCUMENTS') },
{ key: 'link', label: '关联已放舱订舱', priority: 120, visible: canView.value && isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId },
]
}
function handleContainerRowAction(command: string, row: ContainerWorkbenchRow) {
if (command === 'view') openDetail(row)
else if (command === 'edit') void openEdit(row)
else if (command === 'process') goContainerProcess(row)
else if (command === 'empty-return') openDrayageAction(row, 'RECORD_EMPTY_RETURN')
else if (command === 'send-email') void sendPickupEmail(row)
else handleMoreAction(command, row)
}
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
@@ -721,7 +745,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)">查看</el-button><template v-if="isDomestic"><el-button class="density-action" v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)">编辑</el-button><el-button data-tone="primary" class="density-action" v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)">装柜</el-button><el-button class="density-action" v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)">预装清单</el-button><el-button class="density-action" v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)">装柜清单</el-button></template><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)">发送邮件</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button class="density-action" size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview">轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="email-history">邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history">码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents">清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link">关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
@@ -194,9 +194,13 @@ describe('CustomersView', () => {
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
})
it('reserves enough space for qualification validity dates without widening the upload column', () => {
it('keeps qualification file controls usable when the editor dialog is narrow', () => {
expect(customersSource).toContain('class="qualification-validity" role="cell"')
expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px')
expect(customersSource).toContain('.qualification-table { container-type:inline-size;')
expect(customersSource).toContain('@container (max-width:660px)')
expect(customersSource).toContain('.qualification-row { grid-template-columns:112px minmax(0,1fr); }')
expect(customersSource).toContain('.qualification-label { grid-row:span 2; align-items:center; }')
expect(customersSource).toContain('.qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; }')
expect(customersSource).toContain('.qualification-file-name { min-width:0;')
expect(readFileSync(resolve(process.cwd(), 'src/modules/customers/customerDensity.css'), 'utf8')).toContain('max-width: none; overflow-wrap: anywhere; white-space: normal;')
@@ -313,8 +317,41 @@ describe('CustomersView', () => {
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' }))
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(document.body.textContent).toContain('客户资料已保存')
expect(document.body.textContent).not.toContain('尚未提交审核')
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
expect(customersSource).not.toContain('联系电话(登录用户名)')
expect(customersSource.indexOf('if (!failures.length) editorOpen.value = false')).toBeLessThan(customersSource.indexOf('await loadCustomers()'))
})
it('allows an active customer to replace qualifications and select a validity date', async () => {
const activeTenant = { ...fullTenant, status: 'ACTIVE' as const }
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([
{
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null,
},
{
id: 'qualification-freight', tenantId: fullTenant.id, qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE',
certificateNo: null, documentUrl: '/files/freight.png', status: 'APPROVED', reviewComment: null, validTo: null,
},
])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const fileInputs = [...document.querySelectorAll<HTMLInputElement>('.qualification-file input[type="file"]')]
const validityInputs = [...document.querySelectorAll<HTMLInputElement>('.qualification-validity input')]
expect(fileInputs).toHaveLength(4)
expect(fileInputs.every((input) => !input.disabled)).toBe(true)
expect(validityInputs.map((input) => input.disabled)).toEqual([true, false, true, false])
selectQualificationFile(0, new File(['license-new'], 'license-new.png', { type: 'image/png' }))
await flushPromises()
expect(validityInputs[0]?.disabled).toBe(false)
})
it('does not count rejected or superseded history as a current qualification when resubmitting', async () => {
@@ -785,7 +822,7 @@ describe('CustomersView', () => {
await flushPromises()
expect(document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]').length).toBe(3)
expect(document.querySelectorAll('.el-table__body .table-actions > .el-button').length).toBe(9)
expect(document.querySelectorAll('.el-table__body .row-actions > .el-button').length).toBe(9)
})
it('keeps the action row usable when all customer identifiers are missing', async () => {
+41 -31
View File
@@ -3,9 +3,10 @@ import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, MoreHorizontal, ShieldCheck, ShieldX } from '@lucide/vue'
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { createPlatformExportTask } from '@/modules/downloads'
import {
approveEnterpriseReview,
@@ -228,7 +229,8 @@ let reviewLoadSequence = 0
let editorLoadSequence = 0
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
const canUploadQualifications = computed(() => !editorStatus.value
|| ['PROFILE_REQUIRED', 'REJECTED', 'ACTIVE', 'DISABLED'].includes(editorStatus.value))
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
@@ -603,19 +605,22 @@ async function saveCustomer() {
editorStatus.value = saved.status
savedFormSnapshot.value = JSON.stringify(form.value)
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
const canSubmitSavedCustomerForReview = ['PROFILE_REQUIRED', 'REJECTED'].includes(saved.status)
if (failures.length) {
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
} else if (hadBusinessLicense && hadLegalPersonId) {
} else if (canSubmitSavedCustomerForReview && hadBusinessLicense && hadLegalPersonId) {
ElMessage.success('营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核')
} else if (hadBusinessLicense) {
} else if (canSubmitSavedCustomerForReview && hadBusinessLicense) {
ElMessage.success('营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核')
} else if (hadSelectedQualifications) {
} else if (canSubmitSavedCustomerForReview && hadSelectedQualifications) {
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
} else if (hadSelectedQualifications) {
ElMessage.success('客户资料和已选资质已保存')
} else {
ElMessage.success('客户资料已保存')
}
await loadCustomers()
if (!failures.length) editorOpen.value = false
await loadCustomers()
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
finally { saving.value = false }
}
@@ -674,17 +679,6 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio
...(canArchive(row) ? ['delete' as const] : []),
]
}
function customerPrimaryActionCount(row: PlatformTenantResource) {
return Number(access.can('platform:customer:list'))
+ Number(access.can('platform:customer:edit') && row.status !== 'ARCHIVED')
+ Number(hasReviewAction(row))
}
function customerDirectSecondaryActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerOverflowActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerMoreActionsLabel(row: PlatformTenantResource) {
const identity = row.tenantName || row.tenantCode || String(row.id || '当前客户')
return `更多客户操作:${identity}`
@@ -706,6 +700,27 @@ function customerActionDisabled(action: CustomerRowAction) {
if (action === 'delete') return archiveSubmitting.value
return false
}
function customerRowActions(row: PlatformTenantResource): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 500, visible: access.can('platform:customer:list') },
{ key: 'edit', label: '编辑', priority: 400, visible: access.can('platform:customer:edit') && row.status !== 'ARCHIVED' },
{ key: 'review', label: '审核', priority: 300, tone: 'warning', visible: hasReviewAction(row), disabled: reviewSubmitting.value },
...customerSecondaryActions(row).map((action, index): RowActionItem => ({
key: action,
label: customerActionLabel(row, action),
priority: 200 - index,
tone: customerActionType(row, action),
disabled: customerActionDisabled(action),
divided: action === 'delete',
})),
]
}
function handleCustomerRowAction(row: PlatformTenantResource, command: string) {
if (command === 'detail') showDetail(row)
else if (command === 'edit') openEdit(row)
else if (command === 'review') openDecision(row, access.can('platform:customer:approve') ? 'approve' : 'reject')
else handleMoreAction(row, command)
}
function handleMoreAction(row: PlatformTenantResource, command: string) {
if (command === 'reset-password') openResetPassword(row)
if (command === 'impersonate') openImpersonation(row)
@@ -1055,18 +1070,7 @@ onMounted(() => { void loadCustomers() })
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
<template #actions="{ row }">
<div class="table-actions density-actions">
<el-button v-if="access.can('platform:customer:list')" class="density-action customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)">详情</el-button>
<el-button v-if="access.can('platform:customer:edit') && (row as PlatformTenantResource).status !== 'ARCHIVED'" class="density-action customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)">编辑</el-button>
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="density-action" data-tone="warning" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')">审核</el-button>
<el-button v-for="action in customerDirectSecondaryActions(row as PlatformTenantResource)" :key="action" class="density-action" :data-tone="action === 'reset-password' ? 'purple' : action === 'impersonate' ? 'primary' : action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE' ? 'success' : 'danger'" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)">{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
<el-dropdown v-if="customerOverflowActions(row as PlatformTenantResource).length" trigger="click" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, $event as string)">
<el-button class="density-action customer-action-more" size="small" plain :aria-label="customerMoreActionsLabel(row as PlatformTenantResource)" :title="customerMoreActionsLabel(row as PlatformTenantResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in customerOverflowActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :class="{ 'customer-action-menu__danger': customerActionType(row as PlatformTenantResource, action) === 'danger' }">{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
<template #actions="{ row }"><RowActions :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
@@ -1112,7 +1116,7 @@ onMounted(() => { void loadCustomers() })
</button>
<div class="qualification-file-control"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name" :title="draft.file?.name || draft.uploadedName">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
</div>
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></div>
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploading || (draft.uploaded && !draft.file)" /></div>
</div>
</div>
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
@@ -1222,7 +1226,7 @@ onMounted(() => { void loadCustomers() })
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
.qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-surface, #fff); color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
@@ -1230,6 +1234,12 @@ onMounted(() => { void loadCustomers() })
.qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); }
.qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
.qualification-preview-trigger:disabled { cursor:wait; opacity:.72; }
@container (max-width:660px) {
.qualification-row { grid-template-columns:112px minmax(0,1fr); }
.qualification-header { display:none; }
.qualification-label { grid-row:span 2; align-items:center; }
.qualification-file,.qualification-validity { min-height:44px; }
}
.qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:var(--admin-surface, #fff); }
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
@@ -107,15 +107,13 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps high-frequency account actions visible and groups the remaining actions', () => {
expect(page).toContain("{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' }")
expect(page).toContain('@click="openAccount(row, false)">详情</el-button>')
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')")
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')")
expect(page).toContain('aria-label="更多客户账户操作"')
expect(page).toContain('<el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item>')
expect(page).toContain('<el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item>')
expect(page).not.toContain('<el-button link type="primary" @click="openAccount(row, false)">')
expect(page).toContain('class="row-actions density-actions"')
expect(page).toContain('white-space: nowrap')
expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(page).toContain("{ key: 'detail', label: '详情', priority: 300 }")
expect(page).toContain("{ key: 'recharge', label: '充值', priority: 250")
expect(page).toContain("{ key: 'deduction', label: '扣款', priority: 200")
expect(page).toContain("{ key: 'credit', label: '授信', priority: 100")
expect(page).toContain("{ key: 'terms', label: '账期', visible: canUpdateTerms.value")
expect(page).toContain('<RowActions :actions="accountActions(row as FinanceAccountSummary)"')
})
it('shows authoritative CNY wallet balances and independently permitted actions', () => {
@@ -134,7 +132,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) {
const trigger = actions.get('button[aria-label="更多客户账户操作"]')
const trigger = actions.get('button[aria-label^="更多客户账户操作"]')
expect(trigger.text()).toBe('')
expect(trigger.find('svg').exists()).toBe(true)
}
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, MinusCircle, MoreHorizontal, PlusCircle, Save } from '@lucide/vue'
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -18,6 +18,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
@@ -270,6 +271,21 @@ function handleAccountMore(command: string, account: FinanceAccountSummary) {
if (command === 'credit') openCreditLimit(account)
if (command === 'terms') void openAccount(account as unknown as Record<string, unknown>, true)
}
function accountActions(account: FinanceAccountSummary): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 300 },
{ key: 'recharge', label: '充值', priority: 250, visible: canAdjust.value },
{ key: 'deduction', label: '扣款', priority: 200, tone: 'danger', visible: canAdjust.value },
{ key: 'credit', label: '授信', priority: 100, visible: canAdjust.value },
{ key: 'terms', label: '账期', visible: canUpdateTerms.value, divided: canAdjust.value },
]
}
function handleAccountAction(command: string, account: FinanceAccountSummary) {
if (command === 'detail') void openAccount(account as unknown as Record<string, unknown>, false)
else if (command === 'recharge') openAdjustment(account, 'RECHARGE')
else if (command === 'deduction') openAdjustment(account, 'DEDUCTION')
else handleAccountMore(command, account)
}
async function submitCreditLimit() {
if (!adjustmentAccount.value) return
const raw = creditLimitForm.amount.trim()
@@ -425,7 +441,7 @@ onBeforeUnmount(clearVoucherPreview)
<section v-if="!stateError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="neutral" class="density-action" size="small" @click="openAccount(row, false)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')">充值</el-button><el-button data-tone="danger" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')">扣款</el-button><el-button class="density-action" data-tone="neutral" v-if="!canAdjust && canUpdateTerms" size="small" @click="handleAccountMore('terms', row as FinanceAccountSummary)">账期</el-button><el-dropdown v-if="canAdjust" trigger="click" @command="handleAccountMore(String($event), row as FinanceAccountSummary)"><el-button data-tone="neutral" class="density-action" size="small" aria-label="更多客户账户操作" title="更多客户账户操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item><el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><RowActions :actions="accountActions(row as FinanceAccountSummary)" :more-label="`更多客户账户操作 · ${(row as FinanceAccountSummary).customerName || '当前客户'}`" @action="handleAccountAction($event, row as FinanceAccountSummary)" /></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
@@ -113,7 +113,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) {
const trigger = actions.get('button[title="更多费用来源操作"]')
const trigger = actions.get('button[title^="更多费用来源操作"]')
expect(trigger.text()).toBe('')
await trigger.trigger('click')
await flushPromises()
@@ -268,18 +268,18 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(page).not.toContain('max-width: 520px')
})
it('renders permitted fee-source row operations as shared outlined text buttons', () => {
it('renders permitted fee-source row operations through the shared row-actions component', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(page).toContain('@click="openDetail(row)">详情</el-button>')
expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"")
expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"")
expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(page).toContain("{ key: 'detail', label: '详情', priority: 300")
expect(page).toContain("{ key: 'submit', label: '提交审核', priority: 200")
expect(page).toContain("{ key: 'adjust', label: '调整', priority: 150")
expect(page).toContain("{ key: 'void', label: '作废', tone: 'danger'")
expect(page).toContain('!isUpstreamSource(row)')
expect(page).toContain('!isUpstreamSource(detail)')
expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)")
expect(page).toContain("=== 'LASTLEG'")
const actions = page.slice(page.indexOf('<template #actions'), page.indexOf('<template #customerName'))
expect(actions).not.toMatch(/<el-button[^>]*\blink\b/)
expect(page).toContain('white-space: nowrap')
expect(page).toContain('<RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)"')
})
it('resolves blank detail customer names and keeps source labels business-facing', async () => {
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { MoreHorizontal, ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -20,6 +20,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
@@ -136,18 +137,6 @@ function closeCustomerSelectOnScroll() {
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()
}
function sourceActionCount(row: Record<string, unknown>) {
if (!row.feeSourceId) return 0
const available = row.status === 'AVAILABLE'
const draft = ['DRAFT', 'REJECTED'].includes(String(row.reviewStatus))
const editable = canEditSource.value && !isUpstreamSource(row)
return Number(canViewSource.value)
+ Number(editable && draft && available)
+ Number(canEditSource.value && draft && available)
+ Number(canReviewSource.value && row.reviewStatus === 'PENDING' && available)
+ Number(editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated)
+ Number(editable && available && !row.statementGenerated)
}
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
@@ -258,14 +247,13 @@ async function loadDetail(row: ArapRow) {
catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试'); return null }
finally { detailLoading.value = false }
}
async function openDetail(row: Record<string, unknown>) {
const target = row as unknown as ArapRow
if (!target.feeSourceId) return
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: target.feeSourceId } })
async function openDetail(row: ArapRow) {
if (!row.feeSourceId) return
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: row.feeSourceId } })
}
async function openReview(row: Record<string, unknown>) {
const target = row as unknown as ArapRow; selected.value = target
const loaded = await loadDetail(target)
async function openReview(row: ArapRow) {
selected.value = row
const loaded = await loadDetail(row)
if (!loaded) { detailOpen.value = true; return }
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
}
@@ -279,18 +267,39 @@ async function submitReview() {
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
finally { reviewSaving.value = false }
}
async function openMutation(row: Record<string, unknown>, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
const target = row as unknown as ArapRow; selected.value = target
if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(target)) {
ElMessage.info(String(target.sourceSystem || '').toUpperCase() === 'LASTLEG'
async function openMutation(row: ArapRow, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
selected.value = row
if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(row)) {
ElMessage.info(String(row.sourceSystem || '').toUpperCase() === 'LASTLEG'
? '请到对应海外业务费用中调整或作废,审核后会通过跨区事件同步到财务'
: '请到订单入库费用或柜次费用明细中调整或作废,审核后会自动同步到财务')
return
}
const loaded = await loadDetail(target)
const loaded = await loadDetail(row)
if (!loaded) { detailOpen.value = true; return }
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
}
function sourceActions(row: ArapRow): RowActionItem[] {
const available = row.status === 'AVAILABLE'
const draft = ['DRAFT', 'REJECTED'].includes(row.reviewStatus)
const editable = canEditSource.value && !isUpstreamSource(row)
return [
{ key: 'detail', label: '详情', priority: 300, visible: canViewSource.value },
{ key: 'edit', label: '编辑', priority: 250, visible: editable && draft && available },
{ key: 'submit', label: '提交审核', priority: 200, tone: 'warning', visible: canEditSource.value && draft && available },
{ key: 'review', label: '审核', priority: 200, tone: 'warning', visible: canReviewSource.value && row.reviewStatus === 'PENDING' && available },
{ key: 'adjust', label: '调整', priority: 150, visible: editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: editable && available && !row.statementGenerated },
]
}
function handleSourceAction(command: string, row: ArapRow) {
if (command === 'detail') void openDetail(row)
else if (command === 'review') void openReview(row)
else if (command === 'edit') void openMutation(row, 'EDIT')
else if (command === 'submit') void openMutation(row, 'SUBMIT')
else if (command === 'adjust') void openMutation(row, 'ADJUST')
else if (command === 'void') void openMutation(row, 'VOID')
}
async function submitMutation() {
if (!detail.value) return
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
@@ -396,7 +405,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><template v-if="row.feeSourceId"><el-button data-tone="neutral" class="density-action" v-if="canViewSource" size="small" @click="openDetail(row)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'EDIT')">编辑</el-button><el-button data-tone="warning" class="density-action" v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'SUBMIT')">提交审核</el-button><el-button data-tone="warning" class="density-action" v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" @click="openReview(row)">审核</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" @click="openMutation(row, 'ADJUST')">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) <= 3" size="small" @click="openMutation(row, 'VOID')">作废</el-button><el-dropdown v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) > 3" trigger="click"><el-button class="density-action" data-tone="neutral" size="small" title="更多费用来源操作" :aria-label="`更多费用来源操作 · ${row.documentNo || '当前费用'}`"><MoreHorizontal :size="15" aria-hidden="true" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item class="danger-menu-item" @click="openMutation(row, 'VOID')">作废</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template><span v-else class="muted-action">手工成本</span></div></template>
<template #actions="{ row }"><RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)" :more-label="`更多费用来源操作 · ${(row as ArapRow).documentNo || '当前费用'}`" @action="handleSourceAction($event, row as ArapRow)" /><span v-else class="muted-action">手工成本</span></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
@@ -407,7 +416,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
<section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
@@ -126,20 +126,14 @@ describe('FinanceCustomerStatementsView', () => {
expect(moduleCss).toContain('.statement-summary-item--remark { grid-column: span 2; }')
})
it('uses entity buttons for row actions and only folds the fourth action into more', () => {
it('uses the shared row-actions component and prioritizes the first three available operations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
const actions = source.slice(source.indexOf('<template #actions="{ row }">'), source.indexOf('<template #status="{ row }">'))
expect(actions).not.toContain(' link')
for (const label of ['详情', '提交', '通过', '驳回', '出账', '调整', '重开']) {
const actionLine = actions.split('\n').find((line) => line.includes(`${label}</el-button>`))
expect(actionLine).toContain('class="density-action"')
expect(actionLine).toContain('size="small"')
}
expect(actions).toContain('data-tone="danger"')
expect(actions).toContain('aria-label="更多对账单操作" title="更多对账单操作"')
expect(actions).toContain("row.status === 'PENDING_REVIEW' && canReview && canVoid")
expect(actions.match(/<el-dropdown-item/g)).toHaveLength(1)
expect(actions).toContain('class="danger-menu-item"')
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'detail', label: '详情', priority: 300")
expect(source).toContain("{ key: 'approve', label: '通过', priority: 250")
expect(source).toContain("{ key: 'reject', label: '驳回', priority: 200")
expect(source).toContain("{ key: 'void', label: '作废', tone: 'danger', divided: true")
expect(source).toContain('<RowActions :actions="statementActions(row as FinanceStatementReadModel)"')
})
it('keeps three available row actions visible with primary and danger semantics', async () => {
@@ -4,7 +4,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, MoreHorizontal, Plus } from '@lucide/vue'
import { ArrowLeft, Download, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -19,6 +19,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
@@ -531,6 +532,29 @@ async function openAdjustment(row: FinanceStatementReadModel) {
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value },
{ key: 'submit', label: '提交', priority: 250, visible: row.status === 'DRAFT' && canCreate.value, loading: actionBusy.value },
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
]
}
function handleStatementAction(command: string, row: FinanceStatementReadModel) {
if (command === 'detail') void openDetail(row)
else if (command === 'adjust') void openAdjustment(row)
else {
const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
}
const action = actions[command]
if (action) void performAction(row, action)
}
}
async function submitAdjustment() {
const statement = adjustStatement.value
@@ -654,21 +678,7 @@ watch(detailRouteId, (statementId) => {
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template>
<template #actions="{ row }"><div class="density-actions">
<el-button data-tone="neutral" class="density-action" size="small" :disabled="loading" @click.stop="openDetail(row as FinanceStatementReadModel)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'DRAFT' && canCreate" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')">提交</el-button>
<el-button data-tone="warning" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')">通过</el-button>
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REJECT')">驳回</el-button>
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && canReview && canVoid" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
<el-button data-tone="neutral" class="density-action" size="small" :loading="actionBusy" aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal aria-hidden="true" :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="VOID" class="danger-menu-item">作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && !canReview && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'APPROVED' && canIssue" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')">出账</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'ISSUED' && canAdjust" size="small" :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)">调整</el-button>
<el-button data-tone="neutral" class="density-action" v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')">重开</el-button>
<el-button data-tone="danger" class="density-action" v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
</div></template>
<template #actions="{ row }"><RowActions :actions="statementActions(row as FinanceStatementReadModel)" :more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`" @action="handleStatementAction($event, row as FinanceStatementReadModel)" /></template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
+7 -10
View File
@@ -50,6 +50,7 @@ function mountServerView(permissionCodes = ['platform:permission:list', 'platfor
plugins: [pinia],
directives: { loading: () => undefined },
stubs: {
RowActions: false,
AdminPage: { template: '<main><slot /></main>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
FormSection: { template: '<section><slot /></section>' },
@@ -85,16 +86,12 @@ describe('PermissionsView IAM mode', () => {
expect(permissionsSource).toContain("(row as Permission).status || '—'")
})
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
expect(permissionsSource).toContain('size="small" data-tone="neutral" @click="openCreate((row as Permission).id)"')
expect(permissionsSource).toContain('size="small" data-tone="neutral" :disabled="rowActionId')
expect(permissionsSource).toContain('size="small" :data-tone="(row as Permission).status')
expect(permissionsSource).toContain('size="small" data-tone="danger"')
expect(permissionsSource).toContain('size="small" :aria-label="moreActionsLabel(row as Permission)"')
expect(permissionsSource).toContain('>添加下级</el-button>')
expect(permissionsSource).toContain('>编辑</el-button>')
expect(permissionsSource).toContain(':data-tone=')
expect(permissionsSource).toContain('>删除</el-button>')
expect(permissionsSource).toContain('<MoreHorizontal aria-hidden="true" :size="16" />')
expect(permissionsSource).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400")
expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true")
expect(permissionsSource).toContain('<RowActions v-if="permissionActions(row as Permission).some')
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
+17 -26
View File
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '../iamDensity.css'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Braces, FolderTree, MoreHorizontal, Plus, ShieldCheck } from '@lucide/vue'
import { Braces, FolderTree, Plus, ShieldCheck } from '@lucide/vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -12,6 +12,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import {
changePlatformPermissionStatus,
createPlatformPermission,
@@ -247,24 +248,26 @@ function canChangeStatusRow(row: Permission) {
function canDeleteRow(row: Permission) {
return canDelete.value && !row.builtIn
}
function permissionActionCount(row: Permission) {
return Number(canAddChild(row))
+ Number(canEditRow(row))
+ Number(canChangeStatusRow(row))
+ Number(canDeleteRow(row))
}
function showDeleteDirect(row: Permission) {
return canDeleteRow(row) && permissionActionCount(row) <= 3
}
function showDeleteMore(row: Permission) {
return canDeleteRow(row) && permissionActionCount(row) > 3
}
function moreActionsLabel(row: Permission) {
const resourceLabel = [row.name]
.find((value) => typeof value === 'string' && value.trim())
?.trim() || '当前权限'
return `${resourceLabel}的更多操作`
}
function permissionActions(row: Permission): RowActionItem[] {
return [
{ key: 'add-child', label: '添加下级', priority: 400, visible: canAddChild(row) },
{ key: 'edit', label: '编辑', priority: 300, visible: canEditRow(row), disabled: rowActionId.value === row.id },
{ key: 'status', label: row.status === '启用' ? '停用' : '启用', priority: 200, visible: canChangeStatusRow(row), tone: row.status === '启用' ? 'danger' : 'success', loading: rowActionId.value === row.id },
{ key: 'delete', label: '删除', tone: 'danger', divided: true, visible: canDeleteRow(row), disabled: rowActionId.value === row.id },
]
}
function handlePermissionAction(command: string, row: Permission) {
if (command === 'add-child') openCreate(row.id)
else if (command === 'edit') openEdit(row)
else if (command === 'status') void toggleStatus(row)
else if (command === 'delete') void removePermission(row)
}
onMounted(loadPermissions)
</script>
@@ -283,19 +286,7 @@ onMounted(loadPermissions)
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-start><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }">
<div v-if="permissionActionCount(row as Permission) > 0 || (row as Permission).builtIn" class="table-actions density-actions">
<el-button class="density-action" v-if="canAddChild(row as Permission)" size="small" data-tone="neutral" @click="openCreate((row as Permission).id)">添加下级</el-button>
<el-button class="density-action" v-if="canEditRow(row as Permission)" size="small" data-tone="neutral" :disabled="rowActionId === (row as Permission).id" @click="openEdit(row as Permission)">编辑</el-button>
<el-button class="density-action" v-if="canChangeStatusRow(row as Permission)" size="small" :data-tone="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button>
<el-button class="density-action" v-if="showDeleteDirect(row as Permission)" size="small" data-tone="danger" :disabled="rowActionId === (row as Permission).id" @click="removePermission(row as Permission)">删除</el-button>
<el-dropdown v-if="showDeleteMore(row as Permission)" trigger="click" @command="(command: string) => command === 'delete' && removePermission(row as Permission)">
<el-button data-tone="neutral" class="density-action" size="small" :aria-label="moreActionsLabel(row as Permission)" :title="moreActionsLabel(row as Permission)"><MoreHorizontal aria-hidden="true" :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="delete" :disabled="rowActionId === (row as Permission).id" class="danger-menu-item">删除</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
<span v-else class="secondary-text">仅查看</span>
</template>
<template #actions="{ row }"><RowActions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
@@ -37,7 +37,7 @@ describe('InboundTaskList measurement scope', () => {
expect(source).toContain(':tenant-id="selectedInbound.tenantId"')
expect(source).toContain(':customer-id="selectedInbound.tenantId"')
expect(source).toContain(':customer-name="selectedInbound.customerName || selectedInbound.companyName"')
expect(source).toContain(':customer-name="customerDisplayName(selectedInbound)"')
expect(source).toContain(':channel-name="selectedInbound.channelName"')
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
})
@@ -65,6 +65,9 @@ const actionPermission = computed(() => isReview.value
const taskLabel = computed(() => isReview.value ? '审核' : '测量')
const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单')
const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements')
const customerNameById = computed(() => new Map(customers.value.map((customer) => [
String(customer.id), customer.legalName?.trim() || customer.tenantName,
])))
const queryFields = computed<QueryField[]>(() => [
{
@@ -111,6 +114,11 @@ function text(value: unknown) {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
function customerDisplayName(inbound: InboundOrderSummary) {
const snapshot = inbound.customerName?.trim() || inbound.companyName?.trim()
return snapshot || customerNameById.value.get(String(inbound.customerId || inbound.tenantId)) || ''
}
function formatNumber(value: number | null | undefined) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
}
@@ -440,6 +448,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
v-if="!isReview && selectedInbound"
v-model="measurementDialogOpen"
:customer-id="selectedInbound.tenantId"
:customer-name="customerDisplayName(selectedInbound)"
:inbound-id="selectedInbound.id"
:inbound-code="selectedInbound.inboundCode"
@updated="handleMeasurementUpdated"
@@ -40,6 +40,7 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('@click="reviewDialogOpen = true"')
expect(source).toContain(':key="`review-dialog-${revision}`"')
expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
expect(source).toContain(':channel-name="detail?.summary.channelName"')
expect(source).toContain('@changed="handleReviewChanged"')
expect(source).not.toContain('<template #footer>')
@@ -197,6 +197,7 @@ onBeforeUnmount(() => {
<InboundMeasurementDialog
v-model="measurementDialogOpen"
:customer-id="customerId"
:customer-name="detail?.summary.customerName || detail?.summary.companyName"
:inbound-id="inboundId"
:inbound-code="title"
@updated="handleDomainChanged"
@@ -20,6 +20,8 @@ describe('InboundMeasurementDialog', () => {
expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)')
expect(source).toContain('<WeightOcrPanel')
expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('customerName?: string | null')
expect(source).toContain(':customer-name="customerName"')
expect(source).toContain('class="measurement-dialog-weight"')
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
@@ -10,6 +10,7 @@ import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
const props = defineProps<{
modelValue: boolean
customerId: string
customerName?: string | null
inboundId: string
inboundCode?: string | null
openWarehouseSuggestion?: boolean
@@ -92,6 +93,7 @@ function handleSubmitted() {
<InboundMeasurementPanel
ref="measurementPanelRef"
:customer-id="customerId"
:customer-name="customerName"
:inbound-id="inboundId"
:open-warehouse-suggestion="openWarehouseSuggestion"
@updated="handleUpdated"
@@ -52,6 +52,7 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
channelName: '美西海运',
processStatus: 'MEASURING',
measureReviewStatus: null,
customerFeeConfirmed: false,
recommendedPalletType: 'STANDARD',
reviewReason: null,
reviewerId: null,
@@ -135,7 +136,7 @@ async function mountPanel(permissions = [
'platform:firstleg:inbound:measure:edit',
'platform:firstleg:inbound:measure:manual-entry',
'platform:firstleg:inbound:measure:submit',
]) {
], props: Partial<{ customerId: string; inboundId: string; customerName: string }> = {}) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
@@ -143,7 +144,7 @@ async function mountPanel(permissions = [
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(InboundMeasurementPanel, {
props: { customerId: '10001', inboundId: '7001' },
props: { customerId: '10001', inboundId: '7001', ...props },
attachTo: document.body,
global: { plugins: [pinia, ElementPlus] },
})
@@ -195,6 +196,13 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
})
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
expect(wrapper.find('.measurement-summary-context').text()).toContain('柏悦物流')
})
it('warns when declared dimensions or gross weight differ from measurements by at least ten percent', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
actualGrossWeightKg: 132,
@@ -499,7 +507,7 @@ describe('InboundMeasurementPanel', () => {
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
expect.stringContaining('准备托盘与库位占用'),
expect.stringContaining('提交成功后本批次不能继续编辑'),
'提交测量审核',
expect.objectContaining({ confirmButtonText: '确认提交' }),
)
@@ -21,6 +21,7 @@ const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
customerName?: string | null
}>()
const emit = defineEmits<{
@@ -110,6 +111,7 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edi
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
@@ -698,7 +700,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<section class="measurement-section measurement-summary-section">
<header class="measurement-summary-header">
<div class="measurement-summary-context">
<p><span>客户</span>{{ detail.companyName || '客户未命名' }}</p>
<p><span>客户</span>{{ displayCustomerName }}</p>
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
</div>
<div class="measurement-summary-status">
@@ -107,6 +107,7 @@ export interface InboundMeasurementDetail {
expectedPalletCount?: number | null
processStatus: InboundMeasurementProcessStatus
measureReviewStatus: string | null
customerFeeConfirmed: boolean
recommendedPalletType: string | null
reviewReason: string | null
reviewerId: MeasurementIdentifier | null
@@ -53,7 +53,7 @@ async function submit() {
if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量')
submitting.value = true
try {
await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt ? new Date(form.scheduledAt).toISOString() : null, idempotencyKey: idempotencyKey() })
await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt || null, idempotencyKey: idempotencyKey() })
ElMessage.success(form.scheduledAt ? '消息已定时' : '消息发送任务已创建')
open.value = false; reset(); emit('success')
} catch (reason) { error.value = getApiErrorDetails(reason, '消息发送失败') } finally { submitting.value = false }
@@ -129,7 +129,7 @@ describe('notification density in native lists and overlays', () => {
await wrapper.get('[role="switch"]').trigger('click')
await flushPromises()
await click(wrapper, '确认定时发送')
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', templateCode: template.templateCode, recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', variables: {}, scheduledAt: expect.any(String), idempotencyKey: expect.any(String) }))
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', templateCode: template.templateCode, recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', variables: {}, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/), idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('success')).toHaveLength(1)
})
})
@@ -25,7 +25,7 @@ describe('ocean bookings api', () => {
})
it('uses the frozen single-query link candidate contract and normalizes long ids', async () => {
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', billOfLadingNo: 'BL-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
const query = { warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 }
const result = await listOceanBookingLinkCandidates(query)
+1
View File
@@ -87,6 +87,7 @@ export interface OceanBookingQuery { page: number; size: number; keyword?: strin
export interface OceanBookingLinkCandidate {
oceanBookingId: string
bookingNo: string
billOfLadingNo: string
requirementId: string
containerType: string
capacityCbm: number
+4 -2
View File
@@ -19,7 +19,7 @@
|---|---|---|
| 排车车型、托位容量与限重提示 | `dispatchVehicleSpecs.ts`、`components/SmartDispatchDialog.vue`、`pages/DispatchesView.vue` | 预排车型与排车编辑选项共用规格入口:大车 30 托/40000 LB,小车 15 托/10000 LB;超重提示在预排组件,不能把前端提示当作服务端允许提交的依据。回归见 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts` |
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。路线只读并由已排托盘关联的 FBA 仓去重后以英文逗号连接,预排和普通编辑均不接收人工路线。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
@@ -29,6 +29,8 @@
## 履约硬边界
- 智能排车来源支持 `RELABEL`(待出库换标单),切换后不传已选入库计划 ID,按客户已确认、卡车派送且未排车的换标库存查询。提交换标号从候选 `orderRefs` 读取,不能把展示的原入库单号 `orderNo` 当换标号;沿用来源库存、托盘与箱唛,不生成新入库单。
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
@@ -36,7 +38,7 @@
## 验证
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
@@ -33,6 +33,10 @@ export interface DeliveryOrderRow {
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
destinationType?: 'FBA' | 'PRIVATE' | null
deliveryAddress?: string | null
contactName?: string | null
contactPhone?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
status: string
@@ -21,10 +21,12 @@ export interface DispatchPalletCandidateDto {
planNo?: string | null
orderType?: string | null
volumeWeightKg?: number | null
grossWeightKg?: number | null
containerNo?: string | null
appointmentAt?: string | null
expectedLoadingAt?: string | null
overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
orderNo?: string | null
orderRefs?: string | null
@@ -39,13 +41,18 @@ export interface DispatchPalletCandidateDto {
sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL' | null
cartonMarks?: string | null
relabelCartonMarks?: string | null
destinationType?: 'FBA' | 'PRIVATE' | null
deliveryAddress?: string | null
postalCode?: string | null
contactName?: string | null
contactPhone?: string | null
}
export interface DispatchCandidateQuery {
planIds?: string
orderNo?: string
fbaWarehouseCode?: string
sourceStage?: 'PLAN' | 'RECEIVED'
sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL'
draftToken?: string
}
export interface DispatchDraftContainerDto { id: string; capacity: number; palletIds: string[] }
@@ -126,6 +133,8 @@ export interface DispatchItemDto {
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
packageCount?: number | null
grossWeightKg?: number | null
volumeWeightKg?: number | null
volumeCbm?: number | null
sourceStage?: string | null
}
@@ -26,6 +26,7 @@ export interface InboundOrderSummaryDto {
inboundCode?: string | null
lxCode?: string | null
orderType?: string | null
overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
warehouseId?: string | null
@@ -35,6 +36,8 @@ export interface InboundOrderSummaryDto {
customerName?: string | null
packageCount?: number | null
pickedPackageCount?: number | null
grossWeightKg?: number | null
netWeightKg?: number | null
volumeCbm?: number | null
palletCount?: number | null
assigned?: boolean | null
@@ -11,7 +11,7 @@ describe('inbound plan overseas API', () => {
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { params: { customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' } })
})
it('gets detail by encoded plan number', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } })
await getInboundPlan('IP/1', '2')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1', { params: { customerId: '2' } })
})
@@ -34,6 +34,8 @@ export interface InboundPlanSummaryDto {
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
packageCount?: number | null
grossWeightKg?: number | null
netWeightKg?: number | null
totalVolumeCbm?: number | null
palletCount?: number | null
status?: string | null
@@ -56,13 +58,15 @@ export interface InboundPlanDetailDto {
status?: string | null
lineCount: number
palletCount: number
totalGrossWeightKg: number
totalNetWeightKg: number
totalVolumeCbm: number
expectedPackageCount?: number
pickedPackageCount?: number
createdAt?: string | null
updatedAt?: string | null
remark?: string | null
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; grossWeightKg?: number | null; netWeightKg?: number | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }>
allocations: Array<{ lineId: string; palletId: string; palletNo: string; palletOwnerPlanNo: string; palletOwnedByCurrentPlan: boolean; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
}
@@ -1,12 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
import { downloadInventoryBalances, downloadInventoryLedger, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('overseas inventory API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('returns the binary response directly for inventory exports', async () => {
const balances = new Blob(['customerId\\r\\n10001\\r\\n'], { type: 'text/csv' })
const ledger = new Blob(['occurredAt\\r\\n'], { type: 'text/csv' })
vi.mocked(overseasHttp.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger)
await expect(downloadInventoryBalances({ skuCode: 'SKU-1' })).resolves.toBe(balances)
await expect(downloadInventoryLedger({ inboundCode: 'IN-1' })).resolves.toBe(ledger)
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', {
params: { skuCode: 'SKU-1' }, responseType: 'blob',
})
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', {
params: { inboundCode: 'IN-1' }, responseType: 'blob',
})
})
it('keeps skuCode and nullable location filters on real balance queries', async () => {
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } })
const query = { page: 1, size: 20, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' }
@@ -118,8 +118,7 @@ export async function listInventoryBalances(query: InventoryBalanceQuery) {
}
export async function downloadInventoryBalances(query: Partial<InventoryBalanceQuery>) {
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
return response.data
return overseasHttp.get<Blob, Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
}
export async function getInventorySummary(query: InventoryBalanceQuery) {
@@ -168,6 +167,5 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) {
}
export async function downloadInventoryLedger(query: Partial<InventoryLedgerQuery>) {
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
return response.data
return overseasHttp.get<Blob, Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
}
@@ -71,6 +71,10 @@ export interface LastMileOrderSummaryDto {
version?: number | null
palletCount?: number | null
packageCount?: number | null
receivedPackageCount?: number | null
packageDifference?: number | null
grossWeightKg?: number | null
netWeightKg?: number | null
volumeCbm?: number | null
productQuantity?: number | null
volumeWeightKg?: number | null
@@ -111,6 +115,7 @@ export interface LastMileCargoDto {
productQuantity?: number | null
palletCount?: number | null
grossWeightKg?: number | null
netWeightKg?: number | null
dimensionalWeightKg?: number | null
volumeCbm?: number | null
destinationWarehouseName?: string | null
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { cancelOutbound, createOneClickDropshipOutbound, getOutbound, listOutbounds } from './outbounds'
import { cancelOutbound, createOneClickDropshipOutbound, getOutbound, listOneClickOutboundCandidates, listOutbounds } from './outbounds'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
@@ -28,4 +28,16 @@ describe('outbound overseas API', () => {
await createOneClickDropshipOutbound('customer-1', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId: 'customer-1' } })
})
it('normalizes one-click candidate measurements returned as decimal strings', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{
inventoryPositionId: '99', availablePackages: 4,
grossWeightKg: '40.000', volumeWeightKg: '80.000', volumeCbm: '4.0000',
}] })
const [candidate] = await listOneClickOutboundCandidates(undefined, { warehouseId: 'warehouse-1' })
expect(candidate).toMatchObject({
inventoryPositionId: '99', availablePackages: 4,
grossWeightKg: 40, volumeWeightKg: 80, volumeCbm: 4,
})
})
})
@@ -3,7 +3,7 @@ import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import type { DeliveryOrderRow } from './deliveryOrders'
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null; outboundType?: string | null }
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; grossWeightKg?: number | null; volumeWeightKg?: number | null; volumeCbm?: number | null; createdAt?: string | null; outboundType?: string | null }
export interface OutboundPalletRow {
id?: string | null
palletNo?: string | null
@@ -13,13 +13,18 @@ export interface OutboundPalletRow {
destinationWarehouseName?: string | null
skuCode?: string | null
packageCount?: number | null
grossWeightKg?: number | null
volumeWeightKg?: number | null
volumeCbm?: number | null
pickedPackageCount?: number | null
status?: string | null
}
export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; deliveries?: DeliveryOrderRow[] }
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string; outboundType?: string }
export interface ReturnOutboundCandidate { returnNo?: string | null; overseasWarehouseId?: string | null; cartonMark?: string | null; fbaCartonMark?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; totalQuantity?: number | null; status?: string | null }
export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number }
export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number; grossWeightKg: number; volumeWeightKg: number; volumeCbm: number }
export interface WarehouseTransferOutboundCandidate { customerId: string; customerName?: string | null; transferNo: string; overseasWarehouseId: string; transferStatus: string; needRelabel: boolean; relabelNo?: string | null; destinationType?: 'FBA' | 'PRIVATE' | null; destinationWarehouseCode?: string | null; privateDestinationAddress?: string | null; privateDestinationPostalCode?: string | null; packageCount: number; grossWeightKg?: number | null; volumeCbm?: number | null }
export interface WarehouseTransferDestinationPayload { transfers: Array<{ customerId: string; transferNo: string }>; destinationType: 'FBA' | 'PRIVATE'; destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string; idempotencyKey: string }
export async function listOutbounds(query: OutboundQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OutboundRow>>, ApiEnvelope<OverseasPagePayload<OutboundRow>>>('/platform/v1/last-leg/outbound-orders', { params: query })
return normalizeOverseasPage(unwrapApiResponse(response))
@@ -34,7 +39,14 @@ export async function createOneClickDropshipOutbound(customerId: string, payload
}
export async function listOneClickOutboundCandidates(customerId?: string, query: { warehouseId?: string } = {}) {
const response = await overseasHttp.get<ApiEnvelope<OneClickOutboundCandidate[]>, ApiEnvelope<OneClickOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates', { params: { customerId, ...query } })
return unwrapApiResponse(response).map((item) => ({ ...item, inventoryPositionId: String(item.inventoryPositionId), availablePackages: Number(item.availablePackages || 0) }))
return unwrapApiResponse(response).map((item) => ({
...item,
inventoryPositionId: String(item.inventoryPositionId),
availablePackages: Number(item.availablePackages || 0),
grossWeightKg: Number(item.grossWeightKg || 0),
volumeWeightKg: Number(item.volumeWeightKg || 0),
volumeCbm: Number(item.volumeCbm || 0),
}))
}
export async function listReturnOutboundCandidates(customerId: string, warehouseId: string) {
const response = await overseasHttp.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } })
@@ -44,6 +56,14 @@ export async function createReturnOutbound(customerId: string, payload: { recipi
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } })
return unwrapApiResponse(response)
}
export async function listWarehouseTransferOutboundCandidates(query: { customerId?: string; warehouseId?: string } = {}) {
const response = await overseasHttp.get<ApiEnvelope<WarehouseTransferOutboundCandidate[]>, ApiEnvelope<WarehouseTransferOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates', { params: query })
return unwrapApiResponse(response).map(item => ({ ...item, packageCount: Number(item.packageCount || 0) }))
}
export async function bindWarehouseTransferDestination(payload: WarehouseTransferDestinationPayload) {
const response = await overseasHttp.post<ApiEnvelope<unknown[]>, ApiEnvelope<unknown[]>>('/platform/v1/last-leg/warehouse-transfers/bind-destination', payload)
return unwrapApiResponse(response)
}
export async function cancelOutbound(outboundNo: string, reason: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason })
return unwrapApiResponse(response)
@@ -167,6 +167,14 @@ export async function returnRelabelForRework(relabelNo: string, expectedVersion:
return unwrapApiResponse(response)
}
export async function confirmRelabelCompletion(relabelNo: string, remark?: string) {
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`,
{ idempotencyKey: crypto.randomUUID(), remark },
)
return unwrapApiResponse(response)
}
export async function uploadRelabelPdf(relabelNo: string, file: File, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const body = new FormData()
body.append('file', file)
@@ -14,6 +14,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
import OrderReferenceText from './OrderReferenceText.vue'
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
interface LoadingContainer {
id: string
@@ -47,10 +48,10 @@ const selectedVehicle = ref('')
const query = reactive({
orderNo: '',
orderType: '',
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED',
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED' | 'RELABEL',
fbaWarehouseCode: '',
})
const warehouseTab = ref<'ALL' | 'LAX' | 'NYC'>('ALL')
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const orderTypeOptions = [
{ value: 'FIRST_LEG', label: '头程订单' },
@@ -65,9 +66,7 @@ const visibleCandidates = computed(() =>
candidates.value.filter((item) => {
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
if (!typeMatches) return false
if (warehouseTab.value === 'ALL') return true
const warehouse = `${item.overseasWarehouseId || ''} ${item.overseasWarehouseName || ''}`.toLowerCase()
return warehouseTab.value === 'LAX' ? /lax|洛杉矶|los angeles/.test(warehouse) : /nyc|jfk|纽约|new york/.test(warehouse)
return matchesSmartDispatchWarehouseTab(item, warehouseTab.value)
}),
)
const orderGroups = computed(() => {
@@ -88,7 +87,14 @@ const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
const weightLb = (item?: DispatchPalletCandidateDto) => Number(item?.volumeWeightKg || 0) * 2.2046226218
function weightKg(item?: DispatchPalletCandidateDto) {
const grossWeightKg = Number(item?.grossWeightKg)
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) return grossWeightKg
const volumeWeightKg = Number(item?.volumeWeightKg)
return Number.isFinite(volumeWeightKg) && volumeWeightKg > 0 ? volumeWeightKg : 0
}
const weightLb = (item?: DispatchPalletCandidateDto) => weightKg(item) * 2.2046226218
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => pallets.reduce((sum, item) => sum + weightLb(item), 0)
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
function palletId(item: DispatchPalletCandidateDto) {
@@ -105,6 +111,10 @@ function number(value: unknown, digits = 0) {
function dateTime(value: unknown) {
return formatOverseasDateTime(value)
}
function privateWarehouse(item: DispatchPalletCandidateDto) {
if (item.destinationType !== 'PRIVATE') return '—'
return [item.deliveryAddress, item.postalCode].map((value) => String(value || '').trim()).filter(Boolean).join(' · ') || '—'
}
function orderType(value: unknown) {
return (
(
@@ -158,7 +168,7 @@ async function load() {
loadError.value = null
try {
candidates.value = await listDispatchCandidates({
planIds: planIds.value.length ? planIds.value.join(',') : undefined,
planIds: query.sourceStage !== 'RELABEL' && planIds.value.length ? planIds.value.join(',') : undefined,
orderNo: query.orderNo.trim() || undefined,
fbaWarehouseCode: query.fbaWarehouseCode.trim() || undefined,
sourceStage: query.sourceStage || undefined,
@@ -295,7 +305,11 @@ async function submit() {
return
}
const relabelOrders = [...new Set(assigned.value.filter((item) => item.sourceStage === 'RELABEL')
.map((item) => String(item.orderNo || '').trim()).filter(Boolean))]
.map((item) => String(item.orderRefs || '').trim()).filter(Boolean))]
if (assigned.value.some((item) => item.sourceStage === 'RELABEL' && !String(item.orderRefs || '').trim())) {
ElMessage.warning('换标来源单号缺失,请刷新后重新选择')
return
}
if (relabelOrders.length && (relabelOrders.length !== 1 || assigned.value.some((item) => item.sourceStage !== 'RELABEL'))) {
ElMessage.warning('一张换标单的托盘需要单独排车')
return
@@ -363,13 +377,15 @@ watch([warehouseTab, () => query.orderType], () => {
<OperationErrorToast :details="mutationError" />
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" @update:model-value="close($event)">
<section class="dispatch-query" aria-label="智能排车筛选">
<el-input v-model="query.orderNo" clearable placeholder="订单号" @keyup.enter="load" />
<el-input v-model="query.orderNo" clearable :placeholder="query.sourceStage === 'RELABEL' ? '换标单号 / 原入仓码' : '订单号 / 换标单号 / 原入仓码'" @keyup.enter="load" />
<el-select v-model="query.orderType" clearable placeholder="全部订单类型"><el-option v-for="option in orderTypeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select>
<el-select v-model="query.sourceStage" clearable placeholder="全部状态"><el-option label="入库完成托盘" value="RECEIVED" /><el-option label="计划托盘" value="PLAN" /></el-select>
<el-select v-model="query.sourceStage" clearable placeholder="全部来源"><el-option label="已入库 / 待出库换标" value="RECEIVED" /><el-option label="待出库换标单" value="RELABEL" /><el-option label="计划托盘" value="PLAN" /></el-select>
<el-input v-model="query.fbaWarehouseCode" clearable placeholder="FBA仓库" @keyup.enter="load" />
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。沿用原入库库存和托盘,按本次换标箱唛及新目的仓排车,无需重新生成入库单。</p>
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
<button
v-for="tab in [
@@ -380,7 +396,7 @@ watch([warehouseTab, () => query.orderType], () => {
:key="tab.value"
type="button"
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
@click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'"
@click="warehouseTab = tab.value as SmartDispatchWarehouseTab"
>
{{ tab.label }}
</button>
@@ -418,13 +434,16 @@ watch([warehouseTab, () => query.orderType], () => {
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
>
<el-table-column label="重量(lb)" width="100" align="right"
><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column
><template #default="{ row }">{{ number(orderWeightLb(row.pallets), 0) }}LB</template></el-table-column
>
<el-table-column label="托盘数量" width="90" align="right"
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
>
<el-table-column label="目的地FBA" width="110"
><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column
>
<el-table-column label="私人仓" min-width="220" show-overflow-tooltip
><template #default="{ row }">{{ privateWarehouse(row.first) }}</template></el-table-column
>
<el-table-column label="体积(m³)" width="100" align="right"
><template #default="{ row }">{{
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, inventoryBusinessTypeLabel, lastMileOrderReference, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
it('formats overseas timestamps with zero-padded seconds', () => {
@@ -85,6 +85,42 @@ describe('last-mile order presentation adapter', () => {
})
})
it('maps warehouse transfer receiving quantities and formats differences', () => {
const transfer = toLastMileOrderListItem({
id: '4', customerId: '100', orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER',
packageCount: 10, receivedPackageCount: 12, packageDifference: 2,
})
expect(transfer).toMatchObject({ cartons: 10, cartonDifference: 2 })
expect(packageDifferenceText(2)).toBe('+2')
expect(packageDifferenceText(-2)).toBe('-2')
expect(packageDifferenceText(0)).toBe('0')
expect(packageDifferenceText('—')).toBe('—')
const ordinary = toLastMileOrderListItem({ id: '5', customerId: '100', orderNo: 'LM-1', orderType: 'FIRST_LEG', packageCount: 10 })
expect(ordinary).toMatchObject({ cartons: 10, cartonDifference: '—' })
})
it.each([
['PENDING_REVIEW', '待审核'],
['WAIT_PDA_RECEIVING', '入库测量中'],
['WAIT_CUSTOMER_CONFIRMATION', '待客户确认实测'],
['INBOUND_COMPLETED', '入库完成'],
])('maps warehouse transfer status %s to %s', (status, expected) => {
expect(lastMileStatusLabel(status)).toBe(expected)
})
it.each([
['PENDING_ASSIGNMENT', '待入库'],
['ASSIGNED', '待入库'],
['PDA_PENDING', '待入库'],
['SIGNED', '已签收'],
['PICKING', '拣货中'],
['PARTIAL_INBOUND', '部分入库'],
['COMPLETED', '全部入库'],
])('maps inbound plan status %s to %s', (status, expected) => {
expect(inboundPlanStatusLabel(status)).toBe(expected)
})
it.each([
['RELABEL_APPLICATION', '换标下单'],
['WAREHOUSE_TRANSFER', '仓储转运'],
@@ -108,7 +144,7 @@ describe('last-mile order presentation adapter', () => {
it.each([
['PENDING_PROCESSING', '待处理'],
['PROCESSING', '处理中'],
['CUSTOMER_CONFIRM_PENDING', '待处理'],
['CUSTOMER_CONFIRM_PENDING', '换标完成'],
['READY_FOR_OUTBOUND', '换标完成'],
['COMPLETED', '换标完成'],
])('collapses internal relabel node %s into business status %s', (status, expected) => {
@@ -15,8 +15,21 @@ const orderTypeLabels: Record<string, string> = {
const statusLabels: Record<string, string> = {
PENDING_REVIEW: '待审核',
WAIT_PDA_RECEIVING: '入库测量中',
INBOUND_MEASURING: '入库测量中',
WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测',
INBOUND_COMPLETED: '入库完成',
APPROVED: '审核通过',
PENDING_ASSIGNMENT: '待分配',
WAIT_RELABEL: '待换标',
WAIT_FBA_DESTINATION: '待确认目的仓',
READY_FOR_DISPATCH: '待创建出库单',
WAIT_OUTBOUND: '待出库',
ALLOCATED: '已分配库存',
OUTBOUNDED: '已出库',
PARTIALLY_RECEIVED: '部分收货',
RECEIVED: '已收货',
IN_TRANSIT: '运输中',
DESTINATION_ASSIGNED: '已分配目的仓',
INBOUND_PLANNED: '已生成入库计划',
PENDING_PROCESSING: '待处理',
@@ -25,6 +38,16 @@ const statusLabels: Record<string, string> = {
CANCELLED: '已取消',
}
const inboundPlanStatusLabels: Record<string, string> = {
PENDING_ASSIGNMENT: '待入库',
ASSIGNED: '待入库',
PDA_PENDING: '待入库',
SIGNED: '已签收',
PICKING: '拣货中',
PARTIAL_INBOUND: '部分入库',
COMPLETED: '全部入库',
}
const inventoryBusinessTypeLabels: Record<string, string> = {
IN_TRANSIT: '在途入账',
STOCK_IN: '入库',
@@ -61,12 +84,22 @@ export function lastMileStatusLabel(value: unknown) {
return statusLabels[code] || text(code)
}
export function inboundPlanStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
return inboundPlanStatusLabels[code] || text(code)
}
export function packageDifferenceText(value: number | '—' | null | undefined) {
if (value == null || value === '—' || !Number.isFinite(value)) return '—'
return value > 0 ? `+${value}` : String(value)
}
// 认领后显示处理中,可出库节点统一显示换标完成。
export function relabelBusinessStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
if (code === 'PROCESSING') return '处理中'
if (!code || code === 'NOT_REQUIRED') return '—'
return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(code) ? '换标完成' : '待处理'
return ['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'].includes(code) ? '换标完成' : '待处理'
}
export function lastMileOrderTypeLabel(value: unknown) {
@@ -106,6 +139,7 @@ export function palletMergeLabel(value: unknown) {
}
export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileOrderListItem {
const warehouseTransfer = dto.orderType === 'WAREHOUSE_TRANSFER'
return {
id: String(dto.id),
customerId: String(dto.customerId),
@@ -135,6 +169,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
version: Number(dto.version ?? 0),
pallets: metric(dto.palletCount),
cartons: metric(dto.packageCount),
cartonDifference: warehouseTransfer ? metric(dto.packageDifference) : '—',
volume: metric(dto.volumeCbm),
quantity: metric(dto.productQuantity),
volumeWeight: metric(dto.volumeWeightKg),
@@ -147,6 +182,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
outboundOrderNo: text(dto.outboundOrderNo),
dispatchOrderNo: text(dto.deliveryOrderNo),
relabelOrderNo: text(dto.relabelOrderNo),
relabelStatusCode: String(dto.relabelStatus ?? '').trim(),
relabelStatus: relabelBusinessStatusLabel(dto.relabelStatus),
consolidated: palletMergeLabel(dto.palletMergeFlag),
palletMergeFlag: String(dto.palletMergeFlag ?? '').trim(),
@@ -14,7 +14,7 @@ describe('DispatchDetailView', () => {
routerPush.mockReset()
vi.mocked(getDispatch).mockReset().mockResolvedValue({
id: '1', dispatchNo: 'PC/1', vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', volumeCbm: 1.25 }],
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', grossWeightKg: 88.5, volumeCbm: 1.25 }],
})
})
@@ -26,7 +26,7 @@ describe('DispatchDetailView', () => {
expect(wrapper.text()).toContain('CA-100')
expect(wrapper.text()).toContain('待发车')
const table = wrapper.findComponent(DataTableStub)
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: '—', palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
wrapper.unmount()
})
})
@@ -13,7 +13,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: string
weight: number
palletCount: number
fbaWarehouse: string
volume: number
@@ -84,20 +84,21 @@ const documentFields = computed<DetailField[]>(() => [
])
const relatedOrders = computed<RelatedOrderRow[]>(() => {
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; volume: number }>()
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number; volume: number }>()
for (const item of detail.value?.items || []) {
for (const orderNo of orderReferences(item)) {
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), volume: 0 }
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), weight: 0, volume: 0 }
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
if (destination !== '—') current.destinations.add(destination)
current.weight += Number(item.grossWeightKg || 0)
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: '—',
weight: Number(value.weight.toFixed(3)),
palletCount: value.pallets.size,
fbaWarehouse: [...value.destinations].join('、') || '—',
volume: Number(value.volume.toFixed(3)),
@@ -107,7 +108,7 @@ const relatedOrders = computed<RelatedOrderRow[]>(() => {
const orderColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'weight', prop: 'weight', label: '重量(lb)', minWidth: 130, align: 'right' },
{ key: 'weight', prop: 'weight', label: '重量(kg)', minWidth: 130, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '目的FBA仓', minWidth: 150 },
{ key: 'volume', prop: 'volume', label: '体积(m³)', minWidth: 130, align: 'right' },
@@ -60,8 +60,9 @@ describe('DispatchesView', () => {
[['edit'], ['查看', '打印BOL', '编辑'], false],
[['cancel'], ['查看', '打印BOL', '取消'], false],
[['transfer-delivery'], ['查看', '打印BOL', '转派送'], false],
[['edit', 'transfer-delivery'], ['查看', '打印BOL', '转派送', ''], true],
[['edit', 'cancel'], ['查看', '打印BOL', '编辑', ''], true],
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '编辑', ''], true],
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', ''], true],
])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => {
canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action))
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
@@ -129,6 +130,8 @@ describe('DispatchesView', () => {
expect(source).toContain('permission.dispatchEdit')
expect(source).toContain('permission.dispatchCancel')
expect(source).toContain('permission.dispatchTransferDelivery')
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'transfer', label: '转派送', priority: 250")
expect(source).toMatch(/function isPendingDeparture\(row: DispatchListItem\)\s*{\s*return row\.statusCode === 'PENDING_DEPARTURE'/)
expect(source).toMatch(/access\.can\(permission\.dispatchEdit\)\s*&&\s*isPendingDeparture\(row\)/)
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
@@ -1,7 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { MoreHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -13,6 +12,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -341,9 +341,6 @@ async function openBol(row: DispatchListItem) {
bolLoadingNo.value = ''
}
}
function rowActionCount(row: DispatchListItem) {
return 2 + Number(canEdit(row)) + Number(canTransfer(row)) + Number(canCancel(row))
}
function isPendingDeparture(row: DispatchListItem) {
return row.statusCode === 'PENDING_DEPARTURE'
}
@@ -479,8 +476,20 @@ async function cancel(row: DispatchListItem) {
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
}
}
function handleMore(command: string, row: DispatchListItem) {
if (command === 'edit') openEdit(row)
function rowActions(row: DispatchListItem): RowActionItem[] {
return [
{ key: 'detail', label: '查看', priority: 300 },
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
{ key: 'edit', label: '编辑', priority: 100, visible: canEdit(row) },
{ key: 'transfer', label: '转派送', priority: 250, tone: 'success', visible: canTransfer(row) },
{ key: 'cancel', label: '取消', tone: 'danger', divided: true, visible: canCancel(row) },
]
}
function handleRowAction(command: string, row: DispatchListItem) {
if (command === 'detail') openDetail(row)
else if (command === 'bol') void openBol(row)
else if (command === 'edit') openEdit(row)
else if (command === 'transfer') openTransfer(row)
else if (command === 'cancel') void cancel(row)
}
async function submitTransfer() {
@@ -555,7 +564,7 @@ onMounted(load)
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as DispatchListItem)">查看</el-button><el-button class="density-action" data-tone="neutral" size="small" :loading="bolLoadingNo === (row as DispatchListItem).dispatchNo" @click="openBol(row as DispatchListItem)">打印BOL</el-button><el-button class="density-action" data-tone="neutral" v-if="canEdit(row as DispatchListItem)" size="small" @click="handleMore('edit', row as DispatchListItem)">编辑</el-button><el-button class="density-action" data-tone="success" v-if="canTransfer(row as DispatchListItem) && !canEdit(row as DispatchListItem)" size="small" @click="openTransfer(row as DispatchListItem)">转派送</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as DispatchListItem) && rowActionCount(row as DispatchListItem) <= 3" size="small" @click="handleMore('cancel', row as DispatchListItem)">取消</el-button><el-dropdown v-if="rowActionCount(row as DispatchListItem) > 3" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canTransfer(row as DispatchListItem) && canEdit(row as DispatchListItem)" @click="openTransfer(row as DispatchListItem)">转派送</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided>取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><RowActions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /></template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section>
@@ -8,7 +8,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
const route = useRoute(); const router = useRouter()
const planNo = computed(() => String(route.params.orderId || ''))
@@ -21,18 +21,19 @@ function receivingWarehouse(code: unknown, name: unknown) {
const normalizedName = String(name ?? '').trim()
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
}
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待入库', PDA_PENDING: '待入库', ASSIGNED: '待入库', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库' }; return labels[String(value || '')] || text(value) }
function status(value: unknown) { return inboundPlanStatusLabel(value) }
const firstLine = computed(() => detail.value?.lines[0])
const fieldValues = computed<Record<string, string | number>>(() => ({
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: lastMileOrderTypeLabel(detail.value?.orderType), 货主: text(detail.value?.customerName), 关联订单: text(detail.value?.sourceOrderNo),
海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0,
'货物毛重(kg)': Number(detail.value?.totalGrossWeightKg || 0), '货物净重(kg)': Number(detail.value?.totalNetWeightKg || 0),
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: text(detail.value?.remark),
}))
const groups = [
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '计费体积(m³)', '总托盘数'] },
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '货物毛重(kg)', '货物净重(kg)', '计费体积(m³)', '总托盘数'] },
{ title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] },
]
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
@@ -18,7 +18,7 @@ import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
const access = useAccessPreviewStore()
@@ -37,7 +37,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
@@ -76,6 +76,8 @@ const columns: DataTableColumn[] = [
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'cartons', prop: 'cartons', label: '应拣货数', width: 100, align: 'right' },
{ key: 'pickedCartons', prop: 'pickedCartons', label: '实际拣货数', width: 110, align: 'right' },
{ key: 'grossWeight', prop: 'grossWeight', label: '货物毛重(kg)', width: 120, align: 'right' },
{ key: 'netWeight', prop: 'netWeight', label: '货物净重(kg)', width: 120, align: 'right' },
{ key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' },
{ key: 'assigned', prop: 'assigned', label: '分配', width: 82, align: 'center' },
@@ -95,7 +97,6 @@ function receivingWarehouse(code: unknown, name: unknown) {
}
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
function typeLabel(value: unknown) { return lastMileOrderTypeLabel(value) }
function planStatusLabel(value: unknown) { return ({ PENDING_ASSIGNMENT: '待入库', ASSIGNED: '待入库', PDA_PENDING: '待入库', PICKING: '拣货中', COMPLETED: '全部入库', PARTIAL_INBOUND: '部分入库' } as Record<string, string>)[String(value || '')] || text(value) }
function appointmentStatusLabel(value: unknown) { return !value ? '未预约' : ({ PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) }
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
@@ -103,9 +104,10 @@ function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount),
grossWeight: number(dto.grossWeightKg), netWeight: number(dto.netWeightKg), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
assigned: dto.assigned ? '已分配' : '未分配', mergeAllowed: dto.mergeAllowed ? '可合托' : '不合托', urgent: dto.urgent ? '加急' : '普通',
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
status: inboundPlanStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
createdAt: formatOverseasDateTime(dto.createdAt), version: number(dto.version),
}
@@ -14,7 +14,7 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
beforeEach(() => {
push.mockReset()
vi.mocked(getLastMileOrder).mockResolvedValue({
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, grossWeightKg: 30, netWeightKg: 22, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [],
})
})
@@ -30,9 +30,11 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
expect(header.get('.status-badge').classes()).toContain('is-warning')
expect(header.get('.order-reference-text__copy').classes()).toContain('is-text')
const summary = wrapper.get('[aria-label="尾程订单概览"]')
expect(summary.findAll('dd').map(item => item.text())).toEqual(['示例客户', '洛杉矶仓', '0 箱', '2 托', '6.374 m³', expect.stringContaining('2026-09-14')])
expect(summary.findAll('dd').map(item => item.text())).toEqual(['示例客户', '洛杉矶仓', '0 箱', '2 托', '30 kg', '22 kg', '6.374 m³', expect.stringContaining('2026-09-14')])
const fields = wrapper.get('dl[aria-label="基本信息"]')
expect(fields.findAll('dt')).toHaveLength(20)
expect(fields.findAll('dt')).toHaveLength(22)
expect(fields.text()).toContain('货物毛重(kg)30')
expect(fields.text()).toContain('货物净重(kg)22')
expect(fields.text()).toContain('CONT-001')
expect(fields.text()).toContain('完整保留客户备注')
expect(fields.findAll('.wide')).toHaveLength(3)
@@ -55,4 +57,34 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
if (index === 1) expect(panel.text()).toContain('SKU-001')
}
})
it('shows declared, received and highlighted signed difference only for warehouse transfers', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({
order: { id: 'transfer-id', customerId: 'customer-1', orderNo: 'WT-001', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'WAIT_CUSTOMER_CONFIRMATION', packageCount: 10, receivedPackageCount: 12, packageDifference: 2 },
cargo: [], fees: [], statusHistory: [], operationHistory: [],
})
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const fields = wrapper.get('dl[aria-label="基本信息"]')
expect(fields.text()).toContain('客户预报箱数10')
expect(fields.text()).toContain('实收箱数12')
expect(fields.text()).toContain('箱数差异+2')
expect(fields.get('.package-difference--nonzero').text()).toBe('+2')
expect(wrapper.get('.status-badge').text()).toBe('待客户确认实测')
const overview = wrapper.get('[aria-label="尾程订单概览"]')
expect(overview.text()).toContain('实收总箱数12 箱')
expect(overview.text()).not.toContain('客户预报总箱数')
})
it('shows the declared total before a warehouse transfer has a received count', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({
order: { id: 'transfer-id', customerId: 'customer-1', orderNo: 'WT-002', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'WAIT_PDA_RECEIVING', packageCount: 10, receivedPackageCount: null, packageDifference: null },
cargo: [], fees: [], statusHistory: [], operationHistory: [],
})
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const overview = wrapper.get('[aria-label="尾程订单概览"]')
expect(overview.text()).toContain('客户预报总箱数10 箱')
expect(overview.text()).not.toContain('实收总箱数')
})
})
@@ -13,7 +13,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
import { formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
@@ -56,7 +56,7 @@ function isSingleLineBusinessField(field: DetailField) { return field.label ===
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
const fields: DetailField[] = [
{ label: '订单类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
{ label: '入仓码', value: text(order?.inboundCode) },
{ label: '货主', value: text(order?.customerName) },
@@ -69,7 +69,14 @@ const orderFields = computed<DetailField[]>(() => {
{ label: '仓库异常作业备注', wide: true, value: text(order?.warehouseRemark) },
{ label: '备注(客户)', wide: true, value: text(order?.customerRemark) },
]
if (order?.orderType === 'WAREHOUSE_TRANSFER') fields.splice(6, 0,
{ label: '客户预报箱数', value: order.packageCount == null ? '—' : order.packageCount },
{ label: '实收箱数', value: order.receivedPackageCount == null ? '—' : order.receivedPackageCount },
{ label: '箱数差异', value: packageDifferenceText(order.packageDifference) },
)
return fields
})
function isNonzeroDifference(field: DetailField) { return field.label === '箱数差异' && field.value !== '—' && field.value !== '0' }
const containerFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
@@ -77,6 +84,8 @@ const containerFields = computed<DetailField[]>(() => {
{ label: '集装箱号', value: text(order?.containerNo) },
{ label: '托数', value: Number(order?.palletCount || 0) },
{ label: '箱数', value: Number(order?.packageCount || 0) },
{ label: '货物毛重(kg)', value: Number(order?.grossWeightKg || 0) },
{ label: '货物净重(kg)', value: Number(order?.netWeightKg || 0) },
{ label: '体积(m³)', value: Number(order?.volumeCbm || 0) },
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
@@ -87,11 +96,15 @@ const containerFields = computed<DetailField[]>(() => {
const basicFields = computed(() => [...orderFields.value, ...containerFields.value])
const overviewFields = computed(() => {
const order = detail.value?.order
const warehouseTransfer = order?.orderType === 'WAREHOUSE_TRANSFER'
const hasReceivedPackages = warehouseTransfer && order?.receivedPackageCount != null
return [
{ label: '客户', value: text(order?.customerName) },
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
{ label: '总箱数', value: order?.packageCount == null ? '—' : order.packageCount + ' 箱' },
{ label: warehouseTransfer ? (hasReceivedPackages ? '实收总箱数' : '客户预报总箱数') : '总箱数', value: (hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount) == null ? '—' : `${hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount} 箱` },
{ label: '总托数', value: order?.palletCount == null ? '—' : order.palletCount + ' 托' },
{ label: '货物毛重', value: order?.grossWeightKg == null ? '—' : order.grossWeightKg + ' kg' },
{ label: '货物净重', value: order?.netWeightKg == null ? '—' : order.netWeightKg + ' kg' },
{ label: '体积', value: order?.volumeCbm == null ? '—' : order.volumeCbm + ' m³' },
{ label: '最近更新', value: formatOverseasDateTime(order?.updatedAt) },
]
@@ -196,7 +209,7 @@ onMounted(load)
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="basic"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>基本信息</span></template>
<dl class="detail-fields density-fields" aria-label="基本信息">
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
</dl>
</el-tab-pane>
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"></DataTable></section></el-tab-pane>
@@ -226,6 +239,7 @@ onMounted(load)
<style scoped>
.detail-fields > div.wide { grid-column: span 2; }
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
.plan-panel { margin-bottom: var(--admin-space-5); }
.related-panel { margin-top: var(--admin-space-5); }
.fee-form { min-width: 0; overflow-x: hidden; }
@@ -159,11 +159,26 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps the original carton count and adds only the signed difference in the list', async () => {
canAccess.mockReturnValue(false)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'transfer-1', customerId, orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'WAIT_CUSTOMER_CONFIRMATION',
packageCount: 10, receivedPackageCount: 8, packageDifference: -2,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const table = wrapper.findComponent(DataTable)
const columns = table.props('columns') as Array<{ key: string; label: string }>
expect(columns).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'cartonDifference', label: '差异(箱)' })]))
expect(columns.some((column) => ['declaredCartons', 'receivedCartons'].includes(column.key))).toBe(false)
expect((table.props('data') as Array<{ cartons: unknown }>)[0]?.cartons).toBe(10)
expect(wrapper.get('.package-difference--nonzero').text()).toBe('-2')
})
it.each([
[[], ['查看'], false],
[['inbound-order:create'], ['查看', '入库计划'], false],
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', ''], true],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', '暂扣订单', ''], true],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -197,6 +212,21 @@ describe('LastMileOrdersView pending order and container conversion', () => {
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
})
it.each([
['PENDING_ASSIGNMENT', true],
['INBOUND_COMPLETED', false],
])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => {
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
.find(item => item.text().includes('暂扣订单'))!
expect((holdButton.element as HTMLButtonElement).disabled).toBe(disabled)
})
it('keeps the original pending order table and does not render a view switch or container table', async () => {
const wrapper = await openPending()
expect(wrapper.text()).toContain('尾程单号')
@@ -231,6 +261,13 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(availableFields.find((field) => field.key === 'lxCode')).toMatchObject({
label: 'LX代码', placeholder: '请输入LX代码',
})
expect(availableFields.filter((field) => ['orderType', 'status', 'abnormal', 'detained'].includes(field.key)))
.toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'orderType', placeholder: '请选择订单类型' }),
expect.objectContaining({ key: 'status', placeholder: '请选择当前状态' }),
expect.objectContaining({ key: 'abnormal', placeholder: '请选择是否异常' }),
expect.objectContaining({ key: 'detained', placeholder: '请选择是否暂扣' }),
]))
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
@@ -305,6 +342,22 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(payload).not.toHaveProperty('plannedWarehouseName')
})
it('allows a historically held order to generate its inbound plan', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({
detained: true,
orderTypeCode: 'ONE_CLICK_DROPSHIP',
currentStatusCode: 'PENDING_ASSIGNMENT',
})])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(getContainerInboundPlanPreview).toHaveBeenCalledWith(bookingId)
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
})
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
@@ -1,8 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, MoreHorizontal } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { Download, Pencil } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -14,12 +14,14 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { lastMileStatusLabel, toLastMileOrderListItem } from '../lastMilePresentation'
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { saveCsv } from '@/shared/downloadCsv'
@@ -121,7 +123,7 @@ const allFields: QueryField[] = [
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '请选择订单类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
@@ -131,16 +133,19 @@ const allFields: QueryField[] = [
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' },
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '全部状态', options: [
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '待换标', value: 'WAIT_RELABEL' },
{ label: '待排车', value: 'READY_FOR_DISPATCH' },
{ label: '待出库', value: 'WAIT_OUTBOUND' },
{ label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
{ label: '待派送', value: 'PENDING_DELIVERY' },
{ label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' },
] },
{ key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '请选择是否异常', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
@@ -218,6 +223,7 @@ const columns: DataTableColumn[] = [
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
{ key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' },
{ key: 'cartons', prop: 'cartons', label: '箱数', width: 80, align: 'right' },
{ key: 'cartonDifference', prop: 'cartonDifference', label: '差异(箱)', width: 100, align: 'right', slotName: 'cartonDifference' },
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 100, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '数量', width: 80, align: 'right' },
{ key: 'volumeWeight', prop: 'volumeWeight', label: '体积重量', width: 100, align: 'right' },
@@ -487,7 +493,6 @@ function idempotencyKey(prefix: string) {
function openInboundPlan(source?: object) {
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
actionTarget.value = target
const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1
inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum,
@@ -559,6 +564,10 @@ async function submitInboundPlan() {
}
function openHold(source: object) {
const row = source as LastMileOrderListItem
if (!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED') {
ElMessage.warning('货物完成入库后才能暂扣;暂扣只限制后续排车和出库')
return
}
actionTarget.value = row
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
actionError.value = null
@@ -742,7 +751,6 @@ function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness
function blockerText(blockers: string[]) {
const labels: Record<string, string> = {
MISSING_INBOUND_CODE: '缺少入仓码',
ORDER_DETAINED: '订单已暂扣',
ORDER_STATUS_NOT_READY: '订单状态未就绪',
CONTAINER_NO_INCONSISTENT: '货柜号不一致',
RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致',
@@ -768,12 +776,48 @@ watch(containerConvertOpen, (open) => {
containerConversionIdempotencyKey = ''
})
function rowActionCount(source: object) {
const row = source as LastMileOrderListItem
return 1
+ Number(access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW')
+ Number(access.can(permission.inboundOrderCreate))
+ (access.can(permission.lastMileOrderTransfer) ? 2 : 0)
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') }
function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
return [
{ key: 'detail', label: '查看', priority: 500 },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' },
{ key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) },
]
}
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
if (command === 'detail') openDetail(row)
else if (command === 'review') openTransferReview(row)
else if (command === 'inbound-plan') openInboundPlan(row)
else if (command === 'hold') openHold(row)
else if (command === 'manual-nodes') void openManualNodes(row)
else if (command === 'confirm-relabel') void confirmRelabel(row)
else if (command === 'rework-relabel') void reworkRelabel(row)
}
async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成')
await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败').message) }
}
async function reworkRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
const task = await getRelabelReviewTask(relabelNo)
const { value } = await ElMessageBox.prompt('填写退回原因,仓库将重新执行换标并上传照片。', '重新换标', { inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字原因', confirmButtonText: '确认重新换标', cancelButtonText: '取消' })
await returnRelabelForRework(relabelNo, task.version, value.trim(), crypto.randomUUID())
ElMessage.success('已退回重新换标')
await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '重新换标失败').message) }
}
function openDetail(source: object) {
const row = source as LastMileOrderListItem
@@ -847,8 +891,9 @@ onMounted(() => { void load() })
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" size="small" @click="openInboundPlan(row)">入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
@@ -1054,5 +1099,6 @@ onMounted(() => { void load() })
.container-preview__metric { display: flex; min-height: var(--admin-row-height); padding: var(--admin-space-5) var(--admin-space-5); border: 0; border-radius: 0; background: var(--admin-surface); flex-direction: row; justify-content: flex-start; align-items: baseline; gap: var(--admin-space-3); border-right: 1px solid var(--cc-border); }
.container-preview__metric:last-child { border-right: 0; }
.container-preview__metric strong { margin-top: var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-body); line-height: 20px; margin: 0; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
</style>
@@ -30,6 +30,9 @@ const palletColumns: DataTableColumn[] = [
{ key: 'skuCode', label: '商品 SKU', width: 170 },
{ key: 'cartonMarks', label: '箱唛', minWidth: 220 },
{ key: 'packageCount', label: '箱数', width: 100, align: 'right' },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 120, align: 'right' },
{ key: 'volumeWeightKg', label: '材积重(kg)', width: 130, align: 'right' },
{ key: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right' },
{ key: 'pickedPackageCount', label: '已拣箱数', width: 110, align: 'right' },
{ key: 'status', label: '状态', width: 110 },
]
@@ -41,7 +44,7 @@ const warehouseLabel = computed(() => {
return outboundWarehouseId && delivery?.overseasWarehouseId === outboundWarehouseId ? show(delivery.overseasWarehouseName) : '—'
})
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
function outboundTypeLabel(value?: string | null) { return value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
const canCancel = computed(() => access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || '')))
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
@@ -54,7 +57,11 @@ watch(outboundNo, () => { void load() }, { immediate: true })
<template #header><div class="detail-heading"><el-button class="density-back" link aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions class="density-descriptions" :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.deliveries?.length" description="暂无关联派送单" :image-size="56" /><div v-else class="delivery-list"><el-descriptions v-for="delivery in detail.deliveries" :key="delivery.deliveryNo" class="density-descriptions" :column="4" border><el-descriptions-item label="目的 FBA 仓">{{ show(delivery.destinationWarehouseCode || delivery.destinationWarehouseName) }}</el-descriptions-item><el-descriptions-item label="派送单号">{{ show(delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ statusLabel(delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ delivery.palletCount }}</el-descriptions-item><el-descriptions-item label="箱数">{{ delivery.packageCount }}</el-descriptions-item><el-descriptions-item label="备注" :span="2">{{ show(delivery.remark) }}</el-descriptions-item></el-descriptions></div></section></template>
<template v-else-if="detail">
<section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions class="density-descriptions" :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="毛重(kg)">{{ detail.outbound.grossWeightKg ?? '—' }}</el-descriptions-item><el-descriptions-item label="材积重(kg)">{{ detail.outbound.volumeWeightKg ?? '—' }}</el-descriptions-item><el-descriptions-item label="体积(m³)">{{ detail.outbound.volumeCbm ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section>
<section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section>
<section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.deliveries?.length" description="暂无关联派送单" :image-size="56" /><div v-else class="delivery-list"><el-descriptions v-for="delivery in detail.deliveries" :key="delivery.deliveryNo" class="density-descriptions" :column="4" border><el-descriptions-item label="目的地">{{ delivery.destinationType === 'PRIVATE' ? show(delivery.deliveryAddress) : show(delivery.destinationWarehouseCode || delivery.destinationWarehouseName) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ show(delivery.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ show(delivery.contactPhone) }}</el-descriptions-item><el-descriptions-item label="派送单号">{{ show(delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ statusLabel(delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ delivery.palletCount }}</el-descriptions-item><el-descriptions-item label="箱数">{{ delivery.packageCount }}</el-descriptions-item><el-descriptions-item label="备注" :span="2">{{ show(delivery.remark) }}</el-descriptions-item></el-descriptions></div></section>
</template>
</DetailPageLayout></AdminPage>
</template>
@@ -19,6 +19,7 @@ const fixtures = Array.from({ length: 7 }, (_, index) => ({
inventoryPositionId: `position-${index}`, orderNo: `ORDER-${index}`, customerId: 'customer-a',
customerName: '客户甲', overseasWarehouseId: 'warehouse-a', palletNo: `PALLET-${index}`,
inboundCode: `IN-${index}`, skuCode: index < 6 ? 'MATCH' : 'OTHER', productName: '测试品名', availablePackages: index + 1,
grossWeightKg: (index + 1) * 10, volumeWeightKg: (index + 1) * 20, volumeCbm: index + 1,
}))
let wrapper: VueWrapper
async function clickButton(text: string) {
@@ -60,6 +61,9 @@ describe('outbound candidate search and selection', () => {
it('selects and deselects the current page while preserving other pages, then submits all selected quantities', async () => {
await openCreate()
expect(wrapper.text()).toContain('可出毛重(kg)')
expect(wrapper.text()).toContain('可出材积重(kg)')
expect(wrapper.text()).toContain('可出体积(m³)')
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('已选择 5 条')
@@ -81,4 +81,44 @@ describe('OutboundsView permissions and navigation', () => {
expect(source).toContain(':page-sizes="[5, 10, 20, 50]"')
expect(source).toContain('width="min(1320px, calc(100vw - 32px))"')
})
it('resolves warehouse-transfer customer names from the platform customer list', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
expect(source).toContain('function transferCustomerName(row: WarehouseTransferOutboundCandidate)')
expect(source).toContain('String(item.id) === String(row.customerId)')
expect(source).toContain("customer?.legalName?.trim() || customer?.tenantName?.trim()")
expect(source).toContain('transferCustomerName(row as WarehouseTransferOutboundCandidate)')
})
it('keeps warehouse option requests within the platform page-size limit', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
expect(source).toContain("size: 200, warehouseType: 'FBA'")
expect(source).not.toContain('size: 500')
})
it('batch-binds destinationless transfers without creating an outbound order', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
const api = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/api/outbounds.ts'), 'utf8')
expect(source).toContain('无目的地转运单')
expect(source).toContain('绑定目的地并进入排车')
expect(source).toContain('privateDestinationPostalCode: postalCode.value.trim()')
expect(source).toContain('transfers: selected.map(item => ({ customerId: item.customerId, transferNo: item.transferNo }))')
expect(api).toContain('/platform/v1/last-leg/warehouse-transfers/bind-destination')
expect(source).not.toContain('await createWarehouseTransferOutbound')
})
it('auto-selects the sole warehouse-transfer candidate and shows destination state', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
const api = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/api/outbounds.ts'), 'utf8')
expect(source).toContain("if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])]")
expect(source).toContain("transferCandidates.value.length === 1 ? [transferCandidates.value[0]] : []")
expect(source).toContain('label="主单目的去向"')
expect(source).toContain("['私人仓', row.privateDestinationAddress?.trim(), row.privateDestinationPostalCode?.trim()]")
expect(source).toContain('`FBA · ${row.destinationWarehouseCode.trim()}`')
expect(source).toContain("return '未设置'")
expect(api).toContain('destinationWarehouseCode?: string | null')
expect(api).toContain("destinationType?: 'FBA' | 'PRIVATE' | null")
expect(api).toContain('privateDestinationAddress?: string | null')
expect(api).toContain('privateDestinationPostalCode?: string | null')
})
})
@@ -15,7 +15,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listOneClickOutboundCandidates, listReturnOutboundCandidates, listOutbounds, type OneClickOutboundCandidate, type OutboundRow, type ReturnOutboundCandidate } from '../api/outbounds'
import { bindWarehouseTransferDestination, cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listOneClickOutboundCandidates, listReturnOutboundCandidates, listWarehouseTransferOutboundCandidates, listOutbounds, type OneClickOutboundCandidate, type OutboundRow, type ReturnOutboundCandidate, type WarehouseTransferOutboundCandidate } from '../api/outbounds'
import { type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -25,9 +25,15 @@ import { getReturnCarton, listReturnCartons, listReturnStock, type ReturnCartonD
const router = useRouter(); const access = useAccessPreviewStore()
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ documentNo: '', lxCode: '', warehouseId: '', status: '', outboundType: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const cartonMarks = ref(''); const selectedReturnMarks = ref<string[]>([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const dropshipCandidates = ref<OneClickOutboundCandidate[]>([]); const selectedDropshipIds = ref<string[]>([]); const returnCandidates = ref<ReturnOutboundCandidate[]>([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false); const dropshipQueryInput = ref(''); const dropshipQuery = ref(''); const dropshipPage = ref(1); const dropshipPageSize = ref(5)
const filters = ref<QueryModel>({ documentNo: '', lxCode: '', warehouseId: '', status: '', outboundType: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const cartonMarks = ref(''); const selectedReturnMarks = ref<string[]>([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const dropshipCandidates = ref<OneClickOutboundCandidate[]>([]); const selectedDropshipIds = ref<string[]>([]); const returnCandidates = ref<ReturnOutboundCandidate[]>([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND' | 'WAREHOUSE_TRANSFER_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false); const dropshipQueryInput = ref(''); const dropshipQuery = ref(''); const dropshipPage = ref(1); const dropshipPageSize = ref(5)
const tenantOptions = ref<PlatformTenantResource[]>([])
const warehouseOptionsSource = ref<PlatformWarehouseSummaryResource[]>([])
const fbaWarehouseOptionsSource = ref<PlatformWarehouseSummaryResource[]>([])
const transferCandidates = ref<WarehouseTransferOutboundCandidate[]>([])
const selectedTransferPalletIds = ref<string[]>([])
const destinationType = ref<'FBA' | 'PRIVATE'>('FBA')
const selectedDestinationWarehouseId = ref('')
const deliveryAddress = ref(''); const postalCode = ref(''); const contactName = ref(''); const contactPhone = ref('')
const returnStockRows = ref<ReturnStockRow[]>([])
const returnCartonRows = ref<ReturnCartonRow[]>([])
const selectedReturnSkus = ref<string[]>([])
@@ -49,6 +55,14 @@ const customerOptions = computed(() => {
})
const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value))
const warehouseOptions = computed(() => warehouseOptionsSource.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ value: String(item.id), label: [item.warehouseName || '仓库名称待补充', item.warehouseCode].filter(Boolean).join(' · ') })))
const fbaWarehouseOptions = computed(() => fbaWarehouseOptionsSource.value.filter(item => item.status === 'ACTIVE').map(item => ({ value: String(item.id), code: item.warehouseCode || '', name: item.warehouseName || '', label: [item.warehouseName || 'FBA仓名称待补充', item.warehouseCode].filter(Boolean).join(' · ') })))
const selectedTransferRows = computed(() => transferCandidates.value.filter(item => selectedTransferPalletIds.value.includes(`${item.customerId}:${item.transferNo}`)))
function transferCustomerName(row: WarehouseTransferOutboundCandidate) {
const snapshotName = row.customerName?.trim()
if (snapshotName) return snapshotName
const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId))
return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}`
}
const selectedReturnStock = computed(() => returnStockRows.value.filter((item) => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '').toLowerCase()).filter(Boolean)
@@ -70,17 +84,18 @@ const allPageSelected = computed(() => pagedDropshipCandidates.value.length > 0
const somePageSelected = computed(() => selectedPageCount.value > 0 && !allPageSelected.value)
const selectedDropshipCustomerCount = computed(() => new Set(dropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).map(item => item.customerId).filter(Boolean)).size)
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待拣货', value: 'WAIT_PICK' }, { label: '拣货中', value: 'PICKING' }, { label: '已拣货', value: 'PICKED' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'grossWeightKg', prop: 'grossWeightKg', label: '毛重(kg)', width: 120, align: 'right' }, { key: 'volumeWeightKg', prop: 'volumeWeightKg', label: '材积重(kg)', width: 130, align: 'right' }, { key: 'volumeCbm', prop: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right' }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待拣货', value: 'WAIT_PICK' }, { label: '拣货中', value: 'PICKING' }, { label: '已拣货', value: 'PICKED' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '仓储转运出库', value: 'WAREHOUSE_TRANSFER_OUTBOUND' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }]
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, outboundType: String(filters.value.outboundType || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
function outboundTypeLabel(value?: string | null) { return value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
async function loadInventoryOptions() {
inventoryLoading.value = true
try {
const [tenants, warehouses] = await Promise.allSettled([
const [tenants, warehouses, fbaWarehouses] = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'FBA', status: 'ACTIVE' }),
])
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value.records
else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message)
@@ -91,13 +106,40 @@ async function loadInventoryOptions() {
selectedWarehouseId.value = String(lax?.id || warehouses.value.records[0]?.id || '')
}
} else ElMessage.error(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败').message)
if (fbaWarehouses.status === 'fulfilled') fbaWarehouseOptionsSource.value = fbaWarehouses.value.records
else ElMessage.error(getApiErrorDetails(fbaWarehouses.reason, 'FBA目的仓加载失败').message)
} finally { inventoryLoading.value = false }
}
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; dropshipCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function loadTransferCandidates() {
inventoryLoading.value = true
try {
transferCandidates.value = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
const candidateKeys = new Set(transferCandidates.value.map(transferSelectionKey))
selectedTransferPalletIds.value = selectedTransferPalletIds.value.filter(key => candidateKeys.has(key))
if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])]
} catch (cause) {
transferCandidates.value = []
selectedTransferPalletIds.value = []
ElMessage.error(getApiErrorDetails(cause, '无目的地仓储转运单加载失败').message)
} finally { inventoryLoading.value = false }
}
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
function toggleTransferPallet(row: WarehouseTransferOutboundCandidate, checked: boolean | string | number) { const key = transferSelectionKey(row); selectedTransferPalletIds.value = checked ? [...new Set([...selectedTransferPalletIds.value, key])] : selectedTransferPalletIds.value.filter(id => id !== key) }
function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
if (row.destinationType === 'PRIVATE' || row.privateDestinationAddress?.trim()) {
return ['私人仓', row.privateDestinationAddress?.trim(), row.privateDestinationPostalCode?.trim()].filter(Boolean).join(' · ')
}
if (row.destinationWarehouseCode?.trim()) return `FBA · ${row.destinationWarehouseCode.trim()}`
return '未设置'
}
function transferHasDestination(row: WarehouseTransferOutboundCandidate) {
return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim())
}
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
@@ -115,7 +157,35 @@ function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(re
function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 }
function toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter((value) => value !== sku) }
function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) { selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter((value) => value !== cartonMark) }
async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }; if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!recipient.value.trim() || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请填写收件信息并选择退货单和箱唛'); return }; await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }); ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务') } else { const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))); if (!selected.length) { ElMessage.warning('请至少选择一个已完成入库的一件代发订单'); return }; const customerIds = new Set(selected.map(item => item.customerId).filter(Boolean)); if (customerIds.size !== 1) { ElMessage.warning('一张出库单只能选择同一个客户的订单'); return }; const warehouses = new Set(selected.map(item => item.overseasWarehouseId).filter(Boolean)); if (warehouses.size > 1 || (warehouses.size === 1 && !warehouses.has(selectedWarehouseId.value))) { ElMessage.warning('所选订单必须属于当前海外仓'); return }; const items = selected.map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode || '', cartonMarks: [] as string[] })); await createOneClickDropshipOutbound([...customerIds][0] || '', { recipient: '一件代发订单直出', items, remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`) } createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false } }
async function submit() {
if (outboundType.value !== 'WAREHOUSE_TRANSFER_OUTBOUND' && !selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }
if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }
saving.value = true
try {
if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') {
const selected = selectedTransferRows.value.length ? selectedTransferRows.value : transferCandidates.value.length === 1 ? [transferCandidates.value[0]] : []
if (!selected.length) { ElMessage.warning('请至少选择一个无目的地的仓储转运单'); return }
const destination = fbaWarehouseOptions.value.find(item => item.value === selectedDestinationWarehouseId.value)
if (destinationType.value === 'FBA' && !destination) { ElMessage.warning('请选择FBA目的仓'); return }
if (destinationType.value === 'PRIVATE' && (!deliveryAddress.value.trim() || !postalCode.value.trim() || !contactName.value.trim() || !contactPhone.value.trim())) { ElMessage.warning('请完整填写私人仓地址、邮编、联系人和电话'); return }
await bindWarehouseTransferDestination({ transfers: selected.map(item => ({ customerId: item.customerId, transferNo: item.transferNo })), destinationType: destinationType.value, destinationWarehouseId: destination?.value, destinationWarehouseCode: destination?.code, destinationWarehouseName: destination?.name, privateDestinationAddress: deliveryAddress.value.trim() || undefined, privateDestinationPostalCode: postalCode.value.trim() || undefined, privateDestinationContactName: contactName.value.trim() || undefined, privateDestinationContactPhone: contactPhone.value.trim() || undefined, idempotencyKey: `web-transfer-destination-${Date.now()}` })
ElMessage.success(`已为 ${selected.length} 个转运单绑定目的地,可进入智能排车`)
} else if (outboundType.value === 'RETURN_OUTBOUND') {
if (!recipient.value.trim() || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请填写收件信息并选择退货单和箱唛'); return }
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` })
ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务')
} else {
const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item)))
if (!selected.length) { ElMessage.warning('请至少选择一个已完成入库的一件代发订单'); return }
const customerIds = new Set(selected.map(item => item.customerId).filter(Boolean)); if (customerIds.size !== 1) { ElMessage.warning('一张出库单只能选择同一个客户的订单'); return }
const warehouses = new Set(selected.map(item => item.overseasWarehouseId).filter(Boolean)); if (warehouses.size > 1 || (warehouses.size === 1 && !warehouses.has(selectedWarehouseId.value))) { ElMessage.warning('所选订单必须属于当前海外仓'); return }
const items = selected.map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode || '', cartonMarks: [] as string[] }))
await createOneClickDropshipOutbound([...customerIds][0] || '', { recipient: '一件代发订单直出', items, remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` })
ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`)
}
createOpen.value = false; await load()
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false }
}
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
@@ -135,12 +205,13 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit">
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form density-form" label-position="top">
<el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType">
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
<el-radio-button label="RETURN_OUTBOUND">退货出库</el-radio-button>
<el-radio-button label="WAREHOUSE_TRANSFER_OUTBOUND">仓储转运出库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
@@ -148,7 +219,7 @@ onMounted(load)
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<template v-if="outboundType !== 'RETURN_OUTBOUND'">
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
<div class="dropship-search-row">
<el-form-item label="海外仓" required>
<el-select v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
@@ -176,6 +247,9 @@ onMounted(load)
<el-table-column prop="skuCode" label="SKU" min-width="140" show-overflow-tooltip />
<el-table-column prop="productName" label="品名" min-width="140" show-overflow-tooltip />
<el-table-column prop="availablePackages" label="可出箱数" width="95" align="right" />
<el-table-column prop="grossWeightKg" label="可出毛重(kg)" width="125" align="right" />
<el-table-column prop="volumeWeightKg" label="可出材积重(kg)" width="135" align="right" />
<el-table-column prop="volumeCbm" label="可出体积(m³)" width="125" align="right" />
</el-table>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
<div class="candidate-pagination">
@@ -186,6 +260,29 @@ onMounted(load)
<p v-else class="candidate-hint">按托盘与 SKU 选择,默认出库全部可用箱数;仅限同一客户、同一海外仓,翻页保留选择。</p>
</section>
</template>
<template v-else-if="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND'">
<el-alert title="仅展示已完成入库、换标已完成或无需换标,并且尚未设置目的地的仓储转运单。绑定后将从此列表移除并进入智能排车候选。" type="info" :closable="false" />
<el-form-item label="无目的地转运单" required>
<el-table v-loading="inventoryLoading" :data="transferCandidates" border stripe class="dropship-candidate-table" max-height="380">
<el-table-column label="选择" width="68" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedTransferPalletIds.includes(transferSelectionKey(row as WarehouseTransferOutboundCandidate))" @change="toggleTransferPallet(row as WarehouseTransferOutboundCandidate, $event)" /></template></el-table-column>
<el-table-column label="客户" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ transferCustomerName(row as WarehouseTransferOutboundCandidate) }}</template></el-table-column>
<el-table-column prop="transferNo" label="转运单号" min-width="190" show-overflow-tooltip />
<el-table-column prop="packageCount" label="入库箱数" width="100" align="right" />
<el-table-column prop="volumeCbm" label="总体积(m³)" width="120" align="right" />
<el-table-column label="换标" width="110"><template #default="{ row }">{{ row.needRelabel ? `已换标${row.relabelNo ? ` · ${row.relabelNo}` : ''}` : '原箱出库' }}</template></el-table-column>
<el-table-column label="主单目的去向" min-width="240" show-overflow-tooltip><template #default="{ row }"><StatusBadge :status="transferHasDestination(row as WarehouseTransferOutboundCandidate) ? 'READY' : 'PENDING'" :label="transferDestinationLabel(row as WarehouseTransferOutboundCandidate)" /></template></el-table-column>
</el-table>
<div v-if="!inventoryLoading && !transferCandidates.length" class="form-hint">暂无已入库且尚无目的地的仓储转运单</div>
</el-form-item>
<el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="destinationType === 'FBA'" label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<template v-else>
<el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item>
<el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item>
<el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item>
</template>
</template>
<template v-else>
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
@@ -259,8 +259,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).toContain('排车明细已同步到预排列表')
expect(dialog).toContain("router.push({ name: 'overseas-dispatches' })")
expect(dialog).not.toContain('customerId:')
expect(dialog).toContain("planIds: planIds.value.length ? planIds.value.join(',') : undefined")
expect(dialog).toContain("planIds: query.sourceStage !== 'RELABEL' && planIds.value.length ? planIds.value.join(',') : undefined")
expect(dialog).toContain('v-model="query.fbaWarehouseCode" clearable')
expect(dialog).toContain("订单号 / 换标单号 / 原入仓码")
expect(dialog).toContain('label="私人仓"')
expect(dialog).toContain('item.deliveryAddress, item.postalCode')
expect(dialog).not.toContain(':model-value="fbaWarehouseCode" readonly')
expect(dialog).toContain("sourceStage: 'RECEIVED'")
expect(dialog).toContain('一次排车只能装同一客户的托盘')
@@ -335,7 +338,13 @@ describe('overseas warehouse first-batch prototype contract', () => {
for (const [fileName, routeName] of expected) {
const value = source(fileName)
expect(value).toContain(routeName)
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
if (fileName === 'DispatchesView.vue') {
expect(value).toContain('<RowActions :actions="rowActions(row as DispatchListItem)"')
expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }")
expect(value).toContain("if (command === 'detail') openDetail(row)")
} else {
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
}
}
})
@@ -32,26 +32,26 @@ describe('overseas warehouse mounted real API pages', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listInboundOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', sourceOrderNo: 'FL-WH-20260911-000001', customerName: '客户甲', orderType: 'ONE_CLICK_DROPSHIP', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, createdAt: '2026-09-11T10:00:00', updatedAt: '2026-09-11T10:10:00', remark: '页面验收', lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', sourceOrderNo: 'FL-WH-20260911-000001', customerName: '客户甲', orderType: 'ONE_CLICK_DROPSHIP', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalGrossWeightKg: 30, totalNetWeightKg: 22, totalVolumeCbm: 2, createdAt: '2026-09-11T10:00:00', updatedAt: '2026-09-11T10:10:00', remark: '页面验收', lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', grossWeightKg: 30, netWeightKg: 22, volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', inboundCode: 'NYEXX2608280033', containerNo: 'TSTU0000061', containerNode: 'PICKUP_READY', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
})
it('keeps receiving warehouse and FBA warehouse independent in the inbound-plan list', async () => {
vi.mocked(listInboundOrders).mockResolvedValue({
records: [
{ lineId: '1', planId: '11', planNo: 'IP-1', customerId: '10001', orderId: '21', inboundCode: 'IN-1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', warehouseId: '31', warehouseCode: 'ABE3', warehouseName: 'ABE3目的仓', fbaWarehouseCode: 'ABE3' },
{ lineId: '1', planId: '11', planNo: 'IP-1', customerId: '10001', orderId: '21', inboundCode: 'IN-1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', warehouseId: '31', warehouseCode: 'ABE3', warehouseName: 'ABE3目的仓', fbaWarehouseCode: 'ABE3', grossWeightKg: 30, netWeightKg: 22 },
{ lineId: '2', planId: '12', planNo: 'IP-2', customerId: '10001', orderId: '22', inboundCode: 'IN-2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', warehouseId: '32', warehouseCode: 'ONT8', warehouseName: 'ONT8目的仓', fbaWarehouseCode: 'ONT8' },
], page: 1, size: 10, total: 2, pages: 1,
})
const wrapper = mount(InboundOrdersView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const rows = wrapper.findComponent(DataTable).props('data') as Array<{ warehouse: string; fbaWarehouse: string; destinationWarehouseName: string }>
expect(rows[0]).toMatchObject({ warehouse: 'US-LAX-01 · 洛杉矶接柜仓', fbaWarehouse: 'ABE3', destinationWarehouseName: 'ABE3目的仓' })
const rows = wrapper.findComponent(DataTable).props('data') as Array<{ warehouse: string; fbaWarehouse: string; destinationWarehouseName: string; grossWeight: number; netWeight: number }>
expect(rows[0]).toMatchObject({ warehouse: 'US-LAX-01 · 洛杉矶接柜仓', fbaWarehouse: 'ABE3', destinationWarehouseName: 'ABE3目的仓', grossWeight: 30, netWeight: 22 })
expect(rows[1]).toMatchObject({ warehouse: '—', fbaWarehouse: 'ONT8', destinationWarehouseName: 'ONT8目的仓' })
})
it('mounts inbound detail with business references and independent receiving and FBA warehouse references', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).not.toContain('9007199254740995'); expect(wrapper.text()).toContain('FL-WH-20260911-000001'); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('一件代发'); expect(wrapper.text()).toContain('2026-09-11 10:00'); expect(wrapper.text()).toContain('页面验收'); expect(wrapper.text()).toContain('US-LAX-01 · 洛杉矶接柜仓'); expect(wrapper.text()).toContain('ABE3'); expect(wrapper.text()).not.toContain('ABE3目的仓') })
it('mounts inbound detail with business references and independent receiving and FBA warehouse references', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).not.toContain('9007199254740995'); expect(wrapper.text()).toContain('FL-WH-20260911-000001'); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('一件代发'); expect(wrapper.text()).toContain('2026-09-11 10:00'); expect(wrapper.text()).toContain('页面验收'); expect(wrapper.text()).toContain('US-LAX-01 · 洛杉矶接柜仓'); expect(wrapper.text()).toContain('ABE3'); expect(wrapper.text()).toContain('货物毛重(kg)30'); expect(wrapper.text()).toContain('货物净重(kg)22'); expect(wrapper.text()).not.toContain('ABE3目的仓') })
it('shows the real pallet and owner plan for a cross-plan merged allocation', async () => {
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '101', planNo: 'IP-CURRENT', customerId: '10001', sourceOrderId: '1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PICKING', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '201', orderId: '1', inboundCode: 'EXXHK2609100014', fbaWarehouseCode: 'ACY2', requiredPackageCount: 150, plannedPalletCount: 1 }], pallets: [{ id: '301', palletNo: 'DRYU2309389-101-005', sequenceNo: 1, actualPackageCount: 0 }], allocations: [{ lineId: '201', palletId: '302', palletNo: 'DRYU2309389-101-006', palletOwnerPlanNo: 'IP-OWNER', palletOwnedByCurrentPlan: false, plannedPackageCount: 150, countedPackageCount: 150, pickedPackageCount: 150, status: 'PICKED', version: 0 }] })
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '101', planNo: 'IP-CURRENT', customerId: '10001', sourceOrderId: '1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PICKING', lineCount: 1, palletCount: 1, totalGrossWeightKg: 30, totalNetWeightKg: 22, totalVolumeCbm: 2, lines: [{ id: '201', orderId: '1', inboundCode: 'EXXHK2609100014', fbaWarehouseCode: 'ACY2', requiredPackageCount: 150, plannedPalletCount: 1 }], pallets: [{ id: '301', palletNo: 'DRYU2309389-101-005', sequenceNo: 1, actualPackageCount: 0 }], allocations: [{ lineId: '201', palletId: '302', palletNo: 'DRYU2309389-101-006', palletOwnerPlanNo: 'IP-OWNER', palletOwnedByCurrentPlan: false, plannedPackageCount: 150, countedPackageCount: 150, pickedPackageCount: 150, status: 'PICKED', version: 0 }] })
routing.route.params = { orderId: 'IP-CURRENT' }; routing.route.query = { customerId: '10001' }
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } })
@@ -62,7 +62,7 @@ describe('overseas warehouse mounted real API pages', () => {
expect(wrapper.text()).not.toContain('DRYU2309389-101-005(跨计划合托)')
})
it('shows an empty receiving warehouse when either frozen field is missing without falling back to destination data', async () => {
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '1', planNo: 'IP-2', customerId: '10001', sourceOrderId: '2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 0, totalVolumeCbm: 0, lines: [{ id: '3', orderId: '2', inboundCode: 'IN-2', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', requiredPackageCount: 1, plannedPalletCount: 0 }], pallets: [], allocations: [] })
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '1', planNo: 'IP-2', customerId: '10001', sourceOrderId: '2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [{ id: '3', orderId: '2', inboundCode: 'IN-2', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', requiredPackageCount: 1, plannedPalletCount: 0 }], pallets: [], allocations: [] })
routing.route.params = { orderId: 'IP-2' }; routing.route.query = { customerId: '10001' }
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { matchesSmartDispatchWarehouseTab } from './smartDispatchWarehouseTab'
describe('smart dispatch warehouse tabs', () => {
it('uses the overseas warehouse code when the warehouse name is unavailable', () => {
const losAngeles = { overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: null }
const newYork = { overseasWarehouseCode: 'US-NY-01', overseasWarehouseName: null }
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'LAX')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'NYC')).toBe(false)
expect(matchesSmartDispatchWarehouseTab(newYork, 'NYC')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(newYork, 'LAX')).toBe(false)
})
it('keeps the all tab unfiltered and supports warehouse names as a fallback', () => {
const warehouse = { overseasWarehouseCode: null, overseasWarehouseName: '洛杉矶接柜仓' }
expect(matchesSmartDispatchWarehouseTab(warehouse, 'ALL')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(warehouse, 'LAX')).toBe(true)
})
})
@@ -0,0 +1,16 @@
import type { DispatchPalletCandidateDto } from './api/dispatches'
export type SmartDispatchWarehouseTab = 'ALL' | 'LAX' | 'NYC'
export function matchesSmartDispatchWarehouseTab(
candidate: Pick<DispatchPalletCandidateDto, 'overseasWarehouseCode' | 'overseasWarehouseName'>,
tab: SmartDispatchWarehouseTab,
) {
if (tab === 'ALL') return true
const warehouse = [candidate.overseasWarehouseCode, candidate.overseasWarehouseName]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ')
if (tab === 'LAX') return /lax|洛杉矶|los angeles/.test(warehouse)
return /nyc|jfk|(?:^|[^a-z])ny(?:[^a-z]|$)|纽约|new[ -]?york/.test(warehouse)
}
+4
View File
@@ -32,6 +32,7 @@ export interface LastMileOrderListItem {
version: number
pallets: number | '—'
cartons: number | '—'
cartonDifference: number | '—'
volume: number | '—'
quantity: number | '—'
volumeWeight: number | '—'
@@ -46,6 +47,7 @@ export interface LastMileOrderListItem {
/** Read-only association fact; this module does not implement overseas delivery. */
dispatchOrderNo: string
relabelOrderNo: string
relabelStatusCode: string
relabelStatus: string
consolidated: string
palletMergeFlag: string
@@ -74,6 +76,8 @@ export interface InboundOrderListItem {
owner: string
cartons: number
pickedCartons: number
grossWeight: number
netWeight: number
volume: number
pallets: number
assigned: string
@@ -2,12 +2,13 @@
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { FileUp, MoreHorizontal, Plus, RefreshCw, X } from '@lucide/vue'
import { FileUp, Plus, RefreshCw, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
@@ -88,8 +89,23 @@ function canDelete(row: InboundFeeDetail) { return isManual(row) && access.can('
function canReview(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
function canAdjust(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function canVoid(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function rowActionCount(row: InboundFeeDetail) {
return Number(canEdit(row)) + 2 * Number(canReview(row)) + Number(canAdjust(row)) + Number(canVoid(row)) + Number(canDelete(row))
function feeRowActions(row: InboundFeeDetail): RowActionItem[] {
return [
{ key: 'edit', label: '编辑', priority: 400, visible: canEdit(row) },
{ key: 'approve', label: '审核', priority: 350, tone: 'warning', visible: canReview(row) },
{ key: 'reject', label: '驳回', priority: 300, tone: 'warning', visible: canReview(row) },
{ key: 'adjust', label: '调整', priority: 250, visible: canAdjust(row) },
{ key: 'void', label: '作废', priority: 200, tone: 'danger', visible: canVoid(row) },
{ key: 'delete', label: '删除', tone: 'danger', divided: true, visible: canDelete(row) },
]
}
function handleFeeRowAction(command: string, row: InboundFeeDetail) {
if (command === 'edit') openEditor('edit', row)
else if (command === 'approve') void approve(row)
else if (command === 'reject') openReject(row)
else if (command === 'adjust') openEditor('adjust', row)
else if (command === 'void') void voidRow(row)
else if (command === 'delete') void remove(row)
}
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function publicationLabel(value: string) {
@@ -287,22 +303,7 @@ onMounted(() => void load())
<div v-loading="loading" class="fee-table-shell">
<el-table :data="rows" border stripe empty-text="暂无费用数据" row-key="id">
<el-table-column label="操作" fixed="left" width="250">
<template #default="{ row }">
<div class="density-actions">
<el-button v-if="canEdit(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('edit', row as InboundFeeDetail)">编辑</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="approve(row as InboundFeeDetail)">审核</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="openReject(row as InboundFeeDetail)">驳回</el-button>
<el-button v-if="canAdjust(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('adjust', row as InboundFeeDetail)">调整</el-button>
<el-button v-if="canVoid(row as InboundFeeDetail)" class="density-action" data-tone="danger" @click="voidRow(row as InboundFeeDetail)">作废</el-button>
<el-button v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) <= 3" class="density-action" data-tone="danger" @click="remove(row as InboundFeeDetail)">删除</el-button>
<el-dropdown v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) > 3" trigger="click">
<el-button class="density-action" data-tone="neutral" :aria-label="(row.feeTypeNameSnapshot || '当前费用') + '的更多操作'" title="更多操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="remove(row as InboundFeeDetail)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
<template #default="{ row }"><RowActions :actions="feeRowActions(row as InboundFeeDetail)" :more-label="`${(row as InboundFeeDetail).feeTypeNameSnapshot || '当前费用'}的更多操作`" @action="handleFeeRowAction($event, row as InboundFeeDetail)" /></template>
</el-table-column>
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
@@ -28,6 +28,11 @@ describe('UnifiedOrdersView contract', () => {
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
})
it('locks measurement changes as soon as the customer confirms the fee', () => {
expect(source).toContain('&& !measurementDetail.value.customerFeeConfirmed')
expect(source).toContain('费用已由客户确认,测量数据已锁定')
})
it('prevents duplicate confirm-and-send chains and reuses one attempt idempotency key set', () => {
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
@@ -224,10 +229,9 @@ describe('UnifiedOrdersView contract', () => {
})
it('counts detail in the three directly visible row actions', () => {
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(0, 2)')
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(2)')
expect(source).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }")
expect(source).toContain('...unifiedOrderSecondaryActions(row).map')
expect(source).toContain('<RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
})
it('opens smart loading from the order table toolbar with its own permission', () => {
@@ -387,9 +391,8 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('请至少输入一项需要修改的外箱尺寸')
expect(source).toContain(".map((value) => value == null || value === '' ? null : Number(value))")
expect(source).toContain('相关数值已重新计算')
expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)')
expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain('handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)')
expect(source).toContain(':more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain("can('platform:order:note')")
expect(source).toContain('await loadOrders()')
expect(source).toContain('orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus)')
@@ -440,7 +443,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('@visible-change="handleOuterModeDropdownVisibleChange($event)"')
expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !measurementStrategyEditable"/g)).toHaveLength(1)
expect(source).toContain('const measurementStrategyEditable = computed')
expect(source).toContain('客户已确认且该票已装柜完成后,箱规计算规则将冻结')
expect(source).toContain('费用已由客户确认,测量数据已锁定')
expect(source).toContain('if (!measurementStrategyEditable.value)')
expect(source).toContain('const previousMode = measurementSectionModes.outer')
expect(source).toContain('if (!saved) measurementSectionModes.outer = previousMode')
@@ -679,9 +682,9 @@ describe('UnifiedOrdersView contract', () => {
it('uses outlined semantic buttons for row business actions while keeping detail navigation explicit', () => {
const rowActions = source.slice(source.indexOf('<template #actions'), source.indexOf('<template #inboundCode'))
expect(rowActions).toContain('density-action')
expect(rowActions).not.toContain('v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link')
expect(rowActions).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
expect(rowActions).toContain('<RowActions')
expect(rowActions).toContain(':actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
expect(rowActions).toContain('@action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)"')
expect(source).toContain('<el-button link class="back-button density-back"')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, MoreHorizontal, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -42,6 +42,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import {
firstLegBusinessTypeLabel,
firstLegContainerModeLabel,
@@ -231,11 +232,13 @@ const measurementStrategyEditable = computed(() => (
can('platform:firstleg:inbound:measure:edit')
&& Boolean(measurementDetail.value?.measurementId)
&& measurementDetail.value?.version != null
&& !measurementDetail.value.customerFeeConfirmed
))
const measurementStrategyLockedHint = computed(() => {
if (!can('platform:firstleg:inbound:measure:edit')) return '当前账号缺少测量编辑权限'
if (!measurementDetail.value?.measurementId) return '当前没有可修改的测量批次'
return '客户已确认且该票已装柜完成后,箱规计算规则将冻结'
if (measurementDetail.value.customerFeeConfirmed) return '费用已由客户确认,测量数据已锁定'
return '当前测量数据不可修改'
})
const cargoImagePreviewUrls = new Set<string>()
const measurementDialogOpen = ref(false)
@@ -2001,12 +2004,6 @@ function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRo
...(can('platform:order:note') ? ['note' as const] : []),
]
}
function directUnifiedOrderActions(row: UnifiedOrderResource) {
return unifiedOrderSecondaryActions(row).slice(0, 2)
}
function moreUnifiedOrderActions(row: UnifiedOrderResource) {
return unifiedOrderSecondaryActions(row).slice(2)
}
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
return `更多订单操作:${String(row.inboundCode || '').trim() || '当前入仓单'}`
}
@@ -2015,6 +2012,22 @@ function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
if (action === 'approve') return '通过'
return '测量'
}
function unifiedOrderRowActions(row: UnifiedOrderResource): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 500 },
...unifiedOrderSecondaryActions(row).map((action, index): RowActionItem => ({
key: action,
label: unifiedOrderActionLabel(action),
priority: 400 - index,
tone: action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral',
loading: action === 'approve' && approvalLoadingId.value === String(row.orderId),
})),
]
}
function handleUnifiedOrderRowAction(command: string, row: UnifiedOrderResource) {
if (command === 'detail') openDetail(row)
else runMoreAction(command, row)
}
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
if (command === 'note') openNoteDialog(row)
if (command === 'approve') openApproval(row)
@@ -2645,20 +2658,7 @@ onBeforeUnmount(() => {
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }">
<div class="row-actions density-actions">
<el-button class="density-action" size="small" @click="openDetail(row as UnifiedOrderResource)">详情</el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" class="density-action" :data-tone="action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral'" size="small" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)">{{ unifiedOrderActionLabel(action) }}</el-button>
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button class="density-action" size="small" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="action in moreUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :command="action">{{ unifiedOrderActionLabel(action) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template #actions="{ row }"><RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #inboundCode="{ row }">
<div class="inbound-relation-cell order-code-cell">
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
+1
View File
@@ -11,6 +11,7 @@
- 查询与筛选使用 `QueryToolbar.vue`,通过 `fields`、`v-model`、`search`、`reset` 接入;查询即刷新当前结果,不另设独立刷新按钮。
- 数据列表使用 `DataTable.vue`,通过 `columns`、命名插槽、`v-model:page`、`v-model:page-size` 和 `total` 接入,不重复拼装 `el-table` 与分页器。
- “操作”列放在第一列,需要固定时固定在左侧。单行操作不超过 3 个时直接显示“图标 + 文案”;第 4 个起使用 `MoreHorizontal` 菜单,并优先直接保留详情、编辑、下载等高频操作。
- 列表操作统一使用 `RowActions.vue`:默认直接展示 3 个,通过 `priority` 决定高频或关键操作的展示资格,其余自动进入带可访问名称的“更多”菜单;权限和状态由业务页面生成 `actions` 时决定,组件不推导业务资格。
- 新增、导入、导出和批量操作放入 `DataTable` 的 `toolbar-actions`;不放入查询栏,不用 `toolbar-start` 重复列表标题、数量或说明,数量统一放在 `pagination-left`。
- 路由标题和说明默认由 `AppLayout` 的 `page-heading` 根据路由元数据展示;标准列表正文不重复标题。只有明确关闭路由页头的详情页或工作台才自带独立页头。
+88
View File
@@ -0,0 +1,88 @@
import ElementPlus from 'element-plus'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import RowActions, { type RowActionItem } from './RowActions.vue'
const actions: RowActionItem[] = [
{ key: 'view', label: '查看', priority: 300 },
{ key: 'print', label: '打印', priority: 200 },
{ key: 'edit', label: '编辑', priority: 100 },
{ key: 'transfer', label: '转派送', priority: 250, tone: 'success' },
{ key: 'cancel', label: '取消', tone: 'danger' },
]
describe('RowActions', () => {
it.each([
'src/modules/container-loading/pages/ContainersView.vue',
'src/modules/customers/pages/CustomersView.vue',
'src/modules/finance/pages/FinanceCustomerAccountsView.vue',
'src/modules/finance/pages/FinanceCustomerArapView.vue',
'src/modules/finance/pages/FinanceCustomerStatementsView.vue',
'src/modules/iam/pages/PermissionsView.vue',
'src/modules/overseas-warehouse/pages/DispatchesView.vue',
'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue',
'src/modules/unified-orders/components/InboundFeeDetailManager.vue',
'src/modules/unified-orders/pages/UnifiedOrdersView.vue',
])('keeps complex list actions on the shared component: %s', (path) => {
expect(readFileSync(resolve(process.cwd(), path), 'utf8')).toContain('<RowActions')
})
it('shows at most three prioritized actions and moves the rest into more', () => {
const wrapper = mount(RowActions, {
props: { actions, moreLabel: '更多操作 · PC-1' },
global: { plugins: [ElementPlus] },
})
expect(wrapper.findAll('.row-actions > .el-button').map(button => button.text().trim()))
.toEqual(['查看', '打印', '转派送'])
expect(wrapper.get('.el-dropdown .el-button').attributes('aria-label')).toBe('更多操作 · PC-1')
})
it('keeps input order when priorities choose the visible actions', () => {
const wrapper = mount(RowActions, {
props: { actions, maxVisible: 2 },
global: { plugins: [ElementPlus] },
})
expect(wrapper.findAll('.row-actions > .el-button').map(button => button.text().trim()))
.toEqual(['查看', '转派送'])
})
it('emits enabled direct actions and ignores loading actions', async () => {
const wrapper = mount(RowActions, {
props: {
actions: [
{ key: 'view', label: '查看' },
{ key: 'print', label: '打印', loading: true },
],
},
global: { plugins: [ElementPlus] },
})
await wrapper.findAll('.row-actions > .el-button')[0].trigger('click')
await wrapper.findAll('.row-actions > .el-button')[1].trigger('click')
expect(wrapper.emitted('action')).toEqual([['view']])
})
it('gives direct actions a business-specific accessible label when provided', () => {
const wrapper = mount(RowActions, {
props: { actions: [{ key: 'view', label: '查看', ariaLabel: '查看 CB-1' }] },
global: { plugins: [ElementPlus] },
})
expect(wrapper.get('.row-actions > .el-button').attributes('aria-label')).toBe('查看 CB-1')
expect(wrapper.get('.row-actions > .el-button').attributes('title')).toBe('查看 CB-1')
})
it('omits hidden actions without rendering an empty more menu', () => {
const wrapper = mount(RowActions, {
props: { actions: [{ key: 'edit', label: '编辑', visible: false }] },
global: { plugins: [ElementPlus] },
})
expect(wrapper.find('.el-dropdown').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
})
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { MoreHorizontal } from '@lucide/vue'
export type RowActionTone = 'neutral' | 'warning' | 'purple' | 'primary' | 'success' | 'danger'
export interface RowActionItem {
key: string
label: string
ariaLabel?: string
visible?: boolean
disabled?: boolean
loading?: boolean
tone?: RowActionTone
priority?: number
divided?: boolean
}
const props = withDefaults(defineProps<{
actions: RowActionItem[]
maxVisible?: number
moreLabel?: string
}>(), {
maxVisible: 3,
moreLabel: '更多操作',
})
const emit = defineEmits<{
action: [key: string]
}>()
const availableActions = computed(() => props.actions.filter(action => action.visible !== false))
const directKeys = computed(() => new Set(
availableActions.value
.map((action, index) => ({ action, index }))
.sort((left, right) => (right.action.priority ?? 0) - (left.action.priority ?? 0) || left.index - right.index)
.slice(0, Math.max(0, props.maxVisible))
.map(({ action }) => action.key),
))
const directActions = computed(() => availableActions.value.filter(action => directKeys.value.has(action.key)))
const overflowActions = computed(() => availableActions.value.filter(action => !directKeys.value.has(action.key)))
function select(action: RowActionItem) {
if (action.disabled || action.loading) return
emit('action', action.key)
}
</script>
<template>
<div class="density-actions row-actions">
<el-button
v-for="action in directActions"
:key="action.key"
class="density-action"
:data-tone="action.tone || 'neutral'"
size="small"
:disabled="action.disabled"
:loading="action.loading"
:aria-label="action.ariaLabel"
:title="action.ariaLabel"
@click.stop="select(action)"
>{{ action.label }}</el-button>
<el-dropdown v-if="overflowActions.length" trigger="click">
<el-button
class="density-action"
data-tone="neutral"
size="small"
:aria-label="moreLabel"
:title="moreLabel"
@click.stop
><MoreHorizontal :size="15" aria-hidden="true" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="action in overflowActions"
:key="action.key"
:class="{ 'danger-menu-item': action.tone === 'danger' }"
:disabled="action.disabled || action.loading"
:divided="action.divided"
@click="select(action)"
>{{ action.label }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>