From 4cb2d88dabb40b3eb48373cde35ef05d8d2fd4fb Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sun, 1 Nov 2026 23:15:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(pl):=20=E6=8C=89=E8=BF=90=E8=BE=93?= =?UTF-8?q?=E6=AE=B5=E4=BA=A7=E5=93=81=E7=BB=B4=E6=8A=A4=E5=90=88=E6=B3=95?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=AF=81=E6=98=8E=E5=B9=B6=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E4=BC=9A=E8=AF=9D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/pl/quota_cert.py | 3 +- .../app/services/pl_stop_legal_document.py | 8 +- backend/tests/test_pl_stop_legal_document.py | 20 +- web-admin/scripts/quota-list-filters.test.ts | 51 +- web-admin/scripts/stop-documents.test.ts | 511 ++++++++++++++++++ web-admin/src/api/pl/quota-cert.ts | 63 +++ .../pl/legal-use-document/StopDocuments.vue | 497 +++++++++++++++++ .../src/views/pl/legal-use-document/index.vue | 354 +----------- 8 files changed, 1103 insertions(+), 404 deletions(-) create mode 100644 web-admin/scripts/stop-documents.test.ts create mode 100644 web-admin/src/views/pl/legal-use-document/StopDocuments.vue diff --git a/backend/app/api/pl/quota_cert.py b/backend/app/api/pl/quota_cert.py index c950c0565..75ebb4df6 100644 --- a/backend/app/api/pl/quota_cert.py +++ b/backend/app/api/pl/quota_cert.py @@ -262,10 +262,11 @@ def list_documents(keyword: Optional[str] = None, status: Optional[str] = None, @legal_router.get('/stops') def list_stop_documents(keyword: Optional[str] = None, status: Optional[str] = None, + plan_no: Optional[str] = Query(None, alias='planNo'), contract_id: Optional[str] = Query(None, alias='contractId'), page: int = Query(1, ge=1), page_size: int = Query(20, alias='pageSize', ge=1, le=100), db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): - return _wire(stop_documents.list_documents(db, user, keyword, status, contract_id, page, page_size)) + return _wire(stop_documents.list_documents(db, user, keyword, status, contract_id, page, page_size, plan_no=plan_no)) @legal_router.get('/stops/{plan_id}') diff --git a/backend/app/services/pl_stop_legal_document.py b/backend/app/services/pl_stop_legal_document.py index 45c7c20a3..4d2a8d0d9 100644 --- a/backend/app/services/pl_stop_legal_document.py +++ b/backend/app/services/pl_stop_legal_document.py @@ -70,7 +70,9 @@ def _details(db, user, plans, contract_id=None, lock=False): query = _query(db, user, side, model, parent, field, contract_id).filter(model.plan_id.in_(result)) if lock: query = query.with_for_update().populate_existing() - for row, stop, product, contract, document in query.order_by(parent.order_num, parent.id, model.order_num, model.id).all(): + stop_order = (func.coalesce(func.nullif(parent.seq, 0), parent.order_num) + if side == 'delivery' else parent.order_num) + for row, stop, product, contract, document in query.order_by(stop_order, parent.id, model.order_num, model.id).all(): url = document.legal_use_cert_url if document else None result[row.plan_id]['items'].append({ 'stopType': side, 'stopId': stop.id, 'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1, @@ -102,7 +104,7 @@ def get_documents(db, user, plan_id, contract_id=None): return _details(db, user, [plan], contract_id)[plan_id] -def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1, page_size=20): +def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1, page_size=20, plan_no=None): if status not in (None, 'pending', 'uploaded') or page < 1 or not 1 <= page_size <= 100: raise HTTPException(400, '无效资料状态或分页参数') if contract_id: @@ -124,6 +126,8 @@ def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1 query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no) if keyword: query = query.filter(PlPlan.plan_no.contains(keyword)) + if plan_no: + query = query.filter(PlPlan.plan_no.contains(plan_no)) if status == 'pending': query = query.filter(counts.c.uploaded < counts.c.required) elif status == 'uploaded': diff --git a/backend/tests/test_pl_stop_legal_document.py b/backend/tests/test_pl_stop_legal_document.py index ade762793..63cc3b9ac 100644 --- a/backend/tests/test_pl_stop_legal_document.py +++ b/backend/tests/test_pl_stop_legal_document.py @@ -180,7 +180,7 @@ def test_multiple_plans_filter_then_page_and_route_shape(case): c.db.add(c.m.PlPlanDeliveryProduct(id=pid+'dp', delivery_id=pid+'d', plan_id=pid, plan_product_id=pid+'pp', product_id='sku', product_no='S', product_name='产品', tenant_no='t')) c.db.flush() - first = list_stop_documents(keyword=None, status='pending', contract_id=None, page=1, page_size=2, db=c.db, user=c.user) + first = list_stop_documents(keyword=None, plan_no=None, status='pending', contract_id=None, page=1, page_size=2, db=c.db, user=c.user) second = c.svc.list_documents(c.db, c.user, status='pending', page=2, page_size=2) assert first['total'] == second['total'] == 4 assert len(first['list']) == len(second['list']) == 2 @@ -269,3 +269,21 @@ def test_save_response_stop_details_keep_current_read(case): # 装送两侧均有校验读与最终响应读,均须保留当前读。 assert len(statements) == 4 assert all('FOR UPDATE' in sql for sql in statements) + + +def test_keyword_and_plan_number_filter_are_combined(case): + c = case + assert c.svc.list_documents(c.db, c.user, keyword='P', plan_no='P')['total'] == 1 + assert c.svc.list_documents(c.db, c.user, keyword='P', plan_no='OTHER')['total'] == 0 + + +def test_delivery_details_follow_sequence_before_legacy_order(case): + c = case + c.db.get(c.m.PlPlanDelivery, 'd1').seq = 2 + c.db.get(c.m.PlPlanDelivery, 'd1').order_num = 1 + c.db.get(c.m.PlPlanDelivery, 'd2').seq = 1 + c.db.get(c.m.PlPlanDelivery, 'd2').order_num = 2 + c.db.flush() + rows = c.svc.get_documents(c.db, c.user, 'p')['items'] + assert [row['stopProductId'] for row in rows] == ['lp', 'd2p', 'd1p'] + assert [row['stopOrder'] for row in rows] == [1, 1, 2] diff --git a/web-admin/scripts/quota-list-filters.test.ts b/web-admin/scripts/quota-list-filters.test.ts index 5e06cf76a..49fc87dec 100644 --- a/web-admin/scripts/quota-list-filters.test.ts +++ b/web-admin/scripts/quota-list-filters.test.ts @@ -20,6 +20,7 @@ function deferred() { }) return { promise, resolve, reject } } +// 段产品证明已改为服务端单选筛选,其回归见 stop-documents.test.ts。 // 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[] = [], @@ -116,7 +117,7 @@ function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: tr } } -for (const kind of ['cert', 'legal'] as const) { +for (const kind of ['cert'] as const) { test(`${kind}: late list cannot overwrite latest refresh`, async () => { const h = pageHarness(kind), s = h.state @@ -130,7 +131,7 @@ for (const kind of ['cert', 'legal'] as const) { h.stop() }) } -for (const kind of ['cert', 'legal'] as const) { +for (const kind of ['cert'] as const) { const fixture = (index: number) => kind === 'cert' ? { @@ -280,49 +281,3 @@ test('plan: actual header filters send server conditions, reset page, preserve r 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/stop-documents.test.ts b/web-admin/scripts/stop-documents.test.ts new file mode 100644 index 000000000..6e414cf05 --- /dev/null +++ b/web-admin/scripts/stop-documents.test.ts @@ -0,0 +1,511 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { parse } from '@vue/compiler-sfc' +import ts from 'typescript' +import * as vue from 'vue' +import { usePersistedListColumns } from '../src/composables/usePersistedListColumns' +import { useServerColumnFilters } from '../src/composables/useServerColumnFilters' +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve() +} +function deferred() { + let resolve!: (value: any) => void, reject!: (error: any) => void + const promise = new Promise((a, b) => { + resolve = a + reject = b + }) + return { promise, resolve, reject } +} +function harness(fixed?: string) { + const viewportWidth = vue.ref(1120) + const calls: any[] = [], + permissions: any[] = [], + unmount: (() => void)[] = [] + const props = vue.reactive({ + contractId: fixed, + active: true, + embedded: !!fixed + }) + const user = vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }) + const queue = (method: string, args: any[]) => { + const pending = deferred() + calls.push({ method, args, ...pending }) + return pending.promise + } + const api = Object.fromEntries( + ['listStops', 'getStops', 'saveStops'].map((method) => [ + method, + (...args: any[]) => queue(method, args) + ]) + ) + const env = { + ...Object.fromEntries( + Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') + ), + defineProps: () => props, + withDefaults: (v: any) => v, + useUserStore: () => user, + fetchGetMenuCodes: () => { + const p = deferred() + permissions.push(p) + return p.promise + }, + useElementSize: () => ({ width: viewportWidth }), + useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), + usePersistedListColumns, + useServerColumnFilters, + legalDocumentApi: api, + ElMessage: { success() {}, warning() {} }, + onBeforeUnmount: (fn: () => void) => unmount.push(fn) + } + const src = ts.createSourceFile( + 'panel.ts', + parse( + readFileSync( + new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url), + 'utf8' + ) + ).descriptor.scriptSetup!.content, + ts.ScriptTarget.Latest, + true + ) + const code = ts.transpile( + src.statements + .filter((s) => !ts.isImportDeclaration(s)) + .map((s) => s.getText(src)) + .join('\n'), + { target: ts.ScriptTarget.ESNext } + ) + const scope = vue.effectScope() + const state = scope.run(() => + new Function( + ...Object.keys(env), + `${code}; return { rows, details, load, loadDetail, allowed, canUpload, keyword, setFilterState, page, total, expansionStyle, open, save, visible, editing, upload, saving, editorLoading, status, search, statusLabel }` + )(...Object.values(env)) + ) + return { + state, + viewportWidth, + calls, + permissions, + props, + user, + stop: () => { + unmount.forEach((fn) => fn()) + scope.stop() + } + } +} + +test('global and fixed contract preserve server counts and scope every request', async () => { + for (const fixed of [undefined, 'contract-one']) { + const h = harness(fixed), + s = h.state + h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list', 'pl:plan-cert:upload'] }) + await flush() + assert.equal(h.calls[0].method, 'listStops') + assert.equal(h.calls[0].args[0].contractId, fixed) + h.calls[0].resolve({ + list: [{ id: 'plan', uploadedCount: 1, requiredCount: fixed ? 1 : 3 }], + total: 45 + }) + await flush() + assert.equal(s.rows.value[0].requiredCount, fixed ? 1 : 3) + const pending = s.open({ id: 'plan' }, { stopType: 'delivery', stopProductId: 'd2' }) + assert.equal(h.calls[1].args[1], fixed) + h.calls[1].resolve({ + id: 'plan', + items: [ + { stopType: 'delivery', stopProductId: 'd2', productId: 'same', legalUseCertUrl: 'a,b' } + ] + }) + await pending + const saving = s.save() + assert.deepEqual(h.calls[2].args, [ + 'plan', + [{ stopType: 'delivery', stopProductId: 'd2', legalUseCertUrl: 'a,b' }], + fixed + ]) + h.calls[2].resolve({}) + await saving + h.stop() + } +}) +test('busy uploads refuse saves and failures preserve draft', async () => { + const h = harness(), + s = h.state + h.permissions[0].resolve({ all: true }) + await flush() + h.calls[0].resolve({ list: [], total: 0 }) + await flush() + const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' }) + h.calls[1].resolve({ + id: 'p', + items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'original' }] + }) + await opened + s.editing.value.legalUseCertUrl = 'draft' + s.upload.value = { hasUploading: () => true } + await s.save() + assert.equal(h.calls.length, 2) + s.upload.value = { hasUploading: () => false } + const saving = s.save() + h.calls[2].reject(Error('failed')) + await saving + assert.equal(s.editing.value.legalUseCertUrl, 'draft') + assert.equal(s.visible.value, true) + h.stop() +}) +for (const change of ['contract', 'tenant', 'close']) + test(`old editor response is discarded after ${change}`, async () => { + const h = harness('one'), + s = h.state + h.permissions[0].resolve({ all: true }) + await flush() + const old = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' }) + if (change === 'contract') h.props.contractId = 'two' + if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b' + if (change === 'close') s.visible.value = false + h.calls[1].resolve({ id: 'p', items: [{ stopType: 'load', stopProductId: 'l' }] }) + await old + assert.equal(s.editing.value, undefined) + h.stop() + }) +test('search during permission initialization does not cancel it; filters are independent', async () => { + const h = harness(), + s = h.state + s.keyword.value = 'word' + s.setFilterState('planNo', { text: 'PL' }) + await s.load() + h.permissions[0].resolve({ all: true }) + await flush() + assert.equal(h.calls.length, 1) + assert.equal(h.calls[0].args[0].keyword, 'word') + assert.equal(h.calls[0].args[0].planNo, 'PL') + h.stop() +}) +test('refresh preserves page and total; not_required is not uploaded', async () => { + const h = harness(), + s = h.state + h.permissions[0].resolve({ all: true }) + await flush() + h.calls[0].resolve({ list: [], total: 45 }) + await flush() + s.page.value = 2 + const pending = s.load() + assert.equal(s.total.value, 45) + assert.equal(h.calls[1].args[0].page, 2) + h.calls[1].reject(Error('failed')) + await pending + assert.equal(s.page.value, 2) + assert.equal(s.total.value, 45) + assert.equal(s.statusLabel('not_required'), '无需上传') + h.stop() +}) + +// Mount the real Element Plus pager. Its page-count watcher must remain active. +import ElPagination from 'element-plus/es/components/pagination/src/pagination.mjs' +test('real pagination keeps page two while loading and preserves count after request failure', async () => { + const h = harness(), + s = h.state + h.permissions[0].resolve({ all: true }) + await flush() + h.calls[0].resolve({ list: [{ id: 'first' }], total: 45 }) + await flush() + const renderer = vue.createRenderer({ + createElement: () => ({}), + createText: () => ({}), + createComment: () => ({}), + setText: () => {}, + setElementText: () => {}, + parentNode: () => null, + nextSibling: () => null, + insert: () => {}, + remove: () => {}, + patchProp: () => {} + }) + const app = renderer.createApp({ + render: () => + vue.h(ElPagination, { + currentPage: s.page.value, + total: s.total.value, + pageSize: 20, + layout: '', + 'onUpdate:current-page': (value: number) => { + s.page.value = value + }, + onCurrentChange: () => { + void s.load() + } + }) + }) + app.mount({}) + s.page.value = 2 + const pending = s.load() + await vue.nextTick() + await flush() + assert.equal(s.page.value, 2) + assert.equal(h.calls.length, 2) + assert.equal(h.calls[1].args[0].page, 2) + h.calls[1].resolve({ list: [{ id: 'second' }], total: 45 }) + await pending + await vue.nextTick() + assert.equal(s.page.value, 2) + assert.equal(s.rows.value[0].id, 'second') + const failed = s.load() + h.calls[2].reject(Error('failed')) + await failed + await vue.nextTick() + assert.equal(s.page.value, 2) + assert.equal(s.total.value, 45) + assert.deepEqual(s.rows.value, []) + h.user.getUserInfo.tenantNo = 'next' + await vue.nextTick() + assert.equal(s.page.value, 1) + assert.equal(s.total.value, 0) + app.unmount() + h.stop() +}) + +import { compileScript } from '@vue/compiler-sfc' +function compiledSfc(path: string, dependencies: Record, inlineTemplate = true) { + const descriptor = parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor + const compiled = compileScript(descriptor, { id: path, inlineTemplate }) + const source = ts.createSourceFile('compiled.ts', compiled.content, ts.ScriptTarget.Latest, true) + const env: Record = Object.fromEntries( + Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') + ) + Object.assign(env, dependencies) + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue + const clause = statement.importClause + if (clause?.name) env[clause.name.text] = dependencies[clause.name.text] || {} + if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const item of clause.namedBindings.elements) { + if (item.isTypeOnly) continue + const name = (item.propertyName || item.name).text + env[item.name.text] = + statement.moduleSpecifier.getText(source) === '"vue"' || + statement.moduleSpecifier.getText(source) === "'vue'" + ? (vue as any)[name] + : dependencies[name] + } + } + } + const code = ts.transpile( + source.statements + .filter((s) => !ts.isImportDeclaration(s)) + .map((s) => s.getText(source)) + .join('\n') + .replace('export default', 'const component ='), + { target: ts.ScriptTarget.ESNext } + ) + return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env)) +} + +for (const active of [undefined, false]) + test(`compiled proof component active=${active}`, async () => { + const calls: string[] = [] + const component = compiledSfc( + '../src/views/pl/legal-use-document/StopDocuments.vue', + { + useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }), + fetchGetMenuCodes: async () => ({ menuCodes: ['pl:legal-use-document:list'] }), + legalDocumentApi: { + listStops: async () => { + calls.push('proof') + return { list: [], total: 0 } + } + }, + usePersistedListColumns, + useServerColumnFilters, + useElementSize: () => ({ width: vue.ref(1000) }), + useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }) + }, + false + ) + component.render = () => null + const renderer = vue.createRenderer({ + createElement: () => ({}), + createText: () => ({}), + createComment: () => ({}), + setText() {}, + setElementText() {}, + parentNode: () => null, + nextSibling: () => null, + insert() {}, + remove() {}, + patchProp() {} + }) + const app = renderer.createApp(component, active === undefined ? {} : { active }) + app.mount({}) + await flush() + assert.deepEqual(calls, active === false ? [] : ['proof']) + app.unmount() + }) +test('closing and reopening rejects old response and finally without clearing new loading', async () => { + const h = harness(), + s = h.state + h.permissions[0].resolve({ all: true }) + await flush() + const first = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' }) + s.visible.value = false + const second = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' }) + h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l' }] }) + await first + assert.equal(s.editorLoading.value, true) + assert.equal(s.editing.value, undefined) + h.calls[2].resolve({ items: [{ stopType: 'delivery', stopProductId: 'd' }] }) + await second + assert.equal(s.editorLoading.value, false) + assert.equal(s.editing.value.stopProductId, 'd') + h.stop() +}) +test('historical attachments have a separate readonly field and viewport aligned expansion', () => { + const descriptor = parse( + readFileSync( + new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url), + 'utf8' + ) + ).descriptor + assert.match(descriptor.template!.content, /历史计划级资料/) + assert.match( + descriptor.template!.content, + /:model-value="product.legalUseCertUrl \|\| ''"\s+disabled/ + ) + assert.match(descriptor.styles[0].content, /text-align: left/) + const h = harness() + assert.equal(h.state.expansionStyle.value.width, '1120px') + h.viewportWidth.value = 760 + assert.equal(h.state.expansionStyle.value.width, '760px') + h.stop() +}) +test('stop API preserves original legacy methods and scopes all three new endpoints', async () => { + const calls: any[] = [] + const source = ts.createSourceFile( + 'api.ts', + readFileSync(new URL('../src/api/pl/quota-cert.ts', import.meta.url), 'utf8'), + ts.ScriptTarget.Latest, + true + ) + const code = ts.transpile( + source.statements + .filter((s) => !ts.isImportDeclaration(s)) + .map((s) => s.getText(source).replace(/^export /, '')) + .join('\n'), + { target: ts.ScriptTarget.ESNext } + ) + const api = new Function('request', 'pickBxData', `${code};return legalDocumentApi`)( + { + get: (...args: any[]) => { + calls.push(args) + return Promise.resolve({}) + }, + patch: (...args: any[]) => { + calls.push(args) + return Promise.resolve({}) + } + }, + (v: any) => v + ) + await api.listStops({ contractId: 'c', page: 2, status: 'pending', planNo: 'PL' }) + await api.getStops('p', 'c') + await api.saveStops( + 'p', + [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }], + 'c' + ) + assert.deepEqual(calls, [ + [ + '/transport/legal-use-documents/stops', + { params: { contractId: 'c', page: 2, status: 'pending', planNo: 'PL' } } + ], + ['/transport/legal-use-documents/stops/p', { params: { contractId: 'c' } }], + [ + '/transport/legal-use-documents/stops/p', + { items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'a,b' }] }, + { params: { contractId: 'c' } } + ] + ]) + for (const method of ['list', 'get', 'save']) assert.equal(typeof api[method], 'function') +}) + +test('view permission loads proof only and prevents upload requests', async () => { + const h = harness(), + s = h.state + h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list'] }) + await flush() + assert.equal(s.allowed.value, true) + assert.equal(s.canUpload.value, false) + await s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' }) + await s.save() + assert.deepEqual( + h.calls.map((c) => c.method), + ['listStops'] + ) + h.stop() +}) +test('old save completion cannot close or clear a reopened editor', async () => { + const h = harness(), + s = h.state + h.permissions[0].resolve({ all: true }) + await flush() + const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' }) + h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'a' }] }) + await opened + const oldSave = s.save() + s.visible.value = false + const reopened = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' }) + h.calls[3].resolve({ + items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'b' }] + }) + await reopened + const newSave = s.save() + h.calls[2].resolve({}) + await oldSave + assert.equal(s.visible.value, true) + assert.equal(s.saving.value, true) + assert.equal(s.editing.value.stopProductId, 'd') + assert.equal(h.calls.length, 5) + h.calls[4].reject(Error('retry')) + await newSave + assert.equal(s.editing.value.legalUseCertUrl, 'b') + assert.equal(s.saving.value, false) + h.stop() +}) +test('old list and permission responses cannot reauthorize another tenant', async () => { + const h = harness(), + s = h.state + h.user.getUserInfo.tenantNo = 'b' + h.permissions[0].resolve({ all: true }) + await flush() + assert.equal(h.calls.length, 0) + h.permissions[1].resolve({ all: true }) + await flush() + h.user.getUserInfo.tenantNo = 'c' + h.calls[0].resolve({ list: [{ id: 'stale' }], total: 1 }) + await flush() + assert.deepEqual(s.rows.value, []) + assert.equal(s.allowed.value, false) + h.permissions[2].resolve({ menuCodes: [] }) + await flush() + assert.equal(h.calls.length, 1) + h.stop() +}) + +test('status is a server single filter and combines with the number filter', async () => { + const h = harness(), + s = h.state + h.permissions[0].resolve({ all: true }) + await flush() + s.status.value = 'uploaded' + s.search() + assert.equal(h.calls.at(-1).args[0].status, 'uploaded') + s.setFilterState('planNo', { text: 'PL' }) + assert.equal(h.calls.at(-1).args[0].planNo, 'PL') + assert.equal(h.calls.at(-1).args[0].status, 'uploaded') + h.calls.forEach((c) => c.resolve({ list: [], total: 0 })) + await flush() + h.stop() +}) diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index febf327d5..875ba8dc3 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -159,7 +159,70 @@ export const quotaCertApi = { request.get(`${base}/${kind}/${id}/contracts`).then(pickBxData) } const legalBase = '/transport/legal-use-documents' +export interface StopLegalDocumentItem { + stopType: 'load' | 'delivery' + stopId: string + stopOrder: number + stopProductId: string + productId: string + productName: string + model?: string + unit?: string + contractId?: string | null + contractNo?: string | null + contractProductId?: string | null + partyName?: string | null + address?: string | null + legalUseCertUrl: string + documentStatus: 'pending' | 'uploaded' + uploadedBy?: string | null + uploadedTime?: string | null +} +export interface StopLegalDocument { + id: string + planNo: string + planDate?: string + licensePlate?: string + transportStatus: string + archiveStatus: string + requiredCount: number + uploadedCount: number + documentStatus: 'pending' | 'uploaded' | 'not_required' + items: StopLegalDocumentItem[] + legacyProducts: { + id: string + productId: string + productName: string + model?: string + legalUseCertUrl?: string | null + }[] +} export const legalDocumentApi = { + listStops: (params: { + keyword?: string + planNo?: string + status?: 'pending' | 'uploaded' + contractId?: string + page?: number + pageSize?: number + }) => + request + .get(`${legalBase}/stops`, { params }) + .then( + pickBxData<{ list: StopLegalDocument[]; total: number; page: number; pageSize: number }> + ), + getStops: (id: string, contractId?: string) => + request + .get(`${legalBase}/stops/${id}`, { params: { contractId } }) + .then(pickBxData), + saveStops: ( + id: string, + items: Pick[], + contractId?: string + ) => + request + .patch(`${legalBase}/stops/${id}`, { items }, { params: { contractId } }) + .then(pickBxData), list: (params: { keyword?: string; status?: string }) => request.get(legalBase, { params }).then(pickBxData), get: (id: string) => request.get(`${legalBase}/${id}`).then(pickBxData), diff --git a/web-admin/src/views/pl/legal-use-document/StopDocuments.vue b/web-admin/src/views/pl/legal-use-document/StopDocuments.vue new file mode 100644 index 000000000..902ca0d02 --- /dev/null +++ b/web-admin/src/views/pl/legal-use-document/StopDocuments.vue @@ -0,0 +1,497 @@ + + + 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 7529f4900..7183f7a64 100644 --- a/web-admin/src/views/pl/legal-use-document/index.vue +++ b/web-admin/src/views/pl/legal-use-document/index.vue @@ -1,356 +1,6 @@ -