diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index 42146349a..99d5ecb3b 100644 --- a/backend/app/api/pl/plan.py +++ b/backend/app/api/pl/plan.py @@ -2206,6 +2206,8 @@ def create_plan( create_by=user.id, create_dept_id=user.dept_id, ) + from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version + stamp_plan_rule_version(db, user, plan) db.add(plan) # 添加产品明细 @@ -2377,6 +2379,8 @@ def create_plan_full( create_by=user.id, create_dept_id=user.dept_id, ) + from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version + stamp_plan_rule_version(db, user, plan) db.add(plan) db.flush() @@ -2527,6 +2531,8 @@ def create_from_contract( create_by=user.id, create_dept_id=user.dept_id, ) + from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version + stamp_plan_rule_version(db, user, plan) db.add(plan) # 查询合同产品明细 diff --git a/backend/app/api/pl/quota_cert.py b/backend/app/api/pl/quota_cert.py index a126f101e..d6e951dfe 100644 --- a/backend/app/api/pl/quota_cert.py +++ b/backend/app/api/pl/quota_cert.py @@ -137,6 +137,100 @@ def mark_cert_ready(plan_id: str, db: Session = Depends(get_db), user: DbUserCon return _write(db, lambda: mark_ready(db, user, plan_id)) +@cert_ready_router.get('/plans/cert-ready/pending-fulfill') +def list_pending_fulfill(keyword: str = '', page: int = Query(1, ge=1), + page_size: int = Query(20, alias='pageSize', ge=1, le=100), + db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): + from app.services.pl_quota_waiver import list_pending_fulfill_plans + return _wire(list_pending_fulfill_plans(db, user, page, page_size, keyword)) + + +@cert_ready_router.post('/plans/{plan_id}/fulfill-quota-certs') +def fulfill_quota_certs(plan_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): + from app.services.pl_quota_waiver import fulfill_plan_certs + return _write(db, lambda: fulfill_plan_certs(db, user, plan_id)) + + +class FulfillBatchBody(InputBody): + plan_ids: list = Field(default_factory=list, alias='planIds') + + +@cert_ready_router.post('/plans/fulfill-quota-certs/batch') +def fulfill_quota_certs_batch(body: FulfillBatchBody, db: Session = Depends(get_db), + user: DbUserContext = Depends(current_user)): + from app.services.pl_quota_waiver import fulfill_plans_batch + ids = [str(x) for x in (body.plan_ids or []) if x] + if not ids: + raise HTTPException(400, '请选择要完善的计划') + return _write(db, lambda: fulfill_plans_batch(db, user, ids)) + + +@router.get('/rule-config') +def get_rule_config(db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): + from app.services.pl_cert_quota_rule_config_service import read_config + return _wire(read_config(db, user.tenant_no)) + + +class RuleConfigBody(InputBody): + effective_at: Optional[str] = Field(None, alias='effectiveAt') + + +@router.put('/rule-config', dependencies=[Depends(require_admin_or_super_admin)]) +@oper_log('运输证件规则切点', business_type=2) +def put_rule_config(body: RuleConfigBody, db: Session = Depends(get_db), + user: DbUserContext = Depends(current_user)): + from datetime import datetime as dt + from app.services.pl_cert_quota_rule_config_service import save_effective_at + value = None + if body.effective_at: + text = body.effective_at.replace('T', ' ').strip() + try: + value = dt.strptime(text[:19], '%Y-%m-%d %H:%M:%S') + except ValueError: + try: + value = dt.strptime(text[:16], '%Y-%m-%d %H:%M') + except ValueError as exc: + raise HTTPException(400, '生效时间格式无效') from exc + return _write(db, lambda: save_effective_at(db, user, value)) + + +@router.get('/categories') +def list_categories(keyword: Optional[str] = None, status: Optional[str] = None, + db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): + from app.services import pl_cert_category_service as categories + return _wire(categories.list_categories(db, user, keyword, status)) + + +@router.get('/categories/{category_id}') +def get_category(category_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): + from app.services import pl_cert_category_service as categories + return _wire(categories.get_category(db, user, category_id)) + + +@router.post('/categories', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))]) +@oper_log('证用品类', business_type=1) +def create_category(body: dict = Body(...), db: Session = Depends(get_db), + user: DbUserContext = Depends(current_user)): + from app.services import pl_cert_category_service as categories + return _write(db, lambda: categories.save_category(db, user, body)) + + +@router.put('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))]) +@oper_log('证用品类', business_type=2, path_param='category_id') +def update_category(category_id: str, body: dict = Body(...), db: Session = Depends(get_db), + user: DbUserContext = Depends(current_user)): + from app.services import pl_cert_category_service as categories + return _write(db, lambda: categories.save_category(db, user, body, category_id)) + + +@router.delete('/categories/{category_id}', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))]) +@oper_log('证用品类', business_type=3, path_param='category_id') +def delete_category(category_id: str, db: Session = Depends(get_db), + user: DbUserContext = Depends(current_user)): + from app.services import pl_cert_category_service as categories + return _write(db, lambda: categories.delete_category(db, user, category_id) or {'ok': True}) + + @router.get('/plans/{plan_id}/usage') def plan_quota(plan_id: str, arrange_check: bool = Query(False, alias='arrangeCheck'), db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): diff --git a/backend/app/api/pl/transport.py b/backend/app/api/pl/transport.py index b6f1bb105..47e95c9ac 100644 --- a/backend/app/api/pl/transport.py +++ b/backend/app/api/pl/transport.py @@ -725,6 +725,8 @@ def arrange_transport( raise HTTPException(status_code=404, detail="计划不存在") assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no) assert_plan_can_arrange(plan) + from app.services.pl_cert_quota_rule_config_service import assert_cert_quota_rule_configured + assert_cert_quota_rule_configured(db, tenant_no) assert_no_pending_stop_products(db, tenant_no, plan_id) diff --git a/backend/app/models/pl_models.py b/backend/app/models/pl_models.py index f433f39c6..c7a7924d7 100644 --- a/backend/app/models/pl_models.py +++ b/backend/app/models/pl_models.py @@ -366,10 +366,12 @@ class PlPlan(Base): cert_configuration = Column(JSON, nullable=True, comment="用证来源配对、候选选择与逐证条件确认;不作为放行布尔状态") hazard_cert_ready_by = Column(String(64), nullable=True, comment="危化品证书确认人") hazard_cert_ready_time = Column(DateTime, nullable=True, comment="危化品证书确认时间") - quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度") + quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度(新规则仅放行安排)") quota_cert_waived_by = Column(String(64), nullable=True, comment="证书齐备确认人ID") quota_cert_waived_by_name = Column(String(100), nullable=True, comment="证书齐备确认人姓名") quota_cert_waived_time = Column(DateTime, nullable=True, comment="证书齐备确认时间") + cert_quota_rule_version = Column(String(32), nullable=True, comment="证件额度规则版本 legacy|customer_category;创建时打标") + quota_cert_pending_fulfill = Column(SmallInteger, nullable=False, default=0, comment="新规则证书齐备待完善 0-否 1-是") erp_entered = Column(SmallInteger, nullable=False, default=0, comment="是否已录入ERP 0-否 1-是") cancel_reason = Column(String(500), nullable=True, comment="作废原因") cancel_time = Column(DateTime, nullable=True, comment="作废时间") diff --git a/backend/app/models/pl_quota_models.py b/backend/app/models/pl_quota_models.py index c7a43d022..57d910af0 100644 --- a/backend/app/models/pl_quota_models.py +++ b/backend/app/models/pl_quota_models.py @@ -34,6 +34,8 @@ class PlPurchaseCert(Base): seller_legal_representative = Column(String(100), nullable=True, comment='销售方法定代表人') seller_phone = Column(String(100), nullable=True, comment='销售方电话') purchase_purpose = Column(String(1000), nullable=True, comment='用途') + customer_id = Column(String(64), nullable=True, comment='系统客户ID;选证必填') + category_id = Column(String(64), nullable=True, comment='证用品类ID;选证必填') tenant_no = Column(String(64), nullable=False, comment='租户编号,由服务端当前用户上下文设置') order_num = Column(Integer, nullable=True, comment='公共排序号') remark = Column(String(1000), nullable=True, comment='系统备注,与证面备注分开') @@ -80,6 +82,7 @@ class PlTransportCert(Base): route_text = Column(Text, nullable=True, comment='运输路线完整原文') consignee_name = Column(String(255), nullable=True, comment='收货单位名称或姓名') applicant_name = Column(String(255), nullable=True, comment='申请单位名称或姓名') + customer_id = Column(String(64), nullable=True, comment='系统客户ID;选证必填') tenant_no = Column(String(64), nullable=False, comment='租户编号,由服务端当前用户上下文设置') order_num = Column(Integer, nullable=True, comment='公共排序号') remark = Column(String(1000), nullable=True, comment='系统备注,与证面备注分开') @@ -323,3 +326,62 @@ class PlStopLegalDocument(Base): __table_args__ = ( UniqueConstraint('tenant_no', 'plan_id', 'stop_type', 'stop_product_id', name='uk_stop_legal_document'), ) + + +class PlCertCategory(Base): + """证用品类(与 CRM 产品分类独立)。""" + __tablename__ = 'pl_cert_category' + id = Column(String(64), primary_key=True, comment='主键UUID') + category_name = Column(String(128), nullable=False, comment='证用品类名称,如硫酸') + category_code = Column(String(64), nullable=True, comment='品类编码,可选') + status = Column(String(1), nullable=False, default='1', comment='状态 1启用 0停用') + tenant_no = Column(String(64), nullable=False, comment='租户编号') + order_num = Column(Integer, nullable=True, comment='排序号') + remark = Column(String(1000), nullable=True, comment='备注') + create_by = Column(String(64), nullable=True, comment='创建人') + create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门') + create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间') + update_by = Column(String(64), nullable=True, comment='更新人') + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间') + del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除') + + +class PlCertCategoryProduct(Base): + """证用品类关联系统产品。""" + __tablename__ = 'pl_cert_category_product' + id = Column(String(64), primary_key=True, comment='主键UUID') + category_id = Column(String(64), nullable=False, comment='证用品类ID') + product_id = Column(String(64), nullable=False, comment='CRM产品ID') + scope_status = Column(String(20), nullable=False, default='active', comment='active/inactive') + tenant_no = Column(String(64), nullable=False, comment='租户编号') + order_num = Column(Integer, nullable=True, comment='排序号') + remark = Column(String(1000), nullable=True, comment='备注') + create_by = Column(String(64), nullable=True, comment='创建人') + create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门') + create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间') + update_by = Column(String(64), nullable=True, comment='更新人') + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间') + del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除') + __table_args__ = ( + UniqueConstraint('tenant_no', 'category_id', 'product_id', name='uk_cert_category_product'), + Index('idx_cert_cat_prod_product', 'tenant_no', 'product_id', 'del_flag', 'scope_status'), + ) + + +class PlCertQuotaRuleConfig(Base): + """客户品类额度规则切点;每租户一行。""" + __tablename__ = 'pl_cert_quota_rule_config' + id = Column(String(64), primary_key=True, comment='主键UUID') + effective_at = Column(DateTime, nullable=True, comment='客户品类额度生效时间;空表示未配置') + tenant_no = Column(String(64), nullable=False, comment='租户编号') + order_num = Column(Integer, nullable=True, comment='排序号') + remark = Column(String(1000), nullable=True, comment='备注') + create_by = Column(String(64), nullable=True, comment='创建人') + create_dept_id = Column(String(64), nullable=False, default='', comment='创建部门') + create_time = Column(DateTime, nullable=False, default=datetime.now, comment='创建时间') + update_by = Column(String(64), nullable=True, comment='更新人') + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment='更新时间') + del_flag = Column(SmallInteger, nullable=False, default=0, comment='软删除标记:0正常,1删除') + __table_args__ = ( + UniqueConstraint('tenant_no', name='uk_cert_quota_rule_tenant'), + ) diff --git a/backend/app/schemas/pl/plan.py b/backend/app/schemas/pl/plan.py index a57941891..aa069423b 100644 --- a/backend/app/schemas/pl/plan.py +++ b/backend/app/schemas/pl/plan.py @@ -413,10 +413,12 @@ class PlanRead(PlanBase, PlanResourceSnapshotMixin): hazard_cert_ready: int = Field(default=0, alias="hazardCertReady", description="危化品证书是否已人工确认齐备") hazard_cert_ready_by: Optional[str] = Field(default=None, alias="hazardCertReadyBy", description="危化品证书确认人") hazard_cert_ready_time: Optional[datetime] = Field(default=None, alias="hazardCertReadyTime", description="危化品证书确认时间") - quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;为 1 时不校验、不扣购买证与运输证额度") + quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;新规则仅放行安排") quota_cert_waived_by: Optional[str] = Field(default=None, alias="quotaCertWaivedBy", description="证书齐备确认人ID") quota_cert_waived_by_name: Optional[str] = Field(default=None, alias="quotaCertWaivedByName", description="证书齐备确认人姓名") quota_cert_waived_time: Optional[datetime] = Field(default=None, alias="quotaCertWaivedTime", description="证书齐备确认时间") + cert_quota_rule_version: Optional[str] = Field(default=None, alias="certQuotaRuleVersion", description="legacy|customer_category") + quota_cert_pending_fulfill: int = Field(default=0, alias="quotaCertPendingFulfill", description="新规则齐备待完善 0否 1是") erp_entered: int = Field(default=0, alias="erpEntered", description="是否已录入ERP 0-否 1-是") cancel_reason: Optional[str] = Field(default=None, alias="cancelReason", description="作废原因") cancel_time: Optional[datetime] = Field(default=None, alias="cancelTime", description="作废时间") diff --git a/backend/app/schemas/pl/quota_cert.py b/backend/app/schemas/pl/quota_cert.py index 09f929ca9..1cf8e54e7 100644 --- a/backend/app/schemas/pl/quota_cert.py +++ b/backend/app/schemas/pl/quota_cert.py @@ -48,6 +48,8 @@ class PurchaseCertUpdate(CertFields): seller_legal_representative: Optional[str] = Field(None, max_length=100) seller_phone: Optional[str] = Field(None, max_length=100) purchase_purpose: Optional[str] = Field(None, max_length=1000) + customer_id: Optional[str] = Field(None, alias='customerId', max_length=64) + category_id: Optional[str] = Field(None, alias='categoryId', max_length=64) class PurchaseCertCreate(PurchaseCertUpdate): @@ -83,6 +85,7 @@ class TransportCertUpdate(CertFields): route_text: Optional[str] = None consignee_name: Optional[str] = Field(None, max_length=255) applicant_name: Optional[str] = Field(None, max_length=255) + customer_id: Optional[str] = Field(None, alias='customerId', max_length=64) purchase_refs: Optional[List[PurchaseReference]] = None diff --git a/backend/app/services/pl_cert_category_service.py b/backend/app/services/pl_cert_category_service.py new file mode 100644 index 000000000..fdc940964 --- /dev/null +++ b/backend/app/services/pl_cert_category_service.py @@ -0,0 +1,174 @@ +"""证用品类与产品关联;差量保存关联行。""" +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.pl_quota_models import PlCertCategory, PlCertCategoryProduct + + +def _audit(user): + return dict( + id=str(uuid4()), + tenant_no=user.tenant_no, + create_by=user.id, + create_dept_id=getattr(user, 'dept_id', None) or '', + update_by=user.id, + ) + + +def _category_dict(row, products=None): + data = { + 'id': row.id, + 'categoryName': row.category_name, + 'categoryCode': row.category_code, + 'status': row.status, + 'orderNum': row.order_num, + 'remark': row.remark, + 'createTime': row.create_time, + 'updateTime': row.update_time, + } + if products is not None: + data['products'] = products + return data + + +def list_categories(db: Session, user, keyword=None, status=None): + query = db.query(PlCertCategory).filter_by(tenant_no=user.tenant_no, del_flag=0) + if keyword: + query = query.filter(PlCertCategory.category_name.contains(keyword, autoescape=True)) + if status is not None and status != '': + query = query.filter(PlCertCategory.status == status) + rows = query.order_by(PlCertCategory.create_time.desc()).all() + return [_category_dict(r) for r in rows] + + +def get_category(db: Session, user, category_id: str, *, lock=False): + query = db.query(PlCertCategory).filter_by(id=category_id, tenant_no=user.tenant_no, del_flag=0) + if lock: + query = query.with_for_update().populate_existing() + row = query.first() + if not row: + raise HTTPException(404, '证用品类不存在') + links = ( + db.query(PlCertCategoryProduct) + .filter_by(tenant_no=user.tenant_no, category_id=category_id, del_flag=0) + .order_by(PlCertCategoryProduct.create_time.asc()) + .all() + ) + products = [ + { + 'id': link.id, + 'productId': link.product_id, + 'scopeStatus': link.scope_status, + } + for link in links + ] + return _category_dict(row, products) + + +def save_category(db: Session, user, data: dict, category_id=None): + """data: categoryName, categoryCode?, status?, remark?, products? + products 遗漏则不改关联;显式 [] 清空(软删);带 id 就地改。 + """ + name = (data.get('categoryName') or data.get('category_name') or '').strip() + if not name: + raise HTTPException(400, '品类名称不能为空') + if category_id: + row = db.query(PlCertCategory).filter_by( + id=category_id, tenant_no=user.tenant_no, del_flag=0, + ).with_for_update().populate_existing().first() + if not row: + raise HTTPException(404, '证用品类不存在') + else: + row = PlCertCategory(**_audit(user)) + db.add(row) + row.category_name = name + if 'categoryCode' in data or 'category_code' in data: + row.category_code = data.get('categoryCode', data.get('category_code')) + if 'status' in data and data['status'] is not None: + row.status = str(data['status']) + if 'remark' in data: + row.remark = data.get('remark') + if 'orderNum' in data or 'order_num' in data: + row.order_num = data.get('orderNum', data.get('order_num')) + row.update_by = user.id + db.flush() + + if 'products' in data: + submitted = data['products'] + if submitted is None: + raise HTTPException(400, '产品关联集合不可为 null;清空请提交 []') + existing = { + r.id: r + for r in db.query(PlCertCategoryProduct).filter_by( + tenant_no=user.tenant_no, category_id=row.id, del_flag=0, + ).with_for_update().populate_existing().all() + } + seen = set() + product_ids = set() + for item in submitted: + pid = item.get('productId') or item.get('product_id') + if not pid: + raise HTTPException(400, '产品关联缺少 productId') + if pid in product_ids: + raise HTTPException(400, '同一品类不能重复关联同一产品') + product_ids.add(pid) + rid = item.get('id') + scope = item.get('scopeStatus') or item.get('scope_status') or 'active' + if rid: + link = existing.get(rid) + if not link: + raise HTTPException(400, '产品关联明细不属于当前品类') + if rid in seen: + raise HTTPException(400, '产品关联明细 ID 重复') + seen.add(rid) + link.product_id = pid + link.scope_status = scope + link.update_by = user.id + else: + link = PlCertCategoryProduct( + **_audit(user), + category_id=row.id, + product_id=pid, + scope_status=scope, + ) + db.add(link) + for rid, link in existing.items(): + if rid not in seen: + link.del_flag = 1 + link.update_by = user.id + db.flush() + + return get_category(db, user, row.id) + + +def delete_category(db: Session, user, category_id: str): + row = db.query(PlCertCategory).filter_by( + id=category_id, tenant_no=user.tenant_no, del_flag=0, + ).with_for_update().populate_existing().first() + if not row: + raise HTTPException(404, '证用品类不存在') + from app.models.pl_quota_models import PlPurchaseCert + used = db.query(PlPurchaseCert.id).filter_by( + tenant_no=user.tenant_no, category_id=category_id, del_flag=0, + ).first() + if used: + raise HTTPException(400, '品类已被购买证使用,不能删除') + row.del_flag = 1 + row.update_by = user.id + for link in db.query(PlCertCategoryProduct).filter_by( + tenant_no=user.tenant_no, category_id=category_id, del_flag=0, + ).with_for_update().all(): + link.del_flag = 1 + link.update_by = user.id + db.flush() + + +def category_ids_for_product(db: Session, tenant_no: str, product_id: str): + rows = ( + db.query(PlCertCategoryProduct.category_id) + .filter_by(tenant_no=tenant_no, product_id=product_id, del_flag=0, scope_status='active') + .all() + ) + return {r.category_id for r in rows} diff --git a/backend/app/services/pl_cert_quota_rule_config_service.py b/backend/app/services/pl_cert_quota_rule_config_service.py new file mode 100644 index 000000000..b7b936fec --- /dev/null +++ b/backend/app/services/pl_cert_quota_rule_config_service.py @@ -0,0 +1,77 @@ +"""客户品类额度规则切点:系统信息配置;未配置则拦住安排。""" +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.pl_quota_models import PlCertQuotaRuleConfig + +RULE_NOT_CONFIGURED = '请先在系统信息中配置客户品类额度生效时间后再安排运输' +VERSION_LEGACY = 'legacy' +VERSION_CUSTOMER_CATEGORY = 'customer_category' + + +def get_config(db: Session, tenant_no: str) -> Optional[PlCertQuotaRuleConfig]: + return ( + db.query(PlCertQuotaRuleConfig) + .filter_by(tenant_no=tenant_no, del_flag=0) + .first() + ) + + +def get_effective_at(db: Session, tenant_no: str): + row = get_config(db, tenant_no) + return row.effective_at if row else None + + +def read_config(db: Session, tenant_no: str) -> dict: + row = get_config(db, tenant_no) + return { + 'effectiveAt': row.effective_at.isoformat(sep=' ', timespec='seconds') if row and row.effective_at else None, + 'configured': bool(row and row.effective_at), + } + + +def save_effective_at(db: Session, user, effective_at: Optional[datetime]) -> dict: + row = get_config(db, user.tenant_no) + if row is None: + row = PlCertQuotaRuleConfig( + id=str(uuid4()), + tenant_no=user.tenant_no, + create_by=user.id, + create_dept_id=getattr(user, 'dept_id', None) or '', + update_by=user.id, + ) + db.add(row) + row.effective_at = effective_at + row.update_by = user.id + db.flush() + return read_config(db, user.tenant_no) + + +def assert_cert_quota_rule_configured(db: Session, tenant_no: str) -> datetime: + effective_at = get_effective_at(db, tenant_no) + if effective_at is None: + raise HTTPException(409, RULE_NOT_CONFIGURED) + return effective_at + + +def resolve_rule_version_for_create(db: Session, tenant_no: str, create_time: Optional[datetime] = None) -> str: + """计划创建时打标;未配置生效时间时仍标 legacy(安排会被拦住)。""" + effective_at = get_effective_at(db, tenant_no) + when = create_time or datetime.now() + if effective_at is not None and when >= effective_at: + return VERSION_CUSTOMER_CATEGORY + return VERSION_LEGACY + + +def stamp_plan_rule_version(db: Session, user, plan) -> str: + version = resolve_rule_version_for_create(db, user.tenant_no, getattr(plan, 'create_time', None) or datetime.now()) + plan.cert_quota_rule_version = version + return version + + +def plan_uses_customer_category(plan) -> bool: + return (getattr(plan, 'cert_quota_rule_version', None) or VERSION_LEGACY) == VERSION_CUSTOMER_CATEGORY diff --git a/backend/app/services/pl_quota_business.py b/backend/app/services/pl_quota_business.py index 302c0f9a5..c1b7fb8e2 100644 --- a/backend/app/services/pl_quota_business.py +++ b/backend/app/services/pl_quota_business.py @@ -120,10 +120,22 @@ def apply_plan_quota(db, user, plan, action, payload=None): evidenceUrls=getattr(payload, 'quota_evidence_urls', None), sourceNote=getattr(payload, 'quota_source_note', None)) from app.services.pl_quota_waiver import assert_waived_sources, quota_cert_waived - if quota_cert_waived(plan) and action in ('reserve', 'resize', 'settle', 'actual_adjust'): + from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category + # 新规则:齐备仅放行安排/改派预占;归档与实际调整必须真证结转。 + # 旧规则(legacy):齐备仍跳过安排与归档(祖父兼容)。 + skip_actions = ('reserve', 'resize') + if not plan_uses_customer_category(plan): + skip_actions = ('reserve', 'resize', 'settle', 'actual_adjust') + if quota_cert_waived(plan) and action in skip_actions: if action in ('reserve', 'resize'): assert_waived_sources(db, user, plan, lock=True) return {'operationId': ledger_id, 'usageIds': [], 'waived': True} + if ( + plan_uses_customer_category(plan) + and action in ('settle', 'actual_adjust') + and int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) == 1 + ): + raise HTTPException(409, '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档') request, config = build_usage_request( db, user, plan.id, op, action, lock=True, check_conditions=explicit_configuration, ) diff --git a/backend/app/services/pl_quota_cert_service.py b/backend/app/services/pl_quota_cert_service.py index 600dc2421..26f264a80 100644 --- a/backend/app/services/pl_quota_cert_service.py +++ b/backend/app/services/pl_quota_cert_service.py @@ -63,6 +63,28 @@ def _refs(db, user, cert_id, lock=False): return query.order_by(PlTransportCertPurchaseRef.create_time.desc()).all() +def _transport_derived_categories(db, user, transport_cert_id): + from app.models.pl_quota_models import PlPurchaseCert + purchase_ids = [ + r.purchase_cert_id + for r in _refs(db, user, transport_cert_id) + if r.purchase_cert_id + ] + if not purchase_ids: + return [] + rows = ( + db.query(PlPurchaseCert.category_id) + .filter( + PlPurchaseCert.tenant_no == user.tenant_no, + PlPurchaseCert.del_flag == 0, + PlPurchaseCert.id.in_(purchase_ids), + PlPurchaseCert.category_id.isnot(None), + ) + .all() + ) + return list(dict.fromkeys(r.category_id for r in rows if r.category_id)) + + def get_cert(db, user, cert_type, cert_id, current=False): row = _get_row(db, user, cert_type, cert_id, lock=current) result = _row_to_dict(row) @@ -77,6 +99,7 @@ def get_cert(db, user, cert_type, cert_id, current=False): if cert_type == 'transport': result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)] result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current) + result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id) return result @@ -94,7 +117,14 @@ def list_certs(db, user, cert_type, keyword=None, status=None): )) if status: query = query.filter(model.status == status) - return [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()] + else: + # 默认不展示已核销、已作废;列头漏斗显式传 status 时可查看 + query = query.filter(model.status.notin_(('closed', 'void'))) + result = [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()] + if cert_type == 'transport': + for item in result: + item['derivedCategoryIds'] = _transport_derived_categories(db, user, item['id']) + return result def _audit(user): diff --git a/backend/app/services/pl_quota_customer_select.py b/backend/app/services/pl_quota_customer_select.py new file mode 100644 index 000000000..23ed04bcb --- /dev/null +++ b/backend/app/services/pl_quota_customer_select.py @@ -0,0 +1,214 @@ +"""新规则:按段次客户+品类整笔选证(不拆购买证)。""" +from datetime import date +from decimal import Decimal + +from fastapi import HTTPException + +from app.models.pl_quota_models import ( + PlPurchaseCert, + PlTransportCert, + PlTransportCertPurchaseRef, + PlCertQtyLog, +) +from app.schemas.pl.quota_planning import ManualQuantity +from app.services.pl_cert_category_service import category_ids_for_product +from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio + + +def _available(cert): + return Decimal(cert.total_qty) - Decimal(cert.used_qty) - Decimal(cert.reserved_qty) + + +def _in_force(cert, on_date: date) -> bool: + if cert.valid_from and on_date < cert.valid_from: + return False + if cert.valid_to and on_date > cert.valid_to: + return False + return True + + +def _has_opening(db, user, cert_type, cert_id) -> bool: + return ( + db.query(PlCertQtyLog.id) + .filter_by( + tenant_no=user.tenant_no, + cert_type=cert_type, + cert_id=cert_id, + event_type='opening', + del_flag=0, + ) + .first() + is not None + ) + + +def _vehicle_ok(cert, vehicle_no: str) -> bool: + plates = cert.vehicle_numbers_json or [] + if not isinstance(plates, list): + return False + plate = (vehicle_no or '').strip() + if not plate: + return False + return plate in {str(p).strip() for p in plates if p} + + +def _permit_qty_for(source_qty: Decimal, source_unit: str, cert_unit: str) -> Decimal: + if same_quantity_unit(source_unit, cert_unit): + return source_qty + ratio = standard_conversion_ratio(source_unit, cert_unit) + if ratio is None: + raise HTTPException(409, f'来源单位「{source_unit}」与证件单位「{cert_unit}」无法自动换算,请调整单位或证件') + return (source_qty * Decimal(str(ratio))).quantize(Decimal('0.000001')) + + +def _customer_id_from_source(source) -> str: + snap = source.snapshot or {} + cid = snap.get('customerId') or snap.get('customer_id') + if cid: + return str(cid) + raise HTTPException(409, '用证段次缺少客户,无法按客户选证') + + +def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: date): + """返回 purchase_cert_ids, transport_cert_ids, manual_allocations。 + + 每个来源必须由一张购买证整笔覆盖;异地再配一张关联且车号/余量够的运输证。 + """ + if not sources: + return [], [], [] + + purchase_ids = [] + transport_ids = [] + manuals = [] + + for source in sources: + customer_id = _customer_id_from_source(source) + categories = category_ids_for_product(db, user.tenant_no, source.product_id) + if not categories: + raise HTTPException( + 409, + '产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输', + ) + + candidates = ( + db.query(PlPurchaseCert) + .filter( + PlPurchaseCert.tenant_no == user.tenant_no, + PlPurchaseCert.del_flag == 0, + PlPurchaseCert.status == 'active', + PlPurchaseCert.customer_id == customer_id, + PlPurchaseCert.category_id.in_(categories), + ) + .order_by(PlPurchaseCert.valid_to.asc(), PlPurchaseCert.id.asc()) + .all() + ) + + chosen_purchase = None + chosen_transport = None + purchase_permit_qty = None + transport_permit_qty = None + + for purchase in candidates: + if not purchase.verified_by or not purchase.verified_time: + continue + if not _has_opening(db, user, 'purchase', purchase.id): + continue + if not _in_force(purchase, on_date): + continue + if purchase.opening_single_use_consumed: + continue + try: + need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit) + except HTTPException: + continue + if _available(purchase) < need: + continue + + if not source.requires_transport: + chosen_purchase = purchase + purchase_permit_qty = need + break + + refs = ( + db.query(PlTransportCertPurchaseRef) + .filter_by( + tenant_no=user.tenant_no, + purchase_cert_id=purchase.id, + del_flag=0, + ) + .all() + ) + transport_cert_ids = [r.transport_cert_id for r in refs if r.transport_cert_id] + if not transport_cert_ids: + continue + transports = ( + db.query(PlTransportCert) + .filter( + PlTransportCert.tenant_no == user.tenant_no, + PlTransportCert.del_flag == 0, + PlTransportCert.status == 'active', + PlTransportCert.customer_id == customer_id, + PlTransportCert.id.in_(transport_cert_ids), + ) + .order_by(PlTransportCert.valid_to.asc(), PlTransportCert.id.asc()) + .all() + ) + for transport in transports: + if not transport.verified_by or not transport.verified_time: + continue + if not _has_opening(db, user, 'transport', transport.id): + continue + if not _in_force(transport, on_date): + continue + if transport.opening_single_use_consumed: + continue + if not _vehicle_ok(transport, vehicle_no): + continue + try: + t_need = _permit_qty_for(Decimal(source.quantity), source.unit, transport.quantity_unit) + except HTTPException: + continue + if _available(transport) < t_need: + continue + chosen_purchase = purchase + chosen_transport = transport + purchase_permit_qty = need + transport_permit_qty = t_need + break + if chosen_purchase and chosen_transport: + break + + if chosen_purchase is None: + place = '同市' if not source.requires_transport else '异地' + raise HTTPException( + 409, + f'{place}段找不到余量足够、有效期内且匹配品类/车号的购买证' + f'{"和运输证" if source.requires_transport else ""},请补充证件或标记证书齐备后再安排', + ) + + purchase_ids.append(chosen_purchase.id) + manuals.append( + ManualQuantity( + permit_kind='purchase', + source_id=source.source_id, + permit_id=chosen_purchase.id, + source_quantity=Decimal(source.quantity), + permit_quantity=purchase_permit_qty, + ) + ) + if chosen_transport is not None: + transport_ids.append(chosen_transport.id) + manuals.append( + ManualQuantity( + permit_kind='transport', + source_id=source.source_id, + permit_id=chosen_transport.id, + source_quantity=Decimal(source.quantity), + permit_quantity=transport_permit_qty, + ) + ) + + def unique(values): + return list(dict.fromkeys(values)) + + return unique(purchase_ids), unique(transport_ids), manuals diff --git a/backend/app/services/pl_quota_planning.py b/backend/app/services/pl_quota_planning.py index 4225ec486..b7c88af7a 100644 --- a/backend/app/services/pl_quota_planning.py +++ b/backend/app/services/pl_quota_planning.py @@ -131,28 +131,52 @@ def build_usage_request(db, user, plan_id, operation, action, *, lock=False, che sources, actuals = [], {} else: sources, actuals = extract_sources(db, user, plan_id, mapping, actual=action in ('settle', 'actual_adjust'), lock=lock, preview_vehicle=preview_vehicle, measurement_version=config.measurement_version) + from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category + customer_rule = plan_uses_customer_category(plan) if action in ('reserve', 'resize') and not explicit and sources: - purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources) - source_ids = {source.source_id for source in sources} - allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids} - manual = [ - row for row in config.manual_allocations - if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed - ] - config = config.model_copy(update={ - 'purchase_cert_ids': purchase_ids, - 'transport_cert_ids': transport_ids, - 'manual_allocations': manual, - 'conditions': [], - }) + if customer_rule: + # 新规则:禁止手工选证;按客户+品类整笔自动选证 + from app.services.pl_quota_customer_select import select_whole_cover_certs + vehicle = (preview_vehicle or {}).get('licensePlate', plan.license_plate) or '' + on_date = max(date.today(), plan.plan_date or date.today()) + purchase_ids, transport_ids, manuals = select_whole_cover_certs( + db, user, sources, vehicle_no=vehicle, on_date=on_date, + ) + config = config.model_copy(update={ + 'purchase_cert_ids': purchase_ids, + 'transport_cert_ids': transport_ids, + 'manual_allocations': manuals, + 'conditions': [], + }) + else: + purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources) + source_ids = {source.source_id for source in sources} + allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids} + manual = [ + row for row in config.manual_allocations + if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed + ] + config = config.model_copy(update={ + 'purchase_cert_ids': purchase_ids, + 'transport_cert_ids': transport_ids, + 'manual_allocations': manual, + 'conditions': [], + }) + elif action in ('reserve', 'resize') and explicit and customer_rule: + raise HTTPException(409, '新规则下安排仅支持自动选证,请勿手工指定证件;选不到请标记证书齐备') keys = [('purchase', cid) for cid in config.purchase_cert_ids] + [('transport', cid) for cid in config.transport_cert_ids] if len(set(keys)) != len(keys): raise HTTPException(400, '证件选择重复') if action in ('reserve', 'resize') or op.allow_supplement: certs = {key: _get_row(db, user, *key, lock=False) for key in sorted(keys)} if sources and not keys: - raise HTTPException(409, '请先在合同中绑定购买证和运输证后再安排运输') - if check_conditions and explicit: + msg = ( + '找不到适用的购买证和运输证,请补充证件或标记证书齐备后再安排运输' + if customer_rule else + '请先在合同中绑定购买证和运输证后再安排运输' + ) + raise HTTPException(409, msg) + if check_conditions and explicit and not customer_rule: validate_conditions(config, certs, plan_fingerprint(sources)) executed = bool(plan_execution_lock_reason(db, user.tenant_no, plan.id)) request = UsageRequest( @@ -201,8 +225,11 @@ def preview_plan(db, user, plan_id, config, preview_vehicle=None): def save_configuration(db, user, plan_id, config): from app.services.pl_plan_service import record_plan_change + from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config) plan = _plan(db, user, plan_id, lock=True) + if plan_uses_customer_category(plan): + raise HTTPException(409, '新规则下安排仅自动选证,不支持手工保存用证配置') if plan.transport_status == 'arranged' or plan.archive_status == 'archived': raise HTTPException(409, '已安排计划请通过调整运输在同一事务重算用证') # 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。 diff --git a/backend/app/services/pl_quota_sources.py b/backend/app/services/pl_quota_sources.py index bc984ae2f..f0f9cef62 100644 --- a/backend/app/services/pl_quota_sources.py +++ b/backend/app/services/pl_quota_sources.py @@ -159,7 +159,9 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False same_city = int(getattr(stop, 'same_city', 0) or 0) == 1 snapshot = {'source': _facts(row), 'related': [_facts(r) for r in related], 'stops': stop_facts, 'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type, - 'sameCity': 1 if same_city else 0} + 'sameCity': 1 if same_city else 0, + 'customerId': getattr(stop, 'customer_id', None), + 'customerName': getattr(stop, 'customer_name', None)} if measurement_version == 'per_stop_v2': snapshot['measurementVersion'] = measurement_version if preview_vehicle is not None: diff --git a/backend/app/services/pl_quota_waiver.py b/backend/app/services/pl_quota_waiver.py index dee8267a1..bee723f0a 100644 --- a/backend/app/services/pl_quota_waiver.py +++ b/backend/app/services/pl_quota_waiver.py @@ -290,13 +290,19 @@ def mark_ready(db, user, plan_id): plan.quota_cert_waived_by = user.id plan.quota_cert_waived_by_name = name[:100] if name else None plan.quota_cert_waived_time = datetime.now() + from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category + if plan_uses_customer_category(plan): + plan.quota_cert_pending_fulfill = 1 + change_content = "标记证书齐备:可安排运输;归档前须在证书齐备中完善并关闭" + else: + change_content = "标记证书齐备:安排和归档不再校验、不扣购买证与运输证额度" plan.update_by = user.id record_plan_change( db, tenant_no=user.tenant_no, plan_id=plan.id, change_type="quota_cert_waived", - change_content="标记证书齐备:安排和归档不再校验、不扣购买证与运输证额度", + change_content=change_content, user_id=user.id, user_name=plan.quota_cert_waived_by_name, dept_id=user.dept_id or "", @@ -304,3 +310,100 @@ def mark_ready(db, user, plan_id): db.flush() labels = _product_names(db, user, [plan.id]) return _row(plan, labels.get(plan.id, "")) + + +def list_pending_fulfill_plans(db, user, page=1, page_size=20, keyword=""): + """新规则下已齐备、待完善挂证的计划。""" + from app.services.pl_cert_quota_rule_config_service import VERSION_CUSTOMER_CATEGORY + + query = db.query(PlPlan).filter( + PlPlan.tenant_no == user.tenant_no, + PlPlan.del_flag == 0, + PlPlan.follow_status != "cancelled", + PlPlan.archive_status != "cancelled", + PlPlan.cert_quota_rule_version == VERSION_CUSTOMER_CATEGORY, + PlPlan.quota_cert_pending_fulfill == 1, + PlPlan.quota_cert_waived == 1, + ) + query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no) + if keyword: + query = query.filter(or_( + PlPlan.plan_no.contains(keyword, autoescape=True), + PlPlan.supplier_name.contains(keyword, autoescape=True), + PlPlan.customer_name.contains(keyword, autoescape=True), + )) + total = query.count() + rows = ( + query.order_by(PlPlan.plan_date.is_(None), PlPlan.plan_date.asc(), PlPlan.id.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + labels = _product_names(db, user, [row.id for row in rows]) + return {"total": total, "list": [_row(plan, labels.get(plan.id, "")) for plan in rows]} + + +def fulfill_plan_certs(db, user, plan_id, *, operation_id=None): + """完善并关闭:按安排同款自动选证并 reserve,成功后出队。""" + from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category + from app.services.pl_plan_service import record_plan_change + from app.services.pl_quota_business import apply_plan_quota + from app.services.pl_quota_planning import _plan + from types import SimpleNamespace + + plan = _plan(db, user, plan_id, lock=True) + if not plan_uses_customer_category(plan): + raise HTTPException(409, '仅新规则计划支持完善并关闭') + if int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) != 1: + raise HTTPException(409, '该计划不在证书齐备待完善列表中') + if plan.follow_status == 'cancelled' or plan.archive_status in ('cancelled', 'archived'): + raise HTTPException(409, '已作废或已归档的计划不能完善证件') + + # 暂时清齐备跳过,以便真正 reserve;失败则恢复 pending + plan.quota_cert_waived = 0 + payload = SimpleNamespace( + operation_id=operation_id or str(__import__('uuid').uuid4()), + quota_configuration=None, + quota_allow_supplement=False, + quota_manual_actuals=[], + quota_reason='fulfill_quota_certs', + quota_evidence_urls=None, + quota_source_note='证书齐备完善并关闭', + ) + try: + action = 'resize' if plan.transport_status == 'arranged' else 'reserve' + apply_plan_quota(db, user, plan, action, payload) + except Exception: + plan.quota_cert_waived = 1 + raise + + plan.quota_cert_pending_fulfill = 0 + plan.quota_cert_waived = 0 + plan.update_by = user.id + record_plan_change( + db, + tenant_no=user.tenant_no, + plan_id=plan.id, + change_type='quota_cert_fulfilled', + change_content='证书齐备完善并关闭:已挂载购买证/运输证并完成额度预占', + user_id=user.id, + user_name=((getattr(user, 'name', None) or '')[:100] or None), + dept_id=user.dept_id or '', + ) + db.flush() + labels = _product_names(db, user, [plan.id]) + return _row(plan, labels.get(plan.id, '')) + + +def fulfill_plans_batch(db, user, plan_ids): + results = [] + for plan_id in plan_ids: + try: + with db.begin_nested(): + row = fulfill_plan_certs(db, user, plan_id) + results.append({'planId': plan_id, 'ok': True, 'plan': row}) + except HTTPException as exc: + results.append({'planId': plan_id, 'ok': False, 'message': str(exc.detail)}) + except Exception as exc: + results.append({'planId': plan_id, 'ok': False, 'message': str(exc)}) + return {'results': results} diff --git a/backend/scripts/backfill_pl_cert_customer_category.py b/backend/scripts/backfill_pl_cert_customer_category.py new file mode 100644 index 000000000..ddeefb7e2 --- /dev/null +++ b/backend/scripts/backfill_pl_cert_customer_category.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""回填购买证/运输证的客户与证用品类(客户+品类额度改造)。 + +对已有证件: + 1. 按证面品名 product_name_on_cert 去重创建 pl_cert_category(已存在同名则复用) + 2. 购买证 category_id ← 与证面品名同名的品类 + 3. 购买证 customer_id ← buyer_name 精确匹配 crm_customer.customer_name + 4. 运输证 customer_id ← 优先非内部客户的收货方 consignee_name,否则 shipper_name + 5. 可选:品类关联系统产品(仅 product_name 与品类名完全一致) + +不自动: + - 模糊归并「硫酸」与「98% 工业硫酸」为同一品类(需人工合并) + - 模糊挂接相近产品名(默认只精确匹配;可用 --link-exact-products) + +用法: + cd backend && source venv/bin/activate + PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --dry-run + PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --apply + PYTHONPATH=. python scripts/backfill_pl_cert_customer_category.py --apply --link-exact-products + +环境:读取 backend/.env 的 DATABASE_URL(可用 ENV_FILE 覆盖路径)。 +""" +from __future__ import annotations + +import argparse +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional, Tuple +from urllib.parse import unquote, urlparse +from uuid import uuid4 + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +INTERNAL_HINTS = ('山峡', '云栋') + + +def load_database_url() -> str: + env_file = os.environ.get('ENV_FILE') or str(BACKEND_ROOT / '.env') + text = Path(env_file).read_text(encoding='utf-8') + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if line.startswith('DATABASE_URL='): + return line.split('=', 1)[1].strip().strip('"').strip("'") + raise SystemExit(f'DATABASE_URL not found in {env_file}') + + +def connect(): + import pymysql + + raw = load_database_url().replace('mysql+pymysql://', 'mysql://', 1) + p = urlparse(raw) + return pymysql.connect( + host=p.hostname or '127.0.0.1', + port=p.port or 3306, + user=unquote(p.username or ''), + password=unquote(p.password or ''), + database=(p.path or '/').lstrip('/'), + charset='utf8mb4', + autocommit=False, + ) + + +def is_internal_name(name: Optional[str]) -> bool: + text = (name or '').strip() + return any(h in text for h in INTERNAL_HINTS) + + +def normalize_name(value: Optional[str]) -> str: + return (value or '').strip() + + +def main() -> int: + parser = argparse.ArgumentParser(description='Backfill cert customer_id / category_id') + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument('--dry-run', action='store_true', help='只打印计划,不写库') + mode.add_argument('--apply', action='store_true', help='写入数据库') + parser.add_argument('--tenant', default='shanxia2.0', help='租户编号') + parser.add_argument( + '--link-exact-products', + action='store_true', + help='把品类名与 CRM 产品名完全一致的产品写入 pl_cert_category_product', + ) + args = parser.parse_args() + apply = bool(args.apply) + tenant = args.tenant + now = datetime.now().replace(microsecond=0) + + conn = connect() + cur = conn.cursor() + try: + # --- load customers --- + cur.execute( + "SELECT id, customer_name FROM crm_customer WHERE tenant_no=%s AND del_flag=0", + (tenant,), + ) + customer_by_name: Dict[str, str] = {} + dup_names = set() + for cid, cname in cur.fetchall(): + key = normalize_name(cname) + if not key: + continue + if key in customer_by_name and customer_by_name[key] != cid: + dup_names.add(key) + else: + customer_by_name[key] = cid + for key in dup_names: + customer_by_name.pop(key, None) + + # --- existing categories --- + cur.execute( + "SELECT id, category_name FROM pl_cert_category WHERE tenant_no=%s AND del_flag=0", + (tenant,), + ) + category_by_name = {normalize_name(n): i for i, n in cur.fetchall()} + + # --- distinct cert face names --- + cur.execute( + """ + SELECT DISTINCT product_name_on_cert FROM ( + SELECT product_name_on_cert FROM pl_purchase_cert + WHERE tenant_no=%s AND del_flag=0 + UNION + SELECT product_name_on_cert FROM pl_transport_cert + WHERE tenant_no=%s AND del_flag=0 + ) t WHERE product_name_on_cert IS NOT NULL AND TRIM(product_name_on_cert) <> '' + """, + (tenant, tenant), + ) + face_names = [normalize_name(r[0]) for r in cur.fetchall() if normalize_name(r[0])] + + print(f'tenant={tenant} apply={apply} face_names={face_names}') + print(f'crm_customers={len(customer_by_name)} ambiguous_names={sorted(dup_names)[:10]}') + + created_categories = [] + for name in face_names: + if name in category_by_name: + continue + cid = str(uuid4()) + created_categories.append((cid, name)) + category_by_name[name] = cid + print(f' [category+] {name} -> {cid}') + if apply: + cur.execute( + """ + INSERT INTO pl_cert_category + (id, category_name, status, tenant_no, create_dept_id, create_time, update_time, del_flag) + VALUES (%s,%s,'1',%s,'',%s,%s,0) + """, + (cid, name, tenant, now, now), + ) + + # --- purchase backfill --- + cur.execute( + """ + SELECT id, cert_no, product_name_on_cert, buyer_name, customer_id, category_id, status + FROM pl_purchase_cert WHERE tenant_no=%s AND del_flag=0 + """, + (tenant,), + ) + purchase_rows = cur.fetchall() + purchase_ok = purchase_miss_customer = purchase_miss_category = 0 + for pid, cert_no, face, buyer, cust_id, cat_id, status in purchase_rows: + face_n = normalize_name(face) + buyer_n = normalize_name(buyer) + new_cat = category_by_name.get(face_n) + new_cust = customer_by_name.get(buyer_n) + updates = {} + if not cat_id and new_cat: + updates['category_id'] = new_cat + if not cust_id and new_cust: + updates['customer_id'] = new_cust + if updates: + print( + f' [purchase] {cert_no} status={status} face={face_n!r} buyer={buyer_n!r} -> {updates}' + ) + if apply: + sets = ', '.join(f'{k}=%s' for k in updates) + cur.execute( + f"UPDATE pl_purchase_cert SET {sets}, update_time=%s WHERE id=%s", + (*updates.values(), now, pid), + ) + purchase_ok += 1 + if not (cust_id or updates.get('customer_id')): + purchase_miss_customer += 1 + print(f' [purchase!] {cert_no} 无法匹配客户 buyer={buyer_n!r}') + if not (cat_id or updates.get('category_id')): + purchase_miss_category += 1 + + # --- transport backfill --- + cur.execute( + """ + SELECT id, cert_no, product_name_on_cert, shipper_name, consignee_name, customer_id, status + FROM pl_transport_cert WHERE tenant_no=%s AND del_flag=0 + """, + (tenant,), + ) + transport_ok = transport_miss = 0 + for tid, cert_no, face, shipper, consignee, cust_id, status in cur.fetchall(): + candidates = [] + consignee_n = normalize_name(consignee) + shipper_n = normalize_name(shipper) + if consignee_n and not is_internal_name(consignee_n): + candidates.append(('consignee', consignee_n)) + if shipper_n and not is_internal_name(shipper_n): + candidates.append(('shipper', shipper_n)) + if not candidates: + if consignee_n: + candidates.append(('consignee', consignee_n)) + elif shipper_n: + candidates.append(('shipper', shipper_n)) + new_cust = None + picked = None + for role, name in candidates: + hit = customer_by_name.get(name) + if hit: + new_cust = hit + picked = (role, name) + break + if not cust_id and new_cust: + print( + f' [transport] {cert_no} status={status} via {picked} -> customer_id={new_cust}' + ) + if apply: + cur.execute( + "UPDATE pl_transport_cert SET customer_id=%s, update_time=%s WHERE id=%s", + (new_cust, now, tid), + ) + transport_ok += 1 + elif not cust_id: + transport_miss += 1 + print( + f' [transport!] {cert_no} 无法匹配客户 consignee={consignee_n!r} shipper={shipper_n!r}' + ) + + # --- optional exact product links --- + product_links = 0 + if args.link_exact_products: + cur.execute( + "SELECT id, product_name FROM crm_product WHERE tenant_no=%s AND del_flag=0", + (tenant,), + ) + products_by_name: Dict[str, list] = {} + for prid, pname in cur.fetchall(): + key = normalize_name(pname) + products_by_name.setdefault(key, []).append(prid) + for cname, category_id in category_by_name.items(): + ids = products_by_name.get(cname) or [] + if len(ids) != 1: + if ids: + print(f' [product?] 品类 {cname!r} 精确匹配到 {len(ids)} 个产品,跳过') + continue + product_id = ids[0] + cur.execute( + """ + SELECT id FROM pl_cert_category_product + WHERE tenant_no=%s AND category_id=%s AND product_id=%s AND del_flag=0 + """, + (tenant, category_id, product_id), + ) + if cur.fetchone(): + continue + print(f' [product+] 品类 {cname!r} <-> product {product_id}') + product_links += 1 + if apply: + cur.execute( + """ + INSERT INTO pl_cert_category_product + (id, category_id, product_id, scope_status, tenant_no, create_dept_id, + create_time, update_time, del_flag) + VALUES (%s,%s,%s,'active',%s,'',%s,%s,0) + """, + (str(uuid4()), category_id, product_id, tenant, now, now), + ) + + print('--- summary ---') + print(f'categories_created={len(created_categories)}') + print(f'purchase_updated={purchase_ok} miss_customer={purchase_miss_customer} miss_category={purchase_miss_category}') + print(f'transport_updated={transport_ok} miss_customer={transport_miss}') + print(f'product_links={product_links}') + if apply: + conn.commit() + print('COMMITTED') + else: + conn.rollback() + print('DRY-RUN (rolled back)') + return 0 + except Exception: + conn.rollback() + raise + finally: + cur.close() + conn.close() + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/backend/scripts/seed_pl_customer_cert_test_data.py b/backend/scripts/seed_pl_customer_cert_test_data.py new file mode 100644 index 000000000..761045c00 --- /dev/null +++ b/backend/scripts/seed_pl_customer_cert_test_data.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""为客户+品类证件额度联调造数(测试库)。 + +造:客户、报价(accepted)、合同(executing)+ 合同产品、 + 2 张购买证(10吨早到期 / 100吨晚到期)、2 张运输证(引用对应购买证)、期初 opening。 + +不造:品类↔系统产品关联(由用户在界面配置)。 + +用法: + cd backend && source venv/bin/activate + PYTHONPATH=. python scripts/seed_pl_customer_cert_test_data.py --dry-run + PYTHONPATH=. python scripts/seed_pl_customer_cert_test_data.py --apply +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import date, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from urllib.parse import unquote, urlparse +from uuid import uuid4 + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +TENANT = 'shanxia2.0' +# 固定前缀,便于识别/清理 +TAG = 'PL-CERT-SEED-20261114' +CUSTOMER_NAME = 'PL证件额度测试客户' +PRODUCT_ID = '339E64E02E7C41F3A04AACCE71E699BE' # 98% 工业硫酸 is_toxic=1 on +OWNER_USER_ID = '02584218205246EEB5F2AC96DF465A9D' +OWNER_NAME = '方金强' +SELLER = '安庆市山峡化工有限公司' +VEHICLE = '皖A9TEST1' + + +def load_url(): + env_file = os.environ.get('ENV_FILE') or str(BACKEND_ROOT / '.env') + for line in Path(env_file).read_text(encoding='utf-8').splitlines(): + if line.startswith('DATABASE_URL='): + return line.split('=', 1)[1].strip().strip('"').strip("'") + raise SystemExit('DATABASE_URL missing') + + +def connect(): + import pymysql + raw = load_url().replace('mysql+pymysql://', 'mysql://', 1) + p = urlparse(raw) + return pymysql.connect( + host=p.hostname, port=p.port or 3306, + user=unquote(p.username or ''), password=unquote(p.password or ''), + database=(p.path or '/').lstrip('/'), charset='utf8mb4', autocommit=False, + ) + + +def uid(suffix: str) -> str: + # 稳定 ID,重复执行可先删再插 + return f'{TAG}-{suffix}' + + +def main() -> int: + parser = argparse.ArgumentParser() + g = parser.add_mutually_exclusive_group(required=True) + g.add_argument('--dry-run', action='store_true') + g.add_argument('--apply', action='store_true') + args = parser.parse_args() + apply = args.apply + now = datetime.now().replace(microsecond=0) + today = date.today() + + ids = { + 'customer': uid('customer'), + 'quote': uid('quote'), + 'quote_product': uid('quote-prod'), + 'contract': uid('contract'), + 'contract_product': uid('contract-prod'), + 'purchase_early': uid('purchase-10t'), + 'purchase_late': uid('purchase-100t'), + 'transport_early': uid('transport-10t'), + 'transport_late': uid('transport-100t'), + 'ref_early': uid('ref-10t'), + 'ref_late': uid('ref-100t'), + 'open_pe': uid('open-p-10t'), + 'open_pl': uid('open-p-100t'), + 'open_te': uid('open-t-10t'), + 'open_tl': uid('open-t-100t'), + } + + conn = connect() + cur = conn.cursor() + try: + # category for 98% 工业硫酸 + cur.execute( + "SELECT id FROM pl_cert_category WHERE tenant_no=%s AND del_flag=0 AND category_name=%s", + (TENANT, '98% 工业硫酸'), + ) + row = cur.fetchone() + if not row: + raise SystemExit('缺少品类「98% 工业硫酸」,请先跑 backfill 或手工创建') + category_id = row[0] + + cur.execute( + "SELECT product_no, product_name, unit, COALESCE(price,1000) FROM crm_product WHERE id=%s AND del_flag=0", + (PRODUCT_ID,), + ) + prod = cur.fetchone() + if not prod: + raise SystemExit(f'产品不存在: {PRODUCT_ID}') + product_no, product_name, unit, price = prod + price = Decimal(price) + qty = Decimal('100') + subtotal = (price * qty).quantize(Decimal('0.01')) + + print('=== seed plan ===') + print(f'customer={CUSTOMER_NAME} id={ids["customer"]}') + print(f'product={product_name} ({PRODUCT_ID}) toxic unit={unit}') + print(f'category_id={category_id}') + print(f'vehicle={VEHICLE}') + print(f'purchase early 10 / late 100 ; transport pair each') + + # cleanup previous seed with same ids + cleanup_tables = [ + ('pl_cert_qty_log', 'id', [ids['open_pe'], ids['open_pl'], ids['open_te'], ids['open_tl']]), + ('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']]), + ('crm_contract_product', 'id', [ids['contract_product']]), + ('crm_contract', 'id', [ids['contract']]), + ('crm_quote_product', 'id', [ids['quote_product']]), + ('crm_quote', 'id', [ids['quote']]), + ('crm_customer', 'id', [ids['customer']]), + ] + for table, col, values in cleanup_tables: + for vid in values: + cur.execute(f"DELETE FROM {table} WHERE {col}=%s", (vid,)) + + # customer + cur.execute( + """ + INSERT INTO crm_customer + (id, customer_no, customer_name, status, deal_status, owner_user_id, owner_user_name, + tenant_no, create_dept_id, create_by, create_time, update_time, del_flag) + VALUES (%s,%s,%s,'normal','undeal',%s,%s,%s,'',%s,%s,%s,0) + """, + (ids['customer'], f'C-{TAG}', CUSTOMER_NAME, OWNER_USER_ID, OWNER_NAME, + TENANT, OWNER_USER_ID, now, now), + ) + print('+ customer') + + # quote + quote_no = f'BJ-{TAG}' + cur.execute( + """ + INSERT INTO crm_quote + (id, quote_no, quote_title, quote_type, customer_id, customer_name, + owner_user_id, version, is_latest, total_amount, final_amount, + quote_date, expire_date, status, tenant_no, create_dept_id, create_by, + create_time, update_time, del_flag) + VALUES (%s,%s,%s,'sales',%s,%s,%s,1,1,%s,%s,%s,%s,'accepted',%s,'',%s,%s,%s,0) + """, + (ids['quote'], quote_no, f'{CUSTOMER_NAME}-硫酸报价', ids['customer'], CUSTOMER_NAME, + OWNER_USER_ID, subtotal, subtotal, today, today + timedelta(days=30), + TENANT, OWNER_USER_ID, now, now), + ) + cur.execute( + """ + INSERT INTO crm_quote_product + (id, quote_id, product_id, product_name, product_no, unit, quantity, price, discount, + subtotal, sort, tenant_no, create_dept_id, create_by, create_time, update_time, del_flag) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,100,%s,1,%s,'',%s,%s,%s,0) + """, + (ids['quote_product'], ids['quote'], PRODUCT_ID, product_name, product_no, unit, + qty, price, subtotal, TENANT, OWNER_USER_ID, now, now), + ) + print('+ quote', quote_no) + + # contract + contract_no = f'HT-{TAG}' + cur.execute( + """ + INSERT INTO crm_contract + (id, contract_no, contract_name, customer_id, customer_name, quote_id, + contract_type, amount, currency, tax_rate, status, supplier_id, + party_a, party_b, start_date, end_date, sign_date, + tenant_no, create_dept_id, create_by, create_time, update_time, del_flag) + VALUES (%s,%s,%s,%s,%s,%s,'sale',%s,'CNY',13,'executing','1',%s,%s,%s,%s,%s, + %s,'',%s,%s,%s,0) + """, + (ids['contract'], contract_no, f'{CUSTOMER_NAME}-购销合同', ids['customer'], CUSTOMER_NAME, + ids['quote'], subtotal, SELLER, CUSTOMER_NAME, today, today + timedelta(days=365), today, + TENANT, OWNER_USER_ID, now, now), + ) + cur.execute( + """ + INSERT INTO crm_contract_product + (id, contract_id, product_id, product_name, product_no, unit, quantity, price, + tax_rate, subtotal, delivery_status, tenant_no, create_dept_id, create_by, + create_time, update_time, del_flag) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,13,%s,'pending',%s,'',%s,%s,%s,0) + """, + (ids['contract_product'], ids['contract'], PRODUCT_ID, product_name, product_no, unit, + qty, price, subtotal, TENANT, OWNER_USER_ID, now, now), + ) + print('+ contract', contract_no, 'product_row', ids['contract_product']) + + def insert_purchase(pid, cert_no, total, valid_to, face='98% 工业硫酸'): + cur.execute( + """ + INSERT INTO pl_purchase_cert + (id, cert_no, issue_date, product_name_on_cert, total_qty, quantity_unit, + valid_from, valid_to, valid_use_text, entry_mode, status, + verified_by, verified_time, reserved_qty, used_qty, opening_single_use_consumed, + revision, buyer_name, seller_name, customer_id, category_id, + tenant_no, create_dept_id, create_by, create_time, update_time, del_flag) + VALUES (%s,%s,%s,%s,%s,'dun',%s,%s,'多次有效','manual','active', + %s,%s,0,0,0,1,%s,%s,%s,%s,%s,'',%s,%s,%s,0) + """, + (pid, cert_no, today, face, Decimal(total), today, valid_to, + OWNER_USER_ID, now, CUSTOMER_NAME, SELLER, ids['customer'], category_id, + TENANT, OWNER_USER_ID, now, now), + ) + op = str(uuid4()) + cur.execute( + """ + INSERT INTO pl_cert_qty_log + (id, operation_id, request_hash, cert_type, cert_id, event_type, quantity_unit, + total_delta, reserved_delta, used_delta, + before_total_qty, after_total_qty, before_reserved_qty, after_reserved_qty, + before_used_qty, after_used_qty, reason, tenant_no, create_dept_id, create_by, + create_time, update_time, del_flag) + VALUES (%s,%s,%s,'purchase',%s,'opening','dun', + 0,0,0,%s,%s,0,0,0,0,'seed opening',%s,'',%s,%s,%s,0) + """, + (str(uuid4()) if pid.endswith('10t') else str(uuid4()), op, op, pid, + Decimal(total), Decimal(total), TENANT, OWNER_USER_ID, now, now), + ) + + # fix opening ids to stable ones + def insert_opening(log_id, cert_type, cert_id, total): + op = log_id + cur.execute( + """ + INSERT INTO pl_cert_qty_log + (id, operation_id, request_hash, cert_type, cert_id, event_type, quantity_unit, + total_delta, reserved_delta, used_delta, + before_total_qty, after_total_qty, before_reserved_qty, after_reserved_qty, + before_used_qty, after_used_qty, reason, tenant_no, create_dept_id, create_by, + create_time, update_time, del_flag) + VALUES (%s,%s,%s,%s,%s,'opening','dun', + 0,0,0,%s,%s,0,0,0,0,'seed opening',%s,'',%s,%s,%s,0) + """, + (log_id, op, op, cert_type, cert_id, Decimal(total), Decimal(total), + TENANT, OWNER_USER_ID, now, now), + ) + + # rewrite purchase insert without nested opening + def insert_purchase2(pid, cert_no, total, valid_to, open_id): + cur.execute( + """ + INSERT INTO pl_purchase_cert + (id, cert_no, issue_date, product_name_on_cert, total_qty, quantity_unit, + valid_from, valid_to, valid_use_text, entry_mode, status, + verified_by, verified_time, reserved_qty, used_qty, opening_single_use_consumed, + revision, buyer_name, seller_name, customer_id, category_id, + tenant_no, create_dept_id, create_by, create_time, update_time, del_flag, remark) + VALUES (%s,%s,%s,'98%% 工业硫酸',%s,'dun',%s,%s,'多次有效','manual','active', + %s,%s,0,0,0,1,%s,%s,%s,%s,%s,'',%s,%s,%s,0,%s) + """, + (pid, cert_no, today, Decimal(total), today, valid_to, + OWNER_USER_ID, now, CUSTOMER_NAME, SELLER, ids['customer'], category_id, + TENANT, OWNER_USER_ID, now, now, TAG), + ) + insert_opening(open_id, 'purchase', pid, total) + + def insert_transport2(tid, cert_no, total, valid_to, open_id, purchase_id, ref_id): + plates = json.dumps([VEHICLE], ensure_ascii=False) + cur.execute( + """ + INSERT INTO pl_transport_cert + (id, cert_no, issue_date, product_name_on_cert, total_qty, quantity_unit, + valid_from, valid_to, valid_use_text, entry_mode, status, + verified_by, verified_time, reserved_qty, used_qty, opening_single_use_consumed, + revision, shipper_name, consignee_name, vehicle_numbers_json, customer_id, + tenant_no, create_dept_id, create_by, create_time, update_time, del_flag, remark) + VALUES (%s,%s,%s,'98%% 工业硫酸',%s,'dun',%s,%s,'多次有效','manual','active', + %s,%s,0,0,0,1,%s,%s,%s,%s,%s,'',%s,%s,%s,0,%s) + """, + (tid, cert_no, today, Decimal(total), today, valid_to, + OWNER_USER_ID, now, SELLER, CUSTOMER_NAME, plates, ids['customer'], + TENANT, OWNER_USER_ID, now, now, TAG), + ) + insert_opening(open_id, 'transport', tid, total) + cur.execute( + """ + INSERT INTO pl_transport_cert_purchase_ref + (id, transport_cert_id, reference_type, reference_no, purchase_cert_id, + tenant_no, create_dept_id, create_by, create_time, update_time, del_flag) + VALUES (%s,%s,'purchase_permit',%s,%s,%s,'',%s,%s,%s,0) + """, + (ref_id, tid, cert_no.replace('Y-', 'G-'), purchase_id, TENANT, OWNER_USER_ID, now, now), + ) + + early_to = today + timedelta(days=30) + late_to = today + timedelta(days=180) + insert_purchase2(ids['purchase_early'], f'G-{TAG}-10T', 10, early_to, ids['open_pe']) + insert_purchase2(ids['purchase_late'], f'G-{TAG}-100T', 100, late_to, ids['open_pl']) + insert_transport2( + ids['transport_early'], f'Y-{TAG}-10T', 10, early_to, ids['open_te'], + ids['purchase_early'], ids['ref_early'], + ) + insert_transport2( + ids['transport_late'], f'Y-{TAG}-100T', 100, late_to, ids['open_tl'], + ids['purchase_late'], ids['ref_late'], + ) + print('+ purchase x2 + transport x2 + opening + refs') + + print('--- ids ---') + for k, v in ids.items(): + print(f'{k}: {v}') + print('--- how to test ---') + print(f'1. 证用品类「98% 工业硫酸」关联产品 {PRODUCT_ID}({product_name})') + print(f'2. 新建运输计划:客户={CUSTOMER_NAME},产品用上述合同行') + print(f'3. 安排车辆请用车号 {VEHICLE}(或把运输证车号改成你的车)') + print('4. 测规则A:计划量 50 吨时,应跳过 10 吨早到期证,选用 100 吨证') + + if apply: + conn.commit() + print('COMMITTED') + else: + conn.rollback() + print('DRY-RUN rolled back') + return 0 + except Exception: + conn.rollback() + raise + finally: + cur.close() + conn.close() + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/backend/tests/test_pl_customer_cert_quota.py b/backend/tests/test_pl_customer_cert_quota.py new file mode 100644 index 000000000..c5b2b5129 --- /dev/null +++ b/backend/tests/test_pl_customer_cert_quota.py @@ -0,0 +1,196 @@ +"""客户+品类证件额度:切点、列表默认过滤、齐备语义、整笔选证。""" +from datetime import date, datetime +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + + +def test_assert_rule_not_configured_blocks_arrange(): + from app.services import pl_cert_quota_rule_config_service as cfg + + db = MagicMock() + db.query.return_value.filter_by.return_value.first.return_value = None + with pytest.raises(HTTPException) as exc: + cfg.assert_cert_quota_rule_configured(db, 't1') + assert exc.value.status_code == 409 + assert '系统信息' in str(exc.value.detail) + + +def test_resolve_rule_version_by_effective_at(): + from app.services import pl_cert_quota_rule_config_service as cfg + + db = MagicMock() + row = SimpleNamespace(effective_at=datetime(2026, 11, 14, 0, 0, 0)) + db.query.return_value.filter_by.return_value.first.return_value = row + assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 13)) == 'legacy' + assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 14)) == 'customer_category' + db.query.return_value.filter_by.return_value.first.return_value = None + assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 20)) == 'legacy' + + +def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch): + from app.services import pl_quota_business as biz + + plan = SimpleNamespace( + id='p1', + quota_cert_waived=1, + quota_cert_pending_fulfill=1, + cert_quota_rule_version='customer_category', + cert_configuration={}, + ) + called = {} + + def fake_assert(*a, **k): + called['assert'] = True + + monkeypatch.setattr('app.services.pl_quota_waiver.assert_waived_sources', fake_assert) + monkeypatch.setattr( + 'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category', + lambda p: True, + ) + + result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'reserve', None) + assert result.get('waived') is True + assert called.get('assert') is True + + with pytest.raises(HTTPException) as exc: + biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'settle', None) + assert '待完善' in str(exc.value.detail) + + +def test_list_certs_default_excludes_closed_void(monkeypatch): + from app.services import pl_quota_cert_service as svc + + class Col: + def __init__(self, name): + self.name = name + + class FakeModel: + __tablename__ = 'pl_purchase_cert' + tenant_no = SimpleNamespace() + del_flag = 0 + status = SimpleNamespace(notin_=lambda *a, **k: 'STATUS_FILTER') + create_time = SimpleNamespace(desc=lambda: 'DESC') + cert_no = seller_name = buyer_name = SimpleNamespace(contains=lambda *a, **k: True) + __table__ = SimpleNamespace(columns=[Col('id'), Col('status')]) + + @staticmethod + def id(): + return 'id' + + class Query: + def __init__(self): + self.filters = [] + + def filter(self, *a, **k): + self.filters.extend(a) + return self + + def filter_by(self, **k): + return self + + def order_by(self, *a): + return self + + def all(self): + return [] + + q = Query() + db = MagicMock() + db.query.return_value = q + monkeypatch.setattr(svc, '_model', lambda t: FakeModel) + monkeypatch.setattr(svc, 'apply_pl_scope', lambda query, *a, **k: query) + monkeypatch.setattr(svc, 'get_cert', lambda *a, **k: {}) + svc.list_certs(db, SimpleNamespace(tenant_no='t'), 'purchase') + assert any(f == 'STATUS_FILTER' for f in q.filters) + + +def test_whole_cover_skips_insufficient_earlier_purchase(): + """规则 A:早到期 10 吨不够则跳过,选用晚到期够量的证。""" + from app.services import pl_quota_customer_select as sel + from app.schemas.pl.quota_usage import UsageSource + + 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('50'), + unit='吨', + fingerprint='x' * 64, + snapshot={'customerId': 'cust1'}, + requires_transport=False, + ) + + early = SimpleNamespace( + id='buy-early', 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('10'), used_qty=Decimal('0'), reserved_qty=Decimal('0'), + quantity_unit='吨', opening_single_use_consumed=0, + ) + late = SimpleNamespace( + id='buy-late', customer_id='cust1', category_id='cat1', status='active', + verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 6, 1), + total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'), + quantity_unit='吨', opening_single_use_consumed=0, + ) + + class PurchaseQuery: + def filter(self, *a, **k): + return self + + def order_by(self, *a, **k): + return self + + def all(self): + return [early, late] + + class LogQuery: + def filter_by(self, **k): + return self + + def first(self): + return SimpleNamespace(id='opening') + + db = MagicMock() + + def query(model): + name = getattr(model, '__name__', str(model)) + if 'Purchase' in name or model is early.__class__: + return PurchaseQuery() + return LogQuery() + + # route by model class name via side_effect + from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog + + def q_side(model): + if model is PlPurchaseCert: + return PurchaseQuery() + if model is PlCertQtyLog: + return LogQuery() + return MagicMock() + + db.query.side_effect = q_side + user = SimpleNamespace(tenant_no='t') + + import app.services.pl_quota_customer_select as mod + orig_cat = mod.category_ids_for_product + mod.category_ids_for_product = lambda db, tenant, pid: {'cat1'} + try: + purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs( + db, user, [source], vehicle_no='皖A12345', on_date=date(2026, 11, 14), + ) + finally: + mod.category_ids_for_product = orig_cat + + assert purchase_ids == ['buy-late'] + assert transport_ids == [] + assert len(manuals) == 1 + assert manuals[0].permit_id == 'buy-late' + assert manuals[0].permit_quantity == Decimal('50') diff --git a/docs/sql/2026-11-14-pl-customer-cert-quota.sql b/docs/sql/2026-11-14-pl-customer-cert-quota.sql new file mode 100644 index 000000000..5bf0885eb --- /dev/null +++ b/docs/sql/2026-11-14-pl-customer-cert-quota.sql @@ -0,0 +1,112 @@ +-- 客户+品类证件额度:证用品类、规则切点、证件/计划扩展字段。 +-- MySQL 5.7。可重复执行。 +-- 回滚思路(勿自动执行):停用新规则配置;新表可 DROP;新增列可 ALTER DROP(上线后有数据时先备份)。 +SET NAMES utf8mb4; +SET @schema_name = DATABASE(); + +-- ---------- 证用品类 ---------- +CREATE TABLE IF NOT EXISTS `pl_cert_category` ( + `id` varchar(64) NOT NULL COMMENT '主键UUID', + `category_name` varchar(128) NOT NULL COMMENT '证用品类名称,如硫酸', + `category_code` varchar(64) DEFAULT NULL COMMENT '品类编码,可选', + `status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0停用', + `tenant_no` varchar(64) NOT NULL COMMENT '租户编号', + `order_num` int DEFAULT NULL COMMENT '排序号', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除', + PRIMARY KEY (`id`), + KEY `idx_cert_category_tenant` (`tenant_no`, `del_flag`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类'; + +CREATE TABLE IF NOT EXISTS `pl_cert_category_product` ( + `id` varchar(64) NOT NULL COMMENT '主键UUID', + `category_id` varchar(64) NOT NULL COMMENT '证用品类ID', + `product_id` varchar(64) NOT NULL COMMENT 'CRM产品ID', + `scope_status` varchar(20) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', + `tenant_no` varchar(64) NOT NULL COMMENT '租户编号', + `order_num` int DEFAULT NULL COMMENT '排序号', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cert_category_product` (`tenant_no`, `category_id`, `product_id`), + KEY `idx_cert_cat_prod_product` (`tenant_no`, `product_id`, `del_flag`, `scope_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类关联系统产品'; + +-- ---------- 规则切点配置(每租户一行) ---------- +CREATE TABLE IF NOT EXISTS `pl_cert_quota_rule_config` ( + `id` varchar(64) NOT NULL COMMENT '主键UUID', + `effective_at` datetime DEFAULT NULL COMMENT '客户品类额度生效时间;空表示未配置,安排将被拦住', + `tenant_no` varchar(64) NOT NULL COMMENT '租户编号', + `order_num` int DEFAULT NULL COMMENT '排序号', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cert_quota_rule_tenant` (`tenant_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='运输证件额度规则切点'; + +-- ---------- 购买证扩展 ---------- +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='customer_id'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_purchase_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER purchase_purpose', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='category_id'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_purchase_cert ADD COLUMN category_id varchar(64) DEFAULT NULL COMMENT ''证用品类ID;选证必填'' AFTER customer_id', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND INDEX_NAME='idx_purchase_cert_customer'); +SET @sql = IF(@idx=0, + 'ALTER TABLE pl_purchase_cert ADD KEY idx_purchase_cert_customer (tenant_no, customer_id, category_id, status, del_flag)', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +-- ---------- 运输证扩展 ---------- +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND COLUMN_NAME='customer_id'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_transport_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER applicant_name', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND INDEX_NAME='idx_transport_cert_customer'); +SET @sql = IF(@idx=0, + 'ALTER TABLE pl_transport_cert ADD KEY idx_transport_cert_customer (tenant_no, customer_id, status, del_flag)', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +-- ---------- 计划规则版本 / 待完善 ---------- +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_rule_version'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_plan ADD COLUMN cert_quota_rule_version varchar(32) DEFAULT NULL COMMENT ''legacy|customer_category;创建时打标'' AFTER quota_cert_waived_time', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='quota_cert_pending_fulfill'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_plan ADD COLUMN quota_cert_pending_fulfill tinyint NOT NULL DEFAULT 0 COMMENT ''新规则证书齐备待完善 0否 1是'' AFTER cert_quota_rule_version', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/docs/sql/migrations/2026-11-14-pl-cert-category-menu-note.sql b/docs/sql/migrations/2026-11-14-pl-cert-category-menu-note.sql new file mode 100644 index 000000000..bf292b1ce --- /dev/null +++ b/docs/sql/migrations/2026-11-14-pl-cert-category-menu-note.sql @@ -0,0 +1,5 @@ +-- 证用品类、运输证件规则菜单说明(路由已挂 web-admin;若侧栏走库表菜单,按租户补插)。 +-- 证用品类:可挂在「运输证件管理」下,path 对齐 /pl/cert-upload/cert-category,权限复用 pl:quota-cert:list / edit。 +-- 运输证件规则:可挂在「系统信息」下,path 对齐 /system/info/cert-quota-rule;保存接口要求管理员。 +-- 本文件仅文档占位,不强制插入,避免破坏现有菜单树。 +SELECT 1; diff --git a/docs/sql/migrations/2026-11-14-pl-customer-cert-quota.sql b/docs/sql/migrations/2026-11-14-pl-customer-cert-quota.sql new file mode 100644 index 000000000..5bf0885eb --- /dev/null +++ b/docs/sql/migrations/2026-11-14-pl-customer-cert-quota.sql @@ -0,0 +1,112 @@ +-- 客户+品类证件额度:证用品类、规则切点、证件/计划扩展字段。 +-- MySQL 5.7。可重复执行。 +-- 回滚思路(勿自动执行):停用新规则配置;新表可 DROP;新增列可 ALTER DROP(上线后有数据时先备份)。 +SET NAMES utf8mb4; +SET @schema_name = DATABASE(); + +-- ---------- 证用品类 ---------- +CREATE TABLE IF NOT EXISTS `pl_cert_category` ( + `id` varchar(64) NOT NULL COMMENT '主键UUID', + `category_name` varchar(128) NOT NULL COMMENT '证用品类名称,如硫酸', + `category_code` varchar(64) DEFAULT NULL COMMENT '品类编码,可选', + `status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0停用', + `tenant_no` varchar(64) NOT NULL COMMENT '租户编号', + `order_num` int DEFAULT NULL COMMENT '排序号', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除', + PRIMARY KEY (`id`), + KEY `idx_cert_category_tenant` (`tenant_no`, `del_flag`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类'; + +CREATE TABLE IF NOT EXISTS `pl_cert_category_product` ( + `id` varchar(64) NOT NULL COMMENT '主键UUID', + `category_id` varchar(64) NOT NULL COMMENT '证用品类ID', + `product_id` varchar(64) NOT NULL COMMENT 'CRM产品ID', + `scope_status` varchar(20) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', + `tenant_no` varchar(64) NOT NULL COMMENT '租户编号', + `order_num` int DEFAULT NULL COMMENT '排序号', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cert_category_product` (`tenant_no`, `category_id`, `product_id`), + KEY `idx_cert_cat_prod_product` (`tenant_no`, `product_id`, `del_flag`, `scope_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='证用品类关联系统产品'; + +-- ---------- 规则切点配置(每租户一行) ---------- +CREATE TABLE IF NOT EXISTS `pl_cert_quota_rule_config` ( + `id` varchar(64) NOT NULL COMMENT '主键UUID', + `effective_at` datetime DEFAULT NULL COMMENT '客户品类额度生效时间;空表示未配置,安排将被拦住', + `tenant_no` varchar(64) NOT NULL COMMENT '租户编号', + `order_num` int DEFAULT NULL COMMENT '排序号', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cert_quota_rule_tenant` (`tenant_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='运输证件额度规则切点'; + +-- ---------- 购买证扩展 ---------- +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='customer_id'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_purchase_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER purchase_purpose', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND COLUMN_NAME='category_id'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_purchase_cert ADD COLUMN category_id varchar(64) DEFAULT NULL COMMENT ''证用品类ID;选证必填'' AFTER customer_id', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_purchase_cert' AND INDEX_NAME='idx_purchase_cert_customer'); +SET @sql = IF(@idx=0, + 'ALTER TABLE pl_purchase_cert ADD KEY idx_purchase_cert_customer (tenant_no, customer_id, category_id, status, del_flag)', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +-- ---------- 运输证扩展 ---------- +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND COLUMN_NAME='customer_id'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_transport_cert ADD COLUMN customer_id varchar(64) DEFAULT NULL COMMENT ''系统客户ID;选证必填'' AFTER applicant_name', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @idx = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_transport_cert' AND INDEX_NAME='idx_transport_cert_customer'); +SET @sql = IF(@idx=0, + 'ALTER TABLE pl_transport_cert ADD KEY idx_transport_cert_customer (tenant_no, customer_id, status, del_flag)', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +-- ---------- 计划规则版本 / 待完善 ---------- +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_rule_version'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_plan ADD COLUMN cert_quota_rule_version varchar(32) DEFAULT NULL COMMENT ''legacy|customer_category;创建时打标'' AFTER quota_cert_waived_time', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='quota_cert_pending_fulfill'); +SET @sql = IF(@col=0, + 'ALTER TABLE pl_plan ADD COLUMN quota_cert_pending_fulfill tinyint NOT NULL DEFAULT 0 COMMENT ''新规则证书齐备待完善 0否 1是'' AFTER cert_quota_rule_version', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md b/docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md new file mode 100644 index 000000000..c694d82c5 --- /dev/null +++ b/docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md @@ -0,0 +1,227 @@ +# 客户+品类证件额度 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `.agents/skills/subagent-driven-development/SKILL.md`(推荐)或 `.agents/skills/executing-plans/SKILL.md` 按任务推进。步骤用 checkbox(`- [ ]`)跟踪。 +> **设计权威:** `docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md` +> **硬性闸门:** 每期全部 checkbox 完成并验证后,必须停下来等用户回复「开始第N期」再继续;未经确认不得进入下一期或 commit(除非用户当轮明确要求提交)。 + +**Goal:** 将运输计划购买证/运输证从合同产品绑定改为客户+证用品类自动选证,并落地可配置切点与齐备完善流程。 + +**Architecture:** 保留 `pl_plan_cert_usage` 账本;新建证用品类与规则配置表;证件增加 `customer_id`/`category_id`;新规则下用证来源按段次外部客户提取,选证改为整笔贪心(到期早且余量够优先);`quota_cert_waived` 仅放行安排,归档与完善走真证 reserve/settle。 + +**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / Element Plus / pnpm;MySQL 5.7;pytest。 + +--- + +## 文件地图(将创建或重点修改) + +| 区域 | 路径 | +| --- | --- | +| SQL | `docs/sql/2026-11-14-pl-customer-cert-quota.sql`(及 migrations 副本若仓库惯例需要) | +| 模型 | `backend/app/models/pl_quota_models.py`、`pl_models.py` | +| 选证/账本 | `backend/app/services/pl_quota_sources.py`、`pl_quota_planning.py`、`pl_quota_allocation.py`、`pl_quota_usage_service.py`、`pl_quota_business.py`、`pl_quota_waiver.py`、新建 `pl_quota_customer_select.py`(建议)、`pl_cert_category_service.py`、`pl_cert_quota_rule_config_service.py` | +| API | `backend/app/api/pl/quota_cert.py`、`transport.py`、`plan.py`;系统配置 API(新建或挂 system) | +| 测试 | `backend/tests/test_pl_quota_*.py` 扩展;新建 `test_pl_customer_cert_quota.py` | +| Web | `web-admin/src/views/pl/` 品类页、quota-cert、证书齐备、系统信息 Tab、计划详情/查看组件;去掉安排手工用证入口 | +| 路由/菜单 | `web-admin/src/router/modules/sqxp.ts`、`pl.ts`;菜单 SQL 若需要 | + +--- + +## 一期:主数据 + 切点配置 + 列表默认过滤 + +### Task 1.1:DDL + +**Files:** +- Create: `docs/sql/2026-11-14-pl-customer-cert-quota.sql` + +- [ ] **Step 1:** 编写可重复执行的 MySQL 5.7 迁移,包含: + - `pl_cert_category` + - `pl_cert_category_product` + - `pl_cert_quota_rule_config`(tenant_no + effective_at + 审计) + - `pl_purchase_cert.customer_id`、`category_id`(可空,兼容历史) + - `pl_transport_cert.customer_id`(可空) + - `pl_plan.cert_quota_rule_version` VARCHAR(可空/默认 legacy) + - `pl_plan.quota_cert_pending_fulfill` TINYINT 默认 0(或设计等价字段) +- [ ] **Step 2:** 字段注释与 AGENTS 字段序一致;索引含 tenant_no + 常用筛选。 +- [ ] **Step 3:** 在设计实现备注中列出回滚思路(DROP/列删除仅文档说明,不自动执行破坏性回滚)。 + +### Task 1.2:模型与品类 CRUD API + +**Files:** +- Modify: `backend/app/models/pl_quota_models.py`、`pl_models.py` +- Create: `backend/app/services/pl_cert_category_service.py` +- Modify: `backend/app/api/pl/quota_cert.py` 或新建 `quota_category.py` 并注册到 `api/__init__.py`、`main.py` +- Create: schemas under `backend/app/schemas/pl/` + +- [ ] **Step 1:** 增加 SQLAlchemy 模型与 Pydantic schema(camelCase alias)。 +- [ ] **Step 2:** 品类 CRUD + 关联产品差量保存(遵守 child-data-save-rules)。 +- [ ] **Step 3:** pytest:创建品类、关联两产品、停用关联、租户隔离。 +- [ ] **Step 4:** 证件 create/update 接受 `customerId`/`categoryId`;运输证品类由引用推导的只读接口字段。 + +### Task 1.3:规则配置 API + 安排门禁(未配置则拦) + +**Files:** +- Create: `backend/app/services/pl_cert_quota_rule_config_service.py` +- Modify: `backend/app/api/pl/transport.py`(arrange 入口最前) +- Modify: 计划创建服务(写入 `cert_quota_rule_version`) + +- [ ] **Step 1:** GET/PUT 当前租户 `effective_at`。 +- [ ] **Step 2:** `assert_cert_quota_rule_configured`:`effective_at is None` → HTTP 409 固定中文文案。 +- [ ] **Step 3:** 计划创建时按设计 §7 打 `cert_quota_rule_version`。 +- [ ] **Step 4:** pytest:未配置安排失败;配置后创建计划版本正确。 + +### Task 1.4:证件列表默认过滤 + +**Files:** +- Modify: `backend/app/services/pl_quota_cert_service.py` list 默认 +- Modify: `web-admin/src/views/pl/quota-cert/` 状态漏斗 + +- [ ] **Step 1:** 列表 API 默认 `status not in (closed, void)`;显式传 status 时以参数为准。 +- [ ] **Step 2:** 前端列头漏斗可选到已核销/作废;默认不选。 +- [ ] **Step 3:** 回归测试或前端约定注释。 + +### Task 1.5:Web 品类页 + 系统信息 Tab + 证件表单字段 + +**Files:** +- Create: `web-admin/src/views/pl/cert-category/index.vue`(或等价路径) +- Modify: `web-admin/src/router/modules/pl.ts`、`sqxp.ts` +- Modify: 证件建档表单(含 H5 `m/pl/plan-cert` 若同表单模型) + +- [ ] **Step 1:** 品类列表/编辑/关联产品(Fresh Mint,选 P 模式并在说明中写明)。 +- [ ] **Step 2:** 系统信息下 Tab「运输证件规则」编辑生效时间。 +- [ ] **Step 3:** 购买证/运输证表单增加客户、品类(运输证展示派生品类)。 +- [ ] **Step 4:** 菜单 SQL/权限码文档化(若需插入菜单,写 SQL 片段)。 + +### Task 1.6:计划详情展示位(可读) + +**Files:** +- Modify: `web-admin/src/components/business/pl-plan-view-tabs/` 或等价详情 +- Modify: 计划详情 API 若需返回 usage 证件摘要 + +- [ ] **Step 1:** 详情区展示已关联购买证/运输证(无数据时占位「暂无」)。 +- [ ] **Step 2:** 同市来源可只显示购买证。 + +### 一期完成门禁 + +- [ ] 本地执行相关 pytest,记录命令与结果。 +- [ ] `pnpm build:typecheck`(若改了 web-admin)或说明未跑原因。 +- [ ] **停止并等待用户:「开始第二期」** + +--- + +## 二期:安排/归档新选证 + 祖父条款 + +### Task 2.1:用证来源按段客户(D6/D7/D8/D9) + +**Files:** +- Modify: `backend/app/services/pl_quota_sources.py` + +- [ ] **Step 1:** 外部装/送段均进入来源(非仅单侧计量吞掉对侧额度需求——按设计 D7);内部山峡/云栋过滤保留。 +- [ ] **Step 2:** `same_city` → `requires_transport=False` 保留。 +- [ ] **Step 3:** 来源快照写入 `customer_id`/`customer_name`。 +- [ ] **Step 4:** pytest 覆盖双外部段、单侧内部、同市。 + +### Task 2.2:整笔贪心选证模块 + +**Files:** +- Create: `backend/app/services/pl_quota_customer_select.py` +- Modify: `pl_quota_allocation.py` 或在 `execute_usage` 分支按 rule_version 调用 + +- [ ] **Step 1:** 实现设计 §5 伪代码;单测:10 吨跳过、100 吨命中;无运输证跳过该购买证;同市无运输证成功。 +- [ ] **Step 2:** 新规则路径禁止拆分到多购买证。 +- [ ] **Step 3:** legacy 计划不走此模块(祖父)。 + +### Task 2.3:替换合同绑证为客户品类候选 + +**Files:** +- Modify: `pl_quota_planning.py`、`pl_quota_business.py`、`pl_quota_usage_service.py` +- Modify: `backend/app/api/pl/transport.py`、前端安排提交 + +- [ ] **Step 1:** `customer_category` 版本:`build_usage_request` 不再 `resolve_contract_bound_certs`。 +- [ ] **Step 2:** 安排失败文案引导证书齐备;移除/隐藏手工 `quotaConfiguration` 安排入口。 +- [ ] **Step 3:** 归档 settle 仍走账本;新规则余额不足文案更新为客户/品类语境。 +- [ ] **Step 4:** legacy:已安排归档不因缺少客户品类绑证失败(单测)。 + +### Task 2.4:合同绑证 UI 收敛 + +**Files:** +- Modify: `web-admin/src/views/pl/contract-quota/` 等 + +- [ ] **Step 1:** 新规则生效后隐藏或只读提示,避免双轨操作。 + +### 二期完成门禁 + +- [ ] 相关 pytest 全绿并记录。 +- [ ] **停止并等待用户:「开始第三期」** + +--- + +## 三期:证书齐备 2.0 + +### Task 3.1:waiver 语义 + +**Files:** +- Modify: `backend/app/services/pl_quota_waiver.py`、`pl_quota_business.py` +- Modify: `backend/app/api/pl/plan.py` archive + +- [ ] **Step 1:** 标记齐备 → `quota_cert_pending_fulfill=1`,安排可跳过 reserve。 +- [ ] **Step 2:** `settle`/`archive`:若 pending 或 waived 未完善 → 409,不得跳过额度。 +- [ ] **Step 3:** pytest:齐备可安排、不可归档;完善后可归档。 + +### Task 3.2:完善并关闭 API + +**Files:** +- Modify: `backend/app/api/pl/plan.py` 或 `quota_cert.py` +- Create service:对单计划执行与安排相同的选证 + reserve + +- [ ] **Step 1:** `POST .../fulfill-quota-certs`(单条)与 batch 接口。 +- [ ] **Step 2:** 成功:写 usage、清 pending、出队;失败:保留 pending + 原因。 +- [ ] **Step 3:** 并发单测或文档化行锁策略。 + +### Task 3.3:证书齐备 Web + +**Files:** +- Modify/Create: 证书齐备列表页(现有 menu 路由对齐) + +- [ ] **Step 1:** 仅列出新规则待完善计划。 +- [ ] **Step 2:** 上传购买证/运输证入口。 +- [ ] **Step 3:** 操作列完善并关闭 + 批量;成功刷新列表。 +- [ ] **Step 4:** 归档弹窗对接「未完善」错误引导。 + +### Task 3.4:详情展示真实挂证 + +- [ ] **Step 1:** 完善或安排成功后详情可见证号/品类/占用等摘要。 + +### 三期完成门禁 + +- [ ] 全链路 pytest + 必要前端 typecheck。 +- [ ] 更新本 plan 所有 checkbox;在设计文档顶部标注「已实现待验收」或按用户要求提交。 +- [ ] **停止并等待用户验收;非用户要求不要 push。** + +--- + +## 数据迁移作业(可穿插在一期后、切点打开前) + +- [ ] 提供只读 SQL/脚本:按 `product_name_on_cert` 分组列出候选品类名。 +- [ ] 文档说明:用户手工建品类并关联产品、回填证件 `customer_id`/`category_id` 后再配置 `effective_at`。 + +## Spec 覆盖自检 + +| 设计条款 | 任务 | +| --- | --- | +| 品类主数据 | 1.1–1.2, 1.5 | +| 客户绑证/解析保留 | 1.2, 1.5 | +| effective_at + 未配置拦安排 | 1.3, 1.5 | +| 列表过滤 closed/void | 1.4 | +| 详情展示 | 1.6, 3.4 | +| 选证 A + 双段扣证 | 2.1–2.3 | +| 无手工选证 | 2.3 | +| legacy 祖父 | 2.3 | +| 齐备只放行安排 | 3.1–3.3 | +| 合法使用证明不动 | 无任务改动(回归时确认) | + +## 执行说明(给 Agent) + +1. 先完整阅读设计文档与本计划,再读 `backend/AGENTS.md`、将改动的端 AGENTS。 +2. 使用 personal-dev 任务分级;每 Task 优先补/改 pytest 再写实现。 +3. 一期未完成不得开始二期逻辑依赖(可先写二期测试草稿但不合并行为)。 +4. 每期结束用简体中文汇报:改了什么、验证命令与结果、未跑项、请用户确认下一期。 diff --git a/docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md b/docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md new file mode 100644 index 000000000..01f9069d9 --- /dev/null +++ b/docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md @@ -0,0 +1,229 @@ +# 运输计划购买证/运输证:客户+品类额度改造设计 + +> 状态:实现中(2026-11-14 本会话已落地核心后端 + 关键 Web;待执行 SQL 与业务验收) +> 关联计划:`docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md` +> 现状参考:`openspec/changes/pl-contract-cert-quota-control/`(合同绑证模型,本需求起新规则不再作为选证来源) + +## 1. 目标与非目标 + +### 目标 + +将易制毒购买证/运输证的适用范围从「合同 + 合同产品」改为「客户 + 证用品类」,安排仅自动选证(整笔不拆证),证书齐备只放行安排、归档必须真证挂载,并用可配置生效时间做历史祖父,降低上线冲击。 + +### 非目标 + +- 不改变易制爆「合法使用证明」现状。 +- 不推倒 `pl_plan_cert_usage` / `pl_cert_qty_log` 核销账本,只改候选来源与门禁。 +- 不复用 CRM `crm_product_category` 作为证用品类。 +- 安排界面不做手工选证。 +- 产品行 `purchase_cert_url` / `transport_cert_url` 附件能力可保留作展示/打包,但**不再**作为安排/归档额度门禁。 + +## 2. 已拍板决策一览 + +| 编号 | 决策 | +| --- | --- | +| D1 | 证绑定客户,不绑定合同/合同产品 | +| D2 | 独立证用品类;购买证绑一个品类;品类 → 多个系统产品 | +| D3 | 运输证绑客户 + 引用购买证;品类 = 引用购买证品类并集去重 | +| D4 | 同客户下所有合同可用该客户证件(仍受品类/有效期/车号/余量约束) | +| D5 | 选证规则 A:单证须整笔盖住本次需求,不够则跳过换下一张到期更晚但够量的;按 `valid_to` 升序 | +| D6 | 按段次外部客户选证:装货段用供应商客户,送货段用收货客户 | +| D7 | 装、送凡外部且需用证的段,各扣各的(可双套) | +| D8 | 安庆山峡、安徽云栋豁免购买证/运输证 | +| D9 | 同市(段次 `same_city=1`)只要求购买证 | +| D10 | 安排只自动选证;选不到只能「证书齐备」;无手工选证 | +| D11 | 证书齐备:可安排;归档不可绕过 | +| D12 | 齐备列表:上传证 → 单条/批量「完善并关闭」(同安排选证)→ 成功出队 | +| D13 | 计划详情展示已关联购买证/运输证 | +| D14 | 解析后自动绑定系统客户:保留 | +| D15 | 证件列表默认不展示 `closed`/`void`,列头漏斗可筛 | +| D16 | 切点:【系统信息】配置 `effective_at`;未配置则拦住安排;计划创建时打规则版本 | +| D17 | 上线前创建计划:不因新规则挡归档;旧齐备不进待完善 | +| D18 | 品类↔产品映射由人工配置;可从证面品名辅助归纳品类 | + +## 3. 现状要点(改造锚点) + +| 能力 | 路径 | 改造动作 | +| --- | --- | --- | +| 合同绑证选证 | `pl_quota_planning.resolve_contract_bound_certs` 等 | 新规则停用为选证源 | +| 用证来源 | `pl_quota_sources.extract_sources` | 按段客户;双侧外部都进来源(对齐 D7);内部客户过滤保留 | +| 分配 | `pl_quota_allocation.allocate_plan` | 改为整笔贪心(D5),不再为新规则做跨证拆分 | +| 安排挂钩 | `pl/transport.py` → `apply_plan_quota(reserve\|resize)` | 切点校验 + 新候选 | +| 归档挂钩 | `pl/plan.py` → `settle_plan_quota` | 齐备不可跳过 settle(新规则) | +| 齐备 | `pl_quota_waiver` | 安排可 waive;归档不可;完善写账本 | +| 证件主数据 | `pl_purchase_cert` / `pl_transport_cert` | 增加客户 ID、品类 ID 等 | + +## 4. 数据模型 + +### 4.1 新建:证用品类 + +**表 `pl_cert_category`** + +- 标准业务字段序:id → 业务字段 → tenant_no、order_num、remark、审计、del_flag +- 业务字段建议:`category_name`(如硫酸)、`category_code`(可选)、`status`(启用/停用) + +**表 `pl_cert_category_product`** + +- `category_id`、`product_id`(CRM 产品)、`scope_status`(active/inactive) +- 租户内 `(category_id, product_id)` 有效行唯一 + +### 4.2 证件主数据扩展 + +**`pl_purchase_cert`** + +- `customer_id`(系统客户,必填后方可 active 参与选证;解析自动匹配保留) +- `category_id`(证用品类,必填后方可参与选证) +- 保留 `product_name_on_cert` 证面原文 + +**`pl_transport_cert`** + +- `customer_id`(系统客户) +- 品类不落冗余主字段亦可:选证时由 `pl_transport_cert_purchase_ref` → 购买证 `category_id` 并集计算;若需列表筛选可冗余缓存并在引用变更时刷新 + +**`pl_transport_cert_purchase_ref`** + +- 继续作为运输证 ↔ 购买证引用;选证要求引用的购买证参与本段配对 + +### 4.3 合同绑证表 + +- `pl_contract_cert_link` / `pl_contract_cert_product`:**新规则选证不再读取** +- 表可保留只读/历史;前端合同绑证入口对新规则租户隐藏或只读提示「已改为客户+品类」 + +### 4.4 计划与切点 + +**系统配置(新建表,推荐 `pl_cert_quota_rule_config` 或租户参数表)** + +- `tenant_no` +- `effective_at`(DATETIME,可空) +- 审计字段 +- 每租户一行(或最新有效一行) + +**`pl_plan` 增加** + +- `cert_quota_rule_version`:`legacy` | `customer_category`(创建时写入;空/null 视为 legacy) +- 可选保留 `quota_cert_waived*`;语义见 §6 +- 建议增加完善标记,例如: + - `quota_cert_pending_fulfill`:1 = 已齐备待完善(新规则) + - 或用「waived=1 且无 settled/reserved 新规则用量」推断;实现时优先显式字段以免歧义 + +### 4.5 用量账本 + +- 继续使用 `pl_plan_cert_usage` / `pl_plan_cert_usage_item` / `pl_cert_qty_log` +- item 上原「合同绑定」维度改为可空或改为记录 `customer_id` + `category_id` 快照;以最小迁移为准,保证对账可追溯 + +## 5. 选证算法(新规则) + +对每个用证来源(段 × 易制毒产品行,外部客户,非豁免): + +``` +需求量 Q,单位 U,客户 C,产品 P,是否同市 S,车号 V,业务日 D + +1. 解析品类:P 落在哪些 active 的 pl_cert_category_product → 得到 category 集合 +2. 候选购买证: + - customer_id=C, category_id∈集合, status=active 且已复核 + - D ∈ [valid_from, valid_to] + - available = total - reserved - used(及一次有效等现有占用规则)≥ Q(同单位;异单位按现有换算/拦截策略) +3. 按 valid_to ASC, id ASC 排序 +4. 依次尝试购买证 B: + - 若 S(同市):选中 B,结束本源 + - 若异地:在「引用了 B」的运输证中找 T: + - customer_id=C(或与业务确认是否允许仅购买证客户一致) + - 车号覆盖 V,有效期与 active/复核,available ≥ Q + - 可按 T.valid_to ASC 取第一张 + - 找不到 T 则跳过 B,试下一张购买证 +5. 若无一整笔覆盖 → 本源失败 → 整单安排失败(或完善失败) +``` + +多产品/多段:每个来源独立跑上述算法;详情与账本按来源挂多证。 + +**禁止**:把 Q 拆到多张购买证(D5)。 + +## 6. 证书齐备 2.0 + +| 动作 | 新规则行为 | +| --- | --- | +| 标记齐备 | 允许在证件不足时安排;释放已有预占(若有);置待完善;**不**写 usage 预占 | +| 安排 | `apply_plan_quota` 对 waived+pending 跳过 reserve(与今类似,但仅安排路径) | +| 归档 | **禁止**因 waived 跳过 settle;若仍 pending 完善 → 409 提示先去证书齐备完善 | +| 完善并关闭 | 按 §5 自动选证并 `reserve`(计划已安排)或等价补记账;成功清除 pending,出列表 | +| 批量完善 | 对勾选行逐单执行;部分成功部分失败,返回明细 | +| 列表范围 | 仅 `cert_quota_rule_version=customer_category` 且待完善的计划;legacy 齐备不进列表 | + +界面:证书齐备页支持上传购买证/运输证(解析绑客户保留)+ 操作列完善并关闭 + 批量。 + +## 7. 切点与门禁 + +``` +arrange / resize: + if effective_at is null: + 409「请先在系统信息中配置客户品类额度生效时间」 + resolve plan.cert_quota_rule_version(已打标用标;未打标且 create_time>=effective_at 视为新规则并补标——仅创建路径应打标,安排时以已存字段为准) + if version == customer_category: + 走 §5;失败可引导证书齐备 + else: + 祖父:不按新客户品类规则拦截归档相关;安排若仍发生,建议对 legacy 保持「不强制新选证」或只读旧行为—— + 【定稿】上线前创建的计划:已安排不挡归档;若 legacy 计划再次调整运输,实现时默认也不强制新规则选证(避免历史单改派被卡死),在设计实现备注中写清并单测覆盖。 +``` + +**系统信息 Tab** + +- 路由挂在现有「系统信息」下(`web-admin/src/router/modules/sqxp.ts` 的 SystemInfo 子级) +- 配置 `effective_at`;保存权限建议管理员 +- 文案说明:未配置将无法安排运输;修改只影响之后新建且按创建打标的计划 + +**计划创建** + +``` +if effective_at and create_time >= effective_at: + cert_quota_rule_version = customer_category +else: + cert_quota_rule_version = legacy +``` + +## 8. 前端范围 + +| 页面/组件 | 变更 | +| --- | --- | +| 证用品类管理 | 新建列表+关联产品 | +| 购买证/运输证建档 | 客户、品类;运输证引用;列表默认排除 closed/void | +| 合同绑证 UI | 新规则下隐藏或只读说明 | +| 安排 | 去掉手工用证配置;失败文案引导齐备 | +| 计划详情/查看 | 展示关联购买证/运输证 | +| 证书齐备 | 列表、上传、完善并关闭、批量 | +| 系统信息 | 新 Tab 配置生效时间 | +| 归档 | 待完善不可归档的提示 | + +H5 `m/pl/plan-cert`:评估与 Web 对齐的上传/建档;完善列表以 Web 为主时可第二期补齐,但不可破坏现有 H5 建档。 + +## 9. 历史数据迁移 + +1. **证件**:从现有证面 `product_name_on_cert` 归纳候选品类名,脚本/SQL 可生成品类草稿;**品类↔产品**由用户在界面手工完成后再开切点。 +2. **客户**:尽量用现有解析/名称匹配回填 `customer_id`;匹配不上保持空,active 选证前必须补齐。 +3. **计划**:不批量改历史 `quota_cert_waived`;不强制历史单进待完善。 +4. **切点**:业务准备好后在系统信息填写 `effective_at`(可等于「今天 0 点」或切换瞬间)。 + +## 10. 分期 + +| 期 | 内容 | 可验收结果 | +| --- | --- | --- | +| 一期 | 品类主数据、证件绑客户/品类、列表默认过滤、系统信息 effective_at、详情展示位(可先读账本) | 未配时间则安排被拦;可维护品类与证件 | +| 二期 | 新选证算法接入安排/归档;legacy 祖父;停用合同选证源 | 新计划按客户品类自动占证/结转 | +| 三期 | 齐备 2.0 列表与完善并关闭;归档强制已完善 | 齐备可安排、完善后可归档 | + +## 11. 验收要点 + +- 同市只扣购买证;异地购买证+运输证;山峡/云栋不扣。 +- 10 吨早到期 + 100 吨晚到期、需求 >10 → 只用 100 吨那套。 +- 硫酸+盐酸两产品 → 两套(或多段)独立挂证。 +- 未配置 effective_at → 安排 409。 +- legacy 已安排 → 可归档不被新规则卡死。 +- 新规则齐备 → 可安排、不可归档;完善成功后可归档且账本有用量。 +- 证件列表默认无已核销/作废。 +- 合法使用证明行为与改前一致。 + +## 12. 风险 + +- D7 双侧扣证可能使额度消耗快于客户直觉,需在界面展示「本段客户/用证」。 +- 改 `effective_at` 若未打标会漂;必须创建打标。 +- 完善批量并发抢同一证余量:逐单事务 + 行锁(沿用现有证件锁)。 +- 分配器从可拆分改为整笔:旧单测需分流 legacy/新规则。 diff --git a/web-admin/src/App.vue b/web-admin/src/App.vue index 59831ab2b..148952776 100644 --- a/web-admin/src/App.vue +++ b/web-admin/src/App.vue @@ -111,7 +111,7 @@ const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list'] const panelTitleBarClass = 'title-bar-heading' - const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度)$/] + const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度|运输证件规则)$/] const panelTitleHeadingSelector = '.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2' diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index 5113e318b..55119539b 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -44,6 +44,8 @@ export interface CertBody { routeText?: string | null consigneeName?: string | null applicantName?: string | null + customerId?: string | null + categoryId?: string | null contractBindings?: CertificateContractBinding[] purchaseRefs?: PurchaseReference[] } @@ -61,6 +63,7 @@ export interface QuotaCert extends Omit { usagePolicy: 'once' | 'multiple' | 'needs_confirmation' createTime?: string certType?: CertType + derivedCategoryIds?: string[] } export interface ScopeProduct { id?: string @@ -393,7 +396,48 @@ export const certReadyApi = { .get(`${base}/plans/cert-ready`, { params }) .then(pickBxData<{ total: number; list: CertReadyPlan[] }>), mark: (id: string) => - request.post(`${base}/plans/${id}/cert-ready`).then(pickBxData) + request.post(`${base}/plans/${id}/cert-ready`).then(pickBxData), + listPendingFulfill: (params: { keyword?: string; page: number; pageSize: number }) => + request + .get(`${base}/plans/cert-ready/pending-fulfill`, { params }) + .then(pickBxData<{ total: number; list: CertReadyPlan[] }>), + fulfill: (id: string) => + request.post(`${base}/plans/${id}/fulfill-quota-certs`).then(pickBxData), + fulfillBatch: (planIds: string[]) => + request + .post(`${base}/plans/fulfill-quota-certs/batch`, { planIds }) + .then(pickBxData<{ results: Array<{ planId: string; ok: boolean; message?: string }> }>) +} + +export const certCategoryApi = { + list: (params?: { keyword?: string; status?: string }) => + request.get(`${base}/categories`, { params }).then(pickBxData), + get: (id: string) => request.get(`${base}/categories/${id}`).then(pickBxData), + create: (body: Record) => + request.post(`${base}/categories`, body).then(pickBxData), + update: (id: string, body: Record) => + request.put(`${base}/categories/${id}`, body).then(pickBxData), + remove: (id: string) => + request.delete(`${base}/categories/${id}`).then(pickBxData<{ ok?: boolean }>) +} + +export interface CertCategory { + id: string + categoryName: string + categoryCode?: string | null + status: string + products?: Array<{ id?: string; productId: string; scopeStatus?: string }> +} + +export const certQuotaRuleApi = { + get: () => + request + .get(`${base}/rule-config`) + .then(pickBxData<{ effectiveAt: string | null; configured: boolean }>), + save: (effectiveAt: string | null) => + request + .put(`${base}/rule-config`, { effectiveAt }) + .then(pickBxData<{ effectiveAt: string | null; configured: boolean }>) } export const planQuotaApi = { 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 26b02791d..697d12f36 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 @@ -71,6 +71,23 @@

暂无车辆信息

+
+

关联购买证 / 运输证

+

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

+
+
+ + {{ item.kind === 'purchase' ? '购买证' : '运输证' }} + + {{ item.certNo || item.certId }} + 预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }} +
+
+

暂无关联证件

+
+

装送货信息

+ >([]) + + async function loadLinkedCerts(planId: string) { + linkedCertRows.value = [] + try { + const { planQuotaApi } = await import('@/api/pl/quota-cert') + const data = await planQuotaApi.get(planId) + linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({ + key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`, + kind: String(u.certType || u.cert_type || ''), + certId: String(u.certId || u.cert_id || ''), + certNo: u.certNo || u.cert_no, + reservedQty: u.reservedQty ?? u.reserved_qty ?? '—', + usedQty: u.usedQty ?? u.used_qty ?? '—' + })) + } catch { + linkedCertRows.value = [] + } + } + async function runLoadDetail(planId: string) { await loadDetail(planId, { initialPlan: props.initialPlan?.id === planId ? props.initialPlan : null, prefetchedArrange: props.prefetchedArrange }) + await loadLinkedCerts(planId) } watch( diff --git a/web-admin/src/router/modules/pl.ts b/web-admin/src/router/modules/pl.ts index de0a3228d..248449c7e 100644 --- a/web-admin/src/router/modules/pl.ts +++ b/web-admin/src/router/modules/pl.ts @@ -68,6 +68,13 @@ export const plRoutes: AppRouteRecord[] = [ component: '/pl/quota-cert/index', meta: { title: '证件建档', keepAlive: false, menuCode: 'pl:quota-cert:list' } }, + { + id: 'PL_CERT_CATEGORY', + name: 'PlCertCategory', + path: 'cert-category', + component: '/pl/cert-category/index', + meta: { title: '证用品类', keepAlive: false, menuCode: 'pl:quota-cert:list' } + }, { id: 'PL_CONTRACT_QUOTA', name: 'PlContractQuota', diff --git a/web-admin/src/router/modules/sqxp.ts b/web-admin/src/router/modules/sqxp.ts index e75572822..04265c846 100644 --- a/web-admin/src/router/modules/sqxp.ts +++ b/web-admin/src/router/modules/sqxp.ts @@ -727,6 +727,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [ menuCode: 'system:dict:list' } }, + { + id: 'SYSM_CERT_QUOTA_RULE', + name: 'CertQuotaRule', + path: 'cert-quota-rule', + component: '/system/cert-quota-rule/index', + meta: { + title: '运输证件规则', + keepAlive: false, + menuCode: 'system:tenant:list' + } + }, { id: '106', name: 'Menu', diff --git a/web-admin/src/views/pl/cert-category/index.vue b/web-admin/src/views/pl/cert-category/index.vue new file mode 100644 index 000000000..4da91a370 --- /dev/null +++ b/web-admin/src/views/pl/cert-category/index.vue @@ -0,0 +1,190 @@ + + + + diff --git a/web-admin/src/views/pl/cert-ready/index.vue b/web-admin/src/views/pl/cert-ready/index.vue index cc6f9a9b3..25b4c662b 100644 --- a/web-admin/src/views/pl/cert-ready/index.vue +++ b/web-admin/src/views/pl/cert-ready/index.vue @@ -4,12 +4,25 @@

证书齐备

+ + 标记齐备 + 待完善并关闭 + + + 批量完善并关闭 + 清除筛选 @@ -29,6 +42,7 @@ row-key="id" stripe class="list-table" + @selection-change="onSelectionChange" > {{ row[column.prop] || '—' }} + @@ -124,6 +155,9 @@ const total = ref(0) const loading = ref(false) const markingId = ref('') + const batching = ref(false) + const listMode = ref<'mark' | 'fulfill'>('mark') + const selectedIds = ref([]) const rows = ref([]) let epoch = 0 const defaultPlanDates = certReadyDefaultPlanDates() @@ -203,13 +237,21 @@ async function load() { const current = ++epoch loading.value = true + selectedIds.value = [] try { - const value = await certReadyApi.list({ - keyword: keyword.value || undefined, - page: page.value, - pageSize: 20, - ...toQueryParams() - }) + const value = + listMode.value === 'fulfill' + ? await certReadyApi.listPendingFulfill({ + keyword: keyword.value || undefined, + page: page.value, + pageSize: 20 + }) + : await certReadyApi.list({ + keyword: keyword.value || undefined, + page: page.value, + pageSize: 20, + ...toQueryParams() + }) if (current !== epoch) return rows.value = value?.list || [] total.value = value?.total || 0 @@ -225,6 +267,11 @@ void load() } + function onModeChange() { + page.value = 1 + void load() + } + function reset() { keyword.value = '' if (hasActiveFilters.value) clearAllFilters() @@ -234,7 +281,7 @@ async function mark(row: CertReadyPlan) { try { await ElMessageBox.confirm( - '确认后,这条运输计划在安排和归档时都不再校验购买证、运输证,也不扣减额度。确认人和时间会记在本行。', + '确认后可先安排运输;新规则下归档前仍须在「待完善并关闭」中挂上购买证/运输证。确认人和时间会记在本行。', '证书齐备', { type: 'warning', confirmButtonText: '确认齐备', cancelButtonText: '取消' } ) @@ -253,6 +300,42 @@ } } + async function fulfillOne(row: CertReadyPlan) { + markingId.value = row.id + try { + await certReadyApi.fulfill(row.id) + ElMessage.success('已完善并关闭') + await load() + } catch (error) { + ElMessage.error(certReadyErrorText(error, '完善并关闭失败')) + } finally { + markingId.value = '' + } + } + + function onSelectionChange(selection: CertReadyPlan[]) { + selectedIds.value = selection.map((row) => row.id) + } + + async function fulfillBatch() { + if (!selectedIds.value.length) return + batching.value = true + try { + const res = await certReadyApi.fulfillBatch(selectedIds.value) + const failed = (res?.results || []).filter((r) => !r.ok) + if (failed.length) { + ElMessage.warning(`部分失败:${failed.map((f) => f.message || f.planId).join(';')}`) + } else { + ElMessage.success('批量完善完成') + } + await load() + } catch (error) { + ElMessage.error(certReadyErrorText(error, '批量完善失败')) + } finally { + batching.value = false + } + } + const tableWrapRef = ref(null) const pagerRef = ref(null) const tableRef = ref() diff --git a/web-admin/src/views/system/cert-quota-rule/index.vue b/web-admin/src/views/system/cert-quota-rule/index.vue new file mode 100644 index 000000000..3369969c5 --- /dev/null +++ b/web-admin/src/views/system/cert-quota-rule/index.vue @@ -0,0 +1,78 @@ + + + +