diff --git a/AGENTS.md b/AGENTS.md index 57371fb7..1d2ee46d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ | 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | +| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | | 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg | | FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | diff --git a/package.json b/package.json index bc5bbdb5..c9b32f72 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "@lucide/vue": "^1.27.0", "axios": "^1.18.1", "dayjs": "^1.11.21", + "echarts": "^6.1.0", "element-plus": "^2.14.3", "pinia": "^4.0.2", "sortablejs": "^1.15.7", "vue": "^3.5.40", + "vue-echarts": "^8.3.2", "vue-router": "^5.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88a48763..7518878e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: dayjs: specifier: ^1.11.21 version: 1.11.21 + echarts: + specifier: ^6.1.0 + version: 6.1.0 element-plus: specifier: ^2.14.3 version: 2.14.3(vue@3.5.40(typescript@5.9.3)) @@ -32,6 +35,9 @@ importers: vue: specifier: ^3.5.40 version: 3.5.40(typescript@5.9.3) + vue-echarts: + specifier: ^8.3.2 + version: 8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)) vue-router: specifier: ^5.2.0 version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) @@ -641,6 +647,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + editorconfig@1.0.7: resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} engines: {node: '>=14'} @@ -1150,6 +1159,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1325,6 +1337,12 @@ packages: vue-component-type-helpers@3.3.8: resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} + vue-echarts@8.3.2: + resolution: {integrity: sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==} + peerDependencies: + echarts: ^6.0.0 + vue: ^3.3.0 + vue-router@5.2.0: resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} peerDependencies: @@ -1406,6 +1424,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + snapshots: '@asamuzakjp/css-color@5.1.11': @@ -1960,6 +1981,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + editorconfig@1.0.7: dependencies: '@one-ini/wasm': 0.1.1 @@ -2436,6 +2462,8 @@ snapshots: dependencies: punycode: 2.3.1 + tslib@2.3.0: {} + tslib@2.8.1: optional: true @@ -2563,6 +2591,11 @@ snapshots: vue-component-type-helpers@3.3.8: {} + vue-echarts@8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)): + dependencies: + echarts: 6.1.0 + vue: 3.5.40(typescript@5.9.3) + vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)): dependencies: '@babel/generator': 8.0.0 @@ -2658,3 +2691,7 @@ snapshots: xmlchars@2.2.0: {} yaml@2.9.0: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/src/app/router/index.ts b/src/app/router/index.ts index 9fe2ee6e..e1e2b161 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -34,6 +34,7 @@ import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes' import { pdaReleaseRoutes } from '@/modules/pda-releases/routes' import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes' import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes' +import { selfOperatedRoutes } from '@/modules/self-operated/routes' const router = createRouter({ history: createWebHistory(), @@ -81,6 +82,7 @@ const router = createRouter({ ...pdaReleaseRoutes, ...sailingCalendarRoutes, ...appointmentCalendarRoutes, + ...selfOperatedRoutes, { path: '403', name: 'forbidden', diff --git a/src/core/auth/jwt.test.ts b/src/core/auth/jwt.test.ts new file mode 100644 index 00000000..f8869537 --- /dev/null +++ b/src/core/auth/jwt.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { decodeJwtClaims } from './jwt' + +function token(payload: string) { + return `header.${Buffer.from(payload).toString('base64url')}.signature` +} + +describe('JWT claim decoding', () => { + it.each(['2110664514823360515', '"2110664514823360515"'])('preserves the exact account ID encoded as %s', userId => { + const claims = decodeJwtClaims(token(`{"user_id":${userId},"app_code":"PLATFORM","exp":1792060000,"iat":1792058200}`)) + expect(claims.user_id).toBe('2110664514823360515') + expect(claims.exp).toBe(1792060000) + expect(claims).toHaveProperty('iat', 1792058200) + }) + + it('retains safe numeric IDs and ordinary claims', () => { + expect(decodeJwtClaims(token('{"user_id":123,"username":"operator","roles":["ADMIN"],"exp":1}'))) + .toEqual({ user_id: 123, username: 'operator', roles: ['ADMIN'], exp: 1 }) + }) + + it.each(['', 'header..signature'])('rejects a missing payload: %s', value => { + expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 格式无效') + }) + + it.each(['header.%.signature', token('{"user_id":2110664514823360515,')])('rejects malformed encoding or JSON: %s', value => { + expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 无法解析') + }) +}) diff --git a/src/core/auth/jwt.ts b/src/core/auth/jwt.ts index 87465ebf..f104d1e0 100644 --- a/src/core/auth/jwt.ts +++ b/src/core/auth/jwt.ts @@ -1,3 +1,5 @@ +import { parseJsonWithSafeIntegers } from '@chongchong/shared' + export interface PlatformJwtClaims { user_id?: string | number username?: string @@ -19,7 +21,7 @@ export function decodeJwtClaims(token: string): PlatformJwtClaims { const payload = token.split('.')[1] if (!payload) throw new Error('登录响应中的 accessToken 格式无效') try { - return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims + return parseJsonWithSafeIntegers(decodeBase64Url(payload)) as PlatformJwtClaims } catch { throw new Error('登录响应中的 accessToken 无法解析') } diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 12c2c7e8..db1a7e2d 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,6 +8,14 @@ import { } from './adminNavigation' describe('adminNavigation', () => { + it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => { + const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list'])) + expect(getNavigationLeaves(read).map(item => item.path)).toEqual([ + '/dashboard', '/self-operated/overview', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection', + ]) + const sync = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:sync'])) + expect(getNavigationLeaves(sync).map(item => item.path)).toEqual(['/dashboard']) + }) it('offers the common container menu to overseas-only operators', () => { const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list'])) expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic']) @@ -27,11 +35,12 @@ describe('adminNavigation', () => { { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, + { title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) - expect(visible).toHaveLength(12) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(52) + expect(visible).toHaveLength(13) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) @@ -73,7 +82,7 @@ describe('adminNavigation', () => { expect(paths).toContain('/system/permissions') expect(new Set(paths).size).toBe(paths.length) const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index) - expect(new Set(duplicatedPermissions)).toEqual(new Set()) + expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:self-operated-analytics:list'])) expect(leaves.every((item) => Boolean(item.icon))).toBe(true) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 571b3797..65b13116 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -181,6 +181,17 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' }, ], }, + { + key: 'self-operated', + title: '海外仓', + icon: PrototypeSalesIcon, + children: [ + { key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' }, + { key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' }, + { key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' }, + { key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' }, + ], + }, { key: 'general', title: '通用', diff --git a/src/modules/appointment-calendar/AppointmentBatchForm.test.ts b/src/modules/appointment-calendar/AppointmentBatchForm.test.ts new file mode 100644 index 00000000..eb2aea64 --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentBatchForm.test.ts @@ -0,0 +1,183 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, expect, it, vi } from 'vitest' +import AppointmentBatchForm from './AppointmentBatchForm.vue' +import { getAppointmentBatch, repartitionAppointments } from './batchApi' +import { getWorkItems } from './workApi' +import { ApiResponseError } from '@/shared/api/http' +import { batchContext } from './batch.test-support' +import { workItem } from './work.test-support' +import type { LeaveGuard } from './useWorkDraft' +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } })) +vi.mock('./batchApi', async original => ({ ...await original(), getAppointmentBatch: vi.fn(), repartitionAppointments: vi.fn() })) +vi.mock('./workApi', async original => ({ ...await original(), getWorkItems: vi.fn() })) +let guard: LeaveGuard +function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { + ElButton: { props: ['nativeType', 'disabled'], template: '' }, + LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, ApiErrorMeta: true, CargoVerificationForm: true, +} } }) } +beforeEach(() => { + vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext()) + vi.mocked(repartitionAppointments).mockResolvedValue({ replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [batchContext('AP-NEW-1', 'FBA_APPOINTMENT:11').appointment, batchContext('AP-NEW-2', 'FBA_APPOINTMENT:12').appointment] }) + vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ batchKey: 'FBA_APPOINTMENT:2' })], page: 1, size: 8, total: 1, pages: 1 }) +}) +it('submits two conservative allocations without fake confirmation times and renders replacement business numbers', async () => { + const wrapper = render(); await flushPromises() + await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20) + await wrapper.findAll('textarea')[0]!.setValue('分两次送仓'); await wrapper.get('form').trigger('submit'); await flushPromises() + const request = vi.mocked(repartitionAppointments).mock.calls[0]![0] + expect(request.originals).toEqual([{ appointmentNo: 'AP-001', version: 3 }]) + expect(request.batches.map(row => row.plannedDate)).toEqual([null, null]) + expect(request.batches[0]!.allocations.map(row => row.packageCount)).toEqual([50, 30]) + expect(request.batches[1]!.allocations.map(row => row.packageCount)).toEqual([20]) + expect(request).not.toHaveProperty('appointmentAt') + expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:') + expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount() +}) +it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => { + const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1') + context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }] + context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }] + vi.mocked(getAppointmentBatch).mockResolvedValue(context) + const wrapper = render(); await flushPromises() + await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20) + await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排') + await wrapper.get('form').trigger('submit'); await flushPromises() + const request = vi.mocked(repartitionAppointments).mock.calls[0]![0] + expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }]) + expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]]) + expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true) + wrapper.unmount() +}) +it('locks an unknown outcome and retries the same immutable repartition command', async () => { + vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome')) + const wrapper = render(); await flushPromises() + await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('分批') + await wrapper.get('form').trigger('submit'); await flushPromises() + const first = vi.mocked(repartitionAppointments).mock.calls[0]![0] + expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined() + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(repartitionAppointments).toHaveBeenNthCalledWith(2, first); wrapper.unmount() +}) +it('keeps edited allocations after a version conflict and requires a fresh explicit submit', async () => { + vi.mocked(repartitionAppointments).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409)) + const wrapper = render(); await flushPromises() + await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('保留分批原因') + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(repartitionAppointments).toHaveBeenCalledTimes(1) + expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('20') + expect((wrapper.findAll('textarea')[0]!.element as HTMLTextAreaElement).value).toBe('保留分批原因'); wrapper.unmount() +}) +it('shows full appointment cargo when confirmed or replaced batches have no repartition sources', async () => { + const context = batchContext(); context.sources = []; context.allowedActions = []; context.blockReasons = ['原批次已有正式确认']; context.appointment.appointmentStatus = 'SUCCESS' + vi.mocked(getAppointmentBatch).mockResolvedValue(context) + const wrapper = render(); await flushPromises() + expect(wrapper.get('[aria-label="原批次货物快照"]').text()).toContain('IN-A') + expect(wrapper.text()).toContain('FBA-B'); expect(wrapper.text()).toContain('当前批次无分批资格') + await wrapper.get('form').trigger('submit'); expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount() +}) +it('loads a candidate context before merging and preserves both original versions', async () => { + const other = batchContext('AP-002', 'FBA_APPOINTMENT:2'); other.appointment.version = 6 + vi.mocked(getAppointmentBatch).mockImplementation(async key => key === 'FBA_APPOINTMENT:2' ? other : batchContext()) + const wrapper = render(); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '同仓合并安排')!.trigger('click'); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '核对并选择')!.trigger('click'); await flushPromises() + await wrapper.findAll('textarea')[0]!.setValue('同仓合并'); await wrapper.get('form').trigger('submit'); await flushPromises() + expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] })) + wrapper.unmount() +}) +it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => { + const context = batchContext() + const source = context.appointment.items[0]! + context.appointment.items = [source] + context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3'] + context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20'] + context.sources = []; context.allowedActions = [] + const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment + forty.appointmentStatus = 'SUCCESS' + forty.items = [ + { ...source, packageCount: 40 }, + { ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 }, + { ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 }, + { ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 }, + ] + const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment + twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }] + context.currentBatches = [forty, twenty] + vi.mocked(getAppointmentBatch).mockResolvedValue(context) + const wrapper = render(); await flushPromises() + const destinations = wrapper.get('[aria-label="当前货物去向"]') + expect(destinations.findAll('article')).toHaveLength(2) + expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱') + expect(destinations.text()).toContain('已取消,分配保留') + expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE') + expect(destinations.text()).not.toContain('FBA_APPOINTMENT:') + await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click') + expect(wrapper.emitted('appointment')).toEqual([['AP-40']]) + await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises() + expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4') + expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount() +}) +it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => { + const context = batchContext() + context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2'] + context.sources = []; context.allowedActions = [] + const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment + unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }] + const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment + empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }] + context.currentBatches = [unknown, empty] + vi.mocked(getAppointmentBatch).mockResolvedValue(context) + const wrapper = render(); await flushPromises() + const destinations = wrapper.get('[aria-label="当前货物去向"]') + expect(destinations.findAll('article')).toHaveLength(1) + expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱') + expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount() +}) +it('does not fall back to historical replacement links when current destinations are empty', async () => { + const context = batchContext() + context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2'] + context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT'] + context.sources = []; context.allowedActions = []; context.currentBatches = [] + vi.mocked(getAppointmentBatch).mockResolvedValue(context) + const wrapper = render(); await flushPromises() + const destinations = wrapper.get('[aria-label="当前货物去向"]') + expect(destinations.findAll('article')).toHaveLength(0) + expect(destinations.text()).toContain('原快照不代表当前待办') + expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount() +}) +it('keeps every 40/20 destination after the entire customer has left the original current items', async () => { + const context = batchContext(), originalItem = context.appointment.items[0]! + context.appointment.items = [context.appointment.items[1]!] + context.sources = [context.sources[1]!] + const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment + forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }] + const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment + twenty.items = [{ ...originalItem, packageCount: 20 }] + context.currentBatches = [context.appointment, forty, twenty] + vi.mocked(getAppointmentBatch).mockResolvedValue(context) + const wrapper = render(); await flushPromises() + const destinations = wrapper.get('[aria-label="当前货物去向"]') + expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱') + expect(destinations.text()).not.toContain('IN-UNRELATED') + expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1) + expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30') + wrapper.unmount() +}) +it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => { + const original = batchContext() + original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = [] + const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2') + current.appointment.plannedDate = '2026-10-22' + original.currentBatches = [current.appointment] + vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current) + const wrapper = render(); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises() + expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false) + await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('AP-CURRENT') + expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22') + expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0') + expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2') + expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount() +}) diff --git a/src/modules/appointment-calendar/AppointmentBatchForm.vue b/src/modules/appointment-calendar/AppointmentBatchForm.vue new file mode 100644 index 00000000..a404117b --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentBatchForm.vue @@ -0,0 +1,133 @@ + +