diff --git a/backend/app/services/pl_cert_category_service.py b/backend/app/services/pl_cert_category_service.py index fdc940964..3d143220f 100644 --- a/backend/app/services/pl_cert_category_service.py +++ b/backend/app/services/pl_cert_category_service.py @@ -56,17 +56,45 @@ def get_category(db: Session, user, category_id: str, *, lock=False): .order_by(PlCertCategoryProduct.create_time.asc()) .all() ) + names = _product_labels(db, user.tenant_no, [link.product_id for link in links]) products = [ { 'id': link.id, 'productId': link.product_id, 'scopeStatus': link.scope_status, + **names.get(link.product_id, {}), } for link in links ] return _category_dict(row, products) +def _product_labels(db: Session, tenant_no: str, product_ids): + """批量带出 CRM 产品编号/名称/型号,供品类编辑回显。""" + ids = [pid for pid in dict.fromkeys(product_ids or []) if pid] + if not ids: + return {} + from app.models.crm_models import CrmProduct + + rows = ( + db.query(CrmProduct) + .filter( + CrmProduct.tenant_no == tenant_no, + CrmProduct.del_flag == 0, + CrmProduct.id.in_(ids), + ) + .all() + ) + return { + row.id: { + 'productNo': row.product_no, + 'productName': row.product_name, + 'model': row.model, + } + for row in rows + } + + def save_category(db: Session, user, data: dict, category_id=None): """data: categoryName, categoryCode?, status?, remark?, products? products 遗漏则不改关联;显式 [] 清空(软删);带 id 就地改。 diff --git a/backend/app/services/pl_quota_customer_select.py b/backend/app/services/pl_quota_customer_select.py index 23ed04bcb..2813138e5 100644 --- a/backend/app/services/pl_quota_customer_select.py +++ b/backend/app/services/pl_quota_customer_select.py @@ -90,6 +90,7 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat '产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输', ) + # MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5) candidates = ( db.query(PlPurchaseCert) .filter( @@ -99,7 +100,11 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat PlPurchaseCert.customer_id == customer_id, PlPurchaseCert.category_id.in_(categories), ) - .order_by(PlPurchaseCert.valid_to.asc(), PlPurchaseCert.id.asc()) + .order_by( + PlPurchaseCert.valid_to.is_(None).asc(), + PlPurchaseCert.valid_to.asc(), + PlPurchaseCert.id.asc(), + ) .all() ) @@ -150,7 +155,11 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat PlTransportCert.customer_id == customer_id, PlTransportCert.id.in_(transport_cert_ids), ) - .order_by(PlTransportCert.valid_to.asc(), PlTransportCert.id.asc()) + .order_by( + PlTransportCert.valid_to.is_(None).asc(), + PlTransportCert.valid_to.asc(), + PlTransportCert.id.asc(), + ) .all() ) for transport in transports: diff --git a/backend/scripts/seed_pl_customer_cert_test_data.py b/backend/scripts/seed_pl_customer_cert_test_data.py index 761045c00..7f88e95bd 100644 --- a/backend/scripts/seed_pl_customer_cert_test_data.py +++ b/backend/scripts/seed_pl_customer_cert_test_data.py @@ -2,9 +2,8 @@ """为客户+品类证件额度联调造数(测试库)。 造:客户、报价(accepted)、合同(executing)+ 合同产品、 - 2 张购买证(10吨早到期 / 100吨晚到期)、2 张运输证(引用对应购买证)、期初 opening。 - -不造:品类↔系统产品关联(由用户在界面配置)。 + 2 张购买证(10吨早到期 / 100吨晚到期)、2 张运输证(引用对应购买证)、期初 opening、 + 品类↔种子产品关联(否则新规则选证会因「产品未配置证用品类」失败)。 用法: cd backend && source venv/bin/activate @@ -118,6 +117,7 @@ def main() -> int: print(f'category_id={category_id}') print(f'vehicle={VEHICLE}') print(f'purchase early 10 / late 100 ; transport pair each') + ids['category_product'] = uid('cat-prod') # cleanup previous seed with same ids cleanup_tables = [ @@ -125,6 +125,7 @@ def main() -> int: ('pl_transport_cert_purchase_ref', 'id', [ids['ref_early'], ids['ref_late']]), ('pl_transport_cert', 'id', [ids['transport_early'], ids['transport_late']]), ('pl_purchase_cert', 'id', [ids['purchase_early'], ids['purchase_late']]), + ('pl_cert_category_product', 'id', [ids['category_product']]), ('crm_contract_product', 'id', [ids['contract_product']]), ('crm_contract', 'id', [ids['contract']]), ('crm_quote_product', 'id', [ids['quote_product']]), @@ -204,6 +205,18 @@ def main() -> int: ) print('+ contract', contract_no, 'product_row', ids['contract_product']) + # 品类 ↔ 种子产品(选证按产品反查品类;缺关联则安排必败) + cur.execute( + """ + INSERT INTO pl_cert_category_product + (id, category_id, product_id, scope_status, + tenant_no, create_dept_id, create_by, create_time, update_time, del_flag) + VALUES (%s,%s,%s,'active',%s,'',%s,%s,%s,0) + """, + (ids['category_product'], category_id, PRODUCT_ID, TENANT, OWNER_USER_ID, now, now), + ) + print('+ category_product', PRODUCT_ID, '->', category_id) + def insert_purchase(pid, cert_no, total, valid_to, face='98% 工业硫酸'): cur.execute( """ diff --git a/backend/tests/test_pl_customer_cert_quota.py b/backend/tests/test_pl_customer_cert_quota.py index 4b74676f8..ed06fe7c2 100644 --- a/backend/tests/test_pl_customer_cert_quota.py +++ b/backend/tests/test_pl_customer_cert_quota.py @@ -238,6 +238,84 @@ def test_legacy_waive_still_skips_settle(monkeypatch): assert result.get('waived') is True +def test_null_valid_to_sorted_after_dated_certs(): + """无到期日的证不能排在有到期日之前(MySQL NULL ASC 默认会错序)。""" + from app.services import pl_quota_customer_select as sel + from app.schemas.pl.quota_usage import UsageSource + from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog + + source = UsageSource( + source_stop_type='delivery', + source_stop_id='s1', + source_stop_product_id='sp1', + plan_product_id='pp1', + contract_id='c1', + contract_product_id='cp1', + product_id='prod1', + quantity=Decimal('10'), + unit='吨', + fingerprint='z' * 64, + snapshot={'customerId': 'cust1'}, + requires_transport=False, + ) + unbounded = SimpleNamespace( + id='buy-null', customer_id='cust1', category_id='cat1', status='active', + verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=None, + total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'), + quantity_unit='吨', opening_single_use_consumed=0, + ) + dated = SimpleNamespace( + id='buy-dated', customer_id='cust1', category_id='cat1', status='active', + verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1), + total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'), + quantity_unit='吨', opening_single_use_consumed=0, + ) + + class PurchaseQuery: + def __init__(self): + self._order = None + + def filter(self, *a, **k): + return self + + def order_by(self, *exprs): + self._order = exprs + return self + + def all(self): + # 应先按 valid_to IS NULL 升序(非空在前),再按 valid_to ASC + assert self._order is not None and len(self._order) >= 2 + return [dated, unbounded] + + class LogQuery: + def filter_by(self, **k): + return self + + def first(self): + return SimpleNamespace(id='opening') + + db = MagicMock() + + def q_side(model): + if model is PlPurchaseCert: + return PurchaseQuery() + if model is PlCertQtyLog: + return LogQuery() + return MagicMock() + + db.query.side_effect = q_side + orig = sel.category_ids_for_product + sel.category_ids_for_product = lambda db, tenant, pid: {'cat1'} + try: + purchase_ids, _, manuals = sel.select_whole_cover_certs( + db, SimpleNamespace(tenant_no='t'), [source], vehicle_no='皖A1', on_date=date(2026, 11, 14), + ) + finally: + sel.category_ids_for_product = orig + assert purchase_ids == ['buy-dated'] + assert manuals[0].permit_id == 'buy-dated' + + def test_same_city_source_skips_transport_requirement(): """同市来源 requires_transport=False,整笔选证可不带运输证。""" from app.services import pl_quota_customer_select as sel diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index bec5120e6..f775be08a 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -428,7 +428,14 @@ export interface CertCategory { categoryName: string categoryCode?: string | null status: string - products?: Array<{ id?: string; productId: string; scopeStatus?: string }> + products?: Array<{ + id?: string + productId: string + scopeStatus?: string + productNo?: string | null + productName?: string | null + model?: string | null + }> } export const certQuotaRuleApi = { diff --git a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue index d2e6cc07b..d5f326bed 100644 --- a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue @@ -1606,7 +1606,7 @@ message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善') if (title !== '用证没有完成' || pendingFulfill) { const guide = pendingFulfill - ? `${html}
新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 待完善并关闭」上传证件并执行完善并关闭后再归档。
` + ? `${html}新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 已标记待完善证书」上传证件并执行完善并关闭后再归档。
` : html await ElMessageBox.alert(guide, title, { type: 'warning', @@ -1618,7 +1618,7 @@ return undefined } // 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度 - const readyHtml = `${html}如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「待完善并关闭」。
` + const readyHtml = `${html}如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「证书齐备 → 已标记待完善证书」。
` try { await ElMessageBox.confirm(readyHtml, title, { type: 'warning', diff --git a/web-admin/src/components/business/pl-plan-view-tabs/index.vue b/web-admin/src/components/business/pl-plan-view-tabs/index.vue index 697d12f36..f1d7c282a 100644 --- a/web-admin/src/components/business/pl-plan-view-tabs/index.vue +++ b/web-admin/src/components/business/pl-plan-view-tabs/index.vue @@ -74,7 +74,7 @@- 证书齐备(待完善):请到「证书齐备 → 待完善并关闭」挂证 + 证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
暂未关联系统产品
+