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/modules/appointment-calendar/AppointmentBatchForm.test.ts b/src/modules/appointment-calendar/AppointmentBatchForm.test.ts new file mode 100644 index 00000000..32a6df7b --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentBatchForm.test.ts @@ -0,0 +1,73 @@ +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('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() +}) diff --git a/src/modules/appointment-calendar/AppointmentBatchForm.vue b/src/modules/appointment-calendar/AppointmentBatchForm.vue new file mode 100644 index 00000000..1c8d6d99 --- /dev/null +++ b/src/modules/appointment-calendar/AppointmentBatchForm.vue @@ -0,0 +1,108 @@ + +