优化
This commit is contained in:
@@ -42,7 +42,7 @@
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 海外 PDA 拣货员与实际收货仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | `WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId`;候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓,服务端约束在 `iam/`,Claim 与实时校验联查 `common-security/`、`lastleg-service/security/` |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` |
|
||||
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 尾程订单与待分配整柜 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 待分配页按 `sourceContainerBookingId` 消费服务端 `/orders/container-summaries` 柜级分页,不在浏览器拼柜;整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` |
|
||||
| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 按 `sourceContainerBookingId` 消费 `/orders/container-summaries` 的 `allPlansReady`、计划统计与冻结 `plannedArrivalAt`,详情由服务端返回全柜订单且不受列表分页截断;不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` |
|
||||
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
|
||||
| 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
||||
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
||||
- 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
|
||||
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
|
||||
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
|
||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
||||
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
|
||||
|
||||
@@ -153,12 +153,12 @@ describe('InternalUsersView platform internal user management', () => {
|
||||
expect(document.body.textContent).toContain('确认添加')
|
||||
})
|
||||
|
||||
it('requires a shared receiving warehouse for WAREHOUSE_PICKER', async () => {
|
||||
it('requires a shared receiving warehouse for OVERSEAS_WAREHOUSE_PDA_OPERATOR', async () => {
|
||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||
roles: [
|
||||
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
||||
{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
|
||||
{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' },
|
||||
],
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
@@ -180,16 +180,16 @@ describe('InternalUsersView platform internal user management', () => {
|
||||
})
|
||||
|
||||
it('uses the stable role code instead of localized role names to activate the warehouse binding', () => {
|
||||
expect(internalUsersSource).toContain("role.roleCode === 'WAREHOUSE_PICKER'")
|
||||
expect(internalUsersSource).toContain('overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null')
|
||||
expect(internalUsersSource).not.toContain("role.name === '仓库拣货员'")
|
||||
expect(internalUsersSource).toContain("role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'")
|
||||
expect(internalUsersSource).toContain('overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null')
|
||||
expect(internalUsersSource).not.toContain("role.name === '海外仓 PDA 作业员'")
|
||||
})
|
||||
|
||||
it('preserves the picker warehouse binding when editing a WAREHOUSE_PICKER user', async () => {
|
||||
it('preserves the picker warehouse binding when editing a OVERSEAS_WAREHOUSE_PDA_OPERATOR user', async () => {
|
||||
const pickerUser: PlatformManagedUser = {
|
||||
...user,
|
||||
roleIds: ['4000005'],
|
||||
roleNames: ['仓库拣货员'],
|
||||
roleNames: ['海外仓 PDA 作业员'],
|
||||
overseasWarehouseId: '90071992547409931',
|
||||
}
|
||||
vi.mocked(listPlatformManagedUsers).mockResolvedValueOnce({
|
||||
@@ -197,7 +197,7 @@ describe('InternalUsersView platform internal user management', () => {
|
||||
})
|
||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
|
||||
roles: [{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' }],
|
||||
})
|
||||
vi.mocked(updatePlatformManagedUser).mockResolvedValueOnce(pickerUser)
|
||||
|
||||
@@ -220,12 +220,12 @@ describe('InternalUsersView platform internal user management', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a warehouse binding after switching away from WAREHOUSE_PICKER', async () => {
|
||||
it('clears a warehouse binding after switching away from OVERSEAS_WAREHOUSE_PDA_OPERATOR', async () => {
|
||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||
roles: [
|
||||
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
|
||||
{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
|
||||
{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' },
|
||||
],
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
@@ -250,7 +250,7 @@ describe('InternalUsersView platform internal user management', () => {
|
||||
it('submits the selected picker warehouse as an unchanged long-id string', async () => {
|
||||
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
|
||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
|
||||
roles: [{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' }],
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
const setupState = (wrapper.vm.$ as unknown as {
|
||||
|
||||
@@ -105,10 +105,10 @@ const availableRoles = computed(() => options.value.roles.filter((role) =>
|
||||
String(role.tenantId) === '0'
|
||||
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
||||
))
|
||||
const warehousePickerRoleIds = computed(() => new Set(
|
||||
availableRoles.value.filter((role) => role.roleCode === 'WAREHOUSE_PICKER').map((role) => String(role.id)),
|
||||
const overseasPdaRoleIds = computed(() => new Set(
|
||||
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
|
||||
))
|
||||
const hasWarehousePickerRole = computed(() => form.roleIds.some((roleId) => warehousePickerRoleIds.value.has(roleId)))
|
||||
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
|
||||
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: editingUser.value ? [] : [
|
||||
@@ -122,7 +122,7 @@ const formRules = computed<FormRules>(() => ({
|
||||
],
|
||||
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
||||
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
||||
overseasWarehouseId: !hasWarehousePickerRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
||||
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
||||
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
|
||||
],
|
||||
password: editingUser.value && !form.resetPassword ? [] : [
|
||||
@@ -263,7 +263,7 @@ async function saveUser() {
|
||||
email: form.email.trim(),
|
||||
tenantId: '0',
|
||||
roleIds: form.roleIds,
|
||||
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
|
||||
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
||||
password: form.password,
|
||||
status: form.status,
|
||||
})
|
||||
@@ -277,7 +277,7 @@ async function saveUser() {
|
||||
email: form.email.trim(),
|
||||
tenantId: '0',
|
||||
roleIds: form.roleIds,
|
||||
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
|
||||
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
||||
})
|
||||
}
|
||||
if (canChangeStatus.value && form.status !== row.status) {
|
||||
@@ -330,7 +330,7 @@ function applyFilters() {
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => void loadUsers())
|
||||
watch(hasWarehousePickerRole, (selected) => {
|
||||
watch(hasOverseasPdaRole, (selected) => {
|
||||
if (!selected) {
|
||||
form.overseasWarehouseId = ''
|
||||
overseasWarehousesError.value = null
|
||||
@@ -419,7 +419,7 @@ onMounted(loadInitialData)
|
||||
<el-option label="禁用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasWarehousePickerRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
|
||||
<el-form-item v-if="hasOverseasPdaRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
|
||||
<el-select v-model="form.overseasWarehouseId" filterable
|
||||
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||
placeholder="请选择平台共享海外收货仓">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 |
|
||||
|---|---|---|---|
|
||||
| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 |
|
||||
| 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 |
|
||||
| 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?`;目的仓只取国内 Warehouse 正式 `FBA + ACTIVE` 公共候选,候选切换时同步提交精确字符串 ID、编码和名称,不允许自由输入 | `...:last-mile-order:edit`;保留原尾程订单/入仓码多选范围与跨客户能力;候选或提交失败保留弹框并展示错误元数据,不提示成功 |
|
||||
| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA |
|
||||
| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功按接口返回计划数提示并刷新原待分配订单列表,不用当前订单页行数校验整柜总数;错误保留 code/requestId/traceId |
|
||||
| 待分配整柜摘要分页 | `GET /platform/v1/last-leg/orders/container-summaries` | DTO:`sourceContainerBookingId,containerNo?,orderCount,inboundCodeCount,customerCount,totalPackageCount,totalPalletCount,totalVolumeCbm,eligible,ineligibleReason?`;query:`page,size,containerNo?,createdFrom?,createdTo?` | 仅供原待分配订单页的“按柜批量生成”选择弹框加载柜候选;不替换原订单表,也不从订单分页本地聚合 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders } from './lastMileOrders'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getContainerPlanProgress, getLastMileOrder, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
@@ -69,16 +69,16 @@ describe('last-mile order overseas API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads server-side pending container summaries with pagination and filters', async () => {
|
||||
it('loads server-side container progress with pagination and filters', async () => {
|
||||
const record = {
|
||||
sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', orderCount: 15,
|
||||
inboundCodeCount: 15, customerCount: 3, totalPackageCount: 150, totalPalletCount: 30,
|
||||
totalVolumeCbm: 45.5, eligible: true,
|
||||
sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', totalOrderCount: 15,
|
||||
inboundCodeCount: 13, missingInboundCodeCount: 2, planCount: 10, plannedInboundCodeCount: 10,
|
||||
pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false, blockingReason: '存在2票缺少入仓码',
|
||||
}
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [record], page: '2', size: '10', total: '21', pages: '3' },
|
||||
})
|
||||
await expect(listPendingContainerSummaries({
|
||||
await expect(listContainerPlanProgress({
|
||||
page: 2, size: 10, containerNo: 'CONT-15',
|
||||
createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59',
|
||||
})).resolves.toEqual({ records: [record], page: 2, size: 10, total: 21, pages: 3 })
|
||||
@@ -89,4 +89,11 @@ describe('last-mile order overseas API', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('loads one container detail with the encoded stable long id', async () => {
|
||||
const detail = { summary: { sourceContainerBookingId: '2092873463568597001' }, orders: [] }
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
await expect(getContainerPlanProgress('2092873463568597001/unsafe')).resolves.toEqual(detail)
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -155,7 +155,7 @@ export interface ConvertInboundPlanPayload {
|
||||
customerId: string
|
||||
}
|
||||
|
||||
export interface PendingContainerSummaryQuery {
|
||||
export interface ContainerPlanProgressQuery {
|
||||
page: number
|
||||
size: number
|
||||
containerNo?: string
|
||||
@@ -163,17 +163,36 @@ export interface PendingContainerSummaryQuery {
|
||||
createdTo?: string
|
||||
}
|
||||
|
||||
export interface PendingContainerSummaryDto {
|
||||
export interface ContainerPlanProgressSummaryDto {
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
orderCount: number
|
||||
totalOrderCount: number
|
||||
inboundCodeCount: number
|
||||
customerCount: number
|
||||
totalPackageCount: number
|
||||
totalPalletCount: number
|
||||
totalVolumeCbm: number
|
||||
eligible: boolean
|
||||
ineligibleReason?: string | null
|
||||
missingInboundCodeCount: number
|
||||
planCount: number
|
||||
plannedInboundCodeCount: number
|
||||
pendingPlanCount: number
|
||||
plannedArrivalAt: string | null
|
||||
allPlansReady: boolean
|
||||
blockingReason: string | null
|
||||
}
|
||||
|
||||
export interface ContainerPlanProgressItemDto {
|
||||
orderId: string
|
||||
customerId: string
|
||||
orderNo: string | null
|
||||
inboundCode: string | null
|
||||
orderStatus: string
|
||||
planId: string | null
|
||||
planNo: string | null
|
||||
planStatus: string | null
|
||||
planValid: boolean
|
||||
plannedArrivalAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerPlanProgressDetailDto {
|
||||
summary: ContainerPlanProgressSummaryDto
|
||||
orders: ContainerPlanProgressItemDto[]
|
||||
}
|
||||
|
||||
export interface ConvertContainerInboundPlanPayload {
|
||||
@@ -224,14 +243,21 @@ export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundP
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listPendingContainerSummaries(query: PendingContainerSummaryQuery): Promise<OverseasPageResult<PendingContainerSummaryDto>> {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<PendingContainerSummaryDto>>, ApiEnvelope<OverseasPagePayload<PendingContainerSummaryDto>>>(
|
||||
export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise<OverseasPageResult<ContainerPlanProgressSummaryDto>> {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>, ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>>(
|
||||
'/platform/v1/last-leg/orders/container-summaries',
|
||||
{ params: query },
|
||||
)
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getContainerPlanProgress(sourceContainerBookingId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ContainerPlanProgressDetailDto>, ApiEnvelope<ContainerPlanProgressDetailDto>>(
|
||||
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
|
||||
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
|
||||
|
||||
@@ -4,18 +4,22 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import {
|
||||
convertLastMileContainerToInboundPlans,
|
||||
convertLastMileOrderToInboundPlan,
|
||||
getContainerPlanProgress,
|
||||
listContainerPlanProgress,
|
||||
listLastMileOrders,
|
||||
listPendingContainerSummaries,
|
||||
transferLastMileOrders,
|
||||
} from '../api/lastMileOrders'
|
||||
import LastMileOrdersView from './LastMileOrdersView.vue'
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
listLastMileOrders: vi.fn(), listPendingContainerSummaries: vi.fn(),
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -31,9 +35,21 @@ function memoryStorage(): Storage {
|
||||
const bookingId = '2092873463568597001'
|
||||
const customerId = '2092618717861048321'
|
||||
const summary = {
|
||||
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', orderCount: 15,
|
||||
inboundCodeCount: 15, customerCount: 3, totalPackageCount: 150, totalPalletCount: 30,
|
||||
totalVolumeCbm: 45.5, eligible: true, ineligibleReason: null,
|
||||
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15,
|
||||
inboundCodeCount: 15, missingInboundCodeCount: 0, planCount: 10, plannedInboundCodeCount: 10,
|
||||
pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false,
|
||||
blockingReason: '存在5票尚未生成有效入库计划',
|
||||
}
|
||||
const fbaWarehouse = {
|
||||
id: '9007199254740993', tenantId: '0', warehouseCode: 'ONT8', warehouseName: 'Ontario FBA',
|
||||
warehouseType: 'FBA', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null,
|
||||
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
|
||||
primaryAddress: {
|
||||
id: '9007199254740994', tenantId: '0', warehouseId: '9007199254740993', addressType: 'OPERATING',
|
||||
countryCode: 'US', stateProvince: 'CA', city: 'Ontario', district: null, postalCode: '91761',
|
||||
addressLine1: 'FBA address', addressLine2: null, latitude: null, longitude: null,
|
||||
},
|
||||
addressSummary: 'CA Ontario',
|
||||
}
|
||||
function selectedRow(overrides: Record<string, unknown> = {}) {
|
||||
return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides }
|
||||
@@ -83,9 +99,15 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
vi.mocked(listPendingContainerSummaries).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 50, total: 1, pages: 1 })
|
||||
vi.mocked(listContainerPlanProgress).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 50, total: 1, pages: 1 })
|
||||
vi.mocked(getContainerPlanProgress).mockReset().mockResolvedValue({
|
||||
summary,
|
||||
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
|
||||
})
|
||||
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
|
||||
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
|
||||
})
|
||||
|
||||
it('keeps the original pending order table and does not render a view switch or container table', async () => {
|
||||
@@ -98,7 +120,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(wrapper.text()).not.toContain('按订单')
|
||||
expect(wrapper.text()).not.toContain('待分配整柜')
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: 'PENDING_ASSIGNMENT' }))
|
||||
expect(listPendingContainerSummaries).not.toHaveBeenCalled()
|
||||
expect(listContainerPlanProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps single-order conversion isolated with long order and customer ids', async () => {
|
||||
@@ -109,14 +131,14 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1)
|
||||
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId })
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
expect(listPendingContainerSummaries).not.toHaveBeenCalled()
|
||||
expect(listContainerPlanProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
|
||||
const wrapper = await openPending()
|
||||
await openContainerDialog(wrapper)
|
||||
expect(listPendingContainerSummaries).toHaveBeenCalledTimes(1)
|
||||
expect(listPendingContainerSummaries).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
|
||||
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
|
||||
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
|
||||
expect(document.body.textContent).toContain('选择待分配柜')
|
||||
expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划')
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
@@ -152,4 +174,107 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(wrapper.text()).toContain(`request-${code}`)
|
||||
expect(wrapper.text()).toContain(`trace-${code}`)
|
||||
})
|
||||
|
||||
it('loads the cabinet tab from server progress, shows the total and preserves server arrival ordering', async () => {
|
||||
const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 }
|
||||
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 })
|
||||
const wrapper = await mountView()
|
||||
const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))
|
||||
await containerTab!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined })
|
||||
expect(wrapper.text()).toContain('柜子(2)')
|
||||
expect(wrapper.text()).toContain('计划到仓时间')
|
||||
expect(wrapper.text()).toContain('已有 15 / 缺 0')
|
||||
expect(wrapper.text()).toContain('已生成10个计划,剩余5个未操作')
|
||||
expect(wrapper.text()).toContain('未就绪')
|
||||
expect(wrapper.text()).toContain('存在5票尚未生成有效入库计划')
|
||||
expect(wrapper.text().indexOf('CONT-15')).toBeLessThan(wrapper.text().indexOf('CONT-NULL'))
|
||||
})
|
||||
|
||||
it('uses independent cabinet pagination and loads a compact long-id detail drawer', async () => {
|
||||
vi.mocked(listContainerPlanProgress).mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 })
|
||||
const wrapper = await mountView()
|
||||
const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))
|
||||
await containerTab!.trigger('click')
|
||||
await flushPromises()
|
||||
const pageTwo = wrapper.find('button[aria-label="第 2 页"]')
|
||||
await pageTwo.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listContainerPlanProgress).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 10 }))
|
||||
const viewButton = wrapper.findAll('button').find((item) => item.text() === '查看')
|
||||
await viewButton!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
|
||||
expect(document.body.textContent).toContain('柜级入库计划详情')
|
||||
expect(document.body.textContent).toContain('LMO-1')
|
||||
expect(document.body.textContent).toContain('IN-1')
|
||||
expect(document.body.textContent).toContain('LIP-1')
|
||||
})
|
||||
|
||||
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'FBA', status: 'ACTIVE' })
|
||||
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择目的 FBA 仓')!
|
||||
expect(warehouseSelect).toBeDefined()
|
||||
const option = wrapper.findAllComponents({ name: 'ElOption' }).find((item) => item.props('value') === '9007199254740993')!
|
||||
expect(option.props('label')).toBe('ONT8 · Ontario FBA · CA / Ontario')
|
||||
warehouseSelect.vm.$emit('update:modelValue', '9007199254740993')
|
||||
await wrapper.vm.$nextTick()
|
||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认转仓')) as HTMLButtonElement
|
||||
expect(confirm.disabled).toBe(false)
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(transferLastMileOrders).toHaveBeenCalledWith({
|
||||
orderIds: ['2092873463568596994', '2092873463568596995'],
|
||||
warehouseId: '9007199254740993', warehouseCode: 'ONT8', warehouseName: 'Ontario FBA', remark: undefined,
|
||||
})
|
||||
expect(vi.mocked(transferLastMileOrders).mock.calls[0]?.[0].warehouseId).toBeTypeOf('string')
|
||||
expect(wrapper.html()).not.toContain('请输入目的仓 ID')
|
||||
})
|
||||
|
||||
it('clears all selected warehouse facts when the FBA candidate is cleared', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择目的 FBA 仓')!
|
||||
warehouseSelect.vm.$emit('update:modelValue', '9007199254740993')
|
||||
await wrapper.vm.$nextTick()
|
||||
warehouseSelect.vm.$emit('update:modelValue', '')
|
||||
await wrapper.vm.$nextTick()
|
||||
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认转仓')) as HTMLButtonElement
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(transferLastMileOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders an explicit empty state when there are no active FBA candidates', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('暂无可用 FBA 仓候选')
|
||||
expect(transferLastMileOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps candidate load request metadata visible and prevents free-form fallback', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
|
||||
'FBA candidate failed', 'A0503', 200, undefined, 'request-fba', 'trace-fba',
|
||||
))
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '转仓')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('A0503')
|
||||
expect(document.body.textContent).toContain('request-fba')
|
||||
expect(document.body.textContent).toContain('trace-fba')
|
||||
expect(wrapper.html()).not.toContain('请输入目的仓 ID')
|
||||
expect(wrapper.html()).not.toContain('请输入目的仓代码')
|
||||
expect(wrapper.html()).not.toContain('请输入目的仓名称')
|
||||
expect(transferLastMileOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,16 +3,18 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders, type PendingContainerSummaryDto } from '../api/lastMileOrders'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
@@ -27,18 +29,27 @@ const router = useRouter()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const containerTotal = ref(0)
|
||||
const containerRows = ref<ContainerPlanProgressSummaryDto[]>([])
|
||||
const containerDetailOpen = ref(false)
|
||||
const containerDetailLoading = ref(false)
|
||||
const containerDetailError = ref<ApiErrorDetails | null>(null)
|
||||
const containerDetail = ref<ContainerPlanProgressDetailDto | null>(null)
|
||||
const selectedRows = ref<LastMileOrderListItem[]>([])
|
||||
const actionLoading = ref(false)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const transferOpen = ref(false)
|
||||
const transferWarehouseLoading = ref(false)
|
||||
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const transferWarehouseOptions = ref<TransferWarehouseOption[]>([])
|
||||
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
|
||||
const containerConvertOpen = ref(false)
|
||||
const containerOptions = ref<PendingContainerSummaryDto[]>([])
|
||||
const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
|
||||
const containerOptionsLoading = ref(false)
|
||||
const containerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedContainerBookingId = ref('')
|
||||
let containerOptionRequest = 0
|
||||
const statusTab = ref<'全部' | '待分配'>('全部')
|
||||
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
|
||||
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
||||
const allFields: QueryField[] = [
|
||||
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
|
||||
@@ -64,8 +75,40 @@ const allFields: QueryField[] = [
|
||||
{ key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||
{ key: 'createdAt', type: 'daterange', label: '创建时间' },
|
||||
]
|
||||
const fields = computed<QueryField[]>(() => allFields)
|
||||
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
|
||||
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
|
||||
: allFields)
|
||||
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
|
||||
|
||||
interface TransferWarehouseOption {
|
||||
warehouseId: string
|
||||
warehouseCode: string
|
||||
warehouseName: string
|
||||
region: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function warehouseRegion(warehouse: PlatformWarehouseSummaryResource) {
|
||||
const addressRegion = [warehouse.primaryAddress?.stateProvince, warehouse.primaryAddress?.city]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
return addressRegion || String(warehouse.addressSummary || warehouse.countryCode || '').trim()
|
||||
}
|
||||
|
||||
function toTransferWarehouseOption(warehouse: PlatformWarehouseSummaryResource): TransferWarehouseOption {
|
||||
const warehouseId = String(warehouse.id)
|
||||
const warehouseCode = warehouse.warehouseCode.trim()
|
||||
const warehouseName = warehouse.warehouseName.trim()
|
||||
const region = warehouseRegion(warehouse)
|
||||
return {
|
||||
warehouseId,
|
||||
warehouseCode,
|
||||
warehouseName,
|
||||
region,
|
||||
label: [warehouseCode, warehouseName, region].filter(Boolean).join(' · '),
|
||||
}
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||
@@ -101,10 +144,55 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
|
||||
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
|
||||
]
|
||||
const containerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '柜号', minWidth: 170 },
|
||||
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
|
||||
{ key: 'totalOrderCount', prop: 'totalOrderCount', label: '订单总数', width: 100, align: 'right' },
|
||||
{ key: 'inboundCodes', label: '入仓码', minWidth: 170, slotName: 'inboundCodes' },
|
||||
{ key: 'planProgress', label: '入库计划进度', minWidth: 270, slotName: 'planProgress' },
|
||||
{ key: 'readiness', label: '整柜就绪状态', minWidth: 240, slotName: 'readiness' },
|
||||
]
|
||||
const containerDetailColumns: DataTableColumn[] = [
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||||
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130 },
|
||||
{ key: 'planNo', prop: 'planNo', label: '独立入库计划', minWidth: 170 },
|
||||
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130 },
|
||||
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
|
||||
]
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
|
||||
return Boolean(container.containerNo) && container.missingInboundCodeCount === 0 && container.pendingPlanCount > 0
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
if (statusTab.value === '柜子') {
|
||||
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
|
||||
try {
|
||||
const result = await listContainerPlanProgress({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
containerNo: String(filters.value.containerNo || '').trim() || undefined,
|
||||
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
|
||||
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
|
||||
})
|
||||
containerRows.value = result.records
|
||||
containerTotal.value = result.total
|
||||
} catch (error) {
|
||||
containerRows.value = []
|
||||
containerTotal.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '柜级入库计划进度加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
|
||||
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
|
||||
@@ -144,21 +232,64 @@ function changeStatusTab() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
|
||||
containerDetailOpen.value = true
|
||||
containerDetailLoading.value = true
|
||||
containerDetailError.value = null
|
||||
containerDetail.value = null
|
||||
try {
|
||||
containerDetail.value = await getContainerPlanProgress(row.sourceContainerBookingId)
|
||||
} catch (error) {
|
||||
containerDetailError.value = getApiErrorDetails(error, '柜级详情加载失败,请重试')
|
||||
} finally {
|
||||
containerDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
|
||||
async function loadTransferWarehouseOptions() {
|
||||
transferWarehouseLoading.value = true
|
||||
transferWarehouseError.value = null
|
||||
transferWarehouseOptions.value = []
|
||||
try {
|
||||
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })
|
||||
transferWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
|
||||
} catch (error) {
|
||||
transferWarehouseError.value = getApiErrorDetails(error, 'FBA 仓候选加载失败,请重试')
|
||||
} finally {
|
||||
transferWarehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
function selectTransferWarehouse(warehouseId: string) {
|
||||
const selected = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === String(warehouseId || ''))
|
||||
Object.assign(transferForm, selected
|
||||
? { warehouseId: selected.warehouseId, warehouseCode: selected.warehouseCode, warehouseName: selected.warehouseName }
|
||||
: { warehouseId: '', warehouseCode: '', warehouseName: '' })
|
||||
}
|
||||
function openTransfer() {
|
||||
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
|
||||
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
|
||||
transferWarehouseOptions.value = []
|
||||
transferWarehouseError.value = null
|
||||
actionError.value = null
|
||||
transferOpen.value = true
|
||||
void loadTransferWarehouseOptions()
|
||||
}
|
||||
async function submitTransfer() {
|
||||
if (!transferForm.warehouseId.trim() || !transferForm.warehouseCode.trim() || !transferForm.warehouseName.trim()) {
|
||||
ElMessage.warning('请填写目的仓 ID、代码和名称')
|
||||
const selectedWarehouse = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === transferForm.warehouseId)
|
||||
if (!selectedWarehouse) {
|
||||
ElMessage.warning('请选择目的 FBA 仓')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await transferLastMileOrders({ orderIds: selectedRows.value.map((row) => row.id), warehouseId: transferForm.warehouseId.trim(), warehouseCode: transferForm.warehouseCode.trim(), warehouseName: transferForm.warehouseName.trim(), remark: transferForm.remark.trim() || undefined })
|
||||
await transferLastMileOrders({
|
||||
orderIds: selectedRows.value.map((row) => String(row.id)),
|
||||
warehouseId: selectedWarehouse.warehouseId,
|
||||
warehouseCode: selectedWarehouse.warehouseCode,
|
||||
warehouseName: selectedWarehouse.warehouseName,
|
||||
remark: transferForm.remark.trim() || undefined,
|
||||
})
|
||||
transferOpen.value = false; selectedRows.value = []; ElMessage.success('转仓成功'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
@@ -189,7 +320,7 @@ async function loadContainerOptions(containerNo = '') {
|
||||
containerOptionsLoading.value = true
|
||||
containerLoadError.value = null
|
||||
try {
|
||||
const result = await listPendingContainerSummaries({
|
||||
const result = await listContainerPlanProgress({
|
||||
page: 1,
|
||||
size: 50,
|
||||
containerNo: containerNo.trim() || undefined,
|
||||
@@ -215,8 +346,8 @@ function openContainerConversion() {
|
||||
async function convertContainerInboundPlans() {
|
||||
const container = selectedContainer.value
|
||||
if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return }
|
||||
if (!container.eligible) {
|
||||
actionError.value = { message: container.ineligibleReason || '所选柜当前不满足生成资格' }
|
||||
if (!canGenerateContainerPlans(container)) {
|
||||
actionError.value = { message: container.blockingReason || '所选柜当前不满足生成资格' }
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
@@ -249,6 +380,7 @@ onMounted(() => { void load() })
|
||||
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
|
||||
<el-radio-button value="全部">全部</el-radio-button>
|
||||
<el-radio-button value="待分配">待分配</el-radio-button>
|
||||
<el-radio-button value="柜子">柜子({{ containerTotal }})</el-radio-button>
|
||||
</el-radio-group>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
|
||||
</section>
|
||||
@@ -261,22 +393,38 @@ onMounted(() => { void load() })
|
||||
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
|
||||
<template #details><ApiErrorMeta :details="actionError" /></template>
|
||||
</LoadErrorState>
|
||||
<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-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<section v-if="!loadError && statusTab === '柜子'" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="containerRows" :columns="containerColumns" :loading="loading" :total="containerTotal" preference-key="overseas-last-mile-container-progress" row-key="sourceContainerBookingId" empty-text="暂无柜级入库计划进度" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openContainerDetail(row as ContainerPlanProgressSummaryDto)">查看</el-button></template>
|
||||
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressSummaryDto).plannedArrivalAt) }}</template>
|
||||
<template #inboundCodes="{ row }">已有 {{ (row as ContainerPlanProgressSummaryDto).inboundCodeCount }} / 缺 {{ (row as ContainerPlanProgressSummaryDto).missingInboundCodeCount }}</template>
|
||||
<template #planProgress="{ row }">已生成{{ (row as ContainerPlanProgressSummaryDto).planCount }}个计划,剩余{{ (row as ContainerPlanProgressSummaryDto).pendingPlanCount }}个未操作</template>
|
||||
<template #readiness="{ row }"><el-tag :type="(row as ContainerPlanProgressSummaryDto).allPlansReady ? 'success' : 'warning'" effect="plain">{{ (row as ContainerPlanProgressSummaryDto).allPlansReady ? '整柜已就绪' : '未就绪' }}</el-tag><span v-if="!(row as ContainerPlanProgressSummaryDto).allPlansReady" class="readiness-reason">{{ (row as ContainerPlanProgressSummaryDto).blockingReason || '尚未满足全部独立计划条件,不可视为 PDA 可作业' }}</span></template>
|
||||
<template #pagination-left>共 {{ containerTotal }} 个柜</template>
|
||||
</DataTable>
|
||||
</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.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="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :confirm-loading="actionLoading" @confirm="submitTransfer">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="目的仓 ID" required><el-input v-model="transferForm.warehouseId" placeholder="请输入目的仓 ID" /></el-form-item>
|
||||
<el-form-item label="目的仓代码" required><el-input v-model="transferForm.warehouseCode" placeholder="请输入目的仓代码" /></el-form-item>
|
||||
<el-form-item label="目的仓名称" required><el-input v-model="transferForm.warehouseName" placeholder="请输入目的仓名称" /></el-form-item>
|
||||
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
|
||||
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
|
||||
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
|
||||
</LoadErrorState>
|
||||
<el-form v-else label-position="top">
|
||||
<el-form-item label="目的 FBA 仓" required>
|
||||
<el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse">
|
||||
<el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert v-if="!transferWarehouseLoading && !transferWarehouseOptions.length" title="暂无可用 FBA 仓候选" type="info" :closable="false" show-icon />
|
||||
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !selectedContainer.eligible" @confirm="convertContainerInboundPlans">
|
||||
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !canGenerateContainerPlans(selectedContainer)" @confirm="convertContainerInboundPlans">
|
||||
<p class="container-convert-hint">将为所选柜内所有待分配尾程订单分别生成独立入库计划。</p>
|
||||
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
|
||||
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
|
||||
@@ -284,16 +432,35 @@ onMounted(() => { void load() })
|
||||
<el-form v-else label-position="top">
|
||||
<el-form-item label="选择待分配柜" required>
|
||||
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择">
|
||||
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :disabled="!container.eligible" :label="`${container.containerNo || '柜号缺失'} · ${container.orderCount} 票${container.eligible ? '' : ` · ${container.ineligibleReason || '不可生成'}`}`" />
|
||||
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :disabled="!canGenerateContainerPlans(container)" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票${canGenerateContainerPlans(container) ? '' : ` · ${container.blockingReason || '不可生成'}`}`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert v-if="selectedContainer" :title="selectedContainer.eligible ? `确认后将生成该柜全部 ${selectedContainer.orderCount} 票的独立入库计划` : selectedContainer.ineligibleReason || '所选柜不可生成'" :type="selectedContainer.eligible ? 'info' : 'error'" :closable="false" show-icon />
|
||||
<el-alert v-if="selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${selectedContainer.pendingPlanCount} 票分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show-icon />
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDrawer v-model="containerDetailOpen" title="柜级入库计划详情" size="min(1040px, 96vw)" :loading="containerDetailLoading" :show-footer="false">
|
||||
<LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact>
|
||||
<template #details><ApiErrorMeta :details="containerDetailError" /></template>
|
||||
</LoadErrorState>
|
||||
<template v-else-if="containerDetail">
|
||||
<div class="container-detail-summary">
|
||||
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
|
||||
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
|
||||
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
|
||||
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }},缺 {{ containerDetail.summary.missingInboundCodeCount }}</span>
|
||||
<span>计划:{{ containerDetail.summary.planCount }},未操作 {{ containerDetail.summary.pendingPlanCount }}</span>
|
||||
</div>
|
||||
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
|
||||
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.last-mile-toolbar { padding: 14px 16px 0; }
|
||||
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
|
||||
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
|
||||
.readiness-reason { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
|
||||
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user