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 @@

关联购买证 / 运输证

- 证书齐备(待完善):请到「证书齐备 → 待完善并关闭」挂证 + 证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭

diff --git a/web-admin/src/views/pl/cert-category/index.vue b/web-admin/src/views/pl/cert-category/index.vue index 4da91a370..d6a9ccfe5 100644 --- a/web-admin/src/views/pl/cert-category/index.vue +++ b/web-admin/src/views/pl/cert-category/index.vue @@ -43,8 +43,12 @@ @@ -55,9 +59,10 @@ v-model="visible" class="dialog-form-layout" :title="editingId ? '编辑证用品类' : '新增证用品类'" - width="640px" + width="720px" align-center destroy-on-close + :close-on-click-modal="false" >
@@ -74,13 +79,41 @@
- - + +
+
+ 已选 {{ linkedProducts.length }} 项 + + 添加产品 + +
+ + + + + + + + + + + + + + +

暂未关联系统产品

+
+ +
+ + diff --git a/web-admin/src/views/pl/cert-ready/index.vue b/web-admin/src/views/pl/cert-ready/index.vue index 36e7f0959..0485cf4c5 100644 --- a/web-admin/src/views/pl/cert-ready/index.vue +++ b/web-admin/src/views/pl/cert-ready/index.vue @@ -2,12 +2,12 @@

证书齐备

+ + + +
- - 标记齐备 - 待完善并关闭 - - 证书齐备 + 标记齐备 - 已确认 + 已标记 @@ -284,6 +284,7 @@ function onModeChange() { page.value = 1 + selectedIds.value = [] void load() } @@ -296,9 +297,9 @@ async function mark(row: CertReadyPlan) { try { await ElMessageBox.confirm( - '确认后可先安排运输;新规则下归档前仍须在「待完善并关闭」中挂上购买证/运输证。确认人和时间会记在本行。', - '证书齐备', - { type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' } + '确认后可先安排运输;新规则下归档前仍须到「已标记待完善证书」页上传证件并执行完善并关闭。确认人和时间会记在本行。', + '标记齐备', + { type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '取消' } ) } catch { return @@ -365,3 +366,18 @@ void load() }) + + diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index 28b7601ef..abd62518d 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -99,8 +99,8 @@ >{{ row.openingConfirmed ? '复核' : '期初确认' }} - 关联合同查看 @@ -649,7 +649,7 @@ import QuotaHistoryDialog from './QuotaHistoryDialog.vue' const historyDialog = ref>() - import { Check, Close, Connection, Delete, Document, Edit, Plus } from '@element-plus/icons-vue' + import { Check, Close, Delete, Document, Edit, Plus, View } from '@element-plus/icons-vue' import { useUserStore } from '@/store/modules/user' import { useDictStore } from '@/store/modules/dict' import { getProductUnitLabel } from '@/utils/crm-product-display' @@ -1283,6 +1283,10 @@ function applyCertForm(targetKind: CertType, cert: QuotaCert) { const value = { ...certPayload(targetKind, cert), + // 运输证适用品类为只读展示字段,不进保存 payload + ...(targetKind === 'transport' + ? { derivedCategoryIds: [...(cert.derivedCategoryIds || [])] } + : {}), contractBindings: (cert.contractBindings || []).map((binding) => ({ ...binding, products: binding.products.map((product) => ({ ...product })) @@ -1325,6 +1329,9 @@ form.value = row ? { ...certPayload(targetKind, row), + ...(targetKind === 'transport' + ? { derivedCategoryIds: [...(row.derivedCategoryIds || [])] } + : {}), totalQty: inputQty(row.totalQty), contractBindings: [], bindingsEditable: row.bindingsEditable !== false, @@ -1703,7 +1710,6 @@ linkedContracts.value = links detailVisible.value = true } - const showContracts = view async function refreshDetails() { if (detail.value && detailVisible.value) await view(detail.value) }