diff --git a/AGENTS.md b/AGENTS.md index 89254bd6..35f489d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,13 +38,13 @@ | 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | -| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | +| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 | | 供应商、国内整柜 | `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` | 国内 `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/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | | 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | | 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` | @@ -68,7 +68,7 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| -| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`,服务端在分页前排除 `allPlansReady=true`,按冻结 `plannedArrivalAt` 升序、空值最后返回待处理柜;卡片只展示服务端计划统计,详情弹框由服务端返回全柜订单且不受列表分页截断,不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` | +| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端在分页前排除全部入仓码均已有有效独立计划的柜,只按冻结 `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/`、`delivery/`;四期覆盖详情、候选、草稿柜、托盘锁和 `pre-arrange`,五期继续使用 `transfer-delivery` 转派送。后台暂无排车编辑、取消和发车调用时,不得仅凭后端 Controller 声称页面已支持 | | 五期海外出库与派送 | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;一件代发出库、取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` | diff --git a/src/modules/overseas-warehouse/layoutAcceptance.test.ts b/src/modules/overseas-warehouse/layoutAcceptance.test.ts index b3e40871..f172c98f 100644 --- a/src/modules/overseas-warehouse/layoutAcceptance.test.ts +++ b/src/modules/overseas-warehouse/layoutAcceptance.test.ts @@ -34,6 +34,15 @@ describe('overseas warehouse 1920px layout acceptance', () => { } }) + it('provides semantic placeholders for editable form controls', () => { + for (const name of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'DispatchesView.vue', 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'InventoryView.vue']) { + const editableControls = [...page(name).matchAll(/][^>]*>/g)] + .map(([tag]) => tag) + .filter((tag) => !/\s(?:readonly|disabled)(?:\s|\/?>)/.test(tag)) + expect(editableControls.every((tag) => tag.includes('placeholder=')), `${name} has an editable control without placeholder`).toBe(true) + } + }) + it('uses four-field desktop density and full-width form controls', () => { const sharedDetail = component('OverseasReadOnlyDetail.vue') expect(sharedDetail).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))') diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue index b3e66969..56f523d1 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue @@ -76,6 +76,6 @@ function reset(model: QueryModel) { page.value = 1; void load(model) } async function loadWarehouses() { if (!access.can(permission.warehouseList)) return; warehousesLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } finally { warehousesLoading.value = false } } onMounted(() => { void load(); void loadWarehouses() }) - + diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue index d5c84244..992eb24b 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue @@ -192,7 +192,7 @@ onMounted(() => { void load() }) - + diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 90e27f08..a41a9a2f 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -209,7 +209,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { pagination.vm.$emit('current-change', 2) await flushPromises() expect(listContainerPlanProgress).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 10 })) - await wrapper.get('.container-workbench-card').trigger('click') + await wrapper.get('.container-workbench-card__detail').trigger('click') await flushPromises() expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId) expect(document.body.textContent).toContain('柜子详情 · CONT-15') @@ -220,6 +220,22 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(document.body.querySelector('.el-drawer')).toBeNull() }) + it('starts cabinet plan generation directly from the workbench card', async () => { + const wrapper = await mountView() + const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子')) + await containerTab!.trigger('click') + await flushPromises() + await button(wrapper, '生成入库计划')!.trigger('click') + await flushPromises() + expect(document.body.textContent).toContain('确认后将为该柜剩余 5 个入仓码分别生成独立入库计划') + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement + expect(confirm.disabled).toBe(false) + confirm.click() + await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId }) + expect(getContainerPlanProgress).not.toHaveBeenCalled() + }) + 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' })]) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 5d0d2182..f0d2bec3 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -350,6 +350,13 @@ function openContainerConversion() { containerConvertOpen.value = true void loadContainerOptions() } +function openContainerConversionFor(container: ContainerPlanProgressSummaryDto) { + selectedContainerBookingId.value = container.sourceContainerBookingId + containerOptions.value = [container] + containerLoadError.value = null + actionError.value = null + containerConvertOpen.value = true +} async function convertContainerInboundPlans() { const container = selectedContainer.value if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return } @@ -406,8 +413,8 @@ onMounted(() => { void load() }) {{ containerTotal }} 个柜待处理
- + + 查看详情 + 生成入库计划 + +
@@ -441,7 +452,7 @@ onMounted(() => { void load() }) - + @@ -455,7 +466,7 @@ onMounted(() => { void load() }) - + @@ -488,9 +499,8 @@ onMounted(() => { void load() }) .container-workbench__header strong { color: var(--cc-text); font-size: 16px; } .container-workbench__header span { color: var(--cc-muted); font-size: 13px; } .container-workbench__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } -.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color .16s ease, box-shadow .16s ease; } +.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; } .container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: 0 6px 18px rgb(15 23 42 / 8%); } -.container-workbench-card:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; } .container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .container-workbench-card__top strong { min-width: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 16px; } .container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: 13px; } @@ -502,6 +512,7 @@ onMounted(() => { void load() }) .container-workbench-card__pending span { color: #92400e; font-size: 13px; } .container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--cc-muted); font-size: 12px; } .container-workbench-card__facts .is-warning { color: #c2410c; } +.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; } .container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; } @media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/src/modules/overseas-warehouse/pages/PalletDetailView.vue b/src/modules/overseas-warehouse/pages/PalletDetailView.vue index 6aa38788..3b2d44de 100644 --- a/src/modules/overseas-warehouse/pages/PalletDetailView.vue +++ b/src/modules/overseas-warehouse/pages/PalletDetailView.vue @@ -18,7 +18,7 @@ const loading = ref(false) const loadError = ref(null) const groups = [ { title: '基础信息', fields: ['托盘编码', '托盘类型', '仓库', '容积(m³)', '采购价格', '所属FBA仓', '状态'] }, - { title: '使用信息', fields: ['库位ID', '创建时间', '更新时间', '备注'] }, + { title: '使用信息', fields: ['库位', '创建时间', '更新时间', '备注'] }, ] const values = computed(() => ({ 托盘编码: detail.value?.palletCode || '—', 托盘类型: detail.value?.palletType || '—', 仓库: warehouseName.value, @@ -33,7 +33,7 @@ async function load() { const [pallet, warehouses] = await Promise.all([getPallet(warehouseId.value, palletId.value), listReceivingOverseasWarehouses()]) detail.value = pallet const warehouse = warehouses.records.find((item) => String(item.id) === warehouseId.value) - warehouseName.value = warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : warehouseId.value + warehouseName.value = warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : '—' } catch (error) { loadError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') } finally { loading.value = false } } diff --git a/src/modules/overseas-warehouse/pages/PalletsView.vue b/src/modules/overseas-warehouse/pages/PalletsView.vue index cd2e4ea3..ed9ad468 100644 --- a/src/modules/overseas-warehouse/pages/PalletsView.vue +++ b/src/modules/overseas-warehouse/pages/PalletsView.vue @@ -133,13 +133,13 @@ onMounted(loadWarehouses) - - - + + + - - + + diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue index 42ffaae8..5843a8eb 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue @@ -190,17 +190,17 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' }) - - - - + + + +

作业要求

- - + + - +
diff --git a/src/modules/trucking/pages/TruckAppointmentsView.test.ts b/src/modules/trucking/pages/TruckAppointmentsView.test.ts index e66ed7a3..a80e6587 100644 --- a/src/modules/trucking/pages/TruckAppointmentsView.test.ts +++ b/src/modules/trucking/pages/TruckAppointmentsView.test.ts @@ -135,6 +135,10 @@ describe('TruckAppointmentsView', () => { }) it('shows the two frequent row actions directly without a more menu', () => { + expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions', hideable: false, movable: false }") + expect(source).toContain('flex-wrap:nowrap') + expect(source).toContain('white-space:nowrap') + expect(source).toContain('flex:0 0 auto') expect(source).not.toContain('platform:firstleg:truck-appointment:view') expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)") expect(source).toContain('详情') diff --git a/src/modules/trucking/pages/TruckAppointmentsView.vue b/src/modules/trucking/pages/TruckAppointmentsView.vue index 9a48422f..84f52bcd 100644 --- a/src/modules/trucking/pages/TruckAppointmentsView.vue +++ b/src/modules/trucking/pages/TruckAppointmentsView.vue @@ -25,7 +25,7 @@ const fields = computed(() => [ ] }, ]) const columns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions', hideable: false, movable: false }, { key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 }, { key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' }, { key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 160, formatter: (row: TruckAppointmentResource) => row.billOfLadingNo || '—' }, @@ -83,5 +83,5 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ .trucking-page { min-width: 0; } .table-panel { overflow:hidden; } small { display:block; color:var(--el-text-color-secondary); margin-top:3px; } -.table-actions { display:flex; align-items:center; gap:6px; }.table-actions :deep(.el-button) { margin-left:0; } +.table-actions { display:flex; align-items:center; flex-wrap:nowrap; gap:6px; white-space:nowrap; }.table-actions :deep(.el-button) { flex:0 0 auto; margin-left:0; }