diff --git a/output/appointment-calendar/compact-month.png b/output/appointment-calendar/compact-month.png new file mode 100644 index 00000000..d673a5bc Binary files /dev/null and b/output/appointment-calendar/compact-month.png differ diff --git a/output/appointment-calendar/compact-week.png b/output/appointment-calendar/compact-week.png new file mode 100644 index 00000000..3d1a1907 Binary files /dev/null and b/output/appointment-calendar/compact-week.png differ diff --git a/output/appointment-calendar/delete-success.png b/output/appointment-calendar/delete-success.png new file mode 100644 index 00000000..ffc7e6a9 Binary files /dev/null and b/output/appointment-calendar/delete-success.png differ diff --git a/output/appointment-calendar/final-week.png b/output/appointment-calendar/final-week.png new file mode 100644 index 00000000..537754b2 Binary files /dev/null and b/output/appointment-calendar/final-week.png differ diff --git a/output/appointment-calendar/verification.json b/output/appointment-calendar/verification.json index d07ba16f..3d7be27c 100644 --- a/output/appointment-calendar/verification.json +++ b/output/appointment-calendar/verification.json @@ -3,9 +3,9 @@ "testOnly": true, "deployment": { "admin": { - "build": 531, + "build": 536, "result": "SUCCESS", - "commit": "2caeb12" + "commit": "73c29f5" }, "firstleg": { "build": 488, @@ -40,7 +40,13 @@ "stale_update_conflict", "stale_delete_conflict", "delete_and_calendar_absence", - "rebook_after_delete" + "rebook_after_delete", + "two_new_customer_accounts_10_negative_isolation_checks", + "nonempty_customer_75_rows_all_owned", + "nonempty_customer_forged_filter_stays_scoped", + "own_plan_detail_visible_other_plan_hidden", + "existing_customer_platform_appointment_forbidden", + "browser_deleted_appointment_absence_http_confirmed" ], "uiPassed": [ "1920x1080_month", @@ -51,7 +57,11 @@ "two_customer_selection", "cross_page_selection_retained", "create_saved", - "final_month_top_alignment" + "final_month_top_alignment", + "final_week_top_alignment", + "delete_confirmation_and_active_dispatch_rejection", + "month_week_three_previews_233_total_title", + "browser_successful_delete_two_to_one" ], "initialCalendarRequests": [ { @@ -65,14 +75,18 @@ "durationMs": 106 } ], - "pending": [ - "merchant_identity_isolation: configured login returned HTTP401 A0401", - "week_alignment_final_browser_recheck", - "browser_delete_confirmation_flow" - ], + "pending": [], "screenshots": [ "month-five-customers.png", "week-five-customers.png", - "final-month.png" - ] + "final-month.png", + "compact-month.png", + "compact-week.png", + "delete-success.png" + ], + "scope": { + "admin": "appointment calendar and inbound appointment consumer", + "client": "no page or code change; merchant inbound API positive and negative isolation verified", + "remainingSample": "QA-UI-20261009-2CUSTOMERS" + } } \ No newline at end of file diff --git a/output/appointment-gray-release/admin-pr.json b/output/appointment-gray-release/admin-pr.json new file mode 100644 index 00000000..92c49306 --- /dev/null +++ b/output/appointment-gray-release/admin-pr.json @@ -0,0 +1 @@ +{"repo": "html/admin-web", "number": 18, "url": "http://8.163.30.128:10082/html/admin-web/pulls/18", "head": "73c29f5264e46f648713c58b469a8c7bd335cd9c", "branch": "release/appointment-gray-20261009"} \ No newline at end of file diff --git a/output/appointment-gray-release/admin-release-commit.txt b/output/appointment-gray-release/admin-release-commit.txt new file mode 100644 index 00000000..02920573 --- /dev/null +++ b/output/appointment-gray-release/admin-release-commit.txt @@ -0,0 +1 @@ +f9dd30b8b9c6b756c4502e4efc33df4ddb59e566 \ No newline at end of file diff --git a/output/appointment-gray-release/backend-pr.json b/output/appointment-gray-release/backend-pr.json new file mode 100644 index 00000000..18404ce1 --- /dev/null +++ b/output/appointment-gray-release/backend-pr.json @@ -0,0 +1 @@ +{"repo": "java-project/baiyue-firstleg", "number": 25, "url": "http://8.163.30.128:10082/java-project/baiyue-firstleg/pulls/25", "head": "7fd7699f6e83e77e487aceff914d160cf42376a0", "branch": "release/appointment-gray-20261009"} \ No newline at end of file diff --git a/output/appointment-gray-release/backend-release-commit.txt b/output/appointment-gray-release/backend-release-commit.txt new file mode 100644 index 00000000..7c24582c --- /dev/null +++ b/output/appointment-gray-release/backend-release-commit.txt @@ -0,0 +1 @@ +f60f44793f289f7612b561f1c470e093f67f4e32 \ No newline at end of file diff --git a/output/appointment-gray-release/gray-calendar.png b/output/appointment-gray-release/gray-calendar.png new file mode 100644 index 00000000..63752c18 Binary files /dev/null and b/output/appointment-gray-release/gray-calendar.png differ diff --git a/output/appointment-gray-release/http-postdeploy.json b/output/appointment-gray-release/http-postdeploy.json new file mode 100644 index 00000000..c9f4e022 --- /dev/null +++ b/output/appointment-gray-release/http-postdeploy.json @@ -0,0 +1,30 @@ +[ + { + "name": "permissions", + "http": 200, + "code": "200", + "count": 358, + "elapsedMs": 115 + }, + { + "name": "arrivals", + "http": 200, + "code": "200", + "count": 10, + "elapsedMs": 297 + }, + { + "name": "appointments", + "http": 200, + "code": "200", + "count": 0, + "elapsedMs": 287 + }, + { + "name": "inboundCandidates", + "http": 200, + "code": "200", + "count": 10, + "elapsedMs": 221 + } +] \ No newline at end of file diff --git a/output/appointment-gray-release/jobs.json b/output/appointment-gray-release/jobs.json new file mode 100644 index 00000000..d3dd7ef0 --- /dev/null +++ b/output/appointment-gray-release/jobs.json @@ -0,0 +1 @@ +[{"job": "baiyue-firstleg-prod-lastleg-service", "queue": "https://jenkins.buggoglobal.com/queue/item/7294/", "number": 13, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "baiyue-firstleg-prod-firstleg", "queue": "https://jenkins.buggoglobal.com/queue/item/7295/", "number": 14, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "admin-web-prod", "queue": "https://jenkins.buggoglobal.com/queue/item/7300/", "number": 26, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}] \ No newline at end of file diff --git a/output/appointment-gray-release/preflight.json b/output/appointment-gray-release/preflight.json new file mode 100644 index 00000000..7db61862 --- /dev/null +++ b/output/appointment-gray-release/preflight.json @@ -0,0 +1,32 @@ +{ + "scope": [ + "admin-web", + "firstleg", + "lastleg-service" + ], + "databaseAuthorized": true, + "schemaPreflight": { + "appointmentRows": 0, + "itemRows": 0, + "permissionConflicts": 0, + "adminRoleCount": 1, + "affectedAdminSubjects": 1 + }, + "targets": { + "backend": "7fd7699f6", + "admin": "73c29f5" + }, + "sourceEquivalence": { + "backendMergeTreeMatchesTestedSource": true, + "adminMergeTreeMatchesTestedSource": true + }, + "sqlExecuted": true, + "deployed": true, + "pending": null, + "releaseBranchesDeleted": [ + "backend", + "admin" + ], + "browserVerified": true, + "grayWriteCrudRetested": false +} \ No newline at end of file diff --git a/output/appointment-gray-release/review-schema-permissions.sql b/output/appointment-gray-release/review-schema-permissions.sql new file mode 100644 index 00000000..c8a1d26c --- /dev/null +++ b/output/appointment-gray-release/review-schema-permissions.sql @@ -0,0 +1,35 @@ +-- REVIEW ONLY: gray appointment schema and platform administrator permissions. +-- Execute only with explicit gray SQL authorization, after verifying both appointment tables remain empty. +-- No DROP TABLE, DELETE, TRUNCATE, or business-data backfill. +-- DDL auto-commits; stop immediately on any error. Do not use mysql --force. +USE baiyue_lastleg; +ALTER TABLE ll_inbound_fba_appointment + DROP INDEX uk_ll_fba_appointment_no, + DROP INDEX idx_ll_fba_appointment_list, + DROP COLUMN customer_id, + ADD COLUMN appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空' AFTER appointment_at, + ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '并发版本' AFTER appointment_end_at, + ADD UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), + ADD KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code); +ALTER TABLE ll_inbound_fba_appointment_item + ADD COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID' AFTER appointment_id, + ADD KEY idx_ll_fba_appointment_item_cargo (line_id, appointment_id); +USE baiyue_iam; +START TRANSACTION; +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0), + (5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0); +INSERT INTO iam_role_permission (tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r CROSS JOIN iam_permission p +WHERE r.id=4000001 AND r.tenant_id=0 AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0 +AND p.id IN (5107280,5107281); +INSERT INTO iam_security_subject_version (auth_user_id,permission_version,updated_at) +SELECT DISTINCT u.auth_user_id,1,NOW(3) +FROM iam_membership_role mr +JOIN iam_membership m ON m.id=mr.membership_id AND m.tenant_id=mr.tenant_id +JOIN iam_user u ON u.id=m.user_id +WHERE mr.tenant_id=0 AND mr.role_id=4000001 AND m.deleted=0 AND u.deleted=0 +ON DUPLICATE KEY UPDATE permission_version=permission_version+1,updated_at=NOW(3); +COMMIT; diff --git a/output/appointment-gray-release/sql-postcheck.txt b/output/appointment-gray-release/sql-postcheck.txt new file mode 100644 index 00000000..08238f7c --- /dev/null +++ b/output/appointment-gray-release/sql-postcheck.txt @@ -0,0 +1,5 @@ +ll_inbound_fba_appointment 13 +ll_inbound_fba_appointment_item 9 +platform:appointment:list +platform:appointment:delete +2 diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index e8898056..2e3062cd 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -242,6 +242,62 @@ describe('admin auth session', () => { expect(localStorage.getItem('cc-admin-token')).toBeNull() }) + it('rejects an old refresh after another tab has switched to a different account', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'admin-refresh') + localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]') + let finishRefresh!: (value: ReturnType) => void + mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve })) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) + const listeners = vi.spyOn(window, 'addEventListener') + const auth = useAuthStore() + const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void + listeners.mockRestore() + const refreshing = auth.refreshSession() + await Promise.resolve() + const salesToken = token({ user_id: '2000002', username: 'sales' }) + localStorage.setItem('cc-admin-token', salesToken) + localStorage.setItem('cc-admin-refresh-token', 'sales-refresh') + localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]') + onStorage(new StorageEvent('storage', { key: 'cc-admin-token' })) + expect(auth.user?.account).toBe('sales') + finishRefresh(loginResponse(token(), 'admin-rotated')) + await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代') + expect(auth.user?.account).toBe('sales') + expect(auth.token).toBe(salesToken) + expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh') + expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list']) + }) + + it('discards late permission results after another tab switches accounts without clearing the new session', async () => { + mocks.post.mockResolvedValue(loginResponse()) + let finishPermissions!: (value: string[]) => void + let permissionsStarted!: () => void + const started = new Promise(resolve => { permissionsStarted = resolve }) + mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => { + permissionsStarted() + return new Promise(resolve => { finishPermissions = resolve }) + }) + const listeners = vi.spyOn(window, 'addEventListener') + const auth = useAuthStore() + const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void + listeners.mockRestore() + const login = auth.login('platform_admin', 'password') + await started + const salesToken = token({ user_id: '2000002', username: 'sales' }) + localStorage.setItem('cc-admin-token', salesToken) + localStorage.setItem('cc-admin-refresh-token', 'sales-refresh') + localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]') + onStorage(new StorageEvent('storage', { key: 'cc-admin-token' })) + finishPermissions(['platform:warehouse:list']) + await expect(login).rejects.toThrow('登录会话已被后续操作替代') + expect(auth.user?.account).toBe('sales') + expect(auth.token).toBe(salesToken) + expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh') + expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list']) + expect(mocks.post).toHaveBeenCalledTimes(1) + }) + it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => { const previous = token() const rotated = token({ token_version: 2 }) diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 2c206da9..d8072e09 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -144,6 +144,10 @@ export const useAuthStore = defineStore('admin-auth', () => { try { const session = sessionFromToken(storedToken) const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown + // A different account in another tab supersedes this tab's pending login/refresh responses. + if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) { + sessionGeneration += 1 + } token.value = storedToken refreshToken.value = storedRefreshToken user.value = session.user diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index b1dbf3b6..12c2c7e8 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -23,7 +23,7 @@ describe('adminNavigation', () => { { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] }, { title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] }, { title: '预约管理', children: ['预约列表'] }, - { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] }, + { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 4fd3b351..571b3797 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -135,7 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' }, { key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' }, - { key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] }, + { key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] }, { key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' }, ], }, diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index 141ca57d..b252e473 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -10,11 +10,50 @@ describe('appointment calendar interactions', () => { global: { stubs: { ElButton: { template: '' } }, directives: { loading: {} } }, }) + it('opens a readable appointment without exposing write actions', async () => { + const wrapper = render() + const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false} + await wrapper.setProps({events:[event]}) + const actions=wrapper.get('.detail-event footer') + expect(actions.text()).toBe('查看预约') + await actions.get('button').trigger('click') + expect(wrapper.emitted('view')?.[0]).toEqual([event]) + expect(wrapper.emitted('edit')).toBeUndefined() + wrapper.unmount() + }) + it('hides optional references by default and counts only appointments after enabling them', async () => { + const wrapper = render() + await wrapper.setProps({ events: [ + { key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] }, + { key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] }, + { key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] }, + ] }) + expect(wrapper.findAll('.detail-event')).toHaveLength(1) + const switches = wrapper.get('[aria-label="参考图层"]').findAll('input') + await switches[0]!.setValue(true); await switches[1]!.setValue(true) + expect(wrapper.findAll('.detail-event')).toHaveLength(3) + expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约') + expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划') + expect(wrapper.emitted('range')).toBeUndefined() + }) it('selects an already loaded date without requesting data again', async () => { const wrapper = render() await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click') expect(wrapper.emitted('range')).toBeUndefined() - expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无到港或送仓预约') + expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约') + }) + + it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => { + const wrapper = render() + await wrapper.setProps({ events: [ + { key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] }, + { key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] }, + ] }) + expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true) + expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true) + expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓') + expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束') + expect(wrapper.emitted('range')).toBeUndefined() }) it('requests exactly one range for a view switch and navigates seven days', async () => { @@ -31,6 +70,7 @@ describe('appointment calendar interactions', () => { key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00', title: `船舶 ${index}`, fields: [], })) }) + await wrapper.get('[aria-label="参考图层"] input').setValue(true) for (const view of ['month', 'week']) { if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click') const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]') @@ -46,8 +86,22 @@ describe('appointment calendar interactions', () => { await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click') expect(wrapper.emitted('create')).toEqual([['2026-09-24']]) await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click') - await wrapper.findAll('button').find(button => button.text() === '删除')!.trigger('click') + await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click') expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' }) expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' }) }) + + it('renders many inbound codes outside the two-column fields with only three previews', async () => { + const wrapper = render() + await wrapper.setProps({ events: [{ + key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓', + fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }], + inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true, + }] }) + const card = wrapper.get('.detail-event') + expect(card.get('dl').text()).not.toContain('WT-INBOUND') + expect(card.findAll('.inbound-codes li')).toHaveLength(3) + expect(card.get('.inbound-codes').text()).toContain('20 个') + expect(card.get('footer').text()).toContain('编辑') + }) }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index ad532175..cd4d492f 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -1,47 +1,65 @@ diff --git a/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.test.ts b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.test.ts new file mode 100644 index 00000000..6f175466 --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.test.ts @@ -0,0 +1,77 @@ +import { shallowMount, flushPromises } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { createPinia } from 'pinia' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import Dialog from './CapacityOfferSettingsDialog.vue' +import * as offers from '../api/capacityOffers' +import * as trips from '../api/trips' +import type { FleetTrip } from '../tripTypes' +const state = vi.hoisted(() => ({ plan: vi.fn(), push: vi.fn() })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) +vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() })) +vi.mock('../api/trips', () => ({ getTrip: vi.fn(), getTripLoading: vi.fn(), updateTripPlan: vi.fn(), getTripCapacityPlan: vi.fn(), confirmTripCapacityPlan: vi.fn(), confirmTripLoading: vi.fn() })) +const trip = { tripNo: 'TR-1', recordVersion: 5, plannedDepartureAt: '2099-01-01T12:00:00Z', stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey: 'FBA:ACY2' }] } as FleetTrip +const draft = { offerNo: 'FO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, tripRecordVersion: 6, actions: { canEdit: true } } +const wrappers: ReturnType[] = [] +function mount(realPlanner = false) { + const wrapper = shallowMount(Dialog, { props: { modelValue: true, trip }, global: { plugins: [createPinia()], stubs: { + AppDialog: { name: 'AppDialog', props: ['confirmText'], emits: ['confirm'], template: '
' }, + TripLoadingPanel: realPlanner ? false : defineComponent({ name: 'TripLoadingPanel', setup(_, { expose }) { expose({ save: state.plan }); return {} }, template: '
' }), + DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, + ElInputNumber: { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '' }, + ElForm: { template: '
' }, ElFormItem: { template: '
' }, + ElButton: { template: '' }, ElDatePicker: true, ElInput: true, LoadErrorState: true, ApiErrorMeta: true, + } } }); wrappers.push(wrapper); return wrapper +} +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(trips.getTrip).mockResolvedValue({ trip } as Awaited>) + vi.mocked(offers.listCapacityOffers).mockResolvedValue({ records: [], total: 0, page: 1, size: 1, pages: 0 }) + state.plan.mockResolvedValue({ tripNo: 'TR-1', recordVersion: 6 }) + vi.mocked(offers.saveCapacityOffer).mockResolvedValue({ offer: draft, replayed: false } as Awaited>) + vi.mocked(offers.commandCapacityOffer).mockResolvedValue({ offer: { ...draft, status: 'OPEN', recordVersion: 2 }, replayed: false } as Awaited>) +}) +afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())) +it('confirms capacity, saves metadata and publishes from one click using returned versions', async () => { + const wrapper = mount(); await flushPromises() + expect(wrapper.findComponent({ name: 'ElDatePicker' }).attributes('modelvalue')).toBe(trip.plannedDepartureAt) + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(state.plan).toHaveBeenCalledOnce() + expect(offers.saveCapacityOffer).toHaveBeenCalledWith(null, expect.objectContaining({ tripRecordVersion: 6, cutoffAt: trip.plannedDepartureAt })) + expect(offers.commandCapacityOffer).toHaveBeenCalledWith('FO-1', 'publish', expect.objectContaining({ tripRecordVersion: 6, offerRecordVersion: 1 })) + expect(wrapper.text()).toContain('查看需求池发布') +}) +it('does not publish if capacity validation is incomplete', async () => { + state.plan.mockResolvedValue(undefined) + const wrapper = mount(); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(offers.saveCapacityOffer).not.toHaveBeenCalled(); expect(offers.commandCapacityOffer).not.toHaveBeenCalled() +}) +it('retries only the uncertain publish command with the original key', async () => { + vi.mocked(offers.commandCapacityOffer).mockRejectedValueOnce(new Error('network')) + const wrapper = mount(); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + const first = vi.mocked(offers.commandCapacityOffer).mock.calls[0]! + expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmText')).toBe('重试原请求') + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(offers.commandCapacityOffer).toHaveBeenNthCalledWith(2, ...first) + expect(state.plan).toHaveBeenCalledOnce(); expect(offers.saveCapacityOffer).toHaveBeenCalledOnce() +}) + +it('submits measurements from the embedded panel before saving and publishing', async () => { + const basis = { tripNo: 'TR-1', recordVersion: 5, cargo: [{ dispatchNo: 'DP-1', customerId: '1', dispatchItemId: '2', warehousePalletId: '3', palletNo: 'PLT-USLAX01-261010-G0RD3LYHGD8H', packages: 1, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] } + vi.mocked(trips.getTripCapacityPlan).mockResolvedValue(basis) + vi.mocked(trips.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 6, planValid: true }) + const wrapper = mount(true); await flushPromises() + expect(wrapper.text()).toContain('PLT-USLAX01-261010-G0RD3LYHGD8H') + const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string; singleLine?: boolean; width?: number; minWidth?: number }[] + expect(columns.filter(column => ['palletNo', 'spaces', 'weight', 'volume', 'stop'].includes(column.key)).every(column => column.singleLine === false)).toBe(true) + expect(columns.reduce((sum, column) => sum + (column.width || column.minWidth || 0), 0)).toBeLessThanOrEqual(1048) + const fields = wrapper.findAllComponents({ name: 'ElInputNumber' }) + fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 2); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(trips.confirmTripCapacityPlan).toHaveBeenCalledWith('TR-1', expect.objectContaining({ recordVersion: 5, pallets: [{ warehousePalletId: '3', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }] })) + expect(offers.commandCapacityOffer).toHaveBeenCalledOnce() +}) diff --git a/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue index 7f748e32..855a8877 100644 --- a/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue +++ b/src/modules/overseas-master-data/components/CapacityOfferSettingsDialog.vue @@ -1,6 +1,9 @@ -