diff --git a/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md b/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md index 40d33bf3f..db99a085e 100644 --- a/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md +++ b/docs/superpowers/plans/2026-11-01-pl-quota-list-consistency.md @@ -9,19 +9,29 @@ ### 1. 计划列表服务端筛选 Files: `backend/app/services/pl_quota_overview.py`, `backend/app/api/pl/quota_cert.py`, `backend/tests/test_pl_quota_overview_filters.py`。 -- [ ] 先写跨20条分页、租户/删除/取消、组合编号车号日期及多状态测试,并运行确认失败。 -- [ ] `list_plans(..., plan_no=None, license_plate=None, plan_date_from=None, plan_date_to=None, quota_status_in=None)` 可选参数在count/offset之前应用。接口别名planNo/licensePlate/planDateFrom/planDateTo/quotaStatusIn。拒绝非法状态/日期范围。 -- [ ] 使用独立SQLite测试,不连接测试或生产库:backend内 `ENV_FILE=/dev/null <主目录>/backend/venv/bin/python -m pytest tests/test_pl_quota_overview_filters.py tests/test_pl_quota_api.py -q`。 +- [x] 先写跨20条分页、租户/删除/取消、组合编号车号日期及多状态测试,并运行确认失败。 +- [x] `list_plans(..., plan_no=None, license_plate=None, plan_date_from=None, plan_date_to=None, quota_status_in=None)` 可选参数在count/offset之前应用。接口别名planNo/licensePlate/planDateFrom/planDateTo/quotaStatusIn。拒绝非法状态/日期范围。 +- [x] 使用独立SQLite测试,不连接测试或生产库:backend内 `ENV_FILE=/dev/null <主目录>/backend/venv/bin/python -m pytest tests/test_pl_quota_overview_filters.py tests/test_pl_quota_api.py -q`。 ### 2. 三页列表组件 Files: `web-admin/src/views/pl/{plan-quota,quota-cert,legal-use-document}/index.vue`;所需列表筛选定义及专项测试。 -- [ ] 复用运输计划公共工具栏、ListTable、漏斗、列设置;保留已有详情/维护逻辑和权限。 -- [ ] 计划列表新漏斗参数传后端;另外两页在完整集合先筛再分页;隐藏列清漏斗,条件变化回首页,刷新保留条件。 -- [ ] 新增真实筛选/组件handler专项测试:组合、清空、跨页、旧响应失效;H5保持原能力。 +- [x] 复用运输计划公共工具栏、ListTable、漏斗、列设置;保留已有详情/维护逻辑和权限。 +- [x] 计划列表新漏斗参数传后端;另外两页在完整集合先筛再分页;隐藏列清漏斗,条件变化回首页,刷新保留条件。 +- [x] 新增真实筛选/组件handler专项测试:组合、清空、跨页、旧响应失效;H5保持原能力。 ### 3. 审查与交付 -- [ ] 按改动运行专项pytest/Node测试及Web构建;合成API验证三页检索和漏斗交互、截图对比参考组件。 -- [ ] 独立代码审查,修复实际问题后重验;重新读取工单评论。 -- [ ] 保存实际证据/版本和剩余限制;保留待用户测试,未获授权不推送合并/关闭工单。 +- [x] 按改动运行专项pytest/Node测试及Web构建;合成API验证三页检索和漏斗交互、截图对比参考组件。 +- [x] 独立代码审查,修复实际问题后重验;重新读取工单评论。 +- [x] 保存实际证据/版本和剩余限制;保留待用户测试,未获授权不推送合并/关闭工单。 + +## 验证及转测记录(2026-11-01) + +- 后端专项35/35通过:新增9项服务筛选回归及API别名验证,联跑原来源/额度接口测试。先观察新增参数缺失导致的8项失败,再实现。独立后端规格与质量审查PASS。 +- 前端专项50/50通过:真实SFC setup的过滤/分页/乱序/认证失效回归,联跑既有quota移动、配置、归档、补量;漏斗计数渲染覆盖省略计数、0及正数。日志 `/tmp/shanxia-issue8-ui-tests.log`。 +- 规格审查发现部分上传计划被“已上传”筛选遗漏;修正为任一产品命中,两个状态多选为OR。混合行回归先失败后通过,规格复审PASS;独立质量复审自行运行42项相关测试,全部通过,无未解决P1/P2。 +- 合成Playwright验证实际三页组件:筛出原第二页031记录、刷新保留条件、检索030、重置、计划状态条件传服务端、隐藏列清条件、证面品名与余额组合、混合资料上传状态;均0页面错误。1440/1100宽度截图位于忽略目录 `web-admin/.local/issue8/`,已与实际运输计划参考组件截图比较,固定表头、表体滚动、分页可见。全部API由合成响应拦截,外网请求拒绝。 +- 最新Web生产构建通过46.61秒(既有chunk体积警告),日志 `/tmp/shanxia-issue8-build.log`。全库vue-tsc仍失败:主目录和本任务均499条错误,归一化行号与目录后新增错误0;不声称全库类型通过。日志 `/tmp/shanxia-issue8-typecheck{,-baseline}.log`。 +- 本工单未读取运行密钥、连接真实业务数据库或执行SQL,无需新增迁移。H5保留原移动检索、卡片、分页分支。完整应用及真实数据验收仍待用户。 +- 复用本会话既有隔离目录,任务分支 `codex/gitea-issue-8`,#4分支保留原交付提交。用户已确认三个Web页面范围并免除新Figma;延续本会话本地合入V3.0供用户测试的方式,不推送远端、不关闭工单。保留任务分支与工作区用于反馈修复。 diff --git a/web-admin/scripts/column-header-filter-count.test.ts b/web-admin/scripts/column-header-filter-count.test.ts new file mode 100644 index 000000000..6909111b5 --- /dev/null +++ b/web-admin/scripts/column-header-filter-count.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import * as Vue from 'vue' +import { renderToString } from 'vue/server-renderer' + +const source = readFileSync( + new URL('../src/components/business/column-header-filter/index.vue', import.meta.url), + 'utf8' +) +const countTemplate = source.match(/]*class="value-count"[^>]*>[\s\S]*?<\/span>/)![0] +const render = Vue.compile(countTemplate) +for (const [count, visible] of [ + [undefined, false], + [0, true], + [7, true] +] as const) { + test(`count ${count} renders only when supplied, including zero`, async () => { + const html = await renderToString( + Vue.createSSRApp({ data: () => ({ opt: { count } }), render }) + ) + assert.equal(html.includes('class="value-count"'), visible) + if (visible) assert.ok(html.includes(`>${count}`)) + }) +} diff --git a/web-admin/scripts/quota-list-filters.test.ts b/web-admin/scripts/quota-list-filters.test.ts new file mode 100644 index 000000000..9ed19da4a --- /dev/null +++ b/web-admin/scripts/quota-list-filters.test.ts @@ -0,0 +1,327 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parse } from '@vue/compiler-sfc' +import ts from 'typescript' +import * as vue from 'vue' +import { useClientColumnFilters } from '../src/composables/useClientColumnFilters' +import { useServerColumnFilters } from '../src/composables/useServerColumnFilters' +import { usePersistedListColumns } from '../src/composables/usePersistedListColumns' + +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve() +} +function deferred() { + let resolve!: (value?: any) => void + let reject!: (error: any) => void + const promise = new Promise((a, b) => { + resolve = a + reject = b + }) + return { promise, resolve, reject } +} +// Run production setup, including its real synchronous session watchers. Only I/O is substituted. +function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: true }) { + const lists: any[] = [], + gets: any[] = [], + writes: any[] = [], + permissions: any[] = [] + const events: string[] = [], + mounted: Function[] = [], + unmount: Function[] = [] + const user = vue.reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a', roles: ['sales'] } }) + const queue = (items: any[], params?: any) => { + const pending = deferred() + items.push({ ...pending, params }) + return pending.promise + } + const env = { + ...Object.fromEntries( + Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') + ), + useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), + useClientColumnFilters, + useServerColumnFilters, + usePersistedListColumns, + useRoute: () => ({ query: {} }), + newCert: () => ({}), + commonFields: [], + purchaseFields: [], + transportFields: [], + quotaCertApi: { list: (kind: string) => queue(lists, { kind }) }, + useUserStore: () => user, + defineProps: () => props, + onMounted: (fn: Function) => mounted.push(fn), + onBeforeUnmount: (fn: Function) => unmount.push(fn), + request: { get: (url: string, options: any) => queue(lists, { url, ...options.params }) }, + pickBxData: (value: any) => value, + legalDocumentApi: { + list: (params: any) => queue(lists, params), + get: (id: string) => queue(gets, id), + save: (id: string, products: any) => queue(writes, { id, products }) + }, + fetchGetMenuCodes: () => queue(permissions), + ElMessage: { + warning: (msg: string) => events.push(msg), + success: (msg: string) => events.push(msg) + } + } + const path = + kind === 'quota' + ? '../src/views/pl/plan-quota/index.vue' + : kind === 'cert' + ? '../src/views/pl/quota-cert/index.vue' + : '../src/views/pl/legal-use-document/index.vue' + const source = ts.createSourceFile( + 'page.ts', + parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content, + ts.ScriptTarget.Latest, + true + ) + const statements = source.statements.filter((s) => !ts.isImportDeclaration(s)) + const names = statements.flatMap((s) => + ts.isVariableStatement(s) + ? s.declarationList.declarations.flatMap((d) => + ts.isIdentifier(d.name) + ? [d.name.getText(source)] + : ts.isObjectBindingPattern(d.name) + ? d.name.elements.map((e) => e.name.getText(source)) + : [] + ) + : ts.isFunctionDeclaration(s) && s.name + ? [s.name.text] + : [] + ) + const code = ts.transpile(statements.map((s) => s.getText(source)).join('\n'), { + target: ts.ScriptTarget.ESNext + }) + const scope = vue.effectScope() + const state = scope.run(() => + new Function(...Object.keys(env), `${code}; return {${names.join(',')}}`)(...Object.values(env)) + ) + return { + state, + user, + lists, + gets, + writes, + permissions, + events, + mounted, + stop() { + unmount.forEach((fn) => fn()) + scope.stop() + } + } +} + +for (const kind of ['cert', 'legal'] as const) { + test(`${kind}: late list cannot overwrite latest refresh`, async () => { + const h = pageHarness(kind), + s = h.state + const first = s.load(), + second = s.load() + h.lists[1].resolve([{ id: 'new' }]) + await second + h.lists[0].resolve([{ id: 'old' }]) + await first + assert.equal(s.rows.value[0].id, 'new') + h.stop() + }) +} +for (const kind of ['cert', 'legal'] as const) { + const fixture = (index: number) => + kind === 'cert' + ? { + id: String(index), + certNo: `CERT-${index}`, + productNameOnCert: index === 25 ? '目标品名' : '普通', + status: index === 25 ? 'active' : 'draft', + totalQty: String(index), + reservedQty: '0', + usedQty: '0', + availableQty: String(index), + validTo: index === 25 ? '2026-11-01' : '2025-01-01' + } + : { + id: String(index), + planNo: `PLAN-${index}`, + products: [ + { + productName: index === 25 ? '目标品名' : '普通', + documentStatus: index === 25 ? 'pending' : 'uploaded' + } + ] + } + test(`${kind}: filters operate on full rows before pagination, combine, reset and clear hidden columns`, async () => { + const h = pageHarness(kind), + s = h.state + s.rows.value = Array.from({ length: 45 }, (_, i) => fixture(i)) + s.page.value = 3 + s.setFilterState(kind === 'cert' ? 'productNameOnCert' : 'productNames', { text: '目标' }) + assert.equal(s.page.value, 1) + assert.deepEqual( + s.pageRows.value.map((r: any) => r.id), + ['25'] + ) + s.setFilterState(kind === 'cert' ? 'status' : 'documentStatus', { + selected: [kind === 'cert' ? 'draft' : 'uploaded'] + }) + assert.equal(s.filteredRows.value.length, 0) + s.visibleColumns.value = s.visibleColumns.value.filter( + (p: string) => p !== (kind === 'cert' ? 'status' : 'documentStatus') + ) + await vue.nextTick() + assert.deepEqual( + s.pageRows.value.map((r: any) => r.id), + ['25'] + ) + s.reset() + assert.equal(s.filteredRows.value.length, 45) + s.keyword.value = kind === 'cert' ? 'CERT-25' : 'PLAN-25' + s.page.value = 2 + s.search() + assert.equal(s.page.value, 1) + assert.deepEqual( + s.pageRows.value.map((r: any) => r.id), + ['25'] + ) + const refresh = s.load() + h.lists[0].resolve(Array.from({ length: 45 }, (_, i) => fixture(i))) + await refresh + assert.deepEqual( + s.pageRows.value.map((r: any) => r.id), + ['25'] + ) + h.stop() + }) + test(`${kind}: numeric and enum filters compare business values`, () => { + const h = pageHarness(kind), + s = h.state + s.rows.value = Array.from({ length: 45 }, (_, i) => fixture(i)) + s.setFilterState(kind === 'cert' ? 'availableQty' : 'pendingCount', { + op: 'eq', + value: kind === 'cert' ? '25' : '1', + min: '', + max: '' + }) + assert.deepEqual( + s.pageRows.value.map((r: any) => r.id), + ['25'] + ) + if (kind === 'cert') { + s.setFilterState('validTo', { from: '2026-11-01', to: '2026-11-01' }) + assert.equal(s.filteredRows.value.length, 1) + s.setFilterState('validTo', { from: '2026-11-02', to: '' }) + assert.equal(s.filteredRows.value.length, 0) + } + h.stop() + }) + for (const change of ['token', 'tenant', 'roles', 'unmount']) { + test(`${kind}: ${change} rejects stale list and permission responses`, async () => { + const h = pageHarness(kind), + s = h.state + const init = s.init() + const pending = s.load() + s.rows.value = [fixture(25)] + if (change === 'token') h.user.accessToken = 'b' + if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b' + if (change === 'roles') h.user.getUserInfo.roles = ['viewer'] + if (change === 'unmount') h.stop() + h.permissions[0].resolve({ all: true }) + await init + h.lists[0].resolve([fixture(1)]) + await pending + assert.notEqual(s.rows.value[0]?.id, '1') + assert.equal((kind === 'cert' ? s.canEdit : s.canUpload).value, false) + h.stop() + }) + } +} +test('cert: changing kind immediately invalidates an old purchase response', async () => { + const h = pageHarness('cert'), + s = h.state + const pending = s.load() + s.kind.value = 'transport' + assert.equal(h.lists[1].params.kind, 'transport') + h.lists[1].resolve([{ id: 'transport' }]) + await flush() + h.lists[0].resolve([{ id: 'purchase' }]) + await pending + assert.equal(s.rows.value[0].id, 'transport') + h.stop() +}) +test('plan: actual header filters send server conditions, reset page, preserve refresh and clear hidden conditions', async () => { + const h = pageHarness('quota', { mobile: false }), + s = h.state + s.page.value = 3 + s.setFilterState('planNo', { text: 'PLAN' }) + assert.equal(s.page.value, 1) + assert.equal(h.lists.at(-1).params.planNo, 'PLAN') + s.setFilterState('quotaStatus', { selected: ['reserved', 'settled'] }) + s.setFilterState('planDate', { from: '2026-11-01', to: '2026-11-30' }) + s.setFilterState('licensePlate', { text: '鄂A' }) + assert.deepEqual(s.toQueryParams(), { + planNo: 'PLAN', + quotaStatusIn: 'reserved,settled', + planDateFrom: '2026-11-01', + planDateTo: '2026-11-30', + licensePlate: '鄂A' + }) + void s.load() + assert.equal(h.lists.at(-1).params.licensePlate, '鄂A') + s.visibleColumns.value = s.visibleColumns.value.filter((p: string) => p !== 'licensePlate') + await vue.nextTick() + assert.equal(h.lists.at(-1).params.licensePlate, undefined) + s.reset() + assert.deepEqual(s.toQueryParams(), {}) + h.lists.forEach((r) => r.resolve({ list: [], total: 0 })) + await flush() + h.stop() +}) + +test('legal: mixed-product upload filters preserve any-match and OR semantics', async () => { + const h = pageHarness('legal'), + s = h.state + const row = (id: string, statuses: string[]) => ({ + id, + planNo: id, + products: statuses.map((documentStatus) => ({ productName: '产品', documentStatus })) + }) + s.rows.value = [ + row('mixed', ['pending', 'uploaded']), + row('pending', ['pending']), + row('uploaded', ['uploaded']) + ] + s.setFilterState('documentStatus', { selected: ['uploaded'] }) + assert.deepEqual( + s.filteredRows.value.map((r: any) => r.id), + ['mixed', 'uploaded'] + ) + s.setFilterState('documentStatus', { selected: ['pending'] }) + assert.deepEqual( + s.filteredRows.value.map((r: any) => r.id), + ['mixed', 'pending'] + ) + s.setFilterState('documentStatus', { selected: ['pending', 'uploaded'] }) + assert.deepEqual( + s.filteredRows.value.map((r: any) => r.id), + ['mixed', 'pending', 'uploaded'] + ) + assert.deepEqual( + s.collectUniqueOptions('documentStatus').map((o: any) => o.count), + [2, 2] + ) + s.setFilterState('documentStatus', { selected: ['uploaded'] }) + s.setFilterState('planNo', { text: 'mixed' }) + assert.deepEqual( + s.pageRows.value.map((r: any) => r.id), + ['mixed'] + ) + s.clearFilter('planNo') + s.visibleColumns.value = s.visibleColumns.value.filter((p: string) => p !== 'documentStatus') + await vue.nextTick() + assert.equal(s.filteredRows.value.length, 3) + assert.equal(s.hasActiveFilters.value, false) + h.stop() +}) diff --git a/web-admin/scripts/quota-mobile-state.test.ts b/web-admin/scripts/quota-mobile-state.test.ts index c3361f3c8..1344ce9db 100644 --- a/web-admin/scripts/quota-mobile-state.test.ts +++ b/web-admin/scripts/quota-mobile-state.test.ts @@ -4,6 +4,8 @@ import test from 'node:test' import { parse } from '@vue/compiler-sfc' import ts from 'typescript' import * as vue from 'vue' +import { useServerColumnFilters } from '../src/composables/useServerColumnFilters' +import { usePersistedListColumns } from '../src/composables/usePersistedListColumns' const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() @@ -36,6 +38,9 @@ function pageHarness(kind: 'quota' | 'legal', props: any = { mobile: true }) { ...Object.fromEntries( Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') ), + useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), + useServerColumnFilters, + usePersistedListColumns, useUserStore: () => user, defineProps: () => props, onMounted: (fn: Function) => mounted.push(fn), diff --git a/web-admin/src/components/business/column-header-filter/index.vue b/web-admin/src/components/business/column-header-filter/index.vue index 6f875b0f1..29cd7ea92 100644 --- a/web-admin/src/components/business/column-header-filter/index.vue +++ b/web-admin/src/components/business/column-header-filter/index.vue @@ -143,7 +143,7 @@ > {{ opt.label }} - {{ opt.count }} + {{ opt.count }}
暂无可选值
@@ -267,7 +267,7 @@ type: ColumnFilterType isActive: boolean modelValue?: ColumnFilterState | null - options?: Array<{ value: string; label: string; count: number }> + options?: Array<{ value: string; label: string; count?: number }> /** 枚举选项加载中(动态 facet) */ optionsLoading?: boolean /** CRM 外壳 CrmColFilterHeader 已自绘双三角时传 false,避免重复 */ diff --git a/web-admin/src/views/pl/legal-use-document/index.vue b/web-admin/src/views/pl/legal-use-document/index.vue index 9872b5435..7529f4900 100644 --- a/web-admin/src/views/pl/legal-use-document/index.vue +++ b/web-admin/src/views/pl/legal-use-document/index.vue @@ -2,71 +2,84 @@

合法使用证明

-
-
-
合法使用证明可在运输安排或归档后补传。
重置搜索
-
+
清除筛选
-
- - - -
+ +
diff --git a/web-admin/src/views/pl/plan-quota/index.vue b/web-admin/src/views/pl/plan-quota/index.vue index fb0b6a275..1cdc5bb51 100644 --- a/web-admin/src/views/pl/plan-quota/index.vue +++ b/web-admin/src/views/pl/plan-quota/index.vue @@ -2,7 +2,7 @@

计划用证

-
-

按当前来源、合同产品范围和证件余额校验;实际安排时原子预占。

+
清除筛选
+

按当前来源、合同产品范围和证件余额校验;实际安排时原子预占。

{{ row.planNo }}

- + @@ -359,6 +355,16 @@