diff --git a/backend/app/services/pl_quota_planning.py b/backend/app/services/pl_quota_planning.py index 08bffcbf6..b58fa644b 100644 --- a/backend/app/services/pl_quota_planning.py +++ b/backend/app/services/pl_quota_planning.py @@ -178,8 +178,13 @@ def get_plan_quota(db, user, plan_id): contract_numbers[cid] = None contract_bindings.pop(cid, None) options, stop_products = [], [] - for side, values in rows.items(): - for row in values.values(): + for side in ('load', 'delivery'): + def display_order(row): + stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')] + return (getattr(stop, 'seq', None) or stop.order_num or 1, + stop.id, row.order_num or 0, row.id) + + for row in sorted(rows[side].values(), key=display_order): 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, @@ -193,7 +198,7 @@ def get_plan_quota(db, user, plan_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, + 'bindingsReadable': not row.contract_id or 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']]}) diff --git a/backend/tests/test_pl_quota_sources.py b/backend/tests/test_pl_quota_sources.py index 70dae3ad5..d619f6a33 100644 --- a/backend/tests/test_pl_quota_sources.py +++ b/backend/tests/test_pl_quota_sources.py @@ -246,3 +246,35 @@ def test_stop_products_bindings_are_product_specific_and_hidden_contract_is_expl 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'] == [] + + +def test_readonly_stop_products_follow_stop_and_product_order(case): + from app.models.crm_models import CrmContract + 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) + c.db.get(c.m.PlPlanLoad, 'load').order_num = 2 + c.db.add(c.m.PlPlanLoad(id='first', plan_id='plan', order_num=1, tenant_no='t')) + for rid, order in [('z', 2), ('b', 1), ('a', 1)]: + c.db.add(c.m.PlPlanLoadProduct(id=rid, load_id='first', plan_id='plan', plan_product_id='pp', + product_id='sku', product_no='SKU', product_name='硫酸', plan_qty=10, order_num=order, tenant_no='t')) + c.db.flush() + result = get_plan_quota(c.db, c.user, 'plan') + for key in ('sourceOptions', 'stopProducts'): + assert [r['sourceStopProductId'] for r in result[key]] == ['a', 'b', 'z', 'lp', 'dp'] + + +@pytest.mark.parametrize('contract_id', [None, '']) +def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id): + from app.models.crm_models import CrmContract + 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) + c.db.get(c.m.PlPlanLoadProduct, 'lp').contract_id = contract_id + c.db.flush() + rows = get_plan_quota(c.db, c.user, 'plan')['stopProducts'] + assert rows[0]['bindingsReadable'] is True + assert rows[0]['certificates'] == [] + assert rows[1]['bindingsReadable'] is False diff --git a/web-admin/scripts/quota-config-submission.test.ts b/web-admin/scripts/quota-config-submission.test.ts index ecddff784..fc18e14eb 100644 --- a/web-admin/scripts/quota-config-submission.test.ts +++ b/web-admin/scripts/quota-config-submission.test.ts @@ -68,3 +68,12 @@ for (const methodName of ['preview', 'loadContext']) assert.equal(state.context.value, undefined) assert.equal(state.busy.value, false) }) + +test('existing usage table exposes each certificate quantity unit alongside reserved and used values', () => { + const template = parse(readFileSync(new URL('../src/components/business/pl-plan-quota-dialog/index.vue', import.meta.url), 'utf8')).descriptor.template!.content + const usageTable = template.slice(template.indexOf(':data="plan.usages"'), template.indexOf('', template.indexOf(':data="plan.usages"'))) + const columns = [...usageTable.matchAll(/prop="([^"]+)"/g)].map(match => match[1]) + const visible = [{ certNoSnapshot:'BUY', reservedQty:100, usedQty:20, quantityUnit:'吨' }, { certNoSnapshot:'MOVE', reservedQty:100000, usedQty:20000, quantityUnit:'千克' }].map(row => Object.fromEntries(columns.map(key => [key, row[key as keyof typeof row]]))) + assert.equal(visible[0].quantityUnit, '吨') + assert.equal(visible[1].quantityUnit, '千克') +}) 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 e2f193d4d..50c39d44b 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 @@ -284,7 +284,13 @@ usageLabels[row.usageStatus] || row.usageStatus }}