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
}}
{{
- item.contractNo || '未关联或不可读取'
+ item.contractNo || (item.contractId ? '不可读取' : '未关联')
}}