diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 4640f9a..0a86f18 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 81cec55e15c968efcc2cb4c4e5babcd17a40a3f4 -synchronized_at: 2026-10-06T10:02:05Z +wiki_revision: 0c38e2fe68546acb2d780c94b73879107c60b9f0 +synchronized_at: 2026-10-07T03:14:24Z @@ -339,6 +339,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`( ## SYB 档口入库码导入、匹配与回写 +- 页面分页(#363,实现绑定 `955869b`,2026-10-07 工单分支已实现,尚未合并或发布):仅档口入库码页首次进入或刷新默认第 1 页、每页 200 条,保留 20/50/100/200 条选项;搜索和清空筛选保持当前选定条数。其他页面、公共分页组件及后端未传 pageSize 时默认 100 的行为不变;当前页全选仍仅限当前页,匹配、删除和回写的资格与确认规则不变。 + - 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。 - 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。 - 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。 diff --git a/web/src/views/goauto/syb-inner-codes/index.vue b/web/src/views/goauto/syb-inner-codes/index.vue index 46c5efe..04ecdbd 100644 --- a/web/src/views/goauto/syb-inner-codes/index.vue +++ b/web/src/views/goauto/syb-inner-codes/index.vue @@ -68,7 +68,7 @@ import { createRequestId } from '@/utils/request-id' export default { name: 'GoAutoSybInnerCodes', setup() { return { FolderOpened, RefreshLeft, Search, Upload } }, - data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 100, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} }, + data() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return { loading: false, importing: false, busy: false, matchSubmitting: false, loadError: '', items: [], total: 0, selected: [], selectedFile: null, dateRange: [today, today], rowActionId: 0, pollTimer: null, activeJob: null, activeBatch: null, query: { page: 1, pageSize: 200, dateFrom: today, dateTo: today, keyword: '' }, applyDialog: { open: false, loading: false, saving: false, ready: false, error: '', ids: [], selectedCount: 0, preview: {}}, deleteDialog: { open: false, saving: false }, detail: { open: false, loading: false, item: null }} }, computed: { matchingBusy() { return this.matchSubmitting || ['pending', 'running'].includes(this.activeJob?.status) }, matchEligibleRows() { const ids = new Set(this.selected.map(item => item.id)); return this.items.filter(item => ids.has(item.id) && ['pending', 'failed', 'skipped'].includes(item.status)) }, diff --git a/web/tests/e2e/syb-inner-code-page-size.spec.ts b/web/tests/e2e/syb-inner-code-page-size.spec.ts new file mode 100644 index 0000000..cae9852 --- /dev/null +++ b/web/tests/e2e/syb-inner-code-page-size.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test' + +test('page-local 200 default, pagination, filters, selection and other-page isolation', async ({ page, context }) => { + await page.setViewportSize({ width: 1920, height: 1080 }) + await context.addCookies([{ name: 'Admin-Token', value: 'isolated-test-token', domain: 'localhost', path: '/' }]) + const lists: URL[] = []; const shopLists: URL[] = []; const previews: number[][] = []; const writes: string[] = [] + const rows = Array.from({ length: 205 }, (_, i) => ({ id: i + 1, businessDate: '2026-10-07', orderNumber: `MOCK-${i + 1}`, status: 'ready', specRaw: '模拟规格', items: [{ ordinal: 1, code: `CODE-${i + 1}` }] })) + await page.route('**/api/**', async route => { + const request = route.request(); const url = new URL(request.url()); const path = url.pathname + if (path.startsWith('/src/api/')) return route.continue() + const reply = (data: unknown) => route.fulfill({ json: { code: 200, data } }) + if (path.endsWith('/api/v1/getinfo')) return reply({ roles: ['purchaser'], name: '模拟采购员', avatar: '', permissions: [] }) + if (path.endsWith('/api/v1/menurole')) return reply([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [ + { path: '/syb-inner-codes', component: '/goauto/syb-inner-codes/index', menuName: 'GoAutoSybInnerCodes', title: '档口入库码', visible: '0' }, + { path: '/syb-shops', component: '/goauto/syb-shops/index', menuName: 'GoAutoSybShops', title: '店铺管理', visible: '0' } + ] }]) + if (path.endsWith('/syb-inner-codes') && request.method() === 'GET') { + lists.push(url) + const pageNo = Number(url.searchParams.get('page')); const size = Number(url.searchParams.get('pageSize')) + const filtered = url.searchParams.get('keyword') === 'empty' ? [] : rows + return reply({ items: filtered.slice((pageNo - 1) * size, pageNo * size), total: filtered.length, page: pageNo, pageSize: size }) + } + if (path.endsWith('/apply-preview')) { + const ids = request.postDataJSON().ids; previews.push(ids) + return reply({ records: ids.length, inboundCodes: ids.length, placeholderDetails: 0, replaceOldCodes: 0, blocked: [] }) + } + if (path.endsWith('/syb-shops') && request.method() === 'GET') { shopLists.push(url); return reply({ items: [], total: 0 }) } + if (request.method() !== 'GET') writes.push(path) + return reply({ items: [], total: 0 }) + }) + const tableRows = page.locator('.el-table__body-wrapper tbody tr') + const pagination = page.locator('.pagination-container') + const started = Date.now() + await page.goto('/#/syb-inner-codes') + await expect(tableRows).toHaveCount(200) + console.log(`mock 200-row initial navigation and render: ${Date.now() - started}ms`) + expect(lists[0].searchParams.get('page')).toBe('1') + expect(lists[0].searchParams.get('pageSize')).toBe('200') + await expect(pagination.locator('.el-select')).toContainText('200') + + await page.locator('.el-table__header-wrapper .el-checkbox').first().click() + await page.getByRole('button', { name: '回写(200)', exact: true }).click() + await expect.poll(() => previews.length).toBe(1) + expect(previews[0]).toEqual(rows.slice(0, 200).map(row => row.id)) + await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click() + expect(writes).toEqual([]) + + await pagination.locator('.btn-next').click() + await expect(tableRows).toHaveCount(5) + await expect(page.getByRole('cell', { name: 'MOCK-201', exact: true })).toBeVisible() + expect(lists.at(-1)!.searchParams.get('page')).toBe('2') + await expect(page.getByRole('button', { name: '回写(0)', exact: true })).toBeDisabled() + await page.getByRole('button', { name: '搜索', exact: true }).click() + await expect(tableRows).toHaveCount(200) + + for (const size of [20, 50, 100, 200]) { + await pagination.locator('.el-select').click() + await page.getByRole('option', { name: new RegExp(`^${size}\\s*条/页$`) }).click() + await expect(tableRows).toHaveCount(size) + expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size)) + for (const name of ['搜索', '清除']) { + const before = lists.length + await page.getByRole('button', { name, exact: true }).click() + await expect.poll(() => lists.length).toBeGreaterThan(before) + expect(lists.at(-1)!.searchParams.get('pageSize')).toBe(String(size)) + } + } + + await page.getByPlaceholder('订单号或入库码').fill('empty') + await page.getByRole('button', { name: '搜索', exact: true }).click() + await expect(tableRows).toHaveCount(0) + await expect(pagination).not.toBeVisible() + await page.reload() + await expect(tableRows).toHaveCount(200) + expect(lists.at(-1)!.searchParams.get('pageSize')).toBe('200') + + await page.goto('/#/syb-shops') + await expect.poll(() => shopLists.length).toBeGreaterThan(0) + expect(shopLists[0].searchParams.get('pageSize')).toBe('20') + expect(writes).toEqual([]) +}) diff --git a/web/tests/unit/syb-inner-code-page-size.test.cjs b/web/tests/unit/syb-inner-code-page-size.test.cjs new file mode 100644 index 0000000..d94b6f5 --- /dev/null +++ b/web/tests/unit/syb-inner-code-page-size.test.cjs @@ -0,0 +1,40 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const page = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-inner-codes/index.vue'), 'utf8') +const source = page.split('')[0].replace(/^import .*$/gm, '').replace('export default', 'return') +const options = new Function(source)() + +test('inner code page defaults to 200 and keeps a manual size through search and clear', async () => { + const vm = { ...options.data(), ...options.methods } + const requests = [] + vm.load = async () => { requests.push({ ...vm.query }) } + + const now = new Date() + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + assert.equal(vm.query.page, 1) + assert.equal(vm.query.pageSize, 200) + assert.deepEqual(vm.dateRange, [today, today]) + assert.match(page, /:page-sizes="\[20, 50, 100, 200\]"/) + + vm.query.pageSize = 50 + vm.query.page = 4 + vm.query.keyword = 'code' + vm.dateRange = ['2026-10-01', '2026-10-02'] + await vm.search() + assert.equal(requests[0].page, 1) + assert.equal(requests[0].pageSize, 50) + + vm.query.page = 3 + await vm.clearFilters() + assert.equal(requests[1].page, 1) + assert.equal(requests[1].pageSize, 50) + assert.equal(requests[1].keyword, '') + assert.equal(requests[1].dateFrom, '') + assert.equal(requests[1].dateTo, '') + + const freshVm = { ...options.data() } + assert.equal(freshVm.query.pageSize, 200) +})