diff --git a/backend/app/services/pl_quota_planning.py b/backend/app/services/pl_quota_planning.py index d6ca420f1..08bffcbf6 100644 --- a/backend/app/services/pl_quota_planning.py +++ b/backend/app/services/pl_quota_planning.py @@ -156,34 +156,55 @@ def save_configuration(db, user, plan_id, config): def get_plan_quota(db, user, plan_id): with db.no_autoflush: plan, products, rows, stops = source_context(db, user, plan_id) - from app.services.pl_quota_contract_service import _contract - contract_numbers = {} + from app.services.pl_quota_contract_service import _contract, get_contract_bindings + contract_numbers, contract_bindings = {}, {} for cid in {r.contract_id for values in rows.values() for r in values.values() if r.contract_id}: try: contract_numbers[cid] = _contract(db, user, cid).contract_no + bindings = get_contract_bindings(db, user, cid) + contract_bindings[cid] = [] + for binding in bindings: + if binding['linkStatus'] != 'active': + continue + cert = _get_row(db, user, binding['certType'], binding['certId']) + contract_bindings[cid].append({ + 'certId': cert.id, 'certType': binding['certType'], + 'certNo': cert.cert_no, 'quantityUnit': cert.quantity_unit, + 'productIds': [p['contractProductId'] for p in binding['products'] + if p['scopeStatus'] == 'active']}) except HTTPException as exc: if exc.status_code not in (403, 404): raise contract_numbers[cid] = None - options = [] + contract_bindings.pop(cid, None) + options, stop_products = [], [] for side, values in rows.items(): for row in values.values(): - if products[row.plan_product_id].is_toxic == 1: - stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')] - options.append({'sourceStopId': stop.id, 'stopId': stop.id, - 'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1, - 'productId': row.product_id, 'productNo': row.product_no, - 'sourceStopType': side, 'sourceStopProductId': row.id, - 'planProductId': row.plan_product_id, 'productName': row.product_name, - 'model': row.model, 'quantity': row.plan_qty, 'unit': row.unit, - 'contractId': row.contract_id, 'contractNo': contract_numbers.get(row.contract_id), - 'contractProductId': row.contract_product_id, - 'partyName': stop.customer_name}) + product = products[row.plan_product_id] + stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')] + item = {'sourceStopId': stop.id, 'stopId': stop.id, + 'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1, + 'productId': row.product_id, 'productNo': row.product_no, + 'sourceStopType': side, 'sourceStopProductId': row.id, + 'planProductId': row.plan_product_id, 'productName': row.product_name, + 'model': row.model, 'quantity': row.plan_qty, 'unit': row.unit, + 'contractId': row.contract_id, 'contractNo': contract_numbers.get(row.contract_id), + 'contractProductId': row.contract_product_id, + 'partyName': stop.customer_name} + stop_products.append({**item, 'isToxic': product.is_toxic == 1, + 'isExplosive': product.is_explosive == 1, + 'bindingsReadable': row.contract_id in contract_bindings, + 'certificates': [{k: v for k, v in cert.items() if k != 'productIds'} + for cert in contract_bindings.get(row.contract_id, []) + if row.contract_product_id in cert['productIds']]}) + if product.is_toxic == 1: + options.append(item) usages = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan_id, del_flag=0).order_by(PlPlanCertUsage.create_time.desc()).all() version = resolve_measurement_version(db, user, plan) result = {'planId': plan.id, 'planNo': plan.plan_no, 'licensePlate': plan.license_plate, 'measurementVersion': version, 'configuration': {**(plan.cert_configuration or {}), 'measurementVersion': version}, 'sourceOptions': options, + 'stopProducts': stop_products, 'canSaveConfiguration': plan.transport_status != 'arranged' and plan.archive_status != 'archived', 'usages': []} for usage in usages: diff --git a/backend/tests/test_pl_quota_sources.py b/backend/tests/test_pl_quota_sources.py index bff0b3642..70dae3ad5 100644 --- a/backend/tests/test_pl_quota_sources.py +++ b/backend/tests/test_pl_quota_sources.py @@ -194,10 +194,11 @@ def test_server_version_cannot_be_overridden_by_client(case, history): def test_options_include_stop_contract_and_party_without_foreign_contract_leak(case): from app.models.crm_models import CrmContract + from app.models.pl_quota_models import PlContractCertLink, PlContractCertProduct from app.models.pl_quota_models import PlPlanCertUsage, PlPlanCertUsageItem from app.services.pl_quota_planning import get_plan_quota c = case - for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem): model.__table__.create(c.db.bind) + for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlContractCertLink, PlContractCertProduct): model.__table__.create(c.db.bind) c.db.add(CrmContract(id='purchase', contract_no='PUR-1', contract_name='购买合同', customer_id='customer', contract_type='purchase', tenant_no='t')) c.db.add(CrmContract(id='sale', contract_no='SECRET', contract_name='其他租户', customer_id='customer', contract_type='sale', tenant_no='other')) c.db.get(c.m.PlPlanLoad, 'load').customer_name = '装货方' @@ -207,3 +208,41 @@ def test_options_include_stop_contract_and_party_without_foreign_contract_leak(c assert options['load']['partyName'] == '装货方' assert options['load']['stopId'] == 'load' and options['load']['stopOrder'] == 1 assert options['delivery']['contractNo'] is None + + +def test_readonly_stop_products_include_explosive_without_changing_sources(case): + from app.models.crm_models import CrmContract + from app.models.pl_quota_models import PlContractCertLink, PlContractCertProduct + from app.models.pl_quota_models import PlPlanCertUsage, PlPlanCertUsageItem + from app.services.pl_quota_planning import get_plan_quota + c = case + for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlContractCertLink, PlContractCertProduct): model.__table__.create(c.db.bind) + product = c.db.get(c.m.PlPlanProduct, 'pp') + product.is_toxic = 0 + product.is_explosive = 1 + c.db.flush() + result = get_plan_quota(c.db, c.user, 'plan') + assert result['sourceOptions'] == [] + assert len(result['stopProducts']) == 2 + assert all(row['isExplosive'] and not row['isToxic'] for row in result['stopProducts']) + assert {row['contractId'] for row in result['stopProducts']} == {'purchase', 'sale'} + + +def test_stop_products_bindings_are_product_specific_and_hidden_contract_is_explicit(case): + from app.models.crm_models import CrmContract + from app.models.pl_quota_models import (PlPlanCertUsage, PlPlanCertUsageItem, + PlContractCertLink, PlContractCertProduct, PlPurchaseCert) + from app.services.pl_quota_planning import get_plan_quota + c = case + for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlContractCertLink, PlContractCertProduct, PlPurchaseCert): model.__table__.create(c.db.bind) + c.db.add(CrmContract(id='purchase', contract_no='PUR', contract_name='采购', customer_id='customer', contract_type='purchase', tenant_no='t')) + c.db.add(PlPurchaseCert(id='cert', cert_no='BUY', product_name_on_cert='硫酸', valid_use_text='多次有效', buyer_name='买方', seller_name='卖方', total_qty=100, quantity_unit='吨', tenant_no='t')) + c.db.add(PlContractCertLink(id='link',contract_id='purchase',contract_no_snapshot='PUR',cert_type='purchase',cert_id='cert',link_status='active',tenant_no='t')) + c.db.add(PlContractCertProduct(id='scope',contract_cert_link_id='link',contract_product_id='cp',scope_status='active',tenant_no='t')) + c.db.flush() + rows = get_plan_quota(c.db,c.user,'plan')['stopProducts'] + assert rows[0]['certificates'][0]['certNo'] == 'BUY' + assert rows[1]['bindingsReadable'] is False + assert rows[1]['certificates'] == [] + c.db.get(PlContractCertProduct,'scope').scope_status='inactive'; c.db.flush() + assert get_plan_quota(c.db,c.user,'plan')['stopProducts'][0]['certificates'] == [] diff --git a/web-admin/scripts/quota-config-submission.test.ts b/web-admin/scripts/quota-config-submission.test.ts index 4231e426d..ecddff784 100644 --- a/web-admin/scripts/quota-config-submission.test.ts +++ b/web-admin/scripts/quota-config-submission.test.ts @@ -29,3 +29,42 @@ for (const arrange of [false, true]) test(`stale ${arrange ? 'preview' : 'save'} assert.equal(h.state.visible.value, true) assert.equal(h.state.busy.value, true) }) + +for (const methodName of ['preview', 'loadContext']) + test(`${methodName} ignores a result when configuration changes in flight`, async () => { + const script = parse( + readFileSync( + new URL('../src/components/business/pl-plan-quota-dialog/index.vue', import.meta.url), + 'utf8' + ) + ).descriptor.scriptSetup!.content + const ast = ts.createSourceFile('quota.ts', script, ts.ScriptTarget.Latest, true) + const method = ast.statements + .find((n) => ts.isFunctionDeclaration(n) && n.name?.text === methodName)! + .getText(ast) + let finish!: (value: any) => void + const pending = new Promise((r) => { + finish = r + }) + const state: any = { + plan: { value: { planId: 'a' } }, + busy: { value: false }, + result: { value: undefined }, + context: { value: undefined }, + payload: () => ({}), + previewVehicle: {}, + planQuotaApi: { preview: () => pending, context: () => pending } + } + const js = ts.transpileModule( + `let epoch=0,revision=0;${method};return {run:${methodName},change:()=>revision++}`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } } + ).outputText + const actions = new Function(...Object.keys(state), js)(...Object.values(state)) + const task = actions.run() + actions.change() + finish({ status: 'available', sourceFingerprint: 'old' }) + await task + assert.equal(state.result.value, undefined) + assert.equal(state.context.value, undefined) + assert.equal(state.busy.value, false) + }) diff --git a/web-admin/scripts/quota-list-filters.test.ts b/web-admin/scripts/quota-list-filters.test.ts index 9ed19da4a..5e06cf76a 100644 --- a/web-admin/scripts/quota-list-filters.test.ts +++ b/web-admin/scripts/quota-list-filters.test.ts @@ -39,6 +39,7 @@ function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: tr ...Object.fromEntries( Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') ), + useElementSize: () => ({ width: vue.ref(900) }), useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), useClientColumnFilters, useServerColumnFilters, diff --git a/web-admin/scripts/quota-mobile-state.test.ts b/web-admin/scripts/quota-mobile-state.test.ts index 1344ce9db..2851ed2b6 100644 --- a/web-admin/scripts/quota-mobile-state.test.ts +++ b/web-admin/scripts/quota-mobile-state.test.ts @@ -38,6 +38,7 @@ function pageHarness(kind: 'quota' | 'legal', props: any = { mobile: true }) { ...Object.fromEntries( Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default') ), + useElementSize: () => ({ width: vue.ref(900) }), useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), useServerColumnFilters, usePersistedListColumns, @@ -47,6 +48,7 @@ function pageHarness(kind: 'quota' | 'legal', props: any = { mobile: true }) { onBeforeUnmount: (fn: Function) => unmount.push(fn), request: { get: (url: string, options: any) => queue(lists, { url, ...options.params }) }, pickBxData: (value: any) => value, + planQuotaApi: { get: (id: string) => queue(gets, id) }, legalDocumentApi: { list: (params: any) => queue(lists, params), get: (id: string) => queue(gets, id), @@ -330,3 +332,21 @@ test('legal: read-only permission prevents writes even when detail contains atta assert.equal(h.writes.length, 0) h.stop() }) + +test('plan expansion loads on demand, preserves total during refresh and ignores previous tenant', async () => { + const h = pageHarness('quota', { mobile: false }), + s = h.state + s.total.value = 45 + const refresh = s.load() + assert.equal(s.total.value, 45) + h.lists[0].resolve({ list: [{ id: 'plan' }], total: 45 }) + await refresh + s.expand({ id: 'plan' }, [{ id: 'plan' }]) + assert.equal(h.gets.length, 1) + assert.deepEqual(s.expandedIds.value, ['plan']) + h.user.getUserInfo.tenantNo = 'other' + h.gets[0].resolve({ planId: 'plan', sourceOptions: [], usages: [] }) + await flush() + assert.deepEqual(Object.keys(s.details), []) + h.stop() +}) diff --git a/web-admin/scripts/quota-stop-display.test.ts b/web-admin/scripts/quota-stop-display.test.ts new file mode 100644 index 000000000..bcaab8505 --- /dev/null +++ b/web-admin/scripts/quota-stop-display.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + measuredSources, + configurationPayload, + sourceLabel, + stopUsageRows +} from '../src/components/business/pl-plan-quota-dialog/stop-model' +const load: any = { + sourceStopType: 'load', + sourceStopProductId: 'a', + planProductId: 'p', + productName: '硫酸', + stopOrder: 1, + quantity: 100, + unit: '吨', + contractId: 'c1', + contractProductId: 'cp1' +} +const delivery: any = { + ...load, + sourceStopType: 'delivery', + sourceStopProductId: 'b', + quantity: 40, + contractId: 'c2', + contractProductId: 'cp2' +} +const plan: any = { + measurementVersion: 'per_stop_v2', + configuration: {}, + sourceOptions: [load, delivery], + usages: [] +} +test('v2 measures every stop and never sends legacy pairs; absent version stays legacy', () => { + assert.deepEqual(measuredSources(plan, { p: 'load' }), [load, delivery]) + assert.equal( + configurationPayload(plan, { sourceMapping: [{ old: true }] } as any, [load], {}).sourceMapping, + null + ) + assert.deepEqual(measuredSources({ ...plan, measurementVersion: undefined }, { p: 'load' }), [ + load + ]) +}) +test('source labels use stop and product instead of source UUID', () => { + assert.equal(sourceLabel('delivery:b', plan.sourceOptions), '送货段1 · 硫酸') + assert.equal(sourceLabel('load:unknown', []), '来源已不可用') +}) +test('all stop products are visible and each certificate keeps its unit and quantity', () => { + const data = { + ...plan, + stopProducts: [ + { ...load, isToxic: true }, + { ...delivery, isToxic: true }, + { ...load, sourceStopProductId: 'e', isToxic: false, isExplosive: true } + ], + usages: [ + { + certType: 'purchase', + certNoSnapshot: 'BUY', + quantityUnit: '吨', + items: [ + { sourceStopType: 'load', sourceStopProductId: 'a', reservedQty: '100', usedQty: '0' } + ] + }, + { + certType: 'transport', + certNoSnapshot: 'MOVE', + quantityUnit: '千克', + items: [ + { sourceStopType: 'load', sourceStopProductId: 'a', reservedQty: '100000', usedQty: '0' } + ] + } + ] + } + const rows = stopUsageRows(data) + assert.equal(rows.length, 3) + assert.equal(rows[0].purchase, 'BUY · 预占 100 吨 · 已用 0 吨') + assert.equal(rows[0].transport, 'MOVE · 预占 100000 千克 · 已用 0 千克') + assert.equal(rows[1].purchase, '缺配') + assert.equal(rows[2].purchase, '无需两证') +}) +test('draft bindings show zeros in their own units and hidden scope never claims missing', () => { + const rows = stopUsageRows({ + ...plan, + stopProducts: [ + { + ...load, + isToxic: true, + bindingsReadable: true, + certificates: [{ certType: 'purchase', certId: 'cert', certNo: 'BUY', quantityUnit: '桶' }] + }, + { ...delivery, isToxic: true, bindingsReadable: false } + ] + }) + assert.equal(rows[0].purchase, 'BUY · 已绑定 · 预占 0 桶 · 已用 0 桶') + assert.equal(rows[1].purchase, '合同或证件不可读取') +}) +test('legacy counterpart is traceability only, never a second missing allocation', () => { + const legacy = { + ...plan, + measurementVersion: 'legacy_single_side', + configuration: { + sourceMapping: [ + { sourceStopType: 'load', sourceStopProductId: 'a', relatedStopProductIds: ['b'] } + ] + } + } + assert.equal(stopUsageRows(legacy as any)[1].purchase, '原口径:关联追溯,不另计量') +}) diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index 20f35b296..febf327d5 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -167,7 +167,15 @@ export const legalDocumentApi = { request.patch(`${legalBase}/${id}`, { products }).then(pickBxData) } +export type MeasurementVersion = 'per_stop_v2' | 'legacy_single_side' export interface QuotaSourceOption { + sourceStopId?: string + stopOrder?: number + contractNo?: string | null + bindingsReadable?: boolean + certificates?: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[] + isToxic?: boolean + isExplosive?: boolean sourceStopType: 'load' | 'delivery' sourceStopProductId: string planProductId: string @@ -202,6 +210,7 @@ export interface QuotaCondition { note: string } export interface QuotaConfiguration { + measurementVersion?: MeasurementVersion sourceMapping?: QuotaSourcePair[] | null purchaseCertIds: string[] transportCertIds: string[] @@ -209,6 +218,8 @@ export interface QuotaConfiguration { conditions: QuotaCondition[] } export interface PlanQuota { + measurementVersion?: MeasurementVersion + stopProducts?: QuotaSourceOption[] planId: string planNo: string licensePlate?: string diff --git a/web-admin/src/components/business/pl-plan-quota-dialog/index.vue b/web-admin/src/components/business/pl-plan-quota-dialog/index.vue index 0c6fbe3ee..e2f193d4d 100644 --- a/web-admin/src/components/business/pl-plan-quota-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-quota-dialog/index.vue @@ -28,9 +28,23 @@ >已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。

-

1. 明确唯一计量侧与装送配对

+

{{ isPerStop ? '1. 全部装送段用证需求' : '1. 原口径:唯一计量侧与装送配对' }}

+ + + + + +

当前计划没有易制毒产品,无需购买证、运输证额度。

-
+
{{ group.name }}
@@ -215,7 +229,7 @@ : 'warning' " >{{ statusLabels[result.status] || result.status }}

{{ reason }}

+ >

{{ readableReason(reason) }}

-

{{ row.reason }}

+

{{ readableReason(row.reason) }}

+

{{ + row.sourceIds.map((id) => sourceLabel(id, plan?.sourceOptions || [])).join(';') + }}

已有占用与结转

+ +