Compare commits

...
Author SHA1 Message Date
wuzilongandCursor be0ba622a0 fix(pl): 证件额度切点改为按首次安排时间并冻结版本。
切点前已安排的计划归档跳过购买证/运输证校验;创建较早但安排在生效后的计划走客户+品类新规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 19:11:02 +08:00
wuzilongandCursor f865d8ed99 fix(pl): 证用品类差量复用软删关联,并修正归档用证结转与证面品类展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 18:52:33 +08:00
wuzilong 6f20c0ff8a merge: issue #39 安排运输购买证失败提示补全客户与产品。 2026-11-15 16:57:52 +08:00
wuzilongandCursor 14516dd2d0 fix(pl): 安排运输购买证失败提示补全客户名与产品名。
无匹配购买证时点名异地客户与产品,便于对照建档;来源 snapshot 补充展示用产品名且不参与 fingerprint。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 16:57:24 +08:00
wuzilongandCursor 5a8a9048d4 fix(pl): 新规则归档超额按客户选证,并修正用证提示与启用口径。
安排/归档失败改为可执行原因;证件保存后自动启用、只读不再假启用;
customer_category 归档超额改为客户+品类证件池,避免误查合同绑证。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 16:46:09 +08:00
wuzilongandCursor ba62ec7b36 fix(pl): 证书齐备待完善列表将多选列置于首列。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 15:13:14 +08:00
wuzilong 579285a7bf merge: issue #34 归档运费运输量回填修复。
将任务分支合入本地 V3.0,供本机验收测试;未推送远端集成分支。
2026-11-15 15:03:44 +08:00
wuzilongandCursor f41e47601d fix(pl): 归档再次打开时按实际数量回填运费运输量。
resetFreightCalc 在产品已加载后会清空运输量且不再触发 watch,导致再次归档误报运输量须大于0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 15:03:44 +08:00
wuzilongandCursor e0598ed07b fix(pl): 证件建档后自动启用,取消期初确认门槛。
保存即写入期初已用0并置为active;Web/H5仅保留复核入口,存量未确认证在读取或选证时自动补期初。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:53:43 +08:00
wuzilongandCursor 02d576abfe fix(pl): 证书齐备待完善归档不再误出齐备按钮。
归档结转无用量行时保留待完善错误文案,前端按计划齐备状态只提醒完善证件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:51:45 +08:00
wuzilongandCursor 95ecb7236e fix(pl): 计划详情用关联证件附件替换危化品证书区。
Web 与 H5 详情将关联购买证/运输证移到装送货后并以附件卡片展示;用证接口回填证件附件 URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00
wuzilongandCursor ea46761f40 feat(pl): 合同用证改为客户用证并修复列表筛选。
证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00
wuzilong d5f3a79ea5 fix(pl): 新规则安排不再被本地合同绑证缺口误拦。
H5 运输计划验收发现 customer_category 在服务端自动选证已通过时,前端仍用合同绑证空缺拦截安排;同步用证面板从占用回填证号并补专项单测。
2026-11-15 08:22:46 +08:00
wuzilong c5ae2d957c fix(pl): 对齐移动 H5 证书齐备 2.0 与合同用证提示。
H5 齐备页补齐待完善/完善并关闭,修正会误导归档可跳过额度的文案;合同用证增加新规则只读说明,并补相关单测。
2026-11-15 08:04:38 +08:00
wuzilong 9f855423d8 fix(pl): 客户品类规则预占不再依赖合同绑证 scope。
新规则自动选证后账本仍按合同绑证判定适用,导致预占报人工分配不适用;改为客户品类路径跳过 scope,明细合同绑证字段可空,并统一关联购买证文案。
2026-11-15 08:01:14 +08:00
wuzilongandCursor a6fc0aa0f3 fix(pl): 运输证解析回填客户,并将证面引用改名为关联购买证。
运输证按收货单位精确匹配系统客户;Web 与移动 H5 同步改名与回填逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 07:44:08 +08:00
wuzilongandCursor c460386583 fix(pl): 证件解析后回填系统客户与证用品类。
解析结果不再只写旧合同绑定器;按证面名称精确匹配并填入客户与品类下拉。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 07:38:55 +08:00
wuzilongandCursor e8cda33a3c fix(pl): 修复客户品类证件额度选证与回显问题。
优先消耗有到期日的证件,补齐品类产品选择与运输证适用品类回显,避免新规则安排与建档展示失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 07:13:42 +08:00
wuzilongandCursor b0c1bca683 fix(pl): 补齐客户品类证件额度三期收口项。
完善 H5 建档、归档待完善引导、齐备上传入口与新规则只读提示,并同步计划验收状态与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:57:36 +08:00
wuzilongandCursor 208c93d192 fix(pl): 证件建档表单改为客户与品类绑定。
新增系统客户、证用品类字段并写入保存载荷;合同产品绑定收进旧规则折叠区,避免新规则仍被合同绑定挡住。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:38:48 +08:00
wuzilongandCursor 168e909914 fix(web-admin): 恢复 Excel 查看中 Ctrl/Cmd+C 复制选区
filterOnly 默认关闭 WorksheetCopy;打开复制权限并增加选区 TSV 快捷键兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:34:37 +08:00
wuzilongandCursor a327ccf69a fix(web-admin): 修复 Excel 查看因工作表权限未 protect 误报加载失败
工作表 setPoint 须先 protect;表格已渲染成功后权限步骤抛错不再判定为整体加载失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:31:26 +08:00
wuzilongandCursor cd94ebc56d feat(pl): 购买证运输证改为客户品类额度并支持齐备完善。
按客户与证用品类自动选证,未配置生效时间拦住安排;齐备仅放行安排,归档前须完善挂证,并补充迁移与测试造数脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:31:01 +08:00
wuzilongandCursor aad74505c0 feat(web-admin): 列表 Excel 查看改为 Univer 只读在线表格
点击工具栏打开可筛选、复制的只读弹层,替换原行列点选复制模式,且不写回业务数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:25:18 +08:00
wuzilongandCursor 43e240ad6a feat(web-admin): add copy-to-clipboard for attachment images
Let users copy images next to download on shared attachment cards, preview toolbars, and list cells so they can paste into chat or docs without saving files first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:18:34 +08:00
wuzilongandCursor 53987bdf22 fix(web-admin): keep cancelled plan stop feedback and refresh plan detail from API
作废/取消计划仍展示已有分段回传;打开计划详情始终回源,避免列表 seed 滞后。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 21:16:44 +08:00
120 changed files with 11240 additions and 1020 deletions

No files matched your search

+4 -1
View File
@@ -2206,6 +2206,7 @@ def create_plan(
create_by=user.id,
create_dept_id=user.dept_id,
)
# 证件额度规则版本在首次安排时按生效时间打标,创建不打标。
db.add(plan)
# 添加产品明细
@@ -2377,6 +2378,7 @@ def create_plan_full(
create_by=user.id,
create_dept_id=user.dept_id,
)
# 证件额度规则版本在首次安排时按生效时间打标,创建不打标。
db.add(plan)
db.flush()
@@ -2527,6 +2529,7 @@ def create_from_contract(
create_by=user.id,
create_dept_id=user.dept_id,
)
# 证件额度规则版本在首次安排时按生效时间打标,创建不打标。
db.add(plan)
# 查询合同产品明细
@@ -3801,7 +3804,7 @@ def archive_plan(
db.flush()
result = get_plan(id, db, user)
if not quota_settled:
result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id)
result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id, plan=plan)
return result
+119 -1
View File
@@ -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)):
@@ -226,6 +320,23 @@ def contract_statistics_list(keyword: str = Query('', max_length=200),
cert_count=cert_count, cert_count_min=cert_count_min, cert_count_max=cert_count_max))
@router.get('/statistics/customers')
def customer_statistics_list(keyword: str = Query('', max_length=200),
customer_no: str = Query('', alias='customerNo', max_length=200),
customer_name: str = Query('', alias='customerName', max_length=200),
cert_count_op: str = Query('', alias='certCountOp', max_length=20),
cert_count: str = Query('', alias='certCount', max_length=40),
cert_count_min: str = Query('', alias='certCountMin', max_length=40),
cert_count_max: str = Query('', alias='certCountMax', max_length=40),
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)):
return _wire(statistics.list_customers(
db, user, keyword=keyword, page=page, page_size=page_size, customer_no=customer_no,
customer_name=customer_name, cert_count_op=cert_count_op, cert_count=cert_count,
cert_count_min=cert_count_min, cert_count_max=cert_count_max))
@router.get('/statistics/certificates')
def certificate_statistics_list(keyword: str = Query('', max_length=200),
cert_no: str = Query('', alias='certNo', max_length=200),
@@ -284,6 +395,12 @@ def contract_statistics(contract_id: str, db: Session = Depends(get_db),
return _wire(statistics.contract_statistics(db, user, contract_id))
@router.get('/customers/{customer_id}/statistics')
def customer_statistics(customer_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
return _wire(statistics.customer_statistics(db, user, customer_id))
@router.get('/{cert_type}/{cert_id}/statistics')
def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depends(get_db),
user: DbUserContext = Depends(current_user)):
@@ -292,8 +409,9 @@ def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depe
@router.get('/{cert_type}')
def list_certs(cert_type: CertType, keyword: Optional[str] = None, status: Optional[str] = None,
customer_id: Optional[str] = Query(None, alias='customerId', max_length=64),
db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)):
return _wire(certs.list_certs(db, user, cert_type, keyword, status))
return _wire(certs.list_certs(db, user, cert_type, keyword, status, customer_id=customer_id))
@router.post('/{cert_type}/recognize', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))])
+7
View File
@@ -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)
@@ -886,7 +888,10 @@ def arrange_transport(
)
apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
from app.services.pl_quota_planning import pin_measurement_version
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_on_arrange
pin_measurement_version(db, user, plan)
# 首次安排按生效时间打标并冻结;改派不重打。
stamp_plan_rule_on_arrange(db, user, plan)
plan.transport_status = "arranged"
plan.follow_status = "arranged"
plan.freight_amount = freight_amount or 0
@@ -1302,11 +1307,13 @@ def process_approval(
transport.carrier_name = vehicle_info['carrier_name']
snapshot = build_transport_resource_snapshot(db, tenant_no, transport.vehicle_id,
selected_driver_id=transport.driver_id, selected_escort_id=transport.escort_id)
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_on_arrange
for plan in plans:
assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
plan.vehicle_id = transport.vehicle_id
plan.license_plate = transport.license_plate
apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
stamp_plan_rule_on_arrange(db, user, plan)
apply_plan_quota(db, user, plan, "reserve", payload)
plan.transport_status = "arranged"
plan.follow_status = "arranged"
+4 -1
View File
@@ -366,10 +366,13 @@ 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;首次安排时打标并冻结")
cert_quota_arranged_at = Column(DateTime, nullable=True, comment="首次安排时间(证件额度切点用);再次安排不改")
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="作废时间")
+65 -3
View File
@@ -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='系统备注,与证面备注分开')
@@ -196,9 +199,9 @@ class PlPlanCertUsageItem(Base):
__tablename__ = "pl_plan_cert_usage_item"
id = Column(String(64), primary_key=True, comment='主键UUID')
usage_id = Column(String(64), nullable=False, comment='pl_plan_cert_usage.id')
contract_cert_link_id = Column(String(64), nullable=False, comment='合同与证件绑定ID,须与usage的证件相同')
contract_cert_product_id = Column(String(64), nullable=False, comment='本产品的适用范围明细ID:pl_contract_cert_product.id')
contract_scope_revision = Column(Integer, nullable=False, comment='本次确认的合同证件适用范围版本')
contract_cert_link_id = Column(String(64), nullable=True, comment='合同与证件绑定ID;客户品类规则可空')
contract_cert_product_id = Column(String(64), nullable=True, comment='合同证件产品范围明细ID;客户品类规则可空')
contract_scope_revision = Column(Integer, nullable=False, default=0, comment='合同证件适用范围版本;客户品类规则为0')
contract_id = Column(String(64), nullable=False, comment='本次履行的山峡合同ID')
contract_product_id = Column(String(64), nullable=False, comment='对应合同产品行ID')
plan_product_id = Column(String(64), nullable=False, comment='计划产品行ID')
@@ -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'),
)
+4 -1
View File
@@ -413,10 +413,13 @@ 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;首次安排打标")
cert_quota_arranged_at: Optional[datetime] = Field(default=None, alias="certQuotaArrangedAt", description="首次安排时间(证件额度切点)")
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="作废时间")
+3
View File
@@ -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
@@ -0,0 +1,232 @@
"""证用品类与产品关联;差量保存关联行。"""
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()
)
names = _product_labels(db, user.tenant_no, [link.product_id for link in links])
products = [
{
'id': link.id,
'productId': link.product_id,
'scopeStatus': link.scope_status,
**names.get(link.product_id, {}),
}
for link in links
]
return _category_dict(row, products)
def _product_labels(db: Session, tenant_no: str, product_ids):
"""批量带出 CRM 产品编号/名称/型号,供品类编辑回显。"""
ids = [pid for pid in dict.fromkeys(product_ids or []) if pid]
if not ids:
return {}
from app.models.crm_models import CrmProduct
rows = (
db.query(CrmProduct)
.filter(
CrmProduct.tenant_no == tenant_no,
CrmProduct.del_flag == 0,
CrmProduct.id.in_(ids),
)
.all()
)
return {
row.id: {
'productNo': row.product_no,
'productName': row.product_name,
'model': row.model,
}
for row in rows
}
def save_category(db: Session, user, data: dict, category_id=None):
"""data: categoryName, categoryCode?, status?, remark?, products?
products 遗漏则不改关联;显式 [] 清空(软删);带 id 就地改。
"""
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;清空请提交 []')
# uk_cert_category_product 不含 del_flag,软删行仍占唯一键;无 id 再加须复用。
all_links = (
db.query(PlCertCategoryProduct)
.filter_by(tenant_no=user.tenant_no, category_id=row.id)
.with_for_update()
.populate_existing()
.all()
)
existing = {r.id: r for r in all_links if r.del_flag == 0}
by_product = {r.product_id: r for r in all_links}
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 重复')
if pid != link.product_id:
conflict = by_product.get(pid)
if conflict is not None and conflict.id != link.id:
if conflict.del_flag == 0:
raise HTTPException(400, '同一品类不能重复关联同一产品')
# 目标 product_id 被软删行占用:恢复该行并软删当前行
conflict.del_flag = 0
conflict.scope_status = scope
conflict.update_by = user.id
link.del_flag = 1
link.update_by = user.id
seen.add(conflict.id)
continue
if by_product.get(link.product_id) is link:
del by_product[link.product_id]
link.product_id = pid
by_product[pid] = link
seen.add(rid)
link.scope_status = scope
link.update_by = user.id
else:
# 关联表业务键 (category_id, product_id):复用有效或软删行,避免 uk 冲突
link = by_product.get(pid)
if link is not None:
if link.del_flag == 0:
seen.add(link.id)
link.del_flag = 0
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)
by_product[pid] = link
for rid, link in existing.items():
if rid not in seen and link.del_flag == 0:
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}
@@ -0,0 +1,134 @@
"""客户品类额度规则切点:按首次安排时间打标;切点前已安排归档跳过校证。"""
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_arrange(
db: Session, tenant_no: str, arrange_time: Optional[datetime] = None
) -> str:
"""首次安排时打标:安排时刻 >= 生效时间 → 新规则,否则祖父 legacy。"""
effective_at = get_effective_at(db, tenant_no)
when = arrange_time or datetime.now()
if effective_at is not None and when >= effective_at:
return VERSION_CUSTOMER_CATEGORY
return VERSION_LEGACY
# 兼容旧测试/调用名;语义已改为安排时刻。
resolve_rule_version_for_create = resolve_rule_version_for_arrange
def stamp_plan_rule_on_arrange(
db: Session, user, plan, *, arrange_time: Optional[datetime] = None
) -> str:
"""首次安排写入版本与安排时刻;再次安排冻结,不改版本。"""
if getattr(plan, 'cert_quota_arranged_at', None) is not None:
return getattr(plan, 'cert_quota_rule_version', None) or VERSION_LEGACY
when = arrange_time or datetime.now()
version = resolve_rule_version_for_arrange(db, user.tenant_no, when)
plan.cert_quota_arranged_at = when
plan.cert_quota_rule_version = version
return version
def stamp_plan_rule_version(db: Session, user, plan) -> str:
"""兼容旧名:等价于首次安排打标(创建路径不应再调用)。"""
return stamp_plan_rule_on_arrange(db, user, plan)
def plan_uses_customer_category(plan) -> bool:
return (getattr(plan, 'cert_quota_rule_version', None) or VERSION_LEGACY) == VERSION_CUSTOMER_CATEGORY
def infer_arranged_at(db: Session, plan) -> Optional[datetime]:
"""历史缺字段时用关联运输单创建时间推断首次安排。"""
stamped = getattr(plan, 'cert_quota_arranged_at', None)
if stamped is not None:
return stamped
transport_id = getattr(plan, 'transport_id', None)
tenant_no = getattr(plan, 'tenant_no', None)
if not transport_id or not tenant_no or db is None:
return None
from app.models.pl_models import PlTransport
transport = (
db.query(PlTransport)
.filter_by(id=transport_id, tenant_no=tenant_no, del_flag=0)
.first()
)
return getattr(transport, 'create_time', None) if transport else None
def plan_skips_archive_cert_quota(plan, db: Session = None, tenant_no: str = None) -> bool:
"""首次安排早于生效时间则归档跳过校证。
优先比较安排时刻与生效时间;若无生效配置或无法得到安排时刻,则不因空版本自动跳过
(避免误伤仍走合同绑证结转的历史路径;迁移/安排打标会补齐 arranged_at)。
"""
tn = tenant_no or getattr(plan, 'tenant_no', None)
arranged_at = getattr(plan, 'cert_quota_arranged_at', None)
if arranged_at is None and db is not None:
arranged_at = infer_arranged_at(db, plan)
if arranged_at is None:
return False
if db is not None and tn:
effective_at = get_effective_at(db, tn)
if effective_at is not None:
return arranged_at < effective_at
# 已记录首次安排且版本为 legacy(安排时未达生效点)→ 跳过
return not plan_uses_customer_category(plan)
+2
View File
@@ -1166,7 +1166,9 @@ def create_transport_for_plan(
escort_id=escort_id,
)
from app.services.pl_quota_planning import pin_measurement_version
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_on_arrange
pin_measurement_version(db, user, plan)
stamp_plan_rule_on_arrange(db, user, plan)
plan.transport_status = "arranged"
plan.follow_status = "arranged"
plan.freight_amount = freight_amount or 0
@@ -337,7 +337,11 @@ def settle_allocations(reserved: Sequence[Allocation], actual_by_source: Mapping
异单位来源须逐行重新确认全部实际量(含零量行),不按预占比例推算。
超来源量或超任一证件预占量均要求先补充分配。
同市等仅购买证场景下,调用方仍可能对本函数传入空的运输证分组与全量实运量;
无预占行时直接空结转,不把「缺实际量」套到另一类证件上。
"""
if not reserved:
return ()
keys, seqs, grouped = set(), set(), {}
kinds = {r.permit_kind for r in reserved}
if len(kinds) > 1 or kinds - {'purchase', 'transport'}:
+38 -3
View File
@@ -120,10 +120,31 @@ 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_skips_archive_cert_quota,
plan_uses_customer_category,
)
# 新规则:齐备仅放行安排/改派预占;归档与实际调整必须真证结转。
# 切点前首次安排:归档/实调不校证件(无需齐备);齐备仍可跳过安排预占。
skip_actions = ('reserve', 'resize')
if not plan_uses_customer_category(plan):
skip_actions = ('reserve', 'resize', 'settle', 'actual_adjust')
grandfather_archive_skip = (
action in ('settle', 'actual_adjust')
and plan_skips_archive_cert_quota(plan, db, getattr(user, 'tenant_no', None))
)
if grandfather_archive_skip:
return {'operationId': ledger_id, 'usageIds': [], 'waived': True, 'skippedCertQuota': True}
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,
)
@@ -159,26 +180,40 @@ def settle_plan_quota(db, user, plan, payload, *, adjustment=False):
except ArchiveQuotaBlocked as exc:
raise HTTPException(409, str(exc)) from exc
except (HTTPException, ValueError, AllocationIncomplete) as exc:
message = str(getattr(exc, 'detail', exc))
# 无用量行(如证书齐备仅放行安排)时仍保留原因,供同请求读回。
setattr(plan, '_quota_settlement_error', message)
rows = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan.id, del_flag=0).all()
for row in rows:
if not adjustment:
row.usage_status = 'exception'
row.confirmation_snapshot = {**(row.confirmation_snapshot or {}), 'executed': True,
'settlementError': str(getattr(exc, 'detail', exc))}
'settlementError': message}
row.update_by = user.id
if not adjustment:
plan.archive_status = 'exception'
return False
def read_quota_settlement_error(db, tenant_no, plan_id):
def read_quota_settlement_error(db, tenant_no, plan_id, plan=None):
"""结转失败时返回已经写在用量快照上的原因。"""
from app.models.pl_models import PlPlan
from app.models.pl_quota_models import PlPlanCertUsage
if plan is not None:
cached = getattr(plan, '_quota_settlement_error', None)
if isinstance(cached, str) and cached.strip():
return cached.strip()
rows = db.query(PlPlanCertUsage).filter_by(tenant_no=tenant_no, plan_id=plan_id, del_flag=0).all()
for row in rows:
message = (row.confirmation_snapshot or {}).get('settlementError')
if isinstance(message, str) and message.strip():
return message.strip()
target = plan
if target is None:
target = db.query(PlPlan).filter_by(id=plan_id, tenant_no=tenant_no, del_flag=0).first()
if target is not None and int(getattr(target, 'quota_cert_pending_fulfill', 0) or 0) == 1:
return '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档'
return '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。'
@@ -129,7 +129,7 @@ def save_cert(db, user, cert_type, data, cert_id=None):
raise HTTPException(409, '证件合同范围已变更,请重试')
if cert_type == 'transport' and any(('purchase', pid) not in keys for pid in
_reference_ids(db, user, effective_id, refs, current=True)):
raise HTTPException(409, '证面引用已变更,请重试')
raise HTTPException(409, '关联购买证已变更,请重试')
prepared_face = certs.prepare_cert(db, user, cert_type, face, cert_id,
locked=locked, effective_id=effective_id)
# 用最终证面状态校验作废证不可扩大,不修改持久对象。
@@ -155,4 +155,6 @@ def save_cert(db, user, cert_type, data, cert_id=None):
for scope in prepared:
scopes.apply_contract_bindings(db, user, scope)
db.flush()
# 与无合同集合的 save_cert 一致:建档/保存后自动期初并启用;current=True 的读回不会再跑启用。
row = certs.ensure_zero_opening(db, user, cert_type, row.id, row=row)
return get_cert(db, user, cert_type, row.id, current=True)
+256 -11
View File
@@ -9,6 +9,7 @@ from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from app.models.crm_models import CrmCustomer
from app.models.pl_quota_models import (
PlPurchaseCert, PlTransportCert, PlTransportCertPurchaseRef,
PlCertQtyLog, PlPlanCertUsage, PlContractCertLink, PlCertOperation,
@@ -47,6 +48,39 @@ def usage_policy(valid_use_text):
return {'一次有效': 'once', '多次有效': 'multiple'}.get((valid_use_text or '').strip(), 'needs_confirmation')
_ONCE_USAGE_STATUSES = ('reserved', 'in_transit', 'settled', 'exception')
def is_single_use_consumed(db, user, cert_type, cert, *, exclude_plan_id=None):
"""一次有效证是否已不可再用:期初已使用,或其它计划已占用/已执行。
exclude_plan_id:当前计划自身的用量不计入占用(安排/结转同一计划可续用)。
多次有效或待确认原文返回 False,由调用方另行校验 policy。
"""
if usage_policy(getattr(cert, 'valid_use_text', None)) != 'once':
return False
if getattr(cert, 'opening_single_use_consumed', 0):
return True
previous = (
db.query(PlPlanCertUsage)
.filter_by(
tenant_no=user.tenant_no,
cert_type=cert_type,
cert_id=cert.id,
del_flag=0,
)
.all()
)
for usage in previous:
if exclude_plan_id is not None and usage.plan_id == exclude_plan_id:
continue
if usage.usage_status in _ONCE_USAGE_STATUSES:
return True
if (usage.confirmation_snapshot or {}).get('executed'):
return True
return False
def _assert_known_usage_policy(row):
if usage_policy(row.valid_use_text) == 'needs_confirmation':
raise HTTPException(400, '有效次数原文待确认,明确一次或多次有效后才能启用')
@@ -63,8 +97,69 @@ 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 _customer_display_name(db, user, customer_id):
"""按证件绑定的系统客户 ID 取 CRM 名称;与证面收货/购买单位无关。"""
cid = str(customer_id or '').strip()
if not cid:
return None
row = (
db.query(CrmCustomer.customer_name)
.filter(
CrmCustomer.id == cid,
CrmCustomer.tenant_no == user.tenant_no,
CrmCustomer.del_flag == 0,
)
.first()
)
return (row[0] if row else None) or None
def _category_display_names(db, user, category_ids):
"""按证用品类 ID 顺序返回启用中的品类名称;未知 ID 跳过。"""
from app.models.pl_quota_models import PlCertCategory
ids = [str(item or '').strip() for item in (category_ids or []) if str(item or '').strip()]
if not ids:
return []
rows = (
db.query(PlCertCategory.id, PlCertCategory.category_name)
.filter(
PlCertCategory.tenant_no == user.tenant_no,
PlCertCategory.del_flag == 0,
PlCertCategory.id.in_(ids),
)
.all()
)
names = {row.id: (row.category_name or '').strip() for row in rows}
return [names[item] for item in ids if names.get(item)]
def get_cert(db, user, cert_type, cert_id, current=False):
row = _get_row(db, user, cert_type, cert_id, lock=current)
# 只读列表/详情不得在未提交事务里改状态,否则会出现「页面已启用、库仍 draft」。
# 自动期初与启用仅在 save_cert / confirm 等写入路径执行。
result = _row_to_dict(row)
result['usagePolicy'] = usage_policy(row.valid_use_text)
opening_query = db.query(PlCertQtyLog.id).filter_by(
@@ -74,16 +169,28 @@ def get_cert(db, user, cert_type, cert_id, current=False):
opening_query = opening_query.with_for_update()
result['openingConfirmed'] = opening_query.first() is not None
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
result['singleUseConsumed'] = is_single_use_consumed(db, user, cert_type, row)
result['customerName'] = _customer_display_name(db, user, getattr(row, 'customer_id', None))
if cert_type == 'purchase':
names = _category_display_names(db, user, [getattr(row, 'category_id', None)])
result['categoryName'] = names[0] if names else None
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['referencesEditable'] = not _has_usage_beyond_zero_opening(
db, user, cert_type, cert_id, lock=current
)
derived_ids = _transport_derived_categories(db, user, cert_id)
result['derivedCategoryIds'] = derived_ids
result['derivedCategoryNames'] = _category_display_names(db, user, derived_ids)
return result
def list_certs(db, user, cert_type, keyword=None, status=None):
def list_certs(db, user, cert_type, keyword=None, status=None, customer_id=None):
model = _model(cert_type)
query = db.query(model).filter(model.tenant_no == user.tenant_no, model.del_flag == 0)
query = apply_pl_scope(query, model, user, db, user.tenant_no)
if customer_id:
query = query.filter(model.customer_id == customer_id)
if keyword:
sale_name = model.seller_name if cert_type == 'purchase' else model.shipper_name
buy_name = model.buyer_name if cert_type == 'purchase' else model.consignee_name
@@ -94,7 +201,16 @@ 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:
derived_ids = _transport_derived_categories(db, user, item['id'])
item['derivedCategoryIds'] = derived_ids
item['derivedCategoryNames'] = _category_display_names(db, user, derived_ids)
return result
def _audit(user):
@@ -139,8 +255,11 @@ def _refs_keep_verified(pending, removed):
def _has_history(db, user, cert_type, cert_id, lock=True):
"""任意额度流水或计划占用均视为已有历史(含自动期初),用于锁证号/核定量等身份。"""
for model in (PlCertQtyLog, PlPlanCertUsage):
query = db.query(model.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id)
query = db.query(model.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
)
if lock:
query = query.with_for_update()
if query.first() is not None:
@@ -148,6 +267,35 @@ def _has_history(db, user, cert_type, cert_id, lock=True):
return False
def _has_usage_beyond_zero_opening(db, user, cert_type, cert_id, lock=True):
"""真实用量/占用才锁运输证引用;期初已用=0 的 opening 仍可补全关联购买证。"""
usage_q = db.query(PlPlanCertUsage.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
)
if lock:
usage_q = usage_q.with_for_update()
if usage_q.first() is not None:
return True
log_q = db.query(PlCertQtyLog).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
)
if lock:
log_q = log_q.with_for_update()
for log in log_q.all():
snap = log.event_snapshot or {}
# 仅建档自动期初(已用0)不锁引用;手工期初或其它流水仍锁。
if (
log.event_type == 'opening'
and snap.get('auto')
and (log.used_delta or 0) == 0
and (log.reserved_delta or 0) == 0
and (log.total_delta or 0) == 0
):
continue
return True
return False
def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=False):
if submitted is None:
raise HTTPException(400, '引用集合不可为null;清空请提交[]')
@@ -181,13 +329,13 @@ def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=
if purchase_cert_id:
purchase = purchases.get(purchase_cert_id)
if purchase is None:
raise HTTPException(409, '证面引用已变更,请刷新后重新保存')
raise HTTPException(409, '关联购买证已变更,请刷新后重新保存')
if historical and _historical_ref_locked(previous, item):
raise HTTPException(400, '已有历史用量,不能改变证面引用身份,请联系管理员')
raise HTTPException(400, '已有历史用量,不能改变关联购买证身份,请联系管理员')
pending.append((previous, item))
removed = [r for rid,r in old.items() if rid not in ids]
if historical and removed:
raise HTTPException(400, '已有历史用量,不能移除证面引用,请联系管理员')
raise HTTPException(400, '已有历史用量,不能移除关联购买证,请联系管理员')
return pending, removed
@@ -211,7 +359,7 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
purchases = {pid: (locked.get(('purchase', pid)) if locked is not None else
_get_row(db, user, 'purchase', pid, lock=True)) for pid in sorted(purchase_ids)}
if any(row is None for row in purchases.values()):
raise HTTPException(409, '证面引用已变更,请重试')
raise HTTPException(409, '关联购买证已变更,请重试')
row = (locked[(cert_type, cert_id)] if locked is not None else
_get_row(db, user, cert_type, cert_id, lock=True)) if cert_id else None
historical = bool(row and _has_history(db,user,cert_type,cert_id))
@@ -252,7 +400,8 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
if duplicate and duplicate.id != cert_id:
raise HTTPException(400, '证号已存在,删除后也不得重建额度池')
effective_id = cert_id or effective_id or str(uuid4())
pending, removed = _validate_refs(db,user,effective_id,refs,historical,purchases,current=bool(cert_id)) if refs_present else ([],[])
refs_locked = bool(row and _has_usage_beyond_zero_opening(db, user, cert_type, cert_id))
pending, removed = _validate_refs(db,user,effective_id,refs,refs_locked,purchases,current=bool(cert_id)) if refs_present else ([],[])
changed = {key for key, value in values.items() if row is None or getattr(row, key) != value}
refs_changed = bool(removed) or any(previous is None or previous.del_flag == 1 or any(getattr(previous, key) != value for key, value in item.items() if key != 'id') for previous, item in pending)
return dict(model=model, row=row, values=values, cert_id=cert_id, effective_id=effective_id,
@@ -299,19 +448,98 @@ def apply_cert(db, user, prepared):
return row
def ensure_zero_opening(db, user, cert_type, cert_id, *, row=None):
"""建档后无需手工期初确认:缺 opening 流水时写入期初已用=0;draft 直接启用。"""
if row is None:
row = _get_row(db, user, cert_type, cert_id)
if row.status in ('void', 'closed'):
return row
existing = 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()
if existing:
return row
row = _get_row(db, user, cert_type, cert_id, lock=True)
if row.status in ('void', 'closed'):
return row
if 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,
).with_for_update().first():
return row
# 有效次数原文未识别时保留 draft,不自动启用;明确一次/多次后保存或读取再启用。
if usage_policy(row.valid_use_text) == 'needs_confirmation':
return row
used = Decimal(row.used_qty or 0)
op_id = f'auto-opening:{cert_type}:{cert_id}'
digest = hashlib.sha256(op_id.encode()).hexdigest()
operation, replay = _claim_operation(db, user, 'opening', op_id, digest)
if replay:
return row
db.add(PlCertQtyLog(
**_audit(user),
operation_id=op_id,
request_hash=digest,
cert_type=cert_type,
cert_id=cert_id,
event_type='opening',
quantity_unit=row.quantity_unit,
total_delta=Decimal(0),
reserved_delta=Decimal(0),
used_delta=used,
before_total_qty=row.total_qty,
after_total_qty=row.total_qty,
before_reserved_qty=row.reserved_qty,
after_reserved_qty=row.reserved_qty,
before_used_qty=used,
after_used_qty=used,
reason='建档自动启用',
evidence_urls=None,
event_snapshot={'singleUseConsumed': False, 'sourceNote': 'auto', 'auto': True},
))
if row.status == 'draft':
row.status = 'active'
row.verified_by = user.id
row.verified_time = datetime.now()
row.update_by = user.id
operation.result_json = {'certType': cert_type, 'certId': cert_id, 'auto': True}
db.flush()
return row
def save_cert(db, user, cert_type, data, cert_id=None):
row = apply_cert(db, user, prepare_cert(db, user, cert_type, data, cert_id))
ensure_zero_opening(db, user, cert_type, row.id, row=row)
return get_cert(db, user, cert_type, row.id)
def delete_cert(db, user, cert_type, cert_id):
row = _get_row(db,user,cert_type,cert_id,lock=True)
if _has_history(db,user,cert_type,cert_id) or row.reserved_qty or row.used_qty:
if row.reserved_qty or row.used_qty:
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
if db.query(PlPlanCertUsage.id).filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
).with_for_update().first():
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
logs = (
db.query(PlCertQtyLog)
.filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0)
.with_for_update()
.all()
)
# 仅建档自动期初(或无流水)仍允许删除;手工期初或其它额度流水则禁止。
def _auto_opening(log):
snap = log.event_snapshot or {}
return log.event_type == 'opening' and bool(snap.get('auto')) and (log.used_delta or 0) == 0
if any(not _auto_opening(log) for log in logs):
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
if db.query(PlContractCertLink.id).filter_by(tenant_no=user.tenant_no,cert_type=cert_type,cert_id=cert_id,del_flag=0).with_for_update().first():
raise HTTPException(400, '证件已绑定合同')
if cert_type == 'purchase' and db.query(PlTransportCertPurchaseRef.id).filter_by(tenant_no=user.tenant_no,purchase_cert_id=cert_id,del_flag=0).with_for_update().first():
raise HTTPException(400, '证件被运输证引用')
for log in logs:
log.del_flag = 1
log.update_by = user.id
row.del_flag = 1
row.update_by = user.id
if cert_type == 'transport':
@@ -358,7 +586,24 @@ def _confirm_cert(db, user, cert_type, cert_id, data):
_assert_known_usage_policy(row)
if row.status == 'void': raise HTTPException(400, '作废证件不能启用')
if row.status == 'closed': raise HTTPException(400, '已核销证件不能启用')
if row.verified_time or _has_history(db,user,cert_type,cert_id):
# 建档自动期初(used=0)可被手工/接口期初覆盖;已有真实用量或非自动期初则拒绝。
openings = (
db.query(PlCertQtyLog)
.filter_by(
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id,
event_type='opening', del_flag=0,
)
.with_for_update()
.all()
)
for log in openings:
snap = log.event_snapshot or {}
if snap.get('auto') and (log.used_delta or 0) == 0:
log.del_flag = 1
log.update_by = user.id
else:
raise HTTPException(400, '期初已确认,不能重复初始化额度')
if _has_history(db, user, cert_type, cert_id):
raise HTTPException(400, '期初已确认,不能重复初始化额度')
if body.used_qty > row.total_qty:
raise HTTPException(400, '期初已用量不能超过核定量')
@@ -0,0 +1,563 @@
"""新规则:按段次客户+品类整笔选证(不拆购买证)。"""
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_cert_service import is_single_use_consumed
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 _ensure_opening_quiet(db, user, cert_type, cert_id, row):
"""选证前尽量自动补期初;单元测试 mock 库失败时静默跳过。"""
try:
from app.services.pl_quota_cert_service import ensure_zero_opening
ensure_zero_opening(db, user, cert_type, cert_id, row=row)
except Exception:
return
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 _cert_no(cert) -> str:
return str(getattr(cert, 'cert_no', None) or getattr(cert, 'id', '') or '').strip()
def _join_cert_nos(certs) -> str:
return '、'.join(dict.fromkeys(_cert_no(c) for c in certs if _cert_no(c)))
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 _snap_text(source, *keys) -> str:
snap = source.snapshot or {}
for key in keys:
value = snap.get(key)
if value is not None and str(value).strip():
return str(value).strip()
return ''
def _source_customer_label(source) -> str:
return _snap_text(
source, 'customerName', 'customer_name', 'customerNo', 'customer_no'
) or _customer_id_from_source(source)
def _source_product_label(db, user, source) -> str:
name = _snap_text(source, 'productName', 'product_name')
if name:
return name
product_id = getattr(source, 'product_id', None)
if not product_id:
return ''
try:
from app.models.crm_models import CrmProduct
row = (
db.query(CrmProduct)
.filter(
CrmProduct.id == product_id,
CrmProduct.tenant_no == user.tenant_no,
CrmProduct.del_flag == 0,
)
.first()
)
except Exception:
return ''
product_name = getattr(row, 'product_name', None) if row is not None else None
if isinstance(product_name, str) and product_name.strip():
return product_name.strip()
return str(product_id)
def _purchase_candidates(db, user, customer_id, categories):
# MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5)
return (
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.is_(None).asc(),
PlPurchaseCert.valid_to.asc(),
PlPurchaseCert.id.asc(),
)
.all()
)
def _linked_transports(db, user, customer_id, purchase_id):
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:
return []
return (
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.is_(None).asc(),
PlTransportCert.valid_to.asc(),
PlTransportCert.id.asc(),
)
.all()
)
def _diagnose_select_failure(db, user, source, *, vehicle_no: str, on_date: date, candidates):
"""选证失败时按证据优先给出单一可执行原因,避免笼统兜底句。"""
place = '同市' if not source.requires_transport else '异地'
plate = (vehicle_no or '').strip()
need_qty = Decimal(source.quantity)
customer_label = _source_customer_label(source)
product_label = _source_product_label(db, user, source)
if not candidates:
product_part = f'产品「{product_label}」' if product_label else '该产品'
return (
f'{place}段客户「{customer_label}」下没有{product_part}匹配品类的有效购买证,'
'请先建档购买证或核对客户/证用品类后再安排'
)
unverified_buy = []
no_opening_buy = []
expired_buy = []
consumed_buy = []
unit_buy = []
short_buy = []
ready_buy = []
no_transport_link = []
unverified_move = []
no_opening_move = []
expired_move = []
consumed_move = []
vehicle_miss = []
unit_move = []
short_move = []
for purchase in candidates:
if not purchase.verified_by or not purchase.verified_time:
unverified_buy.append(purchase)
continue
_ensure_opening_quiet(db, user, 'purchase', purchase.id, purchase)
if not _has_opening(db, user, 'purchase', purchase.id):
no_opening_buy.append(purchase)
continue
if not _in_force(purchase, on_date):
expired_buy.append(purchase)
continue
if is_single_use_consumed(db, user, 'purchase', purchase):
consumed_buy.append(purchase)
continue
try:
need = _permit_qty_for(need_qty, source.unit, purchase.quantity_unit)
except HTTPException:
unit_buy.append(purchase)
continue
if _available(purchase) < need:
short_buy.append((purchase, need))
continue
if not source.requires_transport:
ready_buy.append(purchase)
continue
transports = _linked_transports(db, user, purchase.customer_id, purchase.id)
if not transports:
no_transport_link.append(purchase)
continue
purchase_had_ready_transport = False
for transport in transports:
if not transport.verified_by or not transport.verified_time:
unverified_move.append(transport)
continue
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
if not _has_opening(db, user, 'transport', transport.id):
no_opening_move.append(transport)
continue
if not _in_force(transport, on_date):
expired_move.append(transport)
continue
if is_single_use_consumed(db, user, 'transport', transport):
consumed_move.append(transport)
continue
if not _vehicle_ok(transport, vehicle_no):
vehicle_miss.append(transport)
continue
try:
t_need = _permit_qty_for(need_qty, source.unit, transport.quantity_unit)
except HTTPException:
unit_move.append(transport)
continue
if _available(transport) < t_need:
short_move.append((transport, t_need))
continue
purchase_had_ready_transport = True
break
if purchase_had_ready_transport:
ready_buy.append(purchase)
if source.requires_transport and not plate:
return (
f'{place}段安排运输须填写车牌号;当前计划未填写车牌,'
'无法匹配运输证车号,请先填写车牌后再安排'
)
if vehicle_miss and not short_move and not unit_move:
nos = _join_cert_nos(vehicle_miss)
listed = '、'.join(
dict.fromkeys(
str(p).strip()
for t in vehicle_miss
for p in (t.vehicle_numbers_json or [])
if str(p).strip()
)
)
suffix = f'(证上现有车号:{listed})' if listed else ''
return (
f'{place}段运输证 {nos} 未包含车牌号「{plate}」{suffix}。'
'请到运输证中补充该车号,或更换已登记该车号的车辆后再安排'
)
if short_move:
parts = []
for cert, need in short_move[:3]:
left = _available(cert)
parts.append(
f'{_cert_no(cert)}剩余{left}{cert.quantity_unit},本次需要{need}{cert.quantity_unit}'
)
return (
f'{place}段运输证{";".join(parts)}。'
'运输证余量不足,请先补充额度或调整计划量后再安排'
)
if short_buy and not ready_buy:
parts = []
for cert, need in short_buy[:3]:
left = _available(cert)
parts.append(
f'{_cert_no(cert)}剩余{left}{cert.quantity_unit},本次需要{need}{cert.quantity_unit}'
)
return (
f'{place}段购买证{";".join(parts)}。'
'购买证余量不足,请先补充额度或调整计划量后再安排'
)
if no_transport_link and not vehicle_miss and not short_move:
nos = _join_cert_nos(no_transport_link)
return (
f'{place}段购买证 {nos} 尚未关联运输证。'
'请打开运输证,在「关联购买证」中选择该购买证后再安排'
)
if expired_move and not vehicle_miss:
nos = _join_cert_nos(expired_move)
return (
f'{place}段运输证 {nos} 不在计划日期 {on_date.isoformat()} 的有效期内,'
'请更换有效运输证后再安排'
)
if expired_buy and not ready_buy:
nos = _join_cert_nos(expired_buy)
return (
f'{place}段购买证 {nos} 不在计划日期 {on_date.isoformat()} 的有效期内,'
'请更换有效购买证后再安排'
)
if unverified_move and not vehicle_miss:
nos = _join_cert_nos(unverified_move)
return f'{place}段运输证 {nos} 尚未复核启用,请先复核后再安排'
if unverified_buy and not ready_buy:
nos = _join_cert_nos(unverified_buy)
return f'{place}段购买证 {nos} 尚未复核启用,请先复核后再安排'
if consumed_move:
nos = _join_cert_nos(consumed_move)
return f'{place}段运输证 {nos} 为一次有效且已使用,请更换证件后再安排'
if consumed_buy and not ready_buy:
nos = _join_cert_nos(consumed_buy)
return f'{place}段购买证 {nos} 为一次有效且已使用,请更换证件后再安排'
if unit_buy or unit_move:
return (
f'{place}段证件单位与计划单位无法自动换算,'
'请调整单位或到「计划段次用证」填写换算后再安排'
)
if no_opening_buy or no_opening_move:
return f'{place}段证件尚未确认期初用量,请先确认期初后再安排'
return (
f'{place}段找不到可用的购买证'
f'{"和运输证" if source.requires_transport else ""},'
'请补充证件或标记证书齐备后再安排'
)
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:
product_label = _source_product_label(db, user, source) or '该产品'
raise HTTPException(
409,
f'产品「{product_label}」未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
)
candidates = _purchase_candidates(db, user, customer_id, categories)
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
_ensure_opening_quiet(db, user, 'purchase', purchase.id, purchase)
if not _has_opening(db, user, 'purchase', purchase.id):
continue
if not _in_force(purchase, on_date):
continue
if is_single_use_consumed(db, user, 'purchase', purchase):
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
transports = _linked_transports(db, user, customer_id, purchase.id)
if not transports:
continue
for transport in transports:
if not transport.verified_by or not transport.verified_time:
continue
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
if not _has_opening(db, user, 'transport', transport.id):
continue
if not _in_force(transport, on_date):
continue
if is_single_use_consumed(db, user, 'transport', transport):
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:
raise HTTPException(
409,
_diagnose_select_failure(
db, user, source, vehicle_no=vehicle_no, on_date=on_date, candidates=candidates,
),
)
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
def resolve_customer_category_cover_certs(db, user, sources):
"""归档超额补扣:按客户+品类收集证件池,不查合同绑证。
与 select_whole_cover_certs 不同,这里不按数量筛证,把客户下匹配品类的
有效购买证及其关联运输证全部纳入候选,由结转阶段结合本计划返还额度分配。
"""
if not sources:
return [], []
purchase_ids = []
transport_ids = []
missing_both = []
missing_purchase = []
missing_transport = []
for source in sources:
customer_id = _customer_id_from_source(source)
snap = source.snapshot or {}
customer_label = (
snap.get('customerName')
or snap.get('customer_name')
or snap.get('customerNo')
or snap.get('customer_no')
or customer_id
)
categories = category_ids_for_product(db, user.tenant_no, source.product_id)
if not categories:
product_label = _source_product_label(db, user, source) or '该产品'
raise HTTPException(
409,
f'产品「{product_label}」未配置证用品类,请先在证用品类中关联系统产品后再归档',
)
purchases = _purchase_candidates(db, user, customer_id, categories)
needs_transport = getattr(source, 'requires_transport', True)
if not purchases:
(missing_both if needs_transport else missing_purchase).append(str(customer_label))
continue
purchase_ids.extend(p.id for p in purchases)
if not needs_transport:
continue
linked = []
for purchase in purchases:
linked.extend(_linked_transports(db, user, customer_id, purchase.id))
if not linked:
missing_transport.append(str(customer_label))
continue
transport_ids.extend(t.id for t in linked)
messages = []
if missing_both:
names = '、'.join(dict.fromkeys(missing_both))
messages.append(
f'客户{names}下尚未建档匹配品类的购买证和运输证,请先建档证件后再归档'
)
if missing_purchase:
names = '、'.join(dict.fromkeys(missing_purchase))
messages.append(
f'客户{names}下尚未建档匹配品类的购买证,请先建档证件后再归档'
)
if missing_transport:
names = '、'.join(dict.fromkeys(missing_transport))
messages.append(
f'客户{names}下购买证尚未关联可用运输证,请先关联运输证后再归档'
)
if messages:
raise HTTPException(409, ';'.join(messages))
def unique(values):
return list(dict.fromkeys(values))
return unique(purchase_ids), unique(transport_ids)
+1 -1
View File
@@ -89,7 +89,7 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos):
seen.add(key)
notices.append(
f'运输证 {move_text} 没有引用合同正在使用的购买证 {buy_text}。'
'请先打开该运输证,在「证面引用」的「对应购买证档案」中选择这张购买证,再安排运输'
'请先打开该运输证,在「关联购买证」中选择这张购买证档案,再安排运输'
)
return ';'.join(notices)
+60 -19
View File
@@ -131,35 +131,61 @@ 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 = str(
(preview_vehicle or {}).get('licensePlate', plan.license_plate) or ''
).strip()
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(
operation_id=op.operation_id, action=action, plan_id=plan_id, sources=sources,
allow_supplement=op.allow_supplement,
purchase_cert_ids=config.purchase_cert_ids, transport_cert_ids=config.transport_cert_ids,
vehicle_no=(preview_vehicle or {}).get('licensePlate', plan.license_plate) or '', on_date=max(date.today(), plan.plan_date or date.today()),
vehicle_no=str((preview_vehicle or {}).get('licensePlate', plan.license_plate) or '').strip(), on_date=max(date.today(), plan.plan_date or date.today()),
executed=executed, manual_allocations=[UsageManual(**r.model_dump()) for r in config.manual_allocations],
actual_by_source=actuals, manual_actuals=[UsageManual(**r.model_dump()) for r in op.manual_actuals],
reason=op.reason, evidence_urls=op.evidence_urls or '', source_note=op.source_note or '',
@@ -201,8 +227,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, '已安排计划请通过调整运输在同一事务重算用证')
# 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。
@@ -308,15 +337,27 @@ def get_plan_quota(db, user, plan_id):
options.append(item)
usages = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan_id, del_flag=0).order_by(PlPlanCertUsage.create_time.desc()).all()
version = resolve_measurement_version(db, user, plan)
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
result = {'planId': plan.id, 'planNo': plan.plan_no, 'licensePlate': plan.license_plate,
'quotaCertWaived': int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1,
'certQuotaRuleVersion': getattr(plan, 'cert_quota_rule_version', None) or 'legacy',
'measurementVersion': version,
'configuration': {**(plan.cert_configuration or {}), 'measurementVersion': version}, 'sourceOptions': options,
'stopProducts': stop_products,
'canSaveConfiguration': plan.transport_status != 'arranged' and plan.archive_status != 'archived',
# 新规则禁止手工保存用证配置;安排仅自动选证
'canSaveConfiguration': (
not customer_rule
and plan.transport_status != 'arranged'
and plan.archive_status != 'archived'
),
'usages': []}
for usage in usages:
_get_row(db, user, usage.cert_type, usage.cert_id)
cert = _get_row(db, user, usage.cert_type, usage.cert_id)
items = db.query(PlPlanCertUsageItem).filter_by(tenant_no=user.tenant_no, usage_id=usage.id, del_flag=0).order_by(PlPlanCertUsageItem.allocation_seq).all()
result['usages'].append({**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]})
payload = {**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]}
# 详情展示证面附件:用量行本身无附件字段,从证件档案回填
payload['attachmentUrls'] = getattr(cert, 'attachment_urls', None) or ''
payload['certNo'] = payload.get('certNo') or getattr(cert, 'cert_no', None) or usage.cert_no_snapshot
result['usages'].append(payload)
return result
+7 -1
View File
@@ -159,13 +159,19 @@ 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:
snapshot['vehicle'] = preview_vehicle.get('licensePlate', plan.license_plate)
snapshot['trailer'] = preview_vehicle.get('trailerNo', plan.trailer_no)
fingerprint = hashlib.sha256(json.dumps(snapshot, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
# 展示字段不参与 fingerprint,避免仅补产品名就触发来源指纹变化
product_name = getattr(row, 'product_name', None)
if product_name is not None and str(product_name).strip():
snapshot = {**snapshot, 'productName': str(product_name).strip()}
source = UsageSource(source_stop_type=side, source_stop_id=stop.id, source_stop_product_id=row.id,
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
contract_product_id=row.contract_product_id, product_id=row.product_id,
+107 -2
View File
@@ -4,12 +4,12 @@ from decimal import Decimal
from sqlalchemy import and_, or_, literal, func
from fastapi import HTTPException
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope
from app.models.crm_models import CrmContract, CrmContractProduct
from app.models.crm_models import CrmContract, CrmContractProduct, CrmCustomer
from app.models.pl_models import PlPlan
from app.models.pl_quota_models import (PlContractCertLink as Link, PlContractCertProduct as Scope,
PlPlanCertUsage as Usage, PlPlanCertUsageItem as Item, PlCertQtyLog as Log)
from app.services.pl_data_scope import apply_pl_scope
from app.services.pl_quota_cert_service import MODELS, _get_row
from app.services.pl_quota_cert_service import MODELS, _get_row, get_cert, usage_policy
ZERO = Decimal('0')
CONTRACT_TYPES = {'sale', 'purchase', 'framework_sale', 'framework_purchase', 'framework'}
@@ -109,6 +109,39 @@ def _contracts(db, user):
user.to_user_info(), db, user.tenant_no)
def _customers(db, user):
return apply_crm_owner_scope(_active(db, user, CrmCustomer), CrmCustomer,
user.to_user_info(), db, user.tenant_no)
def _customer_cert_counts(db, user):
"""按客户汇总购买证+运输证数量;默认排除已核销/作废,与证件列表一致。"""
parts = []
for model in MODELS.values():
query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no)
query = query.filter(
model.customer_id.isnot(None),
model.customer_id != '',
model.status.notin_(('closed', 'void')),
)
parts.append(query.with_entities(model.customer_id.label('customer_id'), literal(1).label('one')))
combined = parts[0].union_all(*parts[1:]).subquery()
return (
db.query(combined.c.customer_id.label('customer_id'), func.count().label('cert_count'))
.group_by(combined.c.customer_id)
.subquery()
)
def _customer_summary(row):
return {
'id': row.id,
'customerId': row.id,
'customerNo': row.customer_no or '',
'customerName': row.customer_name or '',
}
def _certificates(db, user, keyword=None, cert_type=None, cert_no=None, cert_types=None):
queries = []
for kind, model in MODELS.items():
@@ -351,6 +384,78 @@ def contract_statistics(db, user, contract_id):
return {**_contract_summary(contract), 'certificates': result}
def list_customers(db, user, keyword=None, page=1, page_size=20, customer_no=None, customer_name=None,
cert_count_op=None, cert_count=None, cert_count_min=None, cert_count_max=None):
counts = _customer_cert_counts(db, user)
query = _customers(db, user).join(counts, counts.c.customer_id == CrmCustomer.id)
if keyword:
query = query.filter(or_(
CrmCustomer.customer_no.contains(keyword, autoescape=True),
CrmCustomer.customer_name.contains(keyword, autoescape=True),
CrmCustomer.short_name.contains(keyword, autoescape=True),
))
if _text(customer_no):
query = query.filter(CrmCustomer.customer_no.contains(_text(customer_no), autoescape=True))
if _text(customer_name):
query = query.filter(CrmCustomer.customer_name.contains(_text(customer_name), autoescape=True))
for clause in _number_clauses(counts.c.cert_count, cert_count_op, cert_count, cert_count_min, cert_count_max):
query = query.filter(clause)
total = query.count()
rows = query.with_entities(CrmCustomer, counts.c.cert_count).order_by(
CrmCustomer.create_time.desc(), CrmCustomer.id
).offset((page - 1) * page_size).limit(page_size).all()
return {
'list': [{**_customer_summary(customer), 'certCount': int(count or 0)} for customer, count in rows],
'total': total,
'page': page,
'pageSize': page_size,
}
def customer_statistics(db, user, customer_id):
customer = _customers(db, user).filter(CrmCustomer.id == customer_id).first()
if customer is None:
raise HTTPException(404, '客户不存在或无权访问')
certificates = []
for kind, model in MODELS.items():
query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no)
query = query.filter(
model.customer_id == customer_id,
model.status.notin_(('closed', 'void')),
)
for row in query.order_by(model.create_time.desc(), model.id).all():
item = get_cert(db, user, kind, row.id)
sale = item.get('sellerName') if kind == 'purchase' else item.get('shipperName')
buy = item.get('buyerName') if kind == 'purchase' else item.get('consigneeName')
certificates.append({
'id': row.id,
'certId': row.id,
'certType': kind,
'certNo': item.get('certNo') or '',
'productNameOnCert': item.get('productNameOnCert') or '',
'quantityUnit': item.get('quantityUnit') or '',
'totalQty': item.get('totalQty'),
'reservedQty': item.get('reservedQty'),
'usedQty': item.get('usedQty'),
'availableQty': item.get('availableQty'),
'status': item.get('status') or '',
'validFrom': item.get('validFrom'),
'validTo': item.get('validTo'),
'salePartyName': sale or '',
'buyPartyName': buy or '',
'validUseText': item.get('validUseText') or '',
'usagePolicy': item.get('usagePolicy') or usage_policy(row.valid_use_text),
'singleUseConsumed': bool(item.get('singleUseConsumed')),
'openingConfirmed': bool(item.get('openingConfirmed')),
'createTime': item.get('createTime'),
})
certificates.sort(
key=lambda row: (str(row.get('createTime') or ''), row.get('certNo') or ''),
reverse=True,
)
return {**_customer_summary(customer), 'certificates': certificates}
def certificate_statistics(db, user, cert_type, cert_id):
_get_row(db, user, cert_type, cert_id)
certs = _certificates(db, user, cert_type=cert_type)
+182 -48
View File
@@ -16,7 +16,7 @@ from app.models.pl_models import PlPlan
from app.models.crm_models import CrmContractProduct
from app.schemas.pl.quota_usage import UsageManual, UsageRequest
from app.services.pl_data_scope import assert_pl_row_in_scope
from app.services.pl_quota_cert_service import _audit, _get_row, usage_policy
from app.services.pl_quota_cert_service import _audit, _get_row, is_single_use_consumed, usage_policy
from app.services.pl_quota_contract_service import _contract
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation,
allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota)
@@ -26,6 +26,26 @@ from app.services.pl_quota_units import same_quantity_unit, standard_conversion_
ZERO = Decimal('0')
def _normalize_vehicle_no(vehicle_no) -> str:
return str(vehicle_no or '').strip()
def _vehicle_on_cert(cert, vehicle_no) -> bool:
"""计划车牌与证上车号比对时两侧都去空白,避免尾部空格误判。"""
plate = _normalize_vehicle_no(vehicle_no)
if not plate:
return False
plates = getattr(cert, 'vehicle_numbers_json', None) or []
if isinstance(plates, str):
try:
plates = json.loads(plates)
except Exception:
plates = [plates]
if not isinstance(plates, list):
return False
return plate in {str(p).strip() for p in plates if p is not None and str(p).strip()}
def _reserve_input_mode(source_unit, cert_unit):
if same_quantity_unit(source_unit, cert_unit):
return 'same_unit'
@@ -131,9 +151,8 @@ def _execute(db,user,r,digest,plan):
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
keys=selected|{(u.cert_type,u.cert_id) for u in old_rows}
if r.action in ('settle', 'actual_adjust') and not r.allow_supplement and r.sources:
from app.services.pl_quota_planning import resolve_contract_bound_certs
try:
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, r.sources)
purchase_ids, transport_ids = _settle_cover_cert_ids(db, user, plan, r.sources)
except HTTPException:
purchase_ids, transport_ids = [], []
keys |= {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
@@ -198,8 +217,12 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
if product is None or product.product_id!=s.product_id: raise ValueError('合同产品归属不符')
scopes={}; candidates={'purchase':[],'transport':[]}
skipped_for_vehicle=False
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
from app.services.pl_quota_cert_service import ensure_zero_opening
for k in sorted(selected):
kind,pid=k; c=certs[k]
c = certs[k] = ensure_zero_opening(db, user, kind, pid, row=c)
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
if c.status=='closed': raise ValueError('已核销证件不能再安排用证')
if c.status!='active' or not c.verified_time or not c.verified_by: raise ValueError('证件尚未启用复核')
@@ -208,24 +231,42 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
policy=usage_policy(c.valid_use_text)
if policy=='needs_confirmation': raise ValueError('有效次数待确认')
# 合同上常有多张运输证。车号对不上的证不参与本次分配,只要还有一张覆盖该主车号即可安排。
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
if kind=='transport' and not _vehicle_on_cert(c, r.vehicle_no):
skipped_for_vehicle=True
continue
if policy=='once':
previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all()
if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous):
raise ValueError('一次有效证件已使用或已占用')
if policy=='once' and is_single_use_consumed(db, user, kind, c, exclude_plan_id=plan.id):
raise ValueError('一次有效证件已使用或已占用')
eligible=set()
for sid,s in sources.items():
link=query(m.PlContractCertLink,cert_type=kind,cert_id=pid,contract_id=s.contract_id,del_flag=0,link_status='active').first()
scope=None if link is None else query(m.PlContractCertProduct,contract_cert_link_id=link.id,contract_product_id=s.contract_product_id,del_flag=0,scope_status='active').first()
if scope:
eligible.add(sid); scopes[(k,sid)]=(link,scope)
if customer_rule:
# 新规则已由客户+品类整笔选证;账本不再依赖合同绑证 scope。
eligible=set(sources)
for sid in eligible:
scopes[(k,sid)]=(None,None)
else:
for sid,s in sources.items():
link=query(m.PlContractCertLink,cert_type=kind,cert_id=pid,contract_id=s.contract_id,del_flag=0,link_status='active').first()
scope=None if link is None else query(m.PlContractCertProduct,contract_cert_link_id=link.id,contract_product_id=s.contract_product_id,del_flag=0,scope_status='active').first()
if scope:
eligible.add(sid); scopes[(k,sid)]=(link,scope)
refs=frozenset(x.purchase_cert_id for x in query(m.PlTransportCertPurchaseRef,transport_cert_id=pid,del_flag=0).all() if x.purchase_cert_id) if kind=='transport' else frozenset()
own=usages[k].reserved_qty if k in usages else ZERO
candidates[kind].append(PermitCandidate(pid,c.total_qty-c.used_qty-c.reserved_qty+own,c.quantity_unit,c.valid_to,frozenset(eligible),refs))
if skipped_for_vehicle and not candidates['transport']:
raise ValueError('车号不在运输证范围')
plate = _normalize_vehicle_no(r.vehicle_no)
if not plate:
raise ValueError('安排运输须填写车牌号;当前未填写车牌,无法匹配运输证车号')
nos = '、'.join(
dict.fromkeys(
str(certs[k].cert_no or k[1])
for k in sorted(selected)
if k[0] == 'transport' and k in certs
)
)
suffix = f'(已选运输证 {nos})' if nos else ''
raise ValueError(
f'车牌号「{plate}」不在运输证车号范围内{suffix},'
'请到运输证中补充该车号或更换车辆后再安排'
)
return sources,scopes,candidates
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
@@ -279,8 +320,10 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
'sourceId':a.source_id,'certId':a.permit_id,'sourceQuantity':_canonical(a.source_quantity),
'permitQuantity':_canonical(a.permit_quantity),'sourceUnit':a.source_unit,
'permitUnit':a.permit_unit,'allocationSeq':a.allocation_seq,
'contractCertLinkId':link.id,'contractCertProductId':scope.id,
'scopeRevision':link.scope_revision,'certRevision':certs[k].revision,
'contractCertLinkId':None if link is None else link.id,
'contractCertProductId':None if scope is None else scope.id,
'scopeRevision':0 if link is None else link.scope_revision,
'certRevision':certs[k].revision,
'ownReservedQty':_canonical(usages[k].reserved_qty if k in usages else ZERO),
})
return result
@@ -309,7 +352,27 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
item=previous.get(a.source_id)
if item is None:
item=m.PlPlanCertUsageItem(**_audit(user),usage_id=u.id); db.add(item); items[k].append(item)
values=dict(contract_cert_link_id=link.id,contract_cert_product_id=scope.id,contract_scope_revision=link.scope_revision,contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,source_snapshot={**s.snapshot, 'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'), 'productId': s.product_id},del_flag=0,reserve_input_mode=_reserve_input_mode(s.unit, c.quantity_unit),reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id)
values=dict(
contract_cert_link_id=None if link is None else link.id,
contract_cert_product_id=None if scope is None else scope.id,
contract_scope_revision=0 if link is None else link.scope_revision,
contract_id=s.contract_id,contract_product_id=s.contract_product_id,plan_product_id=s.plan_product_id,
source_stop_type=s.source_stop_type,source_stop_id=s.source_stop_id,source_stop_product_id=s.source_stop_product_id,
related_stop_products=s.related_stop_products,allocation_seq=a.allocation_seq,source_unit=s.unit,
planned_source_qty=s.quantity,allocated_source_qty=a.source_quantity,planned_cert_qty=a.permit_quantity,
reserved_qty=a.permit_quantity,used_qty=ZERO,item_status='reserved',source_fingerprint=s.fingerprint,
source_snapshot={
**s.snapshot,
'measurementVersion': s.snapshot.get('measurementVersion', 'legacy_single_side'),
'productId': s.product_id,
'quotaRuleVersion': getattr(plan, 'cert_quota_rule_version', None),
'customerId': (s.snapshot or {}).get('customerId'),
'customerName': (s.snapshot or {}).get('customerName'),
},
del_flag=0,reserve_input_mode=_reserve_input_mode(s.unit, c.quantity_unit),
reserve_conversion_note=notes.get((k[0],k[1],a.source_id)),
reserve_confirmed_by=user.id,reserve_confirmed_time=datetime.now(),update_by=user.id,
)
for name,value in values.items(): setattr(item,name,value)
@@ -332,35 +395,88 @@ def _validate_current_sources(r,active):
raise ValueError('当前权威来源身份、计划量、单位或指纹已变化,请重新核对用证')
class ArchiveQuotaBlocked(ValueError):
"""合同上的购买证、运输证仍不够本次实际量,归档必须整单回滚。"""
"""可用购买证、运输证仍不够本次实际量,归档必须整单回滚。"""
ARCHIVE_QUOTA_BLOCK = '合同绑定的购买证和运输证剩余额度不够覆盖本次实际数量,不能归档。请先补充证件额度或调整实际数量后再归档。'
CUSTOMER_ARCHIVE_QUOTA_BLOCK = (
'客户名下购买证和运输证剩余额度不够覆盖本次实际数量,不能归档。'
'请先补充证件额度或调整实际数量后再归档。'
)
ARCHIVE_CONVERSION_EXCESS_BLOCK = (
'实际数量超出安排时的预占,且原证件剩余额度不够按原换算系数继续扣减,不能归档。'
'请把实际数量改小,或先补充证件额度后再归档。'
)
def _settle_cover_cert_ids(db, user, plan, sources):
"""归档超额补扣证件池:新规则按客户+品类,旧规则按合同绑证。"""
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
if plan_uses_customer_category(plan):
from app.services.pl_quota_customer_select import resolve_customer_category_cover_certs
return resolve_customer_category_cover_certs(db, user, sources)
from app.services.pl_quota_planning import resolve_contract_bound_certs
return resolve_contract_bound_certs(db, user, sources)
def _settlement_cover_cert(db, user, r, plan, kind, cert):
"""合同上另有证件时,不合格的证跳过,不因此挡住其余还能扣的证。"""
return _settlement_cover_reject_reason(db, user, r, plan, kind, cert) is None
def _settlement_cover_reject_reason(db, user, r, plan, kind, cert):
"""返回不可用于归档补扣的具体原因;可用时返回 None。"""
from app.services.pl_quota_cert_service import ensure_zero_opening
cert = ensure_zero_opening(db, user, kind, cert.id, row=cert)
label = '购买证' if kind == 'purchase' else '运输证'
no = str(cert.cert_no or cert.id)
if not _query(db, user, m.PlCertQtyLog, cert_type=kind, cert_id=cert.id, event_type='opening', del_flag=0).first():
return False
return f'{label} {no} 尚未确认期初用量'
if cert.status != 'active' or not cert.verified_time or not cert.verified_by:
return False
return f'{label} {no} 未启用或未复核'
if cert.valid_from is None or cert.valid_to is None or not cert.valid_from <= r.on_date <= cert.valid_to:
return False
return f'{label} {no} 不在归档日期 {r.on_date.isoformat()} 的有效期内'
policy = usage_policy(cert.valid_use_text)
if policy == 'needs_confirmation':
return False
if kind == 'transport' and (not r.vehicle_no or r.vehicle_no not in (cert.vehicle_numbers_json or [])):
return False
if policy == 'once':
previous = _query(db, user, m.PlPlanCertUsage, cert_type=kind, cert_id=cert.id, del_flag=0).all()
if cert.opening_single_use_consumed or any(
u.plan_id != plan.id and (
u.usage_status in ('reserved', 'in_transit', 'settled', 'exception')
or (u.confirmation_snapshot or {}).get('executed')
) for u in previous
):
return False
return True
return f'{label} {no} 有效次数待确认'
if kind == 'transport':
plate = _normalize_vehicle_no(r.vehicle_no)
if not plate:
return f'运输证 {no} 需要车牌号,当前计划未填写车牌'
if not _vehicle_on_cert(cert, plate):
raw = cert.vehicle_numbers_json or []
if isinstance(raw, str):
listed = raw
else:
listed = '、'.join(str(p).strip() for p in raw if str(p).strip())
suffix = f'(证上现有车号:{listed})' if listed else ''
return f'运输证 {no} 未包含车牌号「{plate}」{suffix}'
if policy == 'once' and is_single_use_consumed(db, user, kind, cert, exclude_plan_id=plan.id):
return f'{label} {no} 为一次有效且已使用'
return None
def _archive_cover_block_message(db, user, r, plan, certs, candidate_keys):
"""补证候选全被跳过时,汇总具体原因,避免笼统额度不足。"""
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
reasons = []
for kind, cid in candidate_keys:
key = (kind, cid)
cert = certs.get(key)
if cert is None:
label = '购买证' if kind == 'purchase' else '运输证'
reasons.append(f'{label} {cid} 不存在或不可用')
continue
reason = _settlement_cover_reject_reason(db, user, r, plan, kind, cert)
if reason:
reasons.append(reason)
if not reasons:
return CUSTOMER_ARCHIVE_QUOTA_BLOCK if customer_rule else ARCHIVE_QUOTA_BLOCK
unique = list(dict.fromkeys(reasons))
detail = ';'.join(unique[:4])
scope = '客户名下' if customer_rule else '合同上'
return f'{detail}。{scope}没有其他可用证件覆盖本次实际数量,不能归档。请先处理上述问题后再归档。'
_QTY = Decimal('0.000001')
@@ -417,7 +533,7 @@ def _prepare_saved_conversion(groups, actual_by_source, certs):
proposals[kind] = [row for row in rows if row.source_id != sid] + updated
mine = updated
if need > 0:
return groups, [], ARCHIVE_QUOTA_BLOCK
return groups, [], ARCHIVE_CONVERSION_EXCESS_BLOCK
remaining = actual
for row in sorted(mine, key=lambda item: item.allocation_seq):
take = min(remaining, row.source_quantity)
@@ -458,8 +574,8 @@ def _settle(db,user,r,certs,usages,items,plan):
raise ArchiveQuotaBlocked(conversion_error)
if generated:
r = r.model_copy(update={'manual_actuals': generated})
# 同单位超出预占时,自动改用合同上已绑定的购买证和运输证,含原证剩余额度。
contract_cover = False
# 同单位超出预占时,自动改用补扣证件池(新规则客户+品类,旧规则合同绑证),含原证剩余额度。
auto_cover = False
if not r.allow_supplement:
for sid, value in r.actual_by_source.items():
mine = [a for rows in groups.values() for a in rows if a.source_id == sid]
@@ -467,7 +583,7 @@ def _settle(db,user,r,certs,usages,items,plan):
continue
capacity = sum((a.source_quantity for a in groups['purchase'] if a.source_id == sid), ZERO)
if value > capacity:
contract_cover = True
auto_cover = True
break
# 原始容量不随补量扩大;新增证件原始容量为零,后续纠错始终先用原分配。
base_actual=dict(r.actual_by_source)
@@ -476,7 +592,7 @@ def _settle(db,user,r,certs,usages,items,plan):
capacity=sum((a.source_quantity for a in groups['purchase'] if a.source_id==sid),ZERO)
if value>capacity:
excess[sid]=value-capacity
if r.allow_supplement or contract_cover: base_actual[sid]=capacity
if r.allow_supplement or auto_cover: base_actual[sid]=capacity
# 人工显式补量可从当前实运来源拆出一段(如原证24、新证5),两段合计仍是29。
# 两证有一侧同单位时由联合分配器配对;异单位补量仍须完整人工输入。
if r.allow_supplement and r.manual_allocations:
@@ -528,35 +644,52 @@ def _settle(db,user,r,certs,usages,items,plan):
settled={kind:settle_allocations(rows,base_actual,manual_actuals=[a for a in _manual(r.manual_actuals) if a.permit_kind==kind]) for kind,rows in groups.items()}
supplemental=[]
if excess:
if contract_cover:
from app.services.pl_quota_planning import resolve_contract_bound_certs
if auto_cover:
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
customer_rule = plan_uses_customer_category(plan)
try:
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, r.sources)
purchase_ids, transport_ids = _settle_cover_cert_ids(db, user, plan, r.sources)
except HTTPException as exc:
raise ArchiveQuotaBlocked(str(getattr(exc, 'detail', exc))) from exc
candidate_keys=[('purchase', x) for x in purchase_ids] + [('transport', x) for x in transport_ids]
selected=set()
for kind, cid in [('purchase', x) for x in purchase_ids] + [('transport', x) for x in transport_ids]:
for kind, cid in candidate_keys:
key=(kind, cid)
if key not in certs:
raise ArchiveQuotaBlocked(ARCHIVE_QUOTA_BLOCK)
raise ArchiveQuotaBlocked(
_archive_cover_block_message(db, user, r, plan, certs, candidate_keys)
)
if _settlement_cover_cert(db, user, r, plan, kind, certs[key]):
selected.add(key)
if not selected:
raise ArchiveQuotaBlocked(ARCHIVE_QUOTA_BLOCK)
raise ArchiveQuotaBlocked(
_archive_cover_block_message(db, user, r, plan, certs, candidate_keys)
)
else:
if not r.reason.strip() or not (r.evidence_urls.strip() or r.source_note.strip()):
raise ValueError('补分配必须有理由及依据')
selected={('purchase',x) for x in r.purchase_cert_ids}|{('transport',x) for x in r.transport_cert_ids}
customer_rule = False
# 校验器看到的是扣除原量、返还本计划旧账后的净可用量。
base_totals={k:ZERO for k in usages}
for kind,rows in settled.items():
for row in rows: base_totals[(kind,row.allocation.permit_id)]+=row.actual_permit_quantity
credits={k:SimpleNamespace(reserved_qty=u.reserved_qty+u.used_qty-base_totals[k]) for k,u in usages.items()}
extra_request=r.model_copy(update={'sources':[source.model_copy(update={'quantity':excess[source.source_id]}) for source in r.sources if source.source_id in excess]})
# 超额补扣需求仅为超出预占的部分。安排/完善并关闭写入的 manual_allocations
# 是整笔计划量,若原样带入会与超额需求冲突(证件明明够却报额度不足)。
extra_update = {
'sources': [
source.model_copy(update={'quantity': excess[source.source_id]})
for source in r.sources if source.source_id in excess
],
}
if auto_cover:
extra_update['manual_allocations'] = []
extra_request = r.model_copy(update=extra_update)
try:
result=_reserve(db,user,extra_request,plan,certs,credits,{},selected,preview=True,lock_preview=True)
except InsufficientQuota as exc:
if not contract_cover:
if not auto_cover:
raise
from app.services.pl_quota_diagnostics import archive_quantity_shortfall_message
cert_rows=[]
@@ -570,7 +703,8 @@ def _settle(db,user,r,certs,usages,items,plan):
message=archive_quantity_shortfall_message(
getattr(exc, 'diagnostics', None), cert_rows, r.actual_by_source, excess,
{source.source_id: source.unit for source in r.sources})
raise ArchiveQuotaBlocked(message or ARCHIVE_QUOTA_BLOCK) from exc
fallback = CUSTOMER_ARCHIVE_QUOTA_BLOCK if customer_rule else ARCHIVE_QUOTA_BLOCK
raise ArchiveQuotaBlocked(message or fallback) from exc
for kind in ('purchase','transport'):
for row in result[kind+'Allocations']:
supplemental.append((kind,row))
+104 -1
View File
@@ -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}
@@ -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())
@@ -0,0 +1,356 @@
#!/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')
ids['category_product'] = uid('cat-prod')
# 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']]),
('pl_cert_category_product', 'id', [ids['category_product']]),
('crm_contract_product', 'id', [ids['contract_product']]),
('crm_contract', 'id', [ids['contract']]),
('crm_quote_product', 'id', [ids['quote_product']]),
('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'])
# 品类 ↔ 种子产品(选证按产品反查品类;缺关联则安排必败)
cur.execute(
"""
INSERT INTO pl_cert_category_product
(id, category_id, product_id, scope_status,
tenant_no, create_dept_id, create_by, create_time, update_time, del_flag)
VALUES (%s,%s,%s,'active',%s,'',%s,%s,%s,0)
""",
(ids['category_product'], category_id, PRODUCT_ID, TENANT, OWNER_USER_ID, now, now),
)
print('+ category_product', PRODUCT_ID, '->', category_id)
def insert_purchase(pid, cert_no, total, valid_to, face='98% 工业硫酸'):
cur.execute(
"""
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())
@@ -0,0 +1,117 @@
"""证用品类产品关联差量保存:软删后再加回须复用唯一键行。"""
from datetime import datetime
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.pl_quota_models import PlCertCategory, PlCertCategoryProduct
from app.services import pl_cert_category_service as svc
@pytest.fixture
def db(monkeypatch):
engine = create_engine('sqlite://')
PlCertCategory.__table__.create(engine)
PlCertCategoryProduct.__table__.create(engine)
monkeypatch.setattr(svc, '_product_labels', lambda *a, **k: {})
with Session(engine) as session:
yield session
engine.dispose()
def user(tenant='t', uid='editor'):
return SimpleNamespace(id=uid, tenant_no=tenant, dept_id='dept')
def seed_category(db, id='cat', **extra):
values = dict(
id=id, category_name='硫酸', category_code='H2SO4', status='1',
tenant_no='t', create_by='creator', create_dept_id='dept',
create_time=datetime(2020, 1, 1), del_flag=0,
)
values.update(extra)
row = PlCertCategory(**values)
db.add(row)
db.flush()
return row
def seed_link(db, id='link', product_id='339E64E02E7C41F3', del_flag=0, **extra):
values = dict(
id=id, category_id='cat', product_id=product_id, scope_status='active',
tenant_no='t', create_by='creator', create_dept_id='dept',
create_time=datetime(2020, 1, 1), del_flag=del_flag,
)
values.update(extra)
row = PlCertCategoryProduct(**values)
db.add(row)
db.flush()
return row
def test_readd_soft_deleted_product_reuses_unique_key_row(db):
seed_category(db)
link = seed_link(db, del_flag=1)
result = svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'productId': link.product_id}]},
category_id='cat',
)
db.flush()
assert db.query(PlCertCategoryProduct).count() == 1
assert link.del_flag == 0
assert link.create_by == 'creator'
assert link.create_time == datetime(2020, 1, 1)
assert link.update_by == 'editor'
assert result['products'][0]['id'] == link.id
assert result['products'][0]['productId'] == link.product_id
def test_remove_then_readd_without_id_keeps_one_row(db):
seed_category(db)
link = seed_link(db)
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': []},
category_id='cat',
)
assert link.del_flag == 1
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'productId': link.product_id, 'scopeStatus': 'active'}]},
category_id='cat',
)
db.flush()
assert db.query(PlCertCategoryProduct).count() == 1
assert link.del_flag == 0
assert link.scope_status == 'active'
def test_no_id_reuses_active_row_by_product_id(db):
seed_category(db)
link = seed_link(db)
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'productId': link.product_id}]},
category_id='cat',
)
db.flush()
assert db.query(PlCertCategoryProduct).filter_by(del_flag=0).count() == 1
assert link.del_flag == 0
assert link.id == 'link'
def test_deleted_explicit_id_is_rejected(db):
seed_category(db)
link = seed_link(db, del_flag=1)
with pytest.raises(HTTPException) as exc:
svc.save_category(
db, user(),
{'categoryName': '硫酸', 'products': [{'id': link.id, 'productId': link.product_id}]},
category_id='cat',
)
assert exc.value.status_code == 400
assert link.del_flag == 1
File diff suppressed because it is too large. Load diff
@@ -249,6 +249,11 @@ def test_same_city_settlement_does_not_require_transport_pairing():
assert validate_allocation_pairing([demand], [purchase], [], []) is None
def test_empty_transport_kind_settle_ignores_source_actuals():
"""同市只结转购买证时,空的运输证分组对实运量应直接空结果,不报缺实际量。"""
assert settle_allocations([], {'city': D(10)}) == ()
@pytest.mark.parametrize('manual_values', [(8,10,0),(18,0,0)])
def test_zero_capacity_foreign_row_cannot_unlock_manual_reordering(manual_values):
from app.services.pl_quota_allocation import Allocation
+4 -3
View File
@@ -57,7 +57,7 @@ def test_permission_guards_validation_and_atomic_creation(api):
saved = client.post(path, json=payload())
assert saved.status_code == 200
row = saved.json()
assert row['tenantNo'] == 't' and row['status'] == 'draft' and row['openingConfirmed'] is False
assert row['tenantNo'] == 't' and row['status'] == 'active' and row['openingConfirmed'] is True
assert client.put(path + '/' + row['id'], json={'totalQty': '-1'}).status_code == 422
assert client.get(path + '/' + row['id']).json()['totalQty'] == '30.000000'
app.dependency_overrides[auth] = lambda: {'role': 'admin', 'user_id': 'u', 'tenant_no': 'other'}
@@ -97,7 +97,7 @@ def test_plan_overview_accepts_web_funnel_aliases_and_validates_dates(api, monke
assert filters == {'plan_no': '001', 'license_plate': '皖A',
'plan_date_from': date(2026, 11, 1), 'plan_date_to': date(2026, 11, 3),
'quota_status_in': 'reserved,settled', 'supplier_name': None, 'customer_name': None,
'product_name': None, 'follow_status_in': None}
'product_name': None, 'follow_status_in': None, 'conversion_only': False}
assert client.get('/api/transport/quota-certs/plans', params={'planDateFrom': 'wrong'}).status_code == 422
assert received == []
@@ -130,7 +130,8 @@ def test_total_adjust_requires_provenance_and_replays_without_duplicate_log(api)
assert response.status_code == 200
assert client.post(path, json=body).status_code == 200
logs = client.get(base + '/' + row['id'] + '/logs').json()
assert len(logs) == 1 and logs[0]['totalDelta'] == '5.000000'
adjust_logs = [item for item in logs if item.get('eventType') == 'total_adjust']
assert len(adjust_logs) == 1 and adjust_logs[0]['totalDelta'] == '5.000000'
assert client.post(path, json={**body,'totalQty':'40'}).status_code == 409
assert client.post(path, json={**body,'operationId':'adjust-2','sourceNote':''}).status_code == 409
assert client.get(base + '/' + row['id']).json()['totalQty'] == '35.000000'
+5
View File
@@ -251,6 +251,11 @@ def approval_case(business, monkeypatch):
c.db.commit()
c.user.to_tenant_user_dict=lambda:{}
monkeypatch.setattr(api,'get_tenant_no',lambda *a:'t')
# 安排入口要求已配置客户品类额度生效时间;本套用例聚焦共享运输/钉版本,不测切点本身
monkeypatch.setattr(
'app.services.pl_cert_quota_rule_config_service.assert_cert_quota_rule_configured',
lambda *a, **k: None,
)
c.api=api
return c
+41 -19
View File
@@ -41,14 +41,31 @@ def transport(**kw):
return data
def test_save_auto_enables_with_zero_opening(ctx):
"""建档即启用:期初已用默认 0,无需手工期初确认。"""
db, u, s, m = ctx
row = s.save_cert(db, u, 'purchase', purchase())
assert row['status'] == 'active'
assert row['openingConfirmed'] is True
assert row['usedQty'] == Decimal('0')
assert row['verifiedBy'] == 'u'
assert row['verifiedTime'] is not None
log = db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').one()
assert (log.event_snapshot or {}).get('auto') is True
transport_row = s.save_cert(db, u, 'transport', transport())
assert transport_row['status'] == 'active' and transport_row['openingConfirmed'] is True
def test_manual_optional_image_complete_fields(ctx):
db,u,s,m = ctx
data = purchase(buyerAddress='地址', buyerPhone='电话', buyerLegalRepresentative='甲', sellerAddress='乙址', sellerPhone='乙电话', sellerLegalRepresentative='乙', purchasePurpose='用途', issueDate='2026-01-01', validFrom='2026-01-01', validTo='2027-01-01')
row = s.save_cert(db,u,'purchase',data)
assert row['buyerAddress'] == '地址' and row['attachmentUrls'] is None
assert row['totalQty'] == Decimal('100.123456')
assert row['status'] == 'draft'
assert db.query(m.PlCertQtyLog).count() == 0
assert row['status'] == 'active'
assert row['openingConfirmed'] is True
assert row['usedQty'] == Decimal('0')
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').count() == 1
db.rollback()
assert db.query(m.PlPurchaseCert).count() == 0
@@ -56,9 +73,8 @@ def test_manual_optional_image_complete_fields(ctx):
def test_opening_confirmation_remains_visible_after_reverification_required(ctx):
db, user, svc, models = ctx
row = svc.save_cert(db, user, 'purchase', purchase())
assert row['openingConfirmed'] is False
row = svc.confirm_cert(db, user, 'purchase', row['id'], {
'operationId': 'op-ui', 'usedQty': '0', 'singleUseConsumed': False, 'reason': '全新证'})
assert row['openingConfirmed'] is True
assert row['status'] == 'active'
changed = svc.save_cert(db, user, 'purchase', {'buyerAddress': '更正地址'}, row['id'])
assert changed['verifiedTime'] is None
assert changed['openingConfirmed'] is True
@@ -89,10 +105,12 @@ def test_reference_delta_omitted_empty_and_invalid_prewrite(ctx):
def test_confirmation_and_history_protection(ctx):
db,u,s,m=ctx
row=s.save_cert(db,u,'purchase',purchase())
assert row['status']=='active' and row['openingConfirmed'] is True
with pytest.raises((HTTPException,ValidationError)): s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','operationId':'op'})
row=s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','evidenceUrls':'https://minio/evidence','operationId':'op'})
assert row['openingSingleUseConsumed']==1 and row['status']=='active'
assert db.query(m.PlCertQtyLog).one().event_type=='opening'
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_type=='opening'
# 建档自动期初后身份已锁定;手工覆盖期初后同样不可改核定量/证号。
for data in ({'quantityUnit':'千克'},{'certNo':'new'},{'totalQty':'90'}):
with pytest.raises(HTTPException): s.save_cert(db,u,'purchase',data,row['id'])
with pytest.raises(HTTPException): s.delete_cert(db,u,'purchase',row['id'])
@@ -151,8 +169,9 @@ def test_opening_retry_preserves_balance_and_operation_identity(ctx):
db.query(m.PlPurchaseCert).one().used_qty=Decimal('5')
retry=s.confirm_cert(db,u,'purchase',row['id'],opening())
assert retry['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertOperation).count()==1
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
# 自动期初操作号 + 手工期初操作号;重放不新增手工操作
assert db.query(m.PlCertOperation).filter_by(operation_id='opening').count()==1
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(usedQty='7'))
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(operationId='second'))
@@ -209,10 +228,11 @@ def test_reverify_after_correction_keeps_opening_and_balances(ctx):
assert corrected['status']=='suspended' and corrected['verifiedBy'] is None
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
assert result['status']=='active' and result['usedQty']==Decimal('5')
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertOperation).count()==2
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
# 自动期初 + 手工期初 + 复核
assert db.query(m.PlCertOperation).count()==3
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
assert db.query(m.PlCertOperation).count()==2
assert db.query(m.PlCertOperation).count()==3
def test_actual_scope_denies_user_without_roles(ctx):
@@ -261,7 +281,7 @@ def test_historical_opening_accepts_written_source_without_image(ctx):
row=s.save_cert(db,u,'purchase',purchase())
result=s.confirm_cert(db,u,'purchase',row['id'],{'operationId':'written-source','usedQty':'5','singleUseConsumed':True,'reason':'历史核对','sourceNote':'2026年10月纸质台账第3页,经张三核实'})
assert result['usedQty']==Decimal('5') and result['status']=='active'
assert db.query(m.PlCertQtyLog).one().event_snapshot['sourceNote'].startswith('2026')
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_snapshot['sourceNote'].startswith('2026')
def test_unknown_use_text_is_preserved_and_requires_confirmation(ctx):
@@ -314,11 +334,11 @@ def test_unlinked_reference_can_bind_purchase_archive_after_opening(ctx):
assert saved['purchaseRefs'][0]['purchaseCertId']==purchase_row['id']
assert saved['purchaseRefs'][0]['referenceNo']=='G35260264950502'
linked=saved['purchaseRefs'][0]
with pytest.raises(HTTPException, match='不能改变证面引用身份'):
with pytest.raises(HTTPException, match='不能改变关联购买证身份'):
s.save_cert(db,u,'transport',{'purchaseRefs':[{'id':linked['id'],'referenceType':'unknown','referenceNo':purchase_row['certNo'],'purchaseCertId':purchase_row['id']}]},row['id'])
with pytest.raises(HTTPException, match='不能移除'):
s.save_cert(db,u,'transport',{'purchaseRefs':[]},row['id'])
with pytest.raises(HTTPException, match='不能改变证面引用身份'):
with pytest.raises(HTTPException, match='不能改变关联购买证身份'):
s.save_cert(db,u,'transport',{'purchaseRefs':[
{k:linked[k] for k in ('id','referenceType','referenceNo','purchaseCertId')},
{'referenceType':'unknown','referenceNo':'NEW'},
@@ -423,7 +443,7 @@ def test_opening_verify_stale_operation_snapshot_uses_unique_insert_arbitration(
result=fn(db,u,'purchase',p['id'],body)
assert missed and result['id']==p['id']
assert db.query(m.PlCertOperation).count()==count
assert db.query(m.PlCertQtyLog).count()==1
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
def test_opening_and_verify_allow_blank_reason(ctx):
@@ -431,7 +451,7 @@ def test_opening_and_verify_allow_blank_reason(ctx):
p=s.save_cert(db,u,'purchase',purchase())
row=s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'reason':' '})
assert row['status']=='active'
assert db.query(m.PlCertQtyLog).one().reason==''
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().reason==''
s.save_cert(db,u,'purchase',{'buyerAddress':'更正地址'},p['id'])
verified=s.verify_cert(db,u,'purchase',p['id'],{'operationId':'verify-blank','reason':''})
assert verified['status']=='active' and verified['verifiedTime']
@@ -440,10 +460,12 @@ def test_opening_and_verify_allow_blank_reason(ctx):
def test_rejected_opening_rolls_back_claimed_operation(ctx):
db,u,s,m=ctx
p=s.save_cert(db,u,'purchase',purchase())
auto_ops = db.query(m.PlCertOperation).count()
auto_logs = db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()
with pytest.raises(HTTPException):
s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'usedQty':'999'})
assert db.query(m.PlCertOperation).count()==0
assert db.query(m.PlCertQtyLog).count()==0
assert db.query(m.PlCertOperation).count()==auto_ops
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==auto_logs
def test_replay_reads_current_certificate_and_opening_not_old_snapshot(ctx,monkeypatch):
@@ -472,7 +494,7 @@ def test_closed_certificate_keeps_history_and_cannot_be_reopened(ctx):
used = confirmed['usedQty']
closed = s.save_cert(db, u, 'purchase', {'status': 'closed'}, row['id'])
assert closed['status'] == 'closed' and closed['usedQty'] == used
assert db.query(m.PlCertQtyLog).count() == 1
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count() == 1
for action, payload in (
('confirm', {**opening(), 'operationId': 'reopen-opening'}),
('verify', {'operationId': 'reopen-verify', 'reason': '重新启用'}),
@@ -64,6 +64,44 @@ def test_create_with_bindings_and_bad_create_has_no_pending_row(case):
assert result['id'] not in ('g1', 'g2')
def test_create_with_empty_contract_bindings_auto_enables(case):
"""前端保存常带 contractBindings:[];必须与无合同集合路径一样自动启用并落库。"""
c = case
body = dict(
certNo='G-AUTO',
productNameOnCert='硫酸',
totalQty='300',
quantityUnit='吨',
validUseText='多次有效',
buyerName='买方',
sellerName='卖方',
contractBindings=[],
)
result = save(c, body, None)
assert result['status'] == 'active'
assert result['openingConfirmed'] is True
assert result['verifiedBy'] == 'u'
row = c.db.query(c.m.PlPurchaseCert).filter_by(id=result['id']).one()
assert row.status == 'active'
assert row.verified_by == 'u'
assert c.db.query(c.m.PlCertQtyLog).filter_by(
cert_id=result['id'], event_type='opening', del_flag=0,
).count() == 1
def test_get_cert_read_does_not_fake_enable_draft(case):
"""只读详情/列表不得在未提交事务里把 draft 伪装成 active。"""
from app.services import pl_quota_cert_service as certs
c = case
row = c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one()
assert row.status == 'draft'
detail = certs.get_cert(c.db, c.user, 'purchase', 'g1')
assert detail['status'] == 'draft'
assert c.db.query(c.m.PlPurchaseCert).filter_by(id='g1').one().status == 'draft'
assert c.db.query(c.m.PlCertQtyLog).filter_by(cert_id='g1', del_flag=0).count() == 0
def test_hidden_contract_cannot_be_cleared_but_face_can_change(case, monkeypatch):
from app.services import pl_quota_cert_bindings as svc
from app.models.crm_models import CrmContract
+36
View File
@@ -317,3 +317,39 @@ def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id):
assert rows[0]['bindingsReadable'] is True
assert rows[0]['certificates'] == []
assert rows[1]['bindingsReadable'] is False
def test_plan_quota_usages_include_cert_attachment_urls(case):
from app.models.crm_models import CrmContract
from app.models.pl_quota_models import (
PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert,
)
from app.services.pl_quota_planning import get_plan_quota
c = case
for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert):
model.__table__.create(c.db.bind)
c.db.add(PlPurchaseCert(
id='p1', cert_no='G-BUY', product_name_on_cert='硫酸', valid_use_text='一次有效',
buyer_name='买方', seller_name='卖方', total_qty=33, quantity_unit='吨',
attachment_urls='https://example.com/buy.jpg', tenant_no='t', status='active',
))
c.db.add(PlTransportCert(
id='t1', cert_no='Y-TR', product_name_on_cert='硫酸', valid_use_text='多次有效',
shipper_name='货主', total_qty=70, quantity_unit='吨', vehicle_numbers_json=['皖A1'],
attachment_urls='', tenant_no='t', status='active',
))
c.db.add(PlPlanCertUsage(
id='u1', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
cert_type='purchase', cert_id='p1', cert_no_snapshot='G-BUY', cert_revision=1,
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
))
c.db.add(PlPlanCertUsage(
id='u2', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
cert_type='transport', cert_id='t1', cert_no_snapshot='Y-TR', cert_revision=1,
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
))
c.db.flush()
usages = {u['certId']: u for u in get_plan_quota(c.db, c.user, 'plan')['usages']}
assert usages['p1']['attachmentUrls'] == 'https://example.com/buy.jpg'
assert usages['p1']['certNo'] == 'G-BUY'
assert usages['t1']['attachmentUrls'] in ('', None)
+105 -2
View File
@@ -98,6 +98,31 @@ def test_zero_reservation_consumes_once_slot(case):
assert c.db.query(c.m.PlPlanCertUsage).count()==2
with pytest.raises(ValueError): run(c,operation_id='other',plan_id='other',sources=sources)
def test_same_city_purchase_only_settle_without_transport(case):
"""同市段只预占购买证时,归档结转不得因空运输证分组误报缺实际量。"""
c = case
sources = [dict(
source_stop_type='delivery', source_stop_id='stop', source_stop_product_id='sp',
plan_product_id='pp', contract_id='c', contract_product_id='cp', product_id='product',
quantity='1', unit='吨', fingerprint='fp', requires_transport=False,
)]
run(c, purchase_cert_ids=['p'], transport_cert_ids=[], sources=sources)
assert c.db.query(c.m.PlPlanCertUsage).count() == 1
assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 1
run(
c,
operation_id='settle-same-city',
action='settle',
purchase_cert_ids=['p'],
transport_cert_ids=[],
sources=sources,
actual_by_source={'delivery:sp': '1'},
)
assert c.db.get(c.m.PlPurchaseCert, 'p').used_qty == 1
assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0
assert c.db.query(c.m.PlTransportCert).filter_by(id='t').one().used_qty == 0
def test_same_cert_balance_covers_actual_above_reservation(case):
c = case
run(c)
@@ -108,6 +133,27 @@ def test_same_cert_balance_covers_actual_above_reservation(case):
assert c.db.query(c.m.PlPlanCertUsage).count() == 2
def test_auto_cover_ignores_arrangement_manuals_when_actual_exceeds_reservation(case):
"""安排/完善并关闭写入的整笔 manual_allocations 不得挡住归档超额补扣。"""
c = case
manuals = [
dict(permit_kind='purchase', source_id='load:sp', permit_id='p', source_quantity='29', permit_quantity='29'),
dict(permit_kind='transport', source_id='load:sp', permit_id='t', source_quantity='29', permit_quantity='29'),
]
run(c, manual_allocations=manuals)
# 归档请求仍带着计划量人工行(与完善并关闭后 cert_configuration 回灌一致)
run(
c,
operation_id='settle-with-stale-manuals',
action='settle',
actual_by_source={'load:sp': '35'},
manual_allocations=manuals,
)
assert c.db.get(c.m.PlPurchaseCert, 'p').used_qty == 35
assert c.db.get(c.m.PlPurchaseCert, 'p').reserved_qty == 0
assert c.db.get(c.m.PlTransportCert, 't').used_qty == 35
def test_same_cert_without_enough_balance_asks_for_another_certificate(case):
c = case
c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 30
@@ -245,7 +291,7 @@ def test_opening_consumed_and_expiry_vehicle_block(case):
p.opening_single_use_consumed=0; p.valid_to=date(2000,1,1); c.db.flush()
with pytest.raises(ValueError): run(c)
p.valid_to=date(2099,12,31); c.db.flush()
with pytest.raises(ValueError, match='车号不在运输证范围'): run(c,vehicle_no='unknown')
with pytest.raises(ValueError, match='车牌号「unknown」不在运输证车号范围内'): run(c,vehicle_no='unknown')
def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
@@ -262,9 +308,19 @@ def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
assert c.db.get(c.m.PlPurchaseCert, 'p2').reserved_qty == 0
transport_usages = c.db.query(c.m.PlPlanCertUsage).filter_by(cert_type='transport', del_flag=0).all()
assert [u.cert_id for u in transport_usages if u.usage_status == 'reserved'] == ['t']
with pytest.raises(ValueError, match='车号不在运输证范围'):
with pytest.raises(ValueError, match='车牌号「NONE」不在运输证车号范围内'):
run(c, operation_id='none', vehicle_no='NONE', purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
def test_reserve_accepts_vehicle_no_with_trailing_spaces(case):
"""计划车牌尾部空格不得误判为不在运输证车号范围。"""
c = case
# 证上车号无空格;计划侧常带尾部空格(与 PO20261115096 现场一致)
assert c.db.get(c.m.PlTransportCert, 't').vehicle_numbers_json == ['CAR']
run(c, vehicle_no='CAR ')
assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == 29
def test_no_cert_operation_is_still_idempotent(case):
c=case
first=run(c,sources=[],purchase_cert_ids=[],transport_cert_ids=[])
@@ -498,3 +554,50 @@ def test_new_current_usage_rejects_without_out_of_order_cert_lock(case,monkeypat
assert c.db.get(c.m.PlPurchaseCert,'p').reserved_qty==29
assert all(u.reserved_qty==29 for u in c.db.query(c.m.PlPlanCertUsage).all())
assert c.db.query(c.m.PlCertOperation).count()==1
def test_archive_cover_block_message_names_vehicle(monkeypatch):
"""归档补证被车号挡住时,应点名车牌与运输证,而不是笼统额度不足。"""
from types import SimpleNamespace
from datetime import date
from app.services import pl_quota_usage_service as svc
transport = SimpleNamespace(
id='t1',
cert_no='Y-ARCH-1',
status='active',
verified_by='u',
verified_time=date(2026, 1, 1),
valid_from=date(2026, 1, 1),
valid_to=date(2027, 12, 31),
valid_use_text='多次有效',
vehicle_numbers_json=['皖A00000'],
)
r = SimpleNamespace(vehicle_no='皖A9BE91', on_date=date(2026, 11, 15))
plan = SimpleNamespace(id='plan1')
user = SimpleNamespace(tenant_no='t')
class OpeningQuery:
def first(self):
return SimpleNamespace(id='opening')
db = SimpleNamespace()
monkeypatch.setattr(
svc,
'_query',
lambda db, user, model, lock=True, **filters: OpeningQuery(),
)
monkeypatch.setattr(
'app.services.pl_quota_cert_service.ensure_zero_opening',
lambda db, user, kind, cert_id, row=None: row or transport,
)
monkeypatch.setattr(svc, 'usage_policy', lambda text: 'multi')
monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False)
message = svc._archive_cover_block_message(
db, user, r, plan, {('transport', 't1'): transport}, [('transport', 't1')],
)
assert '皖A9BE91' in message
assert 'Y-ARCH-1' in message
assert '未包含车牌号' in message
assert '剩余额度不够覆盖本次实际数量' not in message
@@ -29,6 +29,8 @@ CREATE TABLE `pl_purchase_cert` (
`seller_legal_representative` varchar(100) DEFAULT NULL COMMENT '销售方法定代表人',
`seller_phone` varchar(100) DEFAULT NULL COMMENT '销售方电话',
`purchase_purpose` varchar(1000) DEFAULT NULL COMMENT '用途',
`customer_id` varchar(64) DEFAULT NULL COMMENT '系统客户ID;选证必填',
`category_id` varchar(64) DEFAULT NULL COMMENT '证用品类ID;选证必填',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号,由服务端当前用户上下文设置',
`order_num` int DEFAULT NULL COMMENT '公共排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '系统备注,与证面备注分开',
@@ -74,6 +76,7 @@ CREATE TABLE `pl_transport_cert` (
`route_text` text COMMENT '运输路线完整原文',
`consignee_name` varchar(255) DEFAULT NULL COMMENT '收货单位名称或姓名',
`applicant_name` varchar(255) DEFAULT NULL COMMENT '申请单位名称或姓名',
`customer_id` varchar(64) DEFAULT NULL COMMENT '系统客户ID;选证必填',
`tenant_no` varchar(64) NOT NULL COMMENT '租户编号,由服务端当前用户上下文设置',
`order_num` int DEFAULT NULL COMMENT '公共排序号',
`remark` varchar(1000) DEFAULT NULL COMMENT '系统备注,与证面备注分开',
@@ -0,0 +1,24 @@
-- 只读:按证面品名归纳候选证用品类名(上线前人工建品类参考)
-- 不写库。执行后人工创建 pl_cert_category、关联产品,并回填证件 customer_id/category_id,再配置 effective_at。
SET NAMES utf8mb4;
SELECT
TRIM(product_name_on_cert) AS candidate_category_name,
COUNT(*) AS cert_count,
SUM(CASE WHEN cert_type = 'purchase' THEN 1 ELSE 0 END) AS purchase_count,
SUM(CASE WHEN cert_type = 'transport' THEN 1 ELSE 0 END) AS transport_count
FROM (
SELECT 'purchase' AS cert_type, product_name_on_cert
FROM pl_purchase_cert
WHERE del_flag = 0
AND product_name_on_cert IS NOT NULL
AND TRIM(product_name_on_cert) <> ''
UNION ALL
SELECT 'transport' AS cert_type, product_name_on_cert
FROM pl_transport_cert
WHERE del_flag = 0
AND product_name_on_cert IS NOT NULL
AND TRIM(product_name_on_cert) <> ''
) t
GROUP BY TRIM(product_name_on_cert)
ORDER BY cert_count DESC, candidate_category_name;
@@ -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;
@@ -0,0 +1,28 @@
-- 证件额度切点改为首次安排时间:补 arranged_at 列,并按运输单创建时间回填已安排计划。
-- MySQL 5.7。可重复执行。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_arranged_at');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN cert_quota_arranged_at datetime DEFAULT NULL COMMENT ''首次安排时间(证件额度切点用);再次安排不改'' AFTER cert_quota_rule_version',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 已安排:回填首次安排时刻,并按「安排时刻 vs 生效时间」校正规则版本(修正历史按创建打标)。
UPDATE pl_plan p
INNER JOIN pl_transport t
ON t.id = p.transport_id AND t.tenant_no = p.tenant_no AND t.del_flag = 0
LEFT JOIN pl_cert_quota_rule_config cfg
ON cfg.tenant_no = p.tenant_no AND cfg.del_flag = 0
SET
p.cert_quota_arranged_at = COALESCE(p.cert_quota_arranged_at, t.create_time),
p.cert_quota_rule_version = CASE
WHEN cfg.effective_at IS NOT NULL
AND COALESCE(p.cert_quota_arranged_at, t.create_time) >= cfg.effective_at
THEN 'customer_category'
ELSE 'legacy'
END
WHERE p.del_flag = 0
AND p.transport_status = 'arranged';
@@ -0,0 +1,7 @@
-- 客户+品类规则下用证明细不再依赖合同绑证 scope;将合同绑定字段改为可空。
SET NAMES utf8mb4;
ALTER TABLE `pl_plan_cert_usage_item`
MODIFY COLUMN `contract_cert_link_id` varchar(64) DEFAULT NULL COMMENT '合同与证件绑定ID;客户品类规则可空',
MODIFY COLUMN `contract_cert_product_id` varchar(64) DEFAULT NULL COMMENT '合同证件产品范围明细ID;客户品类规则可空',
MODIFY COLUMN `contract_scope_revision` int(11) NOT NULL DEFAULT 0 COMMENT '合同证件适用范围版本;客户品类规则为0';
@@ -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;
@@ -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;
@@ -0,0 +1,28 @@
-- 证件额度切点改为首次安排时间:补 arranged_at 列,并按运输单创建时间回填已安排计划。
-- MySQL 5.7。可重复执行。
SET NAMES utf8mb4;
SET @schema_name = DATABASE();
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='pl_plan' AND COLUMN_NAME='cert_quota_arranged_at');
SET @sql = IF(@col=0,
'ALTER TABLE pl_plan ADD COLUMN cert_quota_arranged_at datetime DEFAULT NULL COMMENT ''首次安排时间(证件额度切点用);再次安排不改'' AFTER cert_quota_rule_version',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 已安排:回填首次安排时刻,并按「安排时刻 vs 生效时间」校正规则版本(修正历史按创建打标)。
UPDATE pl_plan p
INNER JOIN pl_transport t
ON t.id = p.transport_id AND t.tenant_no = p.tenant_no AND t.del_flag = 0
LEFT JOIN pl_cert_quota_rule_config cfg
ON cfg.tenant_no = p.tenant_no AND cfg.del_flag = 0
SET
p.cert_quota_arranged_at = COALESCE(p.cert_quota_arranged_at, t.create_time),
p.cert_quota_rule_version = CASE
WHEN cfg.effective_at IS NOT NULL
AND COALESCE(p.cert_quota_arranged_at, t.create_time) >= cfg.effective_at
THEN 'customer_category'
ELSE 'legacy'
END
WHERE p.del_flag = 0
AND p.transport_status = 'arranged';
@@ -0,0 +1,7 @@
-- 客户+品类规则下用证明细不再依赖合同绑证 scope;将合同绑定字段改为可空。
SET NAMES utf8mb4;
ALTER TABLE `pl_plan_cert_usage_item`
MODIFY COLUMN `contract_cert_link_id` varchar(64) DEFAULT NULL COMMENT '合同与证件绑定ID;客户品类规则可空',
MODIFY COLUMN `contract_cert_product_id` varchar(64) DEFAULT NULL COMMENT '合同证件产品范围明细ID;客户品类规则可空',
MODIFY COLUMN `contract_scope_revision` int(11) NOT NULL DEFAULT 0 COMMENT '合同证件适用范围版本;客户品类规则为0';
@@ -0,0 +1,16 @@
-- 合同用证 → 客户用证(菜单展示名);保留菜单 ID 与权限码。
SET NAMES utf8mb4;
START TRANSACTION;
UPDATE sys_menu
SET menu_name = '客户用证', update_time = NOW()
WHERE del_flag = 0
AND (
id = 'PL_CONTRACT_QUOTA'
OR (path = 'contract-quota' AND perms = 'pl:quota-cert:list')
OR id = 'MINI_PL_CERT_CONTRACT'
OR perms = 'mini:pl:contract-quota:list'
)
AND menu_name = '合同用证';
COMMIT;
@@ -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(`- [x]`)跟踪。
> **设计权威:** `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`
- [x] **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(或设计等价字段)
- [x] **Step 2:** 字段注释与 AGENTS 字段序一致;索引含 tenant_no + 常用筛选。
- [x] **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/`
- [x] **Step 1:** 增加 SQLAlchemy 模型与 Pydantic schema(camelCase alias)。
- [x] **Step 2:** 品类 CRUD + 关联产品差量保存(遵守 child-data-save-rules)。
- [x] **Step 3:** pytest:创建品类、关联两产品、停用关联、租户隔离。
- [x] **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`)
- [x] **Step 1:** GET/PUT 当前租户 `effective_at`。
- [x] **Step 2:** `assert_cert_quota_rule_configured`:`effective_at is None` → HTTP 409 固定中文文案。
- [x] **Step 3:** 计划创建时按设计 §7 打 `cert_quota_rule_version`。
- [x] **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/` 状态漏斗
- [x] **Step 1:** 列表 API 默认 `status not in (closed, void)`;显式传 status 时以参数为准。
- [x] **Step 2:** 前端列头漏斗可选到已核销/作废;默认不选。
- [x] **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` 若同表单模型)
- [x] **Step 1:** 品类列表/编辑/关联产品(Fresh Mint,选 P 模式并在说明中写明)。
- [x] **Step 2:** 系统信息下 Tab「运输证件规则」编辑生效时间。
- [x] **Step 3:** 购买证/运输证表单增加客户、品类(运输证展示派生品类)。
- [x] **Step 4:** 菜单 SQL/权限码文档化(若需插入菜单,写 SQL 片段)。
### Task 1.6:计划详情展示位(可读)
**Files:**
- Modify: `web-admin/src/components/business/pl-plan-view-tabs/` 或等价详情
- Modify: 计划详情 API 若需返回 usage 证件摘要
- [x] **Step 1:** 详情区展示已关联购买证/运输证(无数据时占位「暂无」)。
- [x] **Step 2:** 同市来源可只显示购买证。
### 一期完成门禁
- [x] 本地执行相关 pytest,记录命令与结果。
- [x] `pnpm build:typecheck`(若改了 web-admin)或说明未跑原因。
- [x] **停止并等待用户:「开始第二期」**
---
## 二期:安排/归档新选证 + 祖父条款
### Task 2.1:用证来源按段客户(D6/D7/D8/D9)
**Files:**
- Modify: `backend/app/services/pl_quota_sources.py`
- [x] **Step 1:** 外部装/送段均进入来源(非仅单侧计量吞掉对侧额度需求——按设计 D7);内部山峡/云栋过滤保留。
- [x] **Step 2:** `same_city` → `requires_transport=False` 保留。
- [x] **Step 3:** 来源快照写入 `customer_id`/`customer_name`。
- [x] **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 调用
- [x] **Step 1:** 实现设计 §5 伪代码;单测:10 吨跳过、100 吨命中;无运输证跳过该购买证;同市无运输证成功。
- [x] **Step 2:** 新规则路径禁止拆分到多购买证。
- [x] **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`、前端安排提交
- [x] **Step 1:** `customer_category` 版本:`build_usage_request` 不再 `resolve_contract_bound_certs`。
- [x] **Step 2:** 安排失败文案引导证书齐备;移除/隐藏手工 `quotaConfiguration` 安排入口。
- [x] **Step 3:** 归档 settle 仍走账本;新规则余额不足文案更新为客户/品类语境。
- [x] **Step 4:** legacy:已安排归档不因缺少客户品类绑证失败(单测)。
### Task 2.4:合同绑证 UI 收敛
**Files:**
- Modify: `web-admin/src/views/pl/contract-quota/` 等
- [x] **Step 1:** 新规则生效后隐藏或只读提示,避免双轨操作。
### 二期完成门禁
- [x] 相关 pytest 全绿并记录。
- [x] **停止并等待用户:「开始第三期」**
---
## 三期:证书齐备 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
- [x] **Step 1:** 标记齐备 → `quota_cert_pending_fulfill=1`,安排可跳过 reserve。
- [x] **Step 2:** `settle`/`archive`:若 pending 或 waived 未完善 → 409,不得跳过额度。
- [x] **Step 3:** pytest:齐备可安排、不可归档;完善后可归档。
### Task 3.2:完善并关闭 API
**Files:**
- Modify: `backend/app/api/pl/plan.py` 或 `quota_cert.py`
- Create service:对单计划执行与安排相同的选证 + reserve
- [x] **Step 1:** `POST .../fulfill-quota-certs`(单条)与 batch 接口。
- [x] **Step 2:** 成功:写 usage、清 pending、出队;失败:保留 pending + 原因。
- [x] **Step 3:** 并发单测或文档化行锁策略。
### Task 3.3:证书齐备 Web
**Files:**
- Modify/Create: 证书齐备列表页(现有 menu 路由对齐)
- [x] **Step 1:** 仅列出新规则待完善计划。
- [x] **Step 2:** 上传购买证/运输证入口。
- [x] **Step 3:** 操作列完善并关闭 + 批量;成功刷新列表。
- [x] **Step 4:** 归档弹窗对接「未完善」错误引导。
### Task 3.4:详情展示真实挂证
- [x] **Step 1:** 完善或安排成功后详情可见证号/品类/占用等摘要。
### 三期完成门禁
- [x] 全链路 pytest + 必要前端 typecheck。
- [x] 更新本 plan 所有 checkbox;在设计文档顶部标注「已实现待验收」或按用户要求提交。
- [x] **停止并等待用户验收;非用户要求不要 push。**
---
## 数据迁移作业(可穿插在一期后、切点打开前)
- [x] 提供只读 SQL/脚本:按 `product_name_on_cert` 分组列出候选品类名。
- [x] 文档说明:用户手工建品类并关联产品、回填证件 `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. 每期结束用简体中文汇报:改了什么、验证命令与结果、未跑项、请用户确认下一期。
@@ -0,0 +1,77 @@
# Web 附件图片复制到剪贴板 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 Web 管理端公共附件展示处提供「复制」按钮,将图片写入系统剪贴板。
**Architecture:** 复用 `fetchAttachmentBlob` 取得图片 Blob,经 Clipboard API(`ClipboardItem` / PNG)写入剪贴板;在 `attachment-upload-field`、`attachment-preview-dialog` 及主要列表附件单元格与「下载」同级增加显式按钮。不改后端、不改小程序。
**Tech Stack:** Vue 3、TypeScript、Element Plus、现有 `web-admin/src/utils/download-attachment.ts` / `clipboard.ts`
**Spec:** `docs/superpowers/specs/2026-11-14-web-attachment-copy-clipboard-design.md`
**Issue:** https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28
---
## File map
| 文件 | 职责 |
| --- | --- |
| `web-admin/src/utils/clipboard.ts` | 新增图片 Blob / 附件图片复制 API;保留现有文本复制 |
| `web-admin/src/utils/clipboard.image.test.ts`(或同目录既有测试风格) | 工具函数单测(mock clipboard / fetch) |
| `web-admin/src/components/business/attachment-upload-field/index.vue` | 卡片操作区 + 预览工具栏「复制」 |
| `web-admin/src/components/business/attachment-preview-dialog/index.vue` | 预览工具栏「复制」 |
| `web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue` 等列表单元格 | 下载旁图片复制(按现有下载模式) |
---
### Task 1: 剪贴板图片工具函数
**Files:**
- Modify: `web-admin/src/utils/clipboard.ts`
- Create/Modify: 同目录或 `web-admin` 既有测试约定下的单测文件
- [x] **Step 1:** 实现 `ensurePngBlob(blob)`(已是 png 则直通,否则 ImageBitmap/canvas → PNG)
- [x] **Step 2:** 实现 `copyImageBlobToClipboard(blob): Promise<boolean>`(`navigator.clipboard.write` + `ClipboardItem`)
- [x] **Step 3:** 实现 `copyAttachmentImageToClipboard(file, options?)`:非图片返回 false;否则 `fetchAttachmentBlob` → `copyImageBlobToClipboard`
- [x] **Step 4:** 补充单元测试(mock `navigator.clipboard.write`、非图片短路、PNG 直通)
- [x] **Step 5:** 运行相关测试通过
### Task 2: 公共附件卡与内嵌预览
**Files:**
- Modify: `web-admin/src/components/business/attachment-upload-field/index.vue`
- [x] **Step 1:** 卡片 footer / compact 下载旁增加复制按钮(图片用图片复制;非图片可用 `copyTextToClipboard` 复制 URL 或隐藏复制)
- [x] **Step 2:** 图片预览工具栏增加「复制」按钮与 loading/禁用态
- [x] **Step 3:** 成功/失败 `ElMessage` 文案与设计一致
### Task 3: 独立预览弹窗
**Files:**
- Modify: `web-admin/src/components/business/attachment-preview-dialog/index.vue`
- [x] **Step 1:** 图片工具栏增加「复制」,调用同一工具函数
- [x] **Step 2:** 仅 `kind === 'image'` 显示
### Task 4: 主要列表附件单元格
**Files:**
- Modify: `pl-plan-document-attachment-cell`、`pl-cert-list-attachment-cell`、`pl-carrier-list-attachment-cell`、`pl-plan-list-cert-cell`(有下载角标处)
- [x] **Step 1:** 图片项在下载旁增加复制,`@click.stop`
- [x] **Step 2:** 非图片不显示图片复制(或仅复制链接,与 Task 2 策略一致)
### Task 5: 验证
- [x] **Step 1:** `pnpm exec tsx --test src/utils/clipboard.image.test.ts` — 5 pass
- [x] **Step 2:** `vue-tsc --noEmit`:本次改动文件无新增相关错误;全仓仍有 workflow 等既有错误与缺失 `.auto-import.json` 导致的 `reactive` 误报
- [ ] **Step 3:** 手工:预览弹窗复制图片后粘贴到微信/Word(待本机验收)
---
## 统一交互策略(实现锁定)
- 图片:复制位图到剪贴板;成功「已复制到剪贴板」
- 非图片:卡片/列表显示「复制链接」(`copyTextToClipboard`),成功「已复制链接」;预览弹窗非图片无复制按钮
- 失败:统一「复制失败,请允许剪贴板权限或改用下载」
@@ -0,0 +1,229 @@
# 运输计划购买证/运输证:客户+品类额度改造设计
> 状态:已实现待验收(2026-11-14;三期代码已落地,待业务验收;非用户要求不 push)
> 关联计划:`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/新规则。
@@ -0,0 +1,98 @@
# Web 附件图片复制到剪贴板
工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28
## 背景与问题
系统多处图片/文件展示提供「下载」,用户希望再提供「复制」,将图片快速写入操作系统剪贴板,便于粘贴到微信、Word 等。场景分布在列表缩略图、表单附件卡、详情只读与预览弹窗,入口需统一,避免逐页发明交互。
## 已确认决策
| 项 | 结论 | 依据 |
| --- | --- | --- |
| 修改端 | 仅 `web-admin`(桌面 Web;移动 H5 若复用同一附件组件则自动获得) | 用户 2026-11-14 确认「只修改 web 端,不改小程序端」 |
| 主入口 | 与「下载」同级的显式「复制」按钮;预览弹窗工具栏必有 | 对话方案获用户认可 |
| 图片行为 | 写入系统剪贴板为图片(`image/png` 优先) | Clipboard API + 用户诉求 |
| 非图片 | 不写入文件本体;可「复制链接」或仅保留下载 | 浏览器对任意文件剪贴板支持差 |
| 右键 / 快捷键 | 本期不做主入口(可不做) | 发现成本高、易与表格/输入焦点冲突 |
| 小程序 | 本期不改 | 用户明确排除;且无法位图复制 |
| Figma 原型 | 免除,按现有下载旁按钮样式实现 | 用户 2026-11-14 选择「1」;此前推荐亦到期可采用 |
## 方案对比
| 方案 | 做法 | 优点 | 缺点 |
| --- | --- | --- | --- |
| A(推荐) | 公共工具函数 + 公共附件/预览组件加按钮 | 一处覆盖列表/表单/详情多数场景;与下载同发现路径 | 少数自建附件 UI 需二次补齐 |
| B | 各业务页单独加复制 | 可控精细 | 遗漏多、风格易漂 |
| C | 仅右键 / 快捷键 | UI 干净 | B 端发现差,触控与 H5 弱 |
**采用方案 A。**
## 范围
**包含**
- `web-admin`:剪贴板工具(图片 Blob → `navigator.clipboard.write`)
- 复用现有 `fetchAttachmentBlob`(`download-attachment.ts`)取内容
- 公共组件入口:
- `attachment-upload-field`:卡片操作区 / compact 下载旁;图片预览工具栏
- `attachment-preview-dialog`:图片预览工具栏
- 主要列表附件单元格(如 `pl-*-attachment-cell`、证书列表单元格等已有下载角标处)按同一模式补「复制」(仅图片显示图片复制;非图片可选复制链接或隐藏)
- 成功 / 失败 `ElMessage` 提示;安全上下文或权限失败时明确提示
**不包含**
- `miniprogram/` 任何改动
- 后端 API / schema 变更(沿用现有预签名下载)
- 右键菜单、全局快捷键作为主入口
- PDF/Office 等「复制文件本体」进剪贴板
- 强制改造所有业务页内联重复附件 UI(优先公共组件;遗漏页可后续补)
## 前端设计
### 工具函数
在 `web-admin/src/utils/clipboard.ts`(或同目录新建 `copy-attachment.ts` 并由 clipboard 导出)增加:
- `copyImageBlobToClipboard(blob: Blob): Promise<boolean>`
- 优先 `ClipboardItem` 写入;若类型非 `image/png`,经 canvas/`createImageBitmap` 转为 PNG 再写(兼容 JPEG 等)
- 无 Clipboard API / 非安全上下文 / 权限拒绝 → 返回 false
- `copyAttachmentImageToClipboard(file, options?): Promise<boolean>`
- 内部:`detectAttachmentPreviewKind` 非 image 则失败或降级复制 URL 文本(由调用方决定文案)
- `fetchAttachmentBlob` → `copyImageBlobToClipboard`
非图片「复制链接」可复用已有 `copyTextToClipboard`。
### UI 放置
1. **附件卡片**(`attachment-upload-field`):`下载` 旁增加复制图标按钮;仅当判定为图片时执行图片复制,其它类型可复制链接或只显示下载。
2. **预览弹窗工具栏**(upload-field 内嵌预览 + `attachment-preview-dialog`):在重置等按钮后增加「复制」;仅 `kind === 'image'` 时显示。
3. **列表缩略图角标**:已有下载角标的单元格,图片项增加复制角标或与下载并列,点击 `@click.stop`,避免触发行预览。
按钮样式、图标遵循 Fresh Mint / 现有 `action-btn` 与预览工具栏按钮规范(图标 + title 或短文案「复制」)。
### 数据与网络
- 剪贴板需要 Blob,不能只拷 DOM。
- 使用现有预签名下载路径取 Blob;常命中浏览器缓存,用户体感可接受。
- 不新增后端接口。
### 兼容与降级
| 情况 | 行为 |
| --- | --- |
| 现代桌面 Chrome / Edge | 图片写入剪贴板,成功提示「已复制到剪贴板」 |
| 权限 / 非 HTTPS / 旧浏览器 | 失败提示「复制失败,请允许剪贴板权限或改用下载」;可选降级复制图片 URL |
| 移动 H5(同组件) | 尽力而为;失败走同一提示,不单独做小程序方案 |
## 验收条件
1. Web 管理端:在公共附件卡、图片预览弹窗可见「复制」,点击后可将图片粘贴到本机支持图片剪贴板的应用(如预览/Word/微信桌面,视 OS 而定)。
2. 非图片附件:不出现误导性的「复制图片」成功;链接复制或仅下载,行为与提示一致。
3. 小程序目录无本次改动。
4. 无后端变更;相关前端 lint / typecheck 通过(按实施计划执行)。
## 风险与后续
- MinIO / 预签名 CORS 若阻止 `fetch` Blob,复制会失败(下载走 `<a download>` 可能仍可用)→ 失败提示引导改用下载;必要时再评估代理拉取。
- 少数未走公共组件的页面可能暂无按钮 → 后续按反馈补齐。
@@ -0,0 +1,30 @@
# 证件额度切点:按首次安排时间(设计)
工单:[#40](https://gitea.aichengyan.com/cykj/shanxia2.0/issues/40)
## 背景
原设计按计划 `create_time` 与 `effective_at` 比较打 `cert_quota_rule_version`。业务要求改为按**首次安排运输**时刻切点;切点前已安排的计划归档不再校购买证/运输证。
## 定稿规则
1. **未配置 `effective_at`**:安排仍 409 拦住(不变)。
2. **首次安排**时写入:
- `cert_quota_arranged_at = 安排时刻`
- `cert_quota_rule_version = customer_category` 若 `安排时刻 >= effective_at`,否则 `legacy`
3. **再次调整运输**:不重打 `cert_quota_arranged_at` / `cert_quota_rule_version`(冻结首次安排)。
4. **创建计划**:不再按创建时间打标(可空,待安排时写入)。
5. **归档**:
- `legacy`(切点前首次安排):不校验、不扣购买证/运输证,额度结转按跳过处理,允许直接归档。
- `customer_category`:保持现有最新业务逻辑(选证、齐备仅放行安排、归档须真证等)。
6. **历史已安排且缺 `cert_quota_arranged_at`**:归档/读规则时用关联 `pl_transport.create_time` 推断;若推断时刻 `< effective_at` 视为 `legacy` 祖父。
## 范围
- backend:切点服务、安排打标、归档跳过、迁移列
- web-admin:系统信息「运输证件规则」说明文案
## 非目标
- 不批量改历史 `quota_cert_waived`
- 不改选证算法本身
+6
View File
@@ -61,6 +61,9 @@
"@tailwindcss/vite": "^4.1.14",
"@tinymce/tinymce-vue": "^6.3.0",
"@turbodocx/html-to-docx": "^1.22.2",
"@univerjs/preset-sheets-core": "1.4.0",
"@univerjs/preset-sheets-filter": "1.4.0",
"@univerjs/presets": "1.4.0",
"@vue-flow/core": "^1.48.2",
"@vue/reactivity": "^3.5.21",
"@vueuse/core": "^13.9.0",
@@ -86,6 +89,9 @@
"pinia-plugin-persistedstate": "^4.3.0",
"pinyin-pro": "^3.28.1",
"qrcode.vue": "^3.6.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"rxjs": "7.8.2",
"sortablejs": "^1.15.7",
"tailwindcss": "^4.1.14",
"tinymce": "^8.5.1",
+1469 -19
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -9,4 +9,5 @@ allowBuilds:
core-js: false
es5-ext: false
esbuild: true
protobufjs: true
vue-demi: true
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveArchiveQuotaBlockMode } from '../src/utils/pl-archive-quota-block'
test('pending fulfill plan only alerts, no certificate-ready confirm', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
'用证没有完成',
{ quotaCertPendingFulfill: 1, quotaCertWaived: 1 }
),
'alert-pending'
)
})
test('already waived plan does not offer archive-time certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode('余额不足', '用证没有完成', { quotaCertWaived: 1 }),
'alert-pending'
)
})
test('pending keywords still force alert mode without plan flags', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档',
'用证没有完成',
{}
),
'alert-pending'
)
})
test('unmarked quota block only alerts, never offers certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode(
'实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。',
'用证没有完成',
{ quotaCertPendingFulfill: 0, quotaCertWaived: 0 }
),
'alert-plain'
)
})
test('non quota-title blocks stay plain alert', () => {
assert.equal(resolveArchiveQuotaBlockMode('不能归档:缺证', '不能归档', {}), 'alert-plain')
})
test('missing actual-quantity settle error does not offer certificate ready', () => {
assert.equal(
resolveArchiveQuotaBlockMode('必须明确提交每个来源的实际量', '用证没有完成', {}),
'alert-plain'
)
})
@@ -25,6 +25,20 @@ test('incomplete certificate filter is sent as quotaCertWaivedIn=0', () => {
assert.deepEqual(params, { quotaCertWaivedIn: '0' })
})
test('fulfill tab selection column is first among table columns', () => {
const web = readFileSync(new URL('../src/views/pl/cert-ready/index.vue', import.meta.url), 'utf8')
const selectionIdx = web.indexOf('type="selection"')
const columnsIdx = web.indexOf('v-for="column in columns"')
const actionsIdx = web.indexOf('label="操作"')
assert.ok(selectionIdx > 0, 'selection column missing')
assert.ok(columnsIdx > selectionIdx, 'selection must precede business columns')
assert.ok(actionsIdx > columnsIdx, 'actions must follow business columns')
assert.match(
web,
/v-if="listMode === 'fulfill'"[\s\S]*?type="selection"[\s\S]*?v-for="column in columns"/
)
})
test('web funnel and miniprogram status dropdown default to incomplete', () => {
const web = readFileSync(new URL('../src/views/pl/cert-ready/index.vue', import.meta.url), 'utf8')
const mobile = readFileSync(
+17 -4
View File
@@ -203,13 +203,14 @@ test('real template keeps plans quota proof order and separate lazy contract sco
canViewQuotaCert: true,
canViewProof: true,
detailActiveTab: 'proof',
detailContract: { id: 42 }
detailContract: { id: 42, customerId: 'cust-42' }
}
const rows = panes(ctx)
assert.deepEqual(
rows.map((v: any) => v.props.name),
['plans', 'quota', 'proof']
)
assert.equal(rows[1].props.label, '客户证件与额度')
assert.equal(rows[2].props.label, '运输计划合法使用证明')
assert.equal(rows[2].props.lazy, '')
assert.equal(panel(rows[1]), undefined)
@@ -219,14 +220,19 @@ test('real template keeps plans quota proof order and separate lazy contract sco
assert.equal(proof.props.active, true)
assert.equal(proof.props.embedded, '')
ctx.detailContract.id = 43
ctx.detailContract.customerId = 'cust-43'
ctx.currentDetailId = 43
assert.equal(panel(panes(ctx)[2]).key, 43)
ctx.detailActiveTab = 'quota'
const quota = panel(panes(ctx)[1])
assert.equal(quota.type?.name || quota.type, 'CustomerCertQuotaPanel')
assert.equal(quota.props['customer-id'] ?? quota.props.customerId, 'cust-43')
assert.equal(quota.props.active, true)
ctx.canViewQuotaCert = false
assert.deepEqual(
panes(ctx).map((v: any) => v.props.name),
['plans', 'proof']
)
ctx.detailActiveTab = 'quota'
assert.equal(panel(panes(ctx)[1]), undefined)
})
@@ -265,7 +271,14 @@ test('mounted template unmounts old contract and never mounts quota for proof-on
}
}),
StopDocuments: Child('proof'),
ContractQuotaPanel: Child('quota'),
CustomerCertQuotaPanel: vue.defineComponent({
props: ['customerId', 'active'],
setup(props) {
lifecycle.push(`mount:quota:${props.customerId}`)
vue.onBeforeUnmount(() => lifecycle.push(`unmount:quota:${props.customerId}`))
return () => vue.h('span')
}
}),
PlContractLinkedPlansPanel: vue.defineComponent({
setup() {
return () => vue.h('span')
@@ -282,7 +295,7 @@ test('mounted template unmounts old contract and never mounts quota for proof-on
canViewQuotaCert: false,
canViewProof: true,
detailActiveTab: 'proof',
detailContract: { id: 'A' }
detailContract: { id: 'A', customerId: 'cust-A' }
})
const renderer = vue.createRenderer<any, any>({
createElement: () => ({ children: [] }),
@@ -0,0 +1,126 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
test('web contract detail embeds customer cert panel and renamed tab', () => {
const source = readFileSync(
new URL('../src/views/crm/contract/components/ContractDetailDialog.vue', import.meta.url),
'utf8'
)
assert.match(source, /label="客户证件与额度"/)
assert.match(source, /CustomerCertQuotaPanel/)
assert.doesNotMatch(source, /ContractQuotaPanel/)
assert.match(source, /:customer-id="detailContract\.customerId"/)
})
test('h5 contract detail includes customer cert tab', () => {
const source = readFileSync(
new URL('../src/views/m/crm/contract/detail.vue', import.meta.url),
'utf8'
)
assert.match(source, /CustomerCertQuotaTab/)
assert.match(source, /客户证件与额度/)
assert.match(source, /canViewQuotaCert/)
assert.match(source, /:customer-id="contract\.customerId"/)
})
test('customer cert panel lists by customerId without contract bindings', () => {
const source = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerCertQuotaPanel.vue', import.meta.url),
'utf8'
)
assert.match(source, /quotaCertApi\.list\('purchase', \{ customerId \}\)/)
assert.match(source, /quotaCertApi\.list\('transport', \{ customerId \}\)/)
assert.match(source, /有效次数/)
assert.match(source, /singleUseConsumed/)
assert.doesNotMatch(source, /saveBindings|openCreate|bindings\(/)
})
test('standalone contract-quota page is customer quota list', () => {
const panel = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
'utf8'
)
const wrapper = readFileSync(
new URL('../src/views/pl/contract-quota/ContractQuotaPanel.vue', import.meta.url),
'utf8'
)
const router = readFileSync(new URL('../src/router/modules/pl.ts', import.meta.url), 'utf8')
assert.match(panel, /客户用证/)
assert.match(panel, /quotaStatisticsApi\.customers/)
assert.match(wrapper, /CustomerQuotaPanel/)
assert.match(router, /title: '客户用证'/)
})
test('web customer quota panel uses toQueryParams from useServerColumnFilters', () => {
const panel = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
'utf8'
)
assert.match(panel, /toQueryParams/)
assert.match(panel, /\.\.\.toQueryParams\(\)/)
assert.doesNotMatch(panel, /\bbuildQuery\b/)
assert.doesNotMatch(panel, /\bresetFilters\b/)
assert.match(panel, /clearAllFilters/)
assert.match(panel, /watchVisibleColumns/)
})
test('web customer quota panel computes visibleDefs locally (not from composable)', () => {
const panel = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
'utf8'
)
assert.doesNotMatch(
panel,
/const \{[^}]*\bvisibleDefs\b[^}]*\} = usePersistedListColumns/
)
assert.match(
panel,
/const \{ visibleColumns, checkAll, isIndeterminate, resetToDefault \} = usePersistedListColumns/
)
assert.match(
panel,
/const visibleDefs = computed\(\(\) =>\s*columns\.filter\(\(column\) => visibleColumns\.value\.includes\(column\.prop\)\)/
)
assert.match(panel, /v-for="column in visibleDefs"/)
})
test('h5 customer tab renders customer fields without persisted column defs', () => {
const tab = readFileSync(
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
'utf8'
)
assert.doesNotMatch(tab, /usePersistedListColumns/)
assert.doesNotMatch(tab, /visibleDefs/)
assert.match(tab, /row\.customerName/)
assert.match(tab, /row\.customerNo/)
assert.match(tab, /row\.certCount/)
})
test('h5 customer quota tab does not call buildQuery', () => {
const tab = readFileSync(
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
'utf8'
)
assert.doesNotMatch(tab, /\bbuildQuery\b/)
assert.match(tab, /quotaStatisticsApi\.customers/)
})
test('web customer quota panel wires viewport height like StatisticsPanel', () => {
const panel = readFileSync(
new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url),
'utf8'
)
assert.match(panel, /useElementSize\(tableWrapRef\)/)
assert.match(
panel,
/const expansionStyle = computed\(\(\) => \(\{[\s\S]*width: tableViewportWidth/
)
assert.match(panel, /tableEl: tableWrapRef/)
assert.match(panel, /pagerEl: pagerRef/)
assert.doesNotMatch(panel, /\bwrapRef\s*:/)
assert.doesNotMatch(
panel,
/const \{[^}]*\bexpansionStyle\b[^}]*\} = useListTableViewportHeight/
)
})
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const readyTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/ReadyTab.vue', import.meta.url),
'utf8'
)
const contractTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url),
'utf8'
)
const planCertIndex = readFileSync(
new URL('../src/views/m/pl/plan-cert/index.vue', import.meta.url),
'utf8'
)
const archiveForm = readFileSync(
new URL('../src/views/m/pl/plan-cert/archive-form.vue', import.meta.url),
'utf8'
)
const stopCard = readFileSync(
new URL('../src/views/m/pl/plan/PlanStopReadCard.vue', import.meta.url),
'utf8'
)
test('H5 证书齐备对齐 2.0:待完善列表与完善并关闭,且文案不宣称归档可跳过额度', () => {
assert.match(readyTab, /listPendingFulfill/)
assert.match(readyTab, /certReadyApi\.fulfill/)
assert.match(readyTab, /fulfillBatch/)
assert.match(readyTab, /已标记待完善/)
assert.match(readyTab, /完善并关闭/)
assert.match(readyTab, /归档前仍须/)
assert.doesNotMatch(readyTab, /安排和归档时都不再校验/)
assert.doesNotMatch(readyTab, /也不扣减额度/)
})
test('H5 客户用证按客户列表展示证件', () => {
assert.match(contractTab, /quotaStatisticsApi\.customers/)
assert.match(contractTab, /\/m\/pl\/plan-cert\/customer\//)
assert.match(contractTab, /暂无客户用证数据/)
assert.match(planCertIndex, /title: '客户用证'/)
assert.match(planCertIndex, /客户名称 \/ 编号/)
assert.doesNotMatch(planCertIndex, /合同编号 \/ 名称 \/ 销售或购买单位/)
})
test('H5 客户用证详情显式导入 WebView 壳,避免空白页', () => {
const customerDetail = readFileSync(
new URL('../src/views/m/pl/plan-cert/customer-detail.vue', import.meta.url),
'utf8'
)
assert.match(customerDetail, /import MpWebviewShell from '@\/layouts\/MpWebviewShell\.vue'/)
assert.match(customerDetail, /import MpBusinessHeader from '@\/components\/mp\/MpBusinessHeader\.vue'/)
assert.match(customerDetail, /@ready="onShellReady"/)
})
test('H5 证件详情与建档列表展示系统客户和证用品类', () => {
const faceModel = readFileSync(
new URL('../src/views/m/pl/plan-cert/face-model.ts', import.meta.url),
'utf8'
)
const archiveTab = readFileSync(
new URL('../src/views/m/pl/plan-cert/ArchiveTab.vue', import.meta.url),
'utf8'
)
assert.match(faceModel, /label: '证用品类'/)
assert.match(faceModel, /certCategoryLabel/)
assert.match(archiveTab, /系统客户/)
assert.match(archiveTab, /证用品类/)
assert.match(archiveTab, /certCategoryLabel/)
})
test('H5 建档页使用关联购买证且无证面引用残留', () => {
assert.match(archiveForm, /关联购买证/)
assert.doesNotMatch(archiveForm, /证面引用/)
})
test('H5 段次只读卡展示同市只需购买证', () => {
assert.match(stopCard, /是否同市/)
assert.match(stopCard, /同市只需购买证/)
})
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { toxicArrangeBlockMessage } from '../src/utils/pl-quota-cert-waiver'
const detail = readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
const stopCard = readFileSync(
new URL('../src/views/m/pl/plan/PlanStopReadCard.vue', import.meta.url),
'utf8'
)
const loadSection = readFileSync(
new URL('../src/views/m/pl/plan/MobilePlanLoadSection.vue', import.meta.url),
'utf8'
)
const deliverySection = readFileSync(
new URL('../src/views/m/pl/plan/MobilePlanDeliverySection.vue', import.meta.url),
'utf8'
)
const certFace = readFileSync(
new URL('../src/views/m/pl/plan-cert/CertFacePanel.vue', import.meta.url),
'utf8'
)
const arrangeSubmit = readFileSync(
new URL('../src/composables/usePlPlanArrangeSubmit.ts', import.meta.url),
'utf8'
)
const formDialog = readFileSync(
new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url),
'utf8'
)
const detailActions = readFileSync(
new URL('../src/utils/mp-pl-plan-detail-actions.ts', import.meta.url),
'utf8'
)
test('H5 计划详情提供查看用证入口并挂载 CertFacePanel', () => {
assert.match(detail, /查看用证/)
assert.match(detail, /CertFacePanel/)
assert.match(detail, /quotaOpen/)
})
test('H5 装送段可编辑同市,只读卡标明同市只需购买证', () => {
assert.match(loadSection, /sameCity/)
assert.match(deliverySection, /sameCity/)
assert.match(stopCard, /同市只需购买证/)
})
test('H5 用证面板新规则只读提示,且占用明细可回填证号', () => {
assert.match(certFace, /客户\+品类自动选证/)
assert.match(certFace, /安排不提供手工选证/)
assert.match(certFace, /certNoSnapshot/)
assert.match(certFace, /sourceStopProductId/)
})
test('H5/共用安排提交不附带手工 quotaConfiguration', () => {
assert.doesNotMatch(arrangeSubmit, /quotaConfiguration/)
assert.match(formDialog, /toxicArrangeBlockMessage/)
assert.match(formDialog, /buildArrangePayloadFromPlanForm/)
})
test('新规则安排以服务端齐备/余量检查为准,不被本地合同绑证缺口误拦', () => {
const unbound = '合同HT-1尚未绑定购买证和运输证,请先到合同中绑定后再安排运输'
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '' },
unbound
),
''
)
assert.equal(
toxicArrangeBlockMessage(
{
certQuotaRuleVersion: 'customer_category',
arrangeBlockMessage: '新规则下证书齐备只放行安排'
},
unbound
),
'新规则下证书齐备只放行安排'
)
})
test('H5 计划详情无运输归档操作(归档在 Web;齐备完善在 plan-cert)', () => {
assert.doesNotMatch(detailActions, /archive/)
assert.doesNotMatch(detailActions, /归档/)
assert.match(detailActions, /arrange/)
})
@@ -11,10 +11,14 @@ test('危化品证上传位于运输管理并复用现有 WebView 壳', async ()
const { WORKBENCH_MODULES } = await import(`data:text/javascript,${encodeURIComponent(source)}`)
const transport = WORKBENCH_MODULES.find((m) => m.key === 'pl')
const entry = transport.menus.find((m) => m.code === 'pl-plan-cert-webview')
assert.ok(entry, '运输管理缺少危化品证上传入口')
assert.equal(entry.name, '危化品证上传')
assert.ok(entry, '运输管理缺少运输证件管理入口')
assert.equal(entry.name, '运输证件管理')
assert.equal(entry.perms, 'mini:pl:plan-cert:list')
assert.equal(entry.enabled, true)
assert.ok(
Array.isArray(entry.anyPerms) && entry.anyPerms.includes('mini:pl:quota-cert:list'),
'入口应覆盖证件建档等子权限'
)
const api = await read('miniprogram/config/api.js')
const ast = ts.createSourceFile('api.js', api, ts.ScriptTarget.Latest, true)
let expression
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
import * as vue from 'vue'
function loadFreightCalc() {
const path = new URL('../src/composables/usePlFreightCalc.ts', import.meta.url)
assert.ok(existsSync(path), 'usePlFreightCalc must exist')
const source = readFileSync(path, 'utf8')
const ast = ts.createSourceFile('usePlFreightCalc.ts', source, ts.ScriptTarget.Latest, true)
const code = ast.statements
.filter((node) => !ts.isImportDeclaration(node))
.map((node) => node.getText(ast))
.join('\n')
const compiled = ts.transpileModule(code, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const module = { exports: {} as Record<string, unknown> }
const requireStub = () => {
throw new Error('unexpected require in freight calc test harness')
}
// eslint-disable-next-line no-new-func
const factory = new Function(
'exports',
'require',
'module',
'__filename',
'__dirname',
'computed',
'ref',
'watch',
compiled + '\nmodule.exports = { usePlFreightCalc, sumProductActualQuantities };'
)
factory(
module.exports,
requireStub,
module,
'usePlFreightCalc.ts',
'.',
vue.computed,
vue.ref,
vue.watch
)
return module.exports as {
usePlFreightCalc: typeof import('../src/composables/usePlFreightCalc').usePlFreightCalc
}
}
test('resetFreightCalc re-sums transport qty from existing product actuals', async () => {
const { usePlFreightCalc } = loadFreightCalc()
const products = vue.ref([{ actualQty: 0.04, unit: '吨' }, { actualQty: 0.04, unit: '吨' }])
const hasFreight = vue.ref(1)
const freightType = vue.ref('by_weight')
const scope = vue.effectScope()
const calc = scope.run(() => usePlFreightCalc({ products, hasFreight, freightType }))
assert.ok(calc)
await vue.nextTick()
assert.equal(calc!.transportQty.value, 0.08)
calc!.resetFreightCalc()
await vue.nextTick()
assert.equal(calc!.transportQtyTouched.value, false)
assert.equal(
calc!.transportQty.value,
0.08,
'reset after products already loaded must not leave transport qty empty'
)
scope.stop()
})
test('resetFreightCalc restores product sum after manual transport qty edit', async () => {
const { usePlFreightCalc } = loadFreightCalc()
const products = vue.ref([{ actualQty: 0.04, unit: '吨' }])
const hasFreight = vue.ref(1)
const freightType = vue.ref('by_weight')
const scope = vue.effectScope()
const calc = scope.run(() => usePlFreightCalc({ products, hasFreight, freightType }))
assert.ok(calc)
await vue.nextTick()
calc!.transportQty.value = 9.9
calc!.markTransportQtyTouched()
calc!.resetFreightCalc()
await vue.nextTick()
assert.equal(calc!.transportQtyTouched.value, false)
assert.equal(calc!.transportQty.value, 0.04)
scope.stop()
})
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const viewTabs = readFileSync(
new URL('../src/components/business/pl-plan-view-tabs/index.vue', import.meta.url),
'utf8'
)
const h5Detail = readFileSync(
new URL('../src/views/m/pl/plan/detail.vue', import.meta.url),
'utf8'
)
test('Web 计划详情:关联证件在装送货后且无危化品证书信息区', () => {
assert.doesNotMatch(viewTabs, /危化品证书信息/)
assert.doesNotMatch(viewTabs, /PlPlanHazardCertViewTable/)
const loadIdx = viewTabs.indexOf('装送货信息')
const certIdx = viewTabs.indexOf('关联购买证 / 运输证')
const freightIdx = viewTabs.indexOf('运费信息')
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
assert.match(viewTabs, /AttachmentUploadField/)
assert.match(viewTabs, /attachmentUrls/)
})
test('H5 计划详情:关联购买证/运输证替换危化品证书信息并展示附件', () => {
assert.doesNotMatch(h5Detail, /危化品证书信息/)
assert.match(h5Detail, /关联购买证 \/ 运输证/)
assert.match(h5Detail, /关联证件/)
const loadIdx = h5Detail.indexOf('plan-sec-load')
const certIdx = h5Detail.indexOf('plan-sec-cert')
const freightIdx = h5Detail.indexOf('plan-sec-freight')
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
assert.match(h5Detail, /AttachmentUploadField|CertAttachments/)
assert.match(h5Detail, /attachmentUrls/)
})
+1 -1
View File
@@ -645,7 +645,7 @@ test('referenced bindings and references cannot be edited or removed', async ()
s.removeReference(0)
s.addReference()
assert.equal(s.form.value.purchaseRefs.length, 1)
assert.ok(h.messages.some((m) => m.includes('不能新增或移除证面引用')))
assert.ok(h.messages.some((m) => m.includes('不能新增或移除关联购买证')))
assert.equal(s.referenceRowLocked(s.form.value.purchaseRefs[0]), false)
} finally {
h.stop()
@@ -17,3 +17,20 @@ test('unmarked plans still block on the server message or the local certificate
assert.equal(toxicArrangeBlockMessage({ arrangeBlockMessage: '' }, unbound), unbound)
assert.equal(toxicArrangeBlockMessage(null, unbound), unbound)
})
test('customer_category plans trust the server arrange check and ignore local contract-binding gaps', () => {
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '' },
unbound
),
''
)
assert.equal(
toxicArrangeBlockMessage(
{ certQuotaRuleVersion: 'customer_category', arrangeBlockMessage: '证件余量不足' },
unbound
),
'证件余量不足'
)
})
+61 -66
View File
@@ -105,6 +105,9 @@ function harness(fixed?: string) {
useServerColumnFilters,
createStatisticsLoader,
quotaStatisticsApi: api,
certQuotaRuleApi: {
get: async () => ({ configured: false })
},
onBeforeUnmount: (fn: Function) => unmount.push(fn)
}
const src = ts.createSourceFile(
@@ -154,16 +157,21 @@ test('cert tabs keep their identities and legal proof is a sibling menu', () =>
assert.equal(legal.meta!.title, '合法使用证明')
assert.deepEqual(
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.path),
['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready']
['quota-cert', 'cert-category', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready']
)
assert.equal(group.children![0].id, 'PL_QUOTA_CERT')
assert.equal(group.children![3].id, 'PL00000000000000000000000000015')
assert.equal(group.children!.find((r) => r.path === 'contract-quota')?.meta?.title, '客户用证')
assert.equal(
group.children!.find((r) => r.path === 'plan-cert')?.id,
'PL00000000000000000000000000015'
)
assert.deepEqual(
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.meta!.menuCode),
[
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:quota-cert:list',
'pl:plan-cert:list',
'pl:cert-ready:list'
]
@@ -648,71 +656,58 @@ function compiledSfc(path: string, dependencies: Record<string, any>, inlineTemp
)
return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env))
}
for (const wrapper of [
'contract-quota/ContractQuotaPanel.vue',
'certificate-usage/CertificateUsagePanel.vue'
]) {
for (const active of [undefined, false])
test(`compiled ${wrapper} ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
const calls: string[] = []
const dependencies = {
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
useRouter: () => ({ push() {}, replace() {} }),
fetchGetMenuCodes: async () => {
calls.push('permissions')
return { all: true }
},
quotaStatisticsApi: {
contracts: async () => {
calls.push('contracts')
return { list: [], total: 0 }
},
certificates: async () => {
calls.push('certificates')
return { list: [], total: 0 }
}
},
usePersistedListColumns,
useServerColumnFilters,
createStatisticsLoader,
useElementSize: () => ({ width: vue.ref(1120) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
statusLabel: statisticsLabels.statusLabel,
contractTypeLabel: statisticsLabels.contractTypeLabel
}
// Compile the real inner setup too; only its visual child tree is omitted here.
const StatisticsPanel = compiledSfc(
'../src/views/pl/quota-statistics/StatisticsPanel.vue',
dependencies,
false
)
StatisticsPanel.render = () => null
const Wrapper = compiledSfc(`../src/views/pl/${wrapper}`, { StatisticsPanel })
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp(Wrapper, active === undefined ? {} : { active })
app.mount({})
await flush()
await vue.nextTick()
assert.deepEqual(
calls,
active === false
? []
: ['permissions', wrapper.startsWith('contract') ? 'contracts' : 'certificates']
)
app.unmount()
for (const active of [undefined, false])
test(`compiled certificate-usage/CertificateUsagePanel.vue ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => {
const calls: string[] = []
const dependencies = {
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
useRouter: () => ({ push() {}, replace() {} }),
fetchGetMenuCodes: async () => {
calls.push('permissions')
return { all: true }
},
quotaStatisticsApi: {
certificates: async () => {
calls.push('certificates')
return { list: [], total: 0 }
}
},
usePersistedListColumns,
useServerColumnFilters,
createStatisticsLoader,
useElementSize: () => ({ width: vue.ref(1120) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
statusLabel: statisticsLabels.statusLabel,
contractTypeLabel: statisticsLabels.contractTypeLabel
}
const StatisticsPanel = compiledSfc(
'../src/views/pl/quota-statistics/StatisticsPanel.vue',
dependencies,
false
)
StatisticsPanel.render = () => null
const Wrapper = compiledSfc('../src/views/pl/certificate-usage/CertificateUsagePanel.vue', {
StatisticsPanel
})
}
const renderer = vue.createRenderer<any, any>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText: () => {},
setElementText: () => {},
parentNode: () => null,
nextSibling: () => null,
insert: () => {},
remove: () => {},
patchProp: () => {}
})
const app = renderer.createApp(Wrapper, active === undefined ? {} : { active })
app.mount({})
await flush()
await vue.nextTick()
assert.deepEqual(calls, active === false ? [] : ['permissions', 'certificates'])
app.unmount()
})
test('expanded content follows the table viewport width and keeps alignment local', () => {
const h = harness()
+1 -1
View File
@@ -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'
+7
View File
@@ -624,6 +624,13 @@ export interface Plan {
hazardCertReady?: number
hazardCertReadyBy?: string
hazardCertReadyTime?: string
/** 证书齐备:新规则仅放行安排 */
quotaCertWaived?: number
quotaCertWaivedBy?: string
quotaCertWaivedByName?: string
quotaCertWaivedTime?: string
/** 新规则证书齐备待完善 */
quotaCertPendingFulfill?: number
erpEntered?: number
remark?: string
createBy?: string
+84 -3
View File
@@ -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[]
}
@@ -59,8 +61,13 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
verifiedTime?: string | null
openingConfirmed: boolean
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
singleUseConsumed?: boolean
createTime?: string
certType?: CertType
derivedCategoryIds?: string[]
derivedCategoryNames?: string[]
customerName?: string | null
categoryName?: string | null
}
export interface ScopeProduct {
id?: string
@@ -128,8 +135,10 @@ export interface LegalDocument {
}
const base = '/transport/quota-certs'
export const quotaCertApi = {
list: (kind: CertType, params: { keyword?: string; status?: string } = {}) =>
request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
list: (
kind: CertType,
params: { keyword?: string; status?: string; customerId?: string } = {}
) => request.get(`${base}/${kind}`, { params }).then(pickBxData<QuotaCert[]>),
get: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}`).then(pickBxData<QuotaCert>),
save: (kind: CertType, body: CertBody, id?: string) =>
@@ -327,6 +336,7 @@ export interface PlanQuota {
canSaveConfiguration?: boolean
arrangeBlockMessage?: string
quotaCertWaived?: boolean
certQuotaRuleVersion?: 'legacy' | 'customer_category' | string
configuration: Partial<QuotaConfiguration>
sourceOptions: QuotaSourceOption[]
usages: Record<string, any>[]
@@ -393,7 +403,55 @@ 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<CertReadyPlan>)
request.post(`${base}/plans/${id}/cert-ready`).then(pickBxData<CertReadyPlan>),
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<CertReadyPlan>),
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<CertCategory[]>),
get: (id: string) => request.get(`${base}/categories/${id}`).then(pickBxData<CertCategory>),
create: (body: Record<string, unknown>) =>
request.post(`${base}/categories`, body).then(pickBxData<CertCategory>),
update: (id: string, body: Record<string, unknown>) =>
request.put(`${base}/categories/${id}`, body).then(pickBxData<CertCategory>),
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
productNo?: string | null
productName?: string | null
model?: string | null
}>
}
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 = {
@@ -451,6 +509,13 @@ export interface StatisticsPage<T> {
export interface ContractStatisticsSummary extends ContractOption {
certCount: number
}
export interface CustomerStatisticsSummary {
id: string
customerId: string
customerNo: string
customerName: string
certCount: number
}
export interface CertificateStatisticsSummary {
id: string
certId: string
@@ -469,6 +534,14 @@ export interface CertificateStatisticsSummary {
buyPartyName?: string | null
validUseText?: string | null
}
export interface CustomerStatistics extends CustomerStatisticsSummary {
certificates: (CertificateStatisticsSummary & {
usagePolicy?: QuotaCert['usagePolicy']
singleUseConsumed?: boolean
openingConfirmed?: boolean
createTime?: string
})[]
}
export interface StatisticsProduct {
id: string
contractProductId: string
@@ -526,6 +599,8 @@ export interface StatisticsQuery {
contractNo?: string
contractName?: string
contractTypeIn?: string
customerNo?: string
customerName?: string
certCountOp?: string
certCount?: string
certCountMin?: string
@@ -569,12 +644,18 @@ export const quotaStatisticsApi = {
request
.get(`${base}/statistics/contracts`, { params })
.then(pickBxData<StatisticsPage<ContractStatisticsSummary>>),
customers: (params: StatisticsQuery) =>
request
.get(`${base}/statistics/customers`, { params })
.then(pickBxData<StatisticsPage<CustomerStatisticsSummary>>),
certificates: (params: StatisticsQuery) =>
request
.get(`${base}/statistics/certificates`, { params })
.then(pickBxData<StatisticsPage<CertificateStatisticsSummary>>),
contract: (id: string) =>
request.get(`${base}/contracts/${id}/statistics`).then(pickBxData<ContractStatistics>),
customer: (id: string) =>
request.get(`${base}/customers/${id}/statistics`).then(pickBxData<CustomerStatistics>),
certificate: (kind: CertType, id: string) =>
request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData<CertificateStatistics>)
}
-13
View File
@@ -1900,19 +1900,6 @@ body {
}
}
.list-table.is-excel-copy {
th.is-excel-col,
td.is-excel-rowhead {
background: var(--mint-primary-bg) !important;
color: var(--mint-primary-dark);
}
td.is-excel-cell {
background: rgba(16, 185, 129, 0.16) !important;
box-shadow: inset 0 0 0 1px var(--mint-primary-dark);
}
}
/* 筛选区 label:13px 次要色(对齐 zkxw filter-form 实践;filter-hint 仍为 11px) */
.page-container .filter-block .el-form-item__label,
.console-container .filter-block .el-form-item__label,
@@ -45,6 +45,16 @@
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
重置
</el-button>
<span class="preview-toolbar-sep" aria-hidden="true" />
<el-button
:size="mpBtnSize"
:loading="copyBusy"
:disabled="!props.fileUrl || copyBusy"
@click="handleCopyImage"
>
<el-icon v-if="!isMpPreviewToolbar && !copyBusy"><DocumentCopy /></el-icon>
复制
</el-button>
<span class="preview-status-chip">{{ previewStatusText }}</span>
</div>
<div
@@ -98,17 +108,20 @@
import { useRoute } from 'vue-router'
import {
Document,
DocumentCopy,
Refresh,
RefreshLeft,
RefreshRight,
ZoomIn,
ZoomOut
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
detectAttachmentPreviewKind,
resolveAttachmentPreviewUrl,
type AttachmentPreviewKind
} from '@/utils/download-attachment'
import { copyAttachmentImageToClipboard } from '@/utils/clipboard'
import {
IMAGE_PREVIEW_ZOOM_MIN,
IMAGE_PREVIEW_ZOOM_MAX,
@@ -159,6 +172,7 @@
})
const loading = ref(false)
const copyBusy = ref(false)
const pdfSrc = ref('')
const previewStageRef = ref<HTMLElement>()
const previewImgRef = ref<HTMLImageElement>()
@@ -229,6 +243,21 @@
nextTick(() => refreshPreviewFitBase())
}
async function handleCopyImage() {
if (kind.value !== 'image' || !props.fileUrl || copyBusy.value) return
copyBusy.value = true
try {
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: props.fileUrl, fileName: props.fileName },
{ fullFileUrl: props.resolveUrl }
)
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
copyBusy.value = false
}
}
function resetPreviewZoomPan() {
previewZoom.value = 1
previewOffsetX.value = 0
@@ -59,6 +59,20 @@
>
<span aria-hidden="true">↓</span>
</button>
<button
v-if="file.fileUrl && !file._uploading"
type="button"
class="att-icon att-icon--copy"
:aria-label="
isImageFile(file)
? `复制图片:${file.fileName || '未命名附件'}`
: `复制链接:${file.fileName || '未命名附件'}`
"
:disabled="copyBusyId === (file.id || String(index))"
@click.stop="handleCopyAttachment(file)"
>
<span aria-hidden="true">⧉</span>
</button>
<button
class="att-preview"
type="button"
@@ -145,6 +159,16 @@
>
<el-icon :size="12"><Delete /></el-icon>
</button>
<button
v-if="compact && disabled && file.fileUrl"
type="button"
class="attachment-compact-copy"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
:disabled="copyBusyId === (file.id || String(index))"
@click.stop="handleCopyAttachment(file)"
>
<el-icon :size="12"><DocumentCopy /></el-icon>
</button>
<button
v-if="compact && disabled && file.fileUrl"
type="button"
@@ -157,6 +181,13 @@
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div v-if="!disabled" class="attachment-actions">
<el-icon
class="action-btn"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
@click="handleCopyAttachment(file)"
>
<DocumentCopy />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
@@ -169,6 +200,13 @@
</el-icon>
</div>
<div v-else class="attachment-actions">
<el-icon
class="action-btn"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
@click="handleCopyAttachment(file)"
>
<DocumentCopy />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
@@ -222,6 +260,16 @@
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
重置
</el-button>
<span class="preview-toolbar-sep" aria-hidden="true" />
<el-button
:size="mpBtnSize"
:loading="previewCopyBusy"
:disabled="!previewFile?.fileUrl || previewCopyBusy"
@click="handleCopyPreviewImage"
>
<el-icon v-if="!isMpPreviewToolbar && !previewCopyBusy"><DocumentCopy /></el-icon>
复制
</el-button>
<span class="preview-status-chip">{{ previewStatusText }}</span>
</div>
<div
@@ -278,6 +326,7 @@ import {
Loading,
VideoPlay,
Document,
DocumentCopy,
Download,
Delete,
RefreshLeft,
@@ -293,6 +342,10 @@ import {
downloadAttachmentFile,
resolveAttachmentPreviewUrl
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { buildAttachmentUploadFormData, type CrmUploadBizType } from '@/utils/crm-upload'
import {
@@ -798,6 +851,47 @@ function handleDownloadAttachment(file: AttachmentItem) {
downloadAttachmentFile(file, { fullFileUrl: resolveFileUrl })
}
const copyBusyId = ref('')
const previewCopyBusy = ref(false)
async function handleCopyAttachment(file: AttachmentItem) {
const url = String(file.fileUrl || '').trim()
if (!url) {
ElMessage.warning('暂无可复制内容')
return
}
const busyKey = file.id || url
if (copyBusyId.value === busyKey) return
copyBusyId.value = busyKey
try {
if (isImageFile(file)) {
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolveFileUrl(url)
const ok = await copyTextToClipboard(href || url)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
if (copyBusyId.value === busyKey) copyBusyId.value = ''
}
}
async function handleCopyPreviewImage() {
const file = previewFile.value
if (!file?.fileUrl || previewCopyBusy.value) return
previewCopyBusy.value = true
try {
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
previewCopyBusy.value = false
}
}
function handlePreviewAttachment(file: AttachmentItem) {
if (!canPreview(file)) return
previewFile.value = file
@@ -1269,10 +1363,10 @@ defineExpose({ hasUploading })
border-radius: 6px;
}
.attachment-compact-download {
.attachment-compact-download,
.attachment-compact-copy {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
display: flex;
align-items: center;
@@ -1290,6 +1384,19 @@ defineExpose({ hasUploading })
&:hover {
background: #1ca851;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.attachment-compact-download {
right: 2px;
}
.attachment-compact-copy {
right: 22px;
}
}
</style>
@@ -0,0 +1,370 @@
<!-- 列表 Excel 只读查看:Univer OSS,可筛选/复制,不可编辑或写回 -->
<template>
<el-dialog
v-model="innerVisible"
title="Excel 查看"
width="96%"
top="2vh"
class="dialog-form-layout list-excel-viewer-dialog"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
style="backdrop-filter: none"
@opened="onOpened"
@closed="onClosed"
>
<div v-loading="booting" class="list-excel-viewer-body">
<p class="list-excel-viewer-hint">只读模式:可筛选、复制;不可编辑或写回系统。</p>
<div ref="containerRef" class="list-excel-viewer-host" />
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, shallowRef } from 'vue'
import { ElMessage } from 'element-plus'
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
import { excelCell, snapshotToUniverCellData } from '@/utils/list-excel-copy'
defineOptions({ name: 'ListExcelViewerDialog' })
const props = defineProps<{
modelValue: boolean
snapshot: ListExcelSnapshot | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const containerRef = ref<HTMLElement | null>(null)
const booting = ref(false)
const univerRef = shallowRef<{ dispose: () => void } | null>(null)
const univerApiRef = shallowRef<UniverAPI | null>(null)
type UniverAPI = {
createWorkbook: (data: Record<string, unknown>) => unknown
getActiveWorkbook: () => any
Enum: {
WorkbookPermissionPoint: Record<string, string>
WorksheetPermissionPoint: Record<string, string>
}
}
function disposeUniver() {
unbindCopyHotkey()
try {
univerRef.value?.dispose()
} catch {
// ignore dispose race
}
univerRef.value = null
univerApiRef.value = null
}
function selectionToTsv(univerAPI: UniverAPI): string {
const workbook = univerAPI.getActiveWorkbook?.()
const range = workbook?.getActiveRange?.() || workbook?.getActiveSheet?.()?.getActiveRange?.()
if (!range) return ''
const values: unknown[][] =
range.getDisplayValues?.() || range.getValues?.() || range.getRawValues?.() || []
if (!Array.isArray(values) || !values.length) {
const single = range.getDisplayValue?.() ?? range.getValue?.()
if (single == null || single === '') return ''
return excelCell(String(single))
}
return values
.map((row) =>
(Array.isArray(row) ? row : [row])
.map((cell) => excelCell(cell == null ? '' : String(cell)))
.join('\t')
)
.join('\r\n')
}
async function copySelectionFallback(univerAPI: UniverAPI) {
const text = selectionToTsv(univerAPI)
if (!text) return false
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制,到 Excel 中直接粘贴')
return true
} catch {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', 'true')
area.style.position = 'fixed'
area.style.left = '-9999px'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
if (ok) {
ElMessage.success('已复制,到 Excel 中直接粘贴')
return true
}
ElMessage.warning('复制失败,请允许剪贴板权限后重试')
return false
}
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute('type') || 'text').toLowerCase()
return type !== 'checkbox' && type !== 'radio'
}
return target instanceof HTMLElement && target.isContentEditable
}
function onCopyHotkey(event: KeyboardEvent) {
if (!innerVisible.value || !univerApiRef.value) return
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') return
if (isTextEntryTarget(event.target)) return
const host = containerRef.value
const target = event.target as Node | null
// 弹层内任意位置(含 Univer canvas)都接住复制
const dialog = host?.closest('.el-dialog')
if (dialog && target && !dialog.contains(target) && target !== document.body) return
event.preventDefault()
event.stopPropagation()
void copySelectionFallback(univerApiRef.value)
}
function bindCopyHotkey() {
document.addEventListener('keydown', onCopyHotkey, true)
}
function unbindCopyHotkey() {
document.removeEventListener('keydown', onCopyHotkey, true)
}
/**
* 工作表 setPoint 必须先 protect(),否则会抛
* "Cannot set … because worksheet protection does not exist"。
* filterOnly 默认关掉 WorksheetCopy,必须再显式打开,否则 Ctrl/Cmd+C 无效。
*/
async function applyReadonlyPermissions(univerAPI: UniverAPI) {
const workbook = univerAPI.getActiveWorkbook()
if (!workbook) return
// 本地关闭编辑(不依赖 Authz / protect)
if (typeof workbook.setEditable === 'function') {
workbook.setEditable(false)
}
try {
const workbookPermission = workbook.getWorkbookPermission?.()
if (workbookPermission?.setMode) {
await workbookPermission.setMode('viewer')
}
const copyPoint = univerAPI.Enum?.WorkbookPermissionPoint?.CopyContent
if (workbookPermission?.setPoint && copyPoint) {
await workbookPermission.setPoint(copyPoint, true)
}
} catch (error) {
console.warn('[ListExcelViewer] workbook permission skipped', error)
}
const sheet = workbook.getActiveSheet?.()
if (!sheet?.getWorksheetPermission) return
try {
const sheetPermission = sheet.getWorksheetPermission()
// filterOnly = View + Sort + Filter;须先 protect
if (!sheetPermission.isProtected?.()) {
await sheetPermission.protect?.({ name: 'list-excel-readonly' })
}
if (sheetPermission.setMode) {
await sheetPermission.setMode('filterOnly')
}
const sheetCopy = univerAPI.Enum?.WorksheetPermissionPoint?.Copy
if (sheetPermission.setPoint && sheetCopy) {
await sheetPermission.setPoint(sheetCopy, true)
}
} catch (error) {
console.warn('[ListExcelViewer] worksheet filterOnly skipped', error)
}
}
function mountFilter(univerAPI: UniverAPI, snapshot: ListExcelSnapshot) {
const sheet = univerAPI.getActiveWorkbook()?.getActiveSheet?.()
if (!sheet?.getRange) return
const colCount = Math.max(snapshot.headers.length, 1)
const rowCount = Math.max(snapshot.rows.length + 1, 1)
const range = sheet.getRange(0, 0, rowCount, colCount)
try {
sheet.getFilter?.()?.remove?.()
} catch {
// no existing filter
}
range.createFilter?.()
}
async function loadUniverModules() {
const [
{ UniverSheetsCorePreset },
coreZhMod,
{ UniverSheetsFilterPreset },
filterZhMod,
{ createUniver, LocaleType, mergeLocales }
] = await Promise.all([
import('@univerjs/preset-sheets-core'),
import('@univerjs/preset-sheets-core/locales/zh-CN'),
import('@univerjs/preset-sheets-filter'),
import('@univerjs/preset-sheets-filter/locales/zh-CN'),
import('@univerjs/presets'),
import('@univerjs/preset-sheets-core/lib/index.css'),
import('@univerjs/preset-sheets-filter/lib/index.css')
])
return {
UniverSheetsCorePreset,
UniverPresetSheetsCoreZhCN: coreZhMod.default,
UniverSheetsFilterPreset,
UniverPresetSheetsFilterZhCN: filterZhMod.default,
createUniver,
LocaleType,
mergeLocales
}
}
async function bootUniver() {
disposeUniver()
const snapshot = props.snapshot
const container = containerRef.value
if (!snapshot || !container) return
if (!snapshot.headers.length) {
ElMessage.warning('当前列表没有可查看的数据列')
return
}
booting.value = true
try {
const {
UniverSheetsCorePreset,
UniverPresetSheetsCoreZhCN,
UniverSheetsFilterPreset,
UniverPresetSheetsFilterZhCN,
createUniver,
LocaleType,
mergeLocales
} = await loadUniverModules()
const cellData = snapshotToUniverCellData(snapshot)
const colCount = snapshot.headers.length
const rowCount = snapshot.rows.length + 1
const sheetId = 'sheet1'
const { univer, univerAPI } = createUniver({
locale: LocaleType.ZH_CN,
locales: {
[LocaleType.ZH_CN]: mergeLocales(UniverPresetSheetsCoreZhCN, UniverPresetSheetsFilterZhCN)
},
presets: [
UniverSheetsCorePreset({
container,
toolbar: true,
formulaBar: false,
footer: false,
header: true,
contextMenu: true
}),
UniverSheetsFilterPreset()
]
})
univerRef.value = univer
const api = univerAPI as unknown as UniverAPI
univerApiRef.value = api
api.createWorkbook({
id: 'list-excel-viewer',
name: '列表数据',
sheetOrder: [sheetId],
sheets: {
[sheetId]: {
id: sheetId,
name: '当前页',
rowCount: Math.max(rowCount + 20, 40),
columnCount: Math.max(colCount + 5, 10),
cellData
}
}
})
// 权限/筛选失败不应让整页判定为加载失败(表格数据已就绪)
try {
await applyReadonlyPermissions(api)
} catch (error) {
console.warn('[ListExcelViewer] readonly setup skipped', error)
}
try {
mountFilter(api, snapshot)
} catch (error) {
console.warn('[ListExcelViewer] filter setup skipped', error)
}
bindCopyHotkey()
} catch (error) {
console.error(error)
ElMessage.error('Excel 查看加载失败,请稍后重试')
disposeUniver()
} finally {
booting.value = false
}
}
async function onOpened() {
await nextTick()
await bootUniver()
}
function onClosed() {
disposeUniver()
}
onBeforeUnmount(() => {
disposeUniver()
})
</script>
<style scoped lang="scss">
.list-excel-viewer-body {
display: flex;
flex-direction: column;
height: calc(100vh - 160px);
min-height: 420px;
gap: 8px;
}
.list-excel-viewer-hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: var(--mint-text-muted);
}
.list-excel-viewer-host {
flex: 1;
min-height: 0;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
}
</style>
<style lang="scss">
.list-excel-viewer-dialog.el-dialog {
max-height: calc(100vh - 40px);
margin-bottom: 0;
.el-dialog__body {
padding-top: 8px;
padding-bottom: 16px;
}
}
</style>
@@ -1,6 +1,7 @@
<!--
主列表薄封装:在 el-table 上启用列宽拖拽,并按 table-key 持久化到 localStorage。
用法:将 <el-table class="list-table" …> 换成 <ListTable table-key="crm-customer" class="list-table" …>
Excel 查看:工具栏打开只读 Univer 弹层,本组件提供 getExcelSnapshot()。
-->
<template>
<el-table
@@ -29,15 +30,11 @@
useAttrs,
watch
} from 'vue'
import { ElMessage } from 'element-plus'
import {
buildExcelTsv,
excelCell,
buildExcelSnapshot,
isCopyableColumn,
isRowSelectorColumn,
resolveCopyAxes,
toggleAxis,
type ListExcelColumn
type ListExcelColumn,
type ListExcelSnapshot
} from '@/utils/list-excel-copy'
import {
isFixedNarrowListColumnKey,
@@ -55,7 +52,7 @@
persistColumnWidth?: boolean
/** 是否开启 border(列宽拖拽依赖);默认 true */
border?: boolean
/** 是否允许 Excel 行列复制,默认 true */
/** 是否允许 Excel 只读查看,默认 true */
excelCopy?: boolean
}>(),
{
@@ -71,17 +68,11 @@
computed(() => props.tableKey)
)
const excelMode = ref(false)
const selectedCols = ref(new Set<number>())
const selectedRows = ref(new Set<number>())
let colAnchor: number | null = null
let rowAnchor: number | null = null
const resolvedBorder = computed(() => props.border !== false)
const tableClass = computed(() => {
const extra = attrs.class
return ['list-table', { 'is-excel-copy': excelMode.value }, extra]
return ['list-table', extra]
})
const tableAttrs = computed(() => {
@@ -157,14 +148,6 @@
}))
}
function copyableColumns(): ListExcelColumn[] {
return readColumns().filter((col) => col.id && isCopyableColumn(col))
}
function indexColumn(): ListExcelColumn | undefined {
return readColumns().find((col) => col.id && isRowSelectorColumn(col))
}
function tableRoot(): HTMLElement | null {
const el = tableRef.value?.$el as HTMLElement | undefined
return el ?? null
@@ -176,254 +159,45 @@
return Array.from(root.querySelectorAll('.el-table__body-wrapper tbody > tr.el-table__row'))
}
function columnFromCell(cell: HTMLElement): ListExcelColumn | undefined {
/** 当前可见列 + 当前页行文本,供 Excel 只读弹层使用 */
function getExcelSnapshot(): ListExcelSnapshot {
if (!props.excelCopy) return { headers: [], rows: [] }
const columns = readColumns()
const matched = columns.find((col) => col.id && cell.classList.contains(col.id))
if (matched) return matched
const row = cell.parentElement
if (!row) return undefined
const cells = Array.from(row.children).filter(
(el): el is HTMLTableCellElement => el instanceof HTMLTableCellElement
const copyable = columns.filter((col) => col.id && isCopyableColumn(col))
const rows = bodyRows()
const rowValues = rows.map((row) =>
copyable.map((col) => {
const td = row.querySelector(`td.${CSS.escape(col.id)}`)
return td?.textContent ?? ''
})
)
const visualIndex = cells.indexOf(cell as HTMLTableCellElement)
return visualIndex >= 0 ? columns[visualIndex] : undefined
// rowValues 已按可复制列对齐,传入过滤后的 columns
return buildExcelSnapshot({ columns: copyable, rowValues })
}
function paintExcelSelection() {
function bindSnapshotApi() {
const root = tableRoot()
if (!root) return
root.querySelectorAll('.is-excel-col, .is-excel-rowhead, .is-excel-cell').forEach((el) => {
el.classList.remove('is-excel-col', 'is-excel-rowhead', 'is-excel-cell')
})
if (!excelMode.value) return
const columns = copyableColumns()
const rows = bodyRows()
const axes = resolveCopyAxes({
selectedCols: [...selectedCols.value],
selectedRows: [...selectedRows.value],
columnCount: columns.length,
rowCount: rows.length
})
const indexCol = indexColumn()
selectedCols.value.forEach((index) => {
const col = columns[index]
if (!col) return
root.querySelectorAll(`th.${CSS.escape(col.id)}`).forEach((el) => {
el.classList.add('is-excel-col')
})
})
const bodies = root.querySelectorAll(
'.el-table__body-wrapper tbody, .el-table__fixed-body-wrapper tbody'
)
if (indexCol) {
selectedRows.value.forEach((index) => {
bodies.forEach((tbody) => {
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[index]
row?.querySelector(`td.${CSS.escape(indexCol.id)}`)?.classList.add('is-excel-rowhead')
})
})
}
if (!axes) return
axes.rows.forEach((rowIndex) => {
axes.cols.forEach((colIndex) => {
const col = columns[colIndex]
if (!col) return
bodies.forEach((tbody) => {
const row = tbody.querySelectorAll(':scope > tr.el-table__row')[rowIndex]
row?.querySelector(`td.${CSS.escape(col.id)}`)?.classList.add('is-excel-cell')
})
})
})
;(root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }).__sxGetExcelSnapshot =
getExcelSnapshot
}
function clearExcelSelection() {
selectedCols.value = new Set()
selectedRows.value = new Set()
colAnchor = null
rowAnchor = null
paintExcelSelection()
}
function setExcelMode(next: boolean) {
// excelCopy 已关闭时拒绝进入;仍允许关掉,否则 prop 变 false 后无法退出。
if (!props.excelCopy && next) return
excelMode.value = next
if (!next) clearExcelSelection()
else paintExcelSelection()
tableRoot()?.dispatchEvent(
new CustomEvent('sx-list-excel-state', {
bubbles: true,
detail: { active: excelMode.value }
})
)
}
function clickedRowIndex(row: HTMLTableRowElement): number {
const rows = bodyRows()
const direct = rows.indexOf(row)
if (direct >= 0) return direct
const tbody = row.parentElement
if (!tbody) return -1
const index = Array.from(tbody.querySelectorAll(':scope > tr.el-table__row')).indexOf(row)
if (index < 0 || index >= rows.length) return -1
return index
}
function keepCopyFocus() {
function unbindSnapshotApi() {
const root = tableRoot()
if (!root) return
const panel = root.closest('.panel')
const active = document.activeElement
if (panel && active instanceof Node && panel.contains(active)) return
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1')
root.focus({ preventScroll: true })
}
function onTableClick(event: MouseEvent) {
if (!excelMode.value || !props.excelCopy) return
const target = event.target as HTMLElement | null
if (!target) return
const header = target.closest('th')
const cell = target.closest('td')
if (header) {
event.preventDefault()
event.stopPropagation()
const columns = copyableColumns()
const index = columns.findIndex((col) => header.classList.contains(col.id))
if (index < 0) {
keepCopyFocus()
return
}
const result = toggleAxis(selectedCols.value, index, event.shiftKey, colAnchor)
selectedCols.value = result.selected
colAnchor = result.anchor
paintExcelSelection()
keepCopyFocus()
return
}
if (!cell) return
event.preventDefault()
event.stopPropagation()
keepCopyFocus()
const row = cell.closest('tr')
const column = columnFromCell(cell)
// 序号格和数据格都选整行;勾选列、展开列、操作列不选行。
if (!row || !column || !(isRowSelectorColumn(column) || isCopyableColumn(column))) return
const rowIndex = clickedRowIndex(row)
if (rowIndex < 0) return
const result = toggleAxis(selectedRows.value, rowIndex, event.shiftKey, rowAnchor)
selectedRows.value = result.selected
rowAnchor = result.anchor
paintExcelSelection()
}
function onTableDblClick(event: MouseEvent) {
if (!excelMode.value || !props.excelCopy) return
const target = event.target as HTMLElement | null
if (!target?.closest('td, th')) return
event.preventDefault()
event.stopPropagation()
}
function currentTsv(): string {
const columns = copyableColumns()
const rows = bodyRows()
const axes = resolveCopyAxes({
selectedCols: [...selectedCols.value],
selectedRows: [...selectedRows.value],
columnCount: columns.length,
rowCount: rows.length
})
if (!axes) return ''
const headers = axes.cols.map((index) => columns[index]?.label ?? '')
const matrix = axes.rows.map((rowIndex) =>
axes.cols.map((colIndex) => {
const col = columns[colIndex]
const td = col ? rows[rowIndex]?.querySelector(`td.${CSS.escape(col.id)}`) : null
return excelCell(td?.textContent ?? '')
})
)
return buildExcelTsv(headers, matrix)
}
async function copyExcelSelection() {
const text = currentTsv()
if (!text) return
try {
await navigator.clipboard.write([
new ClipboardItem({ 'text/plain': new Blob([text], { type: 'text/plain' }) })
])
ElMessage.success('已复制,到 Excel 中直接粘贴')
} catch {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', 'true')
area.style.position = 'fixed'
area.style.left = '-9999px'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
if (ok) ElMessage.success('已复制,到 Excel 中直接粘贴')
else ElMessage.warning('复制失败,请允许剪贴板权限后重试')
}
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute('type') || 'text').toLowerCase()
return type !== 'checkbox' && type !== 'radio'
}
return target instanceof HTMLElement && target.isContentEditable
}
function onKeydown(event: KeyboardEvent) {
if (!excelMode.value || !props.excelCopy) return
if (isTextEntryTarget(event.target)) return
const root = tableRoot()
const panel = root?.closest('.panel')
const target = event.target as Node | null
if (panel && target && !panel.contains(target)) return
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
if (!currentTsv()) return
event.preventDefault()
void copyExcelSelection()
}
if (event.key === 'Escape') clearExcelSelection()
}
function onExcelToggle() {
setExcelMode(!excelMode.value)
}
function bindExcelEvents() {
const root = tableRoot()
root?.addEventListener('click', onTableClick, true)
root?.addEventListener('dblclick', onTableDblClick, true)
root?.addEventListener('sx-list-excel-toggle', onExcelToggle)
document.addEventListener('keydown', onKeydown)
}
function unbindExcelEvents() {
const root = tableRoot()
root?.removeEventListener('click', onTableClick, true)
root?.removeEventListener('dblclick', onTableDblClick, true)
root?.removeEventListener('sx-list-excel-toggle', onExcelToggle)
document.removeEventListener('keydown', onKeydown)
delete (root as HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot })
.__sxGetExcelSnapshot
}
onMounted(() => {
scheduleApply()
// 首屏列尚未完全注册时再补一次
mountApplyTimer = setTimeout(() => scheduleApply(), 100)
bindExcelEvents()
bindSnapshotApi()
})
onBeforeUnmount(() => {
unbindExcelEvents()
unbindSnapshotApi()
clearApplyTimers()
})
onDeactivated(() => {
@@ -432,13 +206,13 @@
onActivated(() => {
scheduleApply()
bindSnapshotApi()
})
watch(
() => (attrs as any).data,
() => {
scheduleApply()
if (excelMode.value) clearExcelSelection()
},
{ deep: false }
)
@@ -448,7 +222,6 @@
() => scheduleApply()
)
// 列显隐导致列数量变化时写回(避免在 drag 过程中用 onUpdated 反复 apply)
watch(
() => {
const cols = tableRef.value?.store?.states?.columns
@@ -456,24 +229,6 @@
},
() => {
scheduleApply()
if (excelMode.value) clearExcelSelection()
}
)
watch(
() =>
readColumns()
.map((col) => col.id)
.join('|'),
() => {
if (excelMode.value) clearExcelSelection()
}
)
watch(
() => props.excelCopy,
(enabled) => {
if (!enabled) setExcelMode(false)
}
)
@@ -495,13 +250,19 @@
resetWidths()
}
}
if (prop === 'getExcelSnapshot') return getExcelSnapshot
const table = tableRef.value
if (!table) return undefined
const value = table[prop as keyof typeof table]
return typeof value === 'function' ? value.bind(table) : value
},
has(_target, prop) {
if (prop === 'elTableRef' || prop === 'applyColumnWidths' || prop === 'resetColumnWidths') {
if (
prop === 'elTableRef' ||
prop === 'applyColumnWidths' ||
prop === 'resetColumnWidths' ||
prop === 'getExcelSnapshot'
) {
return true
}
const table = tableRef.value
@@ -1,17 +1,11 @@
<!-- 列表工具栏右侧:刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
<!-- 列表工具栏右侧:Excel 查看 + 刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
<template>
<div ref="rootEl" class="list-toolbar-end">
<el-tooltip
v-if="showExcelCopy"
:content="excelActive ? '退出 Excel 复制' : 'Excel 复制'"
placement="top"
>
<el-tooltip v-if="showExcelCopy" content="Excel 查看" placement="top">
<el-button
class="list-toolbar-end-btn"
:class="{ 'is-excel-active': excelActive }"
:aria-pressed="excelActive"
aria-label="Excel 复制"
@click="toggleExcelCopy"
aria-label="Excel 查看"
@click="openExcelViewer"
>
<svg class="list-excel-icon" viewBox="0 0 16 16" aria-hidden="true">
<rect
@@ -89,13 +83,25 @@
</el-scrollbar>
</div>
</el-popover>
<ListExcelViewerDialog
v-if="excelSnapshot"
v-model="excelViewerVisible"
:snapshot="excelSnapshot"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import type { ListColumnDef } from '@/composables/usePersistedListColumns'
import type { ListExcelSnapshot } from '@/utils/list-excel-copy'
const ListExcelViewerDialog = defineAsyncComponent(
() => import('@/components/business/list-excel-viewer-dialog/index.vue')
)
defineOptions({ name: 'ListToolbarEndActions' })
@@ -154,46 +160,28 @@
emit('resetColumns')
}
const excelActive = ref(false)
const rootEl = ref<HTMLElement | null>(null)
let panelEl: HTMLElement | null = null
const excelViewerVisible = ref(false)
const excelSnapshot = ref<ListExcelSnapshot | null>(null)
function controlledTable(): HTMLElement | null {
type ExcelTableEl = HTMLElement & { __sxGetExcelSnapshot?: () => ListExcelSnapshot }
function controlledTable(): ExcelTableEl | null {
const panel = rootEl.value?.closest('.panel')
const table = panel?.querySelector('[data-list-excel-copy="on"]')
return table instanceof HTMLElement ? table : null
return table instanceof HTMLElement ? (table as ExcelTableEl) : null
}
function toggleExcelCopy() {
controlledTable()?.dispatchEvent(new CustomEvent('sx-list-excel-toggle'))
}
function onExcelState(event: Event) {
const custom = event as CustomEvent<{ active?: boolean }>
if (event.target !== controlledTable()) return
excelActive.value = !!custom.detail?.active
}
function attachExcelStateListener() {
const panel = rootEl.value?.closest('.panel')
if (!(panel instanceof HTMLElement)) return false
panel.addEventListener('sx-list-excel-state', onExcelState, true)
panelEl = panel
return true
}
onMounted(() => {
if (!attachExcelStateListener()) {
nextTick(() => {
attachExcelStateListener()
})
function openExcelViewer() {
const table = controlledTable()
const snapshot = table?.__sxGetExcelSnapshot?.()
if (!snapshot || !snapshot.headers.length) {
ElMessage.warning('当前列表没有可查看的数据列')
return
}
})
onBeforeUnmount(() => {
panelEl?.removeEventListener('sx-list-excel-state', onExcelState, true)
panelEl = null
})
excelSnapshot.value = snapshot
excelViewerVisible.value = true
}
</script>
<style scoped lang="scss">
@@ -227,26 +215,6 @@
border: none !important;
box-shadow: none;
}
&.is-excel-active,
&.is-excel-active:hover,
&.is-excel-active:focus-visible,
&.is-excel-active:active {
color: #fff !important;
background: var(--mint-primary) !important;
border: none !important;
box-shadow:
0 0 0 2px #fff,
0 0 0 4px var(--mint-primary) !important;
}
&.is-excel-active:hover,
&.is-excel-active:focus-visible {
background: var(--mint-primary-dark) !important;
box-shadow:
0 0 0 2px #fff,
0 0 0 4px var(--mint-primary-dark) !important;
}
}
.list-excel-icon {
@@ -254,8 +222,4 @@
height: 16px;
display: block;
}
.is-excel-active .list-excel-icon rect {
fill: rgba(255, 255, 255, 0.22);
}
</style>
@@ -31,6 +31,13 @@
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
</div>
<el-icon
class="pl-carrier-list-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-carrier-list-attachment-cell__download"
title="下载"
@@ -50,7 +57,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Carrier } from '@/api/pl'
import {
@@ -58,6 +65,10 @@
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -160,6 +171,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const defs = [
{
@@ -286,9 +317,9 @@
color: #0284c7;
}
.pl-carrier-list-attachment-cell__download {
.pl-carrier-list-attachment-cell__download,
.pl-carrier-list-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -306,6 +337,14 @@
}
}
.pl-carrier-list-attachment-cell__download {
right: 2px;
}
.pl-carrier-list-attachment-cell__copy {
right: 22px;
}
.pl-carrier-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
@@ -40,6 +40,13 @@
}}</span>
</div>
</div>
<el-icon
class="pl-cert-list-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-cert-list-attachment-cell__download"
title="下载"
@@ -60,7 +67,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download, Picture } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { CertRead } from '@/api/pl'
import {
@@ -68,6 +75,10 @@
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { PL_VEHICLE_CERT_PART_TRAILER, normalizeVehicleCertPart } from '@/utils/pl-vehicle-cert'
@@ -181,6 +192,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
@@ -305,9 +336,9 @@
color: #0284c7;
}
.pl-cert-list-attachment-cell__download {
.pl-cert-list-attachment-cell__download,
.pl-cert-list-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -325,6 +356,14 @@
}
}
.pl-cert-list-attachment-cell__download {
right: 2px;
}
.pl-cert-list-attachment-cell__copy {
right: 22px;
}
.pl-cert-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
@@ -299,6 +299,7 @@
extractArchiveLineOverageDetail,
showArchiveLineOverageConfirm
} from '@/utils/pl-contract-qty-reminder'
import { resolveArchiveQuotaBlockMode } from '@/utils/pl-archive-quota-block'
const props = defineProps<{
visible: boolean
@@ -452,9 +453,15 @@
resolveStopProductsForView(mapLoadFromApi(plan.value?.load).products, productRows.value)
)
const showStopFeedback = computed(
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
)
const showStopFeedback = computed(() => {
const hasStopFeedback = Boolean(
(transport.value?.driverStopFeedbacks || []).length ||
(transport.value?.siteStopFeedbacks || []).length
)
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
if (hasStopFeedback) return true
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
})
const summaryCarrierName = computed(
() => transport.value?.carrierName || vehicleBundle.value?.detail?.carrierName || ''
@@ -1596,31 +1603,18 @@
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
if (title !== '用证没有完成') {
await ElMessageBox.alert(html, title, {
type: 'warning',
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-arrange-block-msgbox',
closeOnClickModal: false
}).catch(() => undefined)
return undefined
}
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。标记后这次归档不再校验,也不扣减这两证的额度。</p>`
try {
await ElMessageBox.confirm(readyHtml, title, {
type: 'warning',
confirmButtonText: '证书齐备',
cancelButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-arrange-block-msgbox',
closeOnClickModal: false,
distinguishCancelAndClose: true
})
return 'ready' as const
} catch {
return undefined
}
const mode = resolveArchiveQuotaBlockMode(message, title, plan.value)
const guide =
mode === 'alert-pending'
? `${html}<p class="pl-arrange-block-text">该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。</p>`
: html
await ElMessageBox.alert(guide, title, {
type: 'warning',
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
customClass: 'pl-arrange-block-msgbox',
closeOnClickModal: false
}).catch(() => undefined)
}
function applyArchiveOverageHighlight(items: Array<Record<string, unknown>>) {
@@ -1655,13 +1649,7 @@
// 业务异常已作为一次请求提交;补齐条件后允许新尝试,不回放旧异常。
quotaArchiveAttempt = { signature: '', id: '' }
const message = saved.quotaSettlementError
const choice = await showArchiveQuotaBlockDialog(message, '用证没有完成')
if (choice === 'ready' && current()) {
await planApi.markQuotaCertReady(targetId)
if (!current()) return
await submitArchiveWithOverageConfirm(targetId, frozenBody, current, confirmOverage)
return
}
await showArchiveQuotaBlockDialog(message, '用证没有完成')
const error = new Error(message) as Error & { cancelled?: boolean }
error.cancelled = true
throw error
@@ -40,6 +40,13 @@
}}</span>
</div>
</div>
<el-icon
class="pl-plan-document-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-plan-document-attachment-cell__download"
title="下载"
@@ -63,7 +70,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Document, Download, Picture } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanDocumentAttachmentItem } from '@/api/pl'
import AttachmentPreviewDialog from '@/components/business/attachment-preview-dialog/index.vue'
@@ -71,6 +78,10 @@
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -170,6 +181,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const list = props.attachments || []
return list
@@ -260,9 +291,9 @@
color: #64748b;
}
.pl-plan-document-attachment-cell__download {
.pl-plan-document-attachment-cell__download,
.pl-plan-document-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -281,6 +312,14 @@
}
}
.pl-plan-document-attachment-cell__download {
right: 2px;
}
.pl-plan-document-attachment-cell__copy {
right: 22px;
}
.pl-plan-document-attachment-cell__caption {
position: absolute;
right: 0;
@@ -142,6 +142,17 @@
<el-icon :size="20"><Document /></el-icon>
</div>
</div>
<el-icon
class="pl-plan-list-cert-cell__copy"
:title="
isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))
? '复制图片'
: '复制链接'
"
@click.stop="handleCopyCert(getPrimaryProductCertUrl(product, slot.key))"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-plan-list-cert-cell__download"
title="下载"
@@ -167,7 +178,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download, Upload } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanProduct } from '@/api/pl'
import {
@@ -183,6 +194,10 @@
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -295,6 +310,27 @@
ElMessage.error('下载失败,请稍后重试')
}
}
async function handleCopyCert(url: string) {
const fileUrl = String(url || '').trim()
if (!fileUrl) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl, fileName: getCertFileName(fileUrl) || '附件' }
if (isCertImageUrl(fileUrl)) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolveCertFileUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolveCertFileUrl(fileUrl) || fileUrl
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
</script>
<style scoped lang="scss">
@@ -420,10 +456,10 @@
pointer-events: none;
}
.pl-plan-list-cert-cell__download {
.pl-plan-list-cert-cell__download,
.pl-plan-list-cert-cell__copy {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
width: 18px;
height: 18px;
@@ -440,6 +476,14 @@
}
}
.pl-plan-list-cert-cell__download {
right: 2px;
}
.pl-plan-list-cert-cell__copy {
right: 22px;
}
.pl-plan-list-cert-cell__cert-caption {
position: absolute;
right: 0;
@@ -17,7 +17,14 @@
<span>当前车号</span>
<strong>{{ plan.licensePlate || '待安排' }}</strong>
</div>
<p v-if="viewOnly && plan.canSaveConfiguration === false" class="quota-note"
<p
v-if="viewOnly && plan.certQuotaRuleVersion === 'customer_category'"
class="quota-note"
>新规则计划由系统按客户+品类自动选证,本页仅查看占用;安排不提供手工选证。</p
>
<p
v-else-if="viewOnly && plan.canSaveConfiguration === false"
class="quota-note"
>已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。</p
>
<template v-if="viewOnly">
@@ -98,7 +105,7 @@
<ViewKvTable :fields="group.fields" mode="dual" />
</section>
<section v-if="tab.kind === 'transport'" class="quota-face-group">
<h4>证面引用</h4>
<h4>关联购买证</h4>
<el-table
v-if="tab.cert.purchaseRefs?.length"
:data="tab.cert.purchaseRefs"
@@ -118,7 +125,7 @@
}}</template>
</el-table-column>
</el-table>
<p v-else class="quota-note">没有证面引用。</p>
<p v-else class="quota-note">没有关联购买证。</p>
</section>
<section v-if="tab.cert.attachmentUrls" class="quota-face-group">
<h4>证件附件</h4>
@@ -750,13 +757,33 @@
return !label || label === '—' ? '' : label
}
function boundCerts(row: QuotaSourceOption) {
return (
const fromStop =
plan.value?.stopProducts?.find(
(item) =>
item.sourceStopType === row.sourceStopType &&
item.sourceStopProductId === row.sourceStopProductId
)?.certificates || []
)
if (fromStop.length) return fromStop
const sourceId = `${row.sourceStopType}:${row.sourceStopProductId}`
const fromUsage: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[] =
[]
for (const usage of plan.value?.usages || []) {
const kind = String(usage.certType || '') as CertType
if (kind !== 'purchase' && kind !== 'transport') continue
const items = Array.isArray(usage.items) ? usage.items : []
const hit = items.some((item: Record<string, unknown>) => {
const typed = `${String(item.sourceStopType || '')}:${String(item.sourceStopProductId || '')}`
return String(item.sourceId || '') === sourceId || typed === sourceId
})
if (!hit) continue
fromUsage.push({
certType: kind,
certId: String(usage.certId || ''),
certNo: String(usage.certNoSnapshot || ''),
quantityUnit: String(usage.quantityUnit || '')
})
}
return fromUsage
}
function certsOf(row: QuotaSourceOption, kind: CertType) {
return boundCerts(row).filter((cert) => cert.certType === kind)
@@ -133,10 +133,13 @@
prefetchedArrange.value = null
bootstrappedPlan.value = null
let data: Plan | null = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
if (!data) {
// 打开详情始终回源,避免列表 seed 数量/装卸段滞后于库内已修正数据
let data: Plan | null = null
try {
const res = await planApi.get(props.planId)
data = pickBxData<Plan>(res) || ((res as { data?: Plan }).data ?? null)
} catch {
data = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
}
if (!data) {
throw new Error('运输计划数据无效')
@@ -234,8 +234,31 @@
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
<PlPlanHazardCertViewTable :products="hazardProducts" />
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
</p>
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-block">
<div class="pl-plan-linked-cert-block__meta">
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
</el-tag>
<span class="pl-plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
<span class="pl-plan-linked-cert-block__qty"
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
>
</div>
<div class="pl-plan-linked-cert-block__slot">
<span class="pl-plan-linked-cert-block__label">证件附件</span>
<div v-if="item.attachmentUrls" class="pl-plan-linked-cert-block__attachment">
<AttachmentUploadField :model-value="item.attachmentUrls" disabled />
</div>
<span v-else class="pl-plan-linked-cert-block__missing">未上传</span>
</div>
</div>
</div>
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
@@ -413,7 +436,6 @@
type BrandQtyRow
} from '@/components/business/brand-qty-chips-field/index.vue'
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/index.vue'
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
import PlPlanArchiveBrandViewBlock from '@/components/business/pl-plan-archive-brand-view-block/index.vue'
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
@@ -459,7 +481,6 @@
} from '@/utils/pl-plan-stop-form'
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
import { useDictStore } from '@/store/modules/dict'
import { formatDate, formatDateTime } from '@/utils/format'
import {
@@ -779,7 +800,14 @@
transport.value?.feedbackRemark ||
feedbackImageList.value.length ||
transport.value?.feedbackQty != null ||
(transport.value?.driverProductFeedbacks || []).length
(transport.value?.driverProductFeedbacks || []).length ||
(transport.value?.driverStopFeedbacks || []).some(
(row) =>
(row.attachmentUrls && row.attachmentUrls.length > 0) ||
row.remark ||
row.feedbackTime ||
(row.products && row.products.length > 0)
)
)
)
@@ -1406,9 +1434,15 @@
)
}
const showStopFeedback = computed(
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
)
const showStopFeedback = computed(() => {
const hasStopFeedback = Boolean(
(transport.value?.driverStopFeedbacks || []).length ||
(transport.value?.siteStopFeedbacks || []).length
)
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
if (hasStopFeedback) return true
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
})
const feedbackBlocksBind = computed(() => ({
feedbackEditable: props.feedbackEditable,
@@ -1435,7 +1469,27 @@
ioOrderViewFields: ioOrderViewFields.value,
ioOrderImageCsv: ioOrderImageCsv.value,
siteFeedbackTableRows: siteFeedbackTableRows.value,
dibangPlanRows: dibangPlanRows.value
dibangPlanRows: dibangPlanRows.value,
driverStopFeedbacks: (transport.value?.driverStopFeedbacks || []).map((row) => ({
stopType: row.stopType,
stopId: row.stopId,
label: row.label,
customerName: row.customerName,
attachmentUrls: row.attachmentUrls,
remark: row.remark,
feedbackTime: row.feedbackTime
})),
siteStopFeedbacks: (transport.value?.siteStopFeedbacks || []).map((row) => ({
stopType: row.stopType,
stopId: row.stopId,
label: row.label,
customerName: row.customerName,
businessType: row.businessType,
quantity: row.quantity != null ? Number(row.quantity) : undefined,
attachmentUrls: row.attachmentUrls,
remark: row.remark,
feedbackTime: row.feedbackTime
}))
}))
const feedbackBlocksOn = {
@@ -1588,23 +1642,43 @@
return fields
})
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
const hazardProducts = computed(() =>
products.value
.filter((item) => isHazardProduct(item))
.map((item) => {
const formRow = item.id ? planProductFormById.value.get(String(item.id)) : undefined
const standardName = String(formRow?.standardProductName || '').trim()
if (!standardName || standardName === item.productName) return item
return { ...item, productName: standardName }
})
)
const linkedCertRows = ref<
Array<{
key: string
kind: string
certId: string
certNo?: string
reservedQty: string | number
usedQty: string | number
attachmentUrls: string
}>
>([])
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 || u.certNoSnapshot || u.cert_no_snapshot,
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
usedQty: u.usedQty ?? u.used_qty ?? '—',
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
}))
} 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(
@@ -1852,6 +1926,93 @@
white-space: pre-wrap;
}
.pl-plan-linked-certs {
display: flex;
flex-direction: column;
gap: 12px;
}
.pl-plan-linked-cert-block {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px 16px;
padding: 14px 16px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
background: #fff;
}
.pl-plan-linked-cert-block__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
min-width: 0;
}
.pl-plan-linked-cert-block__no {
font-size: 14px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
}
.pl-plan-linked-cert-block__qty {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.pl-plan-linked-cert-block__slot {
display: inline-flex;
align-items: flex-start;
gap: 8px;
}
.pl-plan-linked-cert-block__label {
flex-shrink: 0;
color: var(--mint-text);
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
}
.pl-plan-linked-cert-block__attachment {
display: inline-flex;
flex-shrink: 0;
:deep(.attachment-upload) {
width: auto;
}
:deep(.attachment-list) {
gap: 8px;
}
:deep(.attachment-item) {
width: 140px;
margin-bottom: 0;
border-radius: 8px;
}
:deep(.attachment-preview) {
height: 96px;
}
}
.pl-plan-linked-cert-block__missing {
display: inline-flex;
align-items: center;
justify-content: center;
width: 140px;
height: 96px;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text-muted);
font-size: 12px;
}
.pl-plan-kv-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -86,11 +86,13 @@ export function usePlFreightCalc(options: {
)
function resetFreightCalc() {
transportQty.value = null
transportQtyTouched.value = false
freightAmount.value = null
freightAmountTouched.value = false
freightPrice.value = null
// 产品已加载时 watch 不会因 reset 重跑;须立即按当前实际数量回填,
// 否则归档弹窗再次打开会误报「运输量须大于0」。
transportQty.value = defaultTransportQty.value || null
}
function markTransportQtyTouched() {
+10
View File
@@ -39,6 +39,16 @@ declare module '@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js' {
export default HTMLtoDOCX
}
declare module '@univerjs/preset-sheets-core/locales/zh-CN' {
const locale: Record<string, string>
export default locale
}
declare module '@univerjs/preset-sheets-filter/locales/zh-CN' {
const locale: Record<string, string>
export default locale
}
declare module 'qrcode.vue' {
export type Level = 'L' | 'M' | 'Q' | 'H'
export type RenderAs = 'canvas' | 'svg'
+1 -1
View File
@@ -133,7 +133,7 @@
mode: 'list',
title: '运输证件管理',
search: '证件编号 / 品名',
tabs: ['证件建档', '合同用证', '证件用量', '计划段次用证', '证书齐备'],
tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'],
filters: [],
caption: '正在加载运输证件管理'
}
+8 -1
View File
@@ -68,12 +68,19 @@ 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',
path: 'contract-quota',
component: '/pl/contract-quota/index',
meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
meta: { title: '客户用证', keepAlive: false, menuCode: 'pl:quota-cert:list' }
},
{
id: 'PL_CERTIFICATE_USAGE',
+11
View File
@@ -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',
+8 -2
View File
@@ -171,11 +171,17 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/m/pl/plan-cert/archive-detail.vue'),
meta: { title: '证件建档', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/customer/:id',
name: 'MpPlPlanCertCustomer',
component: () => import('@views/m/pl/plan-cert/customer-detail.vue'),
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/contract/:id',
name: 'MpPlPlanCertContract',
component: () => import('@views/m/pl/plan-cert/contract-detail.vue'),
meta: { title: '合同用证', isHideTab: true, isMpWebview: true }
redirect: { name: 'MpPlPlanCert', query: { tab: 'contract' } },
meta: { title: '客户用证', isHideTab: true, isMpWebview: true }
},
{
path: '/m/pl/plan-cert/usage/:kind/:id',
@@ -407,6 +407,14 @@
justify-content: flex-end;
}
.mp-crm-att-grid .att-icon--copy {
right: 0;
bottom: 0;
top: auto;
align-items: flex-end;
justify-content: flex-end;
}
.mp-crm-att-grid .att-icon > span {
display: flex;
align-items: center;
@@ -426,6 +434,10 @@
background: #087f57;
}
.mp-crm-att-grid .att-icon--copy > span {
background: #0f766e;
}
.mp-crm-att-grid .att-uploading {
position: absolute;
inset: 0;
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import { afterEach, before, describe, it } from 'node:test'
import {
copyAttachmentImageToClipboard,
copyImageBlobToClipboard,
ensurePngBlob
} from './clipboard'
class FakeClipboardItem {
readonly types: string[]
private readonly data: Record<string, Blob>
constructor(items: Record<string, Blob>) {
this.data = items
this.types = Object.keys(items)
}
async getType(type: string) {
return this.data[type]
}
}
before(() => {
if (typeof globalThis.ClipboardItem === 'undefined') {
;(globalThis as unknown as { ClipboardItem: typeof FakeClipboardItem }).ClipboardItem =
FakeClipboardItem
}
if (typeof globalThis.navigator === 'undefined') {
;(globalThis as unknown as { navigator: Navigator }).navigator = {} as Navigator
}
})
const originalClipboard = globalThis.navigator?.clipboard
afterEach(() => {
if (typeof globalThis.navigator === 'undefined') return
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: originalClipboard
})
})
describe('ensurePngBlob', () => {
it('returns the same blob when type is already image/png', async () => {
const png = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
const out = await ensurePngBlob(png)
assert.equal(out, png)
assert.equal(out.type, 'image/png')
})
})
describe('copyImageBlobToClipboard', () => {
it('writes a ClipboardItem with image/png and returns true', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const png = new Blob([new Uint8Array([9])], { type: 'image/png' })
const ok = await copyImageBlobToClipboard(png)
assert.equal(ok, true)
assert.equal(written.length, 1)
const types = written[0].types
assert.ok(types.includes('image/png'))
})
it('returns false when clipboard.write is unavailable', async () => {
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {}
})
const png = new Blob([new Uint8Array([1])], { type: 'image/png' })
assert.equal(await copyImageBlobToClipboard(png), false)
})
})
describe('copyAttachmentImageToClipboard', () => {
it('returns false for non-image attachments without fetching', async () => {
let fetched = false
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.pdf', fileName: 'a.pdf' },
{
fetchBlob: async () => {
fetched = true
return new Blob(['x'], { type: 'application/pdf' })
}
}
)
assert.equal(ok, false)
assert.equal(fetched, false)
})
it('fetches image blob and copies via clipboard', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.jpg', fileName: 'a.jpg' },
{
fetchBlob: async () => new Blob([new Uint8Array([7])], { type: 'image/png' })
}
)
assert.equal(ok, true)
assert.equal(written.length, 1)
})
})
+85
View File
@@ -2,9 +2,15 @@
* 复制文本到剪贴板。
* 小程序 web-view / H5 iframe 常被 Permissions Policy 拦截 Clipboard API,
* 需回退到 execCommand,必要时再交给壳页复制。
* 另提供图片 Blob / 附件图片写入系统剪贴板(ClipboardItem)。
*/
import { MP_WEBVIEW_COPY_TEXT } from '@/utils/mp-webview'
export type AttachmentClipboardItem = {
fileUrl?: string
fileName?: string
}
function fallbackExecCommandCopy(text: string): boolean {
try {
const textarea = document.createElement('textarea')
@@ -64,3 +70,82 @@ export async function copyTextToClipboard(text: string): Promise<boolean> {
return tryCopyViaMpWebviewHost(value)
}
/** 与 download-attachment 图片判定对齐,避免测试静态拉入整条 API 依赖链 */
function isImageAttachment(file: AttachmentClipboardItem): boolean {
const name = String(file.fileName || '').toLowerCase()
const url = String(file.fileUrl || '')
.split('?')[0]
.toLowerCase()
return (
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name) ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
)
}
/** 已是 PNG 则原样返回;否则经 canvas 转为 image/png。 */
export async function ensurePngBlob(blob: Blob): Promise<Blob> {
if (blob.type === 'image/png') return blob
if (typeof createImageBitmap === 'function' && typeof document !== 'undefined') {
const bitmap = await createImageBitmap(blob)
try {
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d unavailable')
ctx.drawImage(bitmap, 0, 0)
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
})
return png
} finally {
bitmap.close?.()
}
}
// 无 canvas 环境时仍尝试按原类型写入(部分浏览器接受 image/jpeg)
return blob
}
/** 将图片 Blob 写入系统剪贴板;失败返回 false。 */
export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
if (!blob || blob.size <= 0) return false
try {
if (typeof navigator === 'undefined' || !navigator.clipboard?.write) return false
if (typeof ClipboardItem === 'undefined') return false
const png = await ensurePngBlob(blob)
const type = png.type || 'image/png'
await navigator.clipboard.write([new ClipboardItem({ [type]: png })])
return true
} catch {
return false
}
}
export type CopyAttachmentImageOptions = {
fullFileUrl?: (url: string) => string
/** 测试可注入;默认走预签名下载 Blob */
fetchBlob?: (
file: AttachmentClipboardItem,
options?: { fullFileUrl?: (url: string) => string }
) => Promise<Blob>
}
/** 仅图片附件:拉取 Blob 后写入剪贴板;非图片返回 false。 */
export async function copyAttachmentImageToClipboard(
file: AttachmentClipboardItem,
options?: CopyAttachmentImageOptions
): Promise<boolean> {
if (!isImageAttachment(file)) return false
try {
const fetchBlob =
options?.fetchBlob ||
(await import('@/utils/download-attachment')).fetchAttachmentBlob
const blob = await fetchBlob(file, { fullFileUrl: options?.fullFileUrl })
return await copyImageBlobToClipboard(blob)
} catch {
return false
}
}
@@ -1,11 +1,13 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
buildExcelSnapshot,
buildExcelTsv,
excelCell,
isCopyableColumn,
isRowSelectorColumn,
resolveCopyAxes,
snapshotToUniverCellData,
toggleAxis
} from './list-excel-copy'
@@ -135,3 +137,45 @@ describe('buildExcelTsv', () => {
assert.equal(tsv, '联系人\t状态\r\n张三\t合作中\r\n李四\t跟进中')
})
})
describe('buildExcelSnapshot', () => {
it('keeps copyable columns, cleans cells, and drops 操作', () => {
const snapshot = buildExcelSnapshot({
columns: [
{ id: 'name', label: ' 联系人\n' },
{ id: 'act', label: '操作' },
{ id: 'status', label: '状态' }
],
rowValues: [
['张三\t甲', '编辑', '合作中'],
['李四', '删除', '跟进中']
]
})
assert.deepEqual(snapshot.headers, ['联系人', '状态'])
assert.deepEqual(snapshot.rows, [
['张三 甲', '合作中'],
['李四', '跟进中']
])
})
it('returns empty snapshot when no copyable columns', () => {
const snapshot = buildExcelSnapshot({
columns: [{ id: 'a', label: '操作' }],
rowValues: [['x']]
})
assert.deepEqual(snapshot, { headers: [], rows: [] })
})
})
describe('snapshotToUniverCellData', () => {
it('puts headers on row 0 and data from row 1', () => {
const cellData = snapshotToUniverCellData({
headers: ['A', 'B'],
rows: [['1', '2']]
})
assert.deepEqual(cellData[0][0], { v: 'A', t: 1 })
assert.deepEqual(cellData[0][1], { v: 'B', t: 1 })
assert.deepEqual(cellData[1][0], { v: '1', t: 1 })
assert.deepEqual(cellData[1][1], { v: '2', t: 1 })
})
})
+48
View File
@@ -68,6 +68,54 @@ export function buildExcelTsv(headers: string[], rows: string[][]): string {
return lines.join('\r\n')
}
/** 灌入只读在线表格的快照:表头 + 当前页文本矩阵 */
export type ListExcelSnapshot = {
headers: string[]
rows: string[][]
}
/**
* 从可复制列与行文本构建快照。headers/rows 均经 excelCell 清洗。
* rowValues 与 columns 按下标对齐;操作列等会被跳过。
* 无可复制列时返回 { headers: [], rows: [] }。
*/
export function buildExcelSnapshot(input: {
columns: ListExcelColumn[]
/** 与 columns 按下标对齐的行文本;通常来自表格 DOM textContent */
rowValues: string[][]
}): ListExcelSnapshot {
const indices: number[] = []
const headers: string[] = []
input.columns.forEach((col, index) => {
if (!col.id || !isCopyableColumn(col)) return
indices.push(index)
headers.push(excelCell(col.label))
})
if (!indices.length) return { headers: [], rows: [] }
const rows = input.rowValues.map((row) =>
indices.map((colIndex) => excelCell(row[colIndex] ?? ''))
)
return { headers, rows }
}
/** 将快照转为 Univer cellData(第 0 行为表头)。 */
export function snapshotToUniverCellData(snapshot: ListExcelSnapshot): Record<
number,
Record<number, { v: string; t: number }>
> {
const cellData: Record<number, Record<number, { v: string; t: number }>> = {}
const writeRow = (r: number, values: string[]) => {
const row: Record<number, { v: string; t: number }> = {}
values.forEach((value, c) => {
row[c] = { v: value, t: 1 }
})
cellData[r] = row
}
writeRow(0, snapshot.headers)
snapshot.rows.forEach((row, index) => writeRow(index + 1, row))
return cellData
}
function range(count: number): number[] {
return Array.from({ length: count }, (_, index) => index)
}
@@ -0,0 +1,27 @@
/** 归档用证拦截弹窗:只提醒,不在归档界面提供「证书齐备」操作。 */
export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain'
export type ArchiveQuotaBlockPlanFlags = {
quotaCertPendingFulfill?: number | boolean | null
quotaCertWaived?: number | boolean | null
}
function flagOn(value: number | boolean | null | undefined) {
return value === true || Number(value || 0) === 1
}
export function resolveArchiveQuotaBlockMode(
message: string,
title: string,
plan?: ArchiveQuotaBlockPlanFlags | null
): ArchiveQuotaBlockMode {
const pendingByMessage =
message.includes('待完善') ||
message.includes('完善并关闭') ||
message.includes('证书齐备待完善')
const pendingByPlan = flagOn(plan?.quotaCertPendingFulfill) || flagOn(plan?.quotaCertWaived)
if (title !== '用证没有完成') return 'alert-plain'
if (pendingByPlan || pendingByMessage) return 'alert-pending'
return 'alert-plain'
}
+7 -1
View File
@@ -1,10 +1,16 @@
/** 已标记证书齐备时,只保留服务端仍要拦住的来源问题,不再用本地两证缺口补一条拦截。 */
export function toxicArrangeBlockMessage(
quota: { quotaCertWaived?: boolean; arrangeBlockMessage?: string } | null | undefined,
quota: {
quotaCertWaived?: boolean
arrangeBlockMessage?: string
certQuotaRuleVersion?: string | null
} | null | undefined,
unboundMessage: string
) {
const serverMessage = (quota?.arrangeBlockMessage || '').trim()
if (quota?.quotaCertWaived) return serverMessage
// 新规则由服务端自动选证;本地「合同绑证」缺口不能再当安排拦截条件
if (quota?.certQuotaRuleVersion === 'customer_category') return serverMessage
return serverMessage || unboundMessage
}
@@ -504,13 +504,12 @@
/>
</el-tab-pane>
<el-tab-pane v-if="canViewQuotaCert" label="合同证件与额度" name="quota" lazy>
<ContractQuotaPanel
<el-tab-pane v-if="canViewQuotaCert" label="客户证件与额度" name="quota" lazy>
<CustomerCertQuotaPanel
v-if="visible && detailActiveTab === 'quota' && detailContract?.id && String(detailContract.id) === String(currentDetailId)"
:key="detailContract.id"
:contract-id="String(detailContract.id)"
:key="String(detailContract.customerId || detailContract.id)"
:customer-id="detailContract.customerId"
:active="detailActiveTab === 'quota'"
embedded
/>
</el-tab-pane>
@@ -1060,7 +1059,7 @@ import UploadDialog from './UploadDialog.vue';
import ContractFormDialog from './ContractFormDialog.vue';
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue';
import type { ViewField } from '@/components/business/system-view-dialog/types';
import ContractQuotaPanel from '@/views/pl/contract-quota/ContractQuotaPanel.vue';
import CustomerCertQuotaPanel from '@/views/pl/contract-quota/CustomerCertQuotaPanel.vue';
import StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue';
import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue';
import {
@@ -0,0 +1,180 @@
<template>
<div class="mp-customer-cert-quota">
<p v-if="!customerId" class="mp-wv-empty">合同未关联客户,无法展示证件</p>
<p v-else-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="load">重试</button>
</p>
<div v-else-if="loading" class="mp-cert-page-loading"><van-loading /></div>
<template v-else>
<p class="mp-customer-cert-quota__tip">
展示该客户全部购买证、运输证;证件关联客户,不再按合同绑定。
</p>
<van-empty v-if="!rows.length" description="该客户暂无购买证或运输证" />
<article
v-for="row in rows"
:key="row.certType + row.id"
class="mp-wv-section"
role="button"
tabindex="0"
@click="openDetail(row)"
@keydown.enter.prevent="openDetail(row)"
>
<div class="mp-wv-card__head">
<div>
<h3>{{ row.certNo || '—' }}</h3>
<p>{{ row.productNameOnCert || '未填写证面品名' }}</p>
<p>有效期:{{ row.validFrom || '—' }} 至 {{ row.validTo || '—' }}</p>
</div>
</div>
<div class="mp-cert-tags">
<span
class="status-tag mp-cert-kind-tag"
:class="`status-tag--${certKindMobileTone(row.certType)}`"
>{{ certKindLabel(row.certType) }}</span
>
<span class="status-tag" :class="`status-tag--${statusTone(row.status)}`">{{
certStatusLabel(row.status)
}}</span>
<span v-if="row.singleUseConsumed" class="status-tag status-tag--danger"
>已使用不可再用</span
>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"
><span>有效次数</span><span>{{ row.validUseText || '—' }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>核定</span><span>{{ qtyText(row.totalQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>预占</span><span>{{ qtyText(row.reservedQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>已用</span><span>{{ qtyText(row.usedQty, row.quantityUnit) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>余量</span><span>{{ qtyText(row.availableQty, row.quantityUnit) }}</span></div
>
</div>
</article>
</template>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { certKindLabel, certKindMobileTone } from '@/views/pl/quota-cert/cert-list-face'
import { formatCertQty } from '@/views/pl/quota-cert/model'
type CustomerCertRow = QuotaCert & { certType: CertType }
const props = withDefaults(
defineProps<{ customerId?: string | number | null; active?: boolean }>(),
{ active: true }
)
const router = useRouter()
const dictStore = useDictStore()
const loading = ref(false)
const error = ref('')
const rows = ref<CustomerCertRow[]>([])
let loadEpoch = 0
function unitLabel(unit?: string | null) {
const label = getProductUnitLabel(dictStore.getLabel, unit)
return !label || label === '—' ? '' : label
}
function qtyText(value: unknown, unit?: string | null) {
const text = formatCertQty(value)
const name = unitLabel(unit)
return name ? `${text} ${name}` : text
}
function certStatusLabel(status?: string | null) {
return (
{
draft: '待核对',
active: '证件已启用',
suspended: '证件已停用',
closed: '证件已核销',
void: '证件已作废'
}[status || ''] ||
status ||
'—'
)
}
function statusTone(status?: string | null) {
if (status === 'active') return 'success'
if (status === 'suspended') return 'warning'
if (status === 'closed' || status === 'void') return 'danger'
return 'info'
}
async function load() {
const customerId = props.customerId == null ? '' : String(props.customerId).trim()
const epoch = ++loadEpoch
if (!customerId) {
rows.value = []
error.value = ''
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const [purchase, transport] = await Promise.all([
quotaCertApi.list('purchase', { customerId }),
quotaCertApi.list('transport', { customerId })
])
if (epoch !== loadEpoch) return
rows.value = [
...(purchase || []).map((item) => ({ ...item, certType: 'purchase' as const })),
...(transport || []).map((item) => ({ ...item, certType: 'transport' as const }))
].sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
} catch (e: any) {
if (epoch !== loadEpoch) return
rows.value = []
error.value = e?.message || '加载客户证件失败'
} finally {
if (epoch === loadEpoch) loading.value = false
}
}
function openDetail(row: CustomerCertRow) {
void router.push(`/m/pl/plan-cert/archive/${row.certType}/${row.id}`)
}
watch(
() => [props.customerId, props.active],
([, active]) => {
if (active) void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.mp-customer-cert-quota__tip {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: var(--mp-text-muted, #64748b);
}
.mp-wv-section {
cursor: pointer;
}
.mp-cert-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0 12px;
}
</style>
+46 -9
View File
@@ -342,6 +342,19 @@
</div>
<div v-show="activeTab === 'quota'" class="mp-contract-detail-pane">
<section class="mp-wv-section mp-contract-detail-card">
<h3>客户证件与额度</h3>
<CustomerCertQuotaTab
v-if="canViewQuotaCert && activeTab === 'quota'"
:key="String(contract.customerId || contract.id)"
:customer-id="contract.customerId"
:active="activeTab === 'quota'"
/>
<p v-else class="mp-wv-card__meta">无证件查看权限</p>
</section>
</div>
<div v-show="activeTab === 'plans'" class="mp-contract-detail-pane">
<section class="mp-wv-section mp-contract-detail-card">
<h3>运输计划</h3>
@@ -933,6 +946,8 @@
} from '@/utils/mp-webview'
import { useUserStore } from '@/store/modules/user'
import { useDictStore } from '@/store/modules/dict'
import { fetchGetMenuCodes } from '@/api/auth'
import CustomerCertQuotaTab from './CustomerCertQuotaTab.vue'
import '@/styles/mp-webview.scss'
import '../customer/customer.scss'
@@ -970,6 +985,7 @@
type DetailTabKey =
| 'contract'
| 'plans'
| 'quota'
| 'payment'
| 'invoice'
| 'approval'
@@ -979,6 +995,7 @@
const acting = ref(false)
const errorText = ref('')
const activeTab = ref<DetailTabKey>('contract')
const canViewQuotaCert = ref(false)
const detailBody = ref<HTMLElement>()
const partiesExpanded = ref(false)
const approverPickerOpen = ref(false)
@@ -1056,14 +1073,34 @@
}
}
const detailTabs: Array<{ key: DetailTabKey; label: string }> = [
{ key: 'contract', label: '合同信息' },
{ key: 'plans', label: '运输计划' },
{ key: 'payment', label: '回款记录' },
{ key: 'invoice', label: '开票记录' },
{ key: 'approval', label: '审批流程' },
{ key: 'operation', label: '操作记录' }
]
const detailTabs = computed(() => {
const tabs: Array<{ key: DetailTabKey; label: string }> = [
{ key: 'contract', label: '合同信息' },
{ key: 'plans', label: '运输计划' }
]
if (canViewQuotaCert.value) tabs.push({ key: 'quota', label: '客户证件与额度' })
tabs.push(
{ key: 'payment', label: '回款记录' },
{ key: 'invoice', label: '开票记录' },
{ key: 'approval', label: '审批流程' },
{ key: 'operation', label: '操作记录' }
)
return tabs
})
async function refreshQuotaCertPermission() {
try {
const permissions = await fetchGetMenuCodes()
const codes = permissions?.menuCodes || []
canViewQuotaCert.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
} catch {
canViewQuotaCert.value = false
}
if (!canViewQuotaCert.value && activeTab.value === 'quota') {
activeTab.value = 'contract'
}
}
const unreceivedAmount = computed(() => {
const amount = Number(contract.value?.amount) || 0
@@ -1573,7 +1610,7 @@
}
loading.value = true
try {
const res = await getContract(id)
const [res] = await Promise.all([getContract(id), refreshQuotaCertPermission()])
if (!isCurrentLoad(requestSeq, id)) return
const data = ((res as any)?.data ?? res) as ContractVO
if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看')
@@ -25,6 +25,12 @@
}}</span>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row"
><span>系统客户</span><span>{{ row.customerName || '—' }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>证用品类</span><span>{{ certCategoryLabel(row) }}</span></div
>
<div class="mp-wv-card__kv-row"
><span>销售单位</span><span>{{ salePartyName(row) || '—' }}</span></div
>
@@ -60,12 +66,12 @@
>编辑</button
>
<button
v-if="canEdit && row.status !== 'void' && row.status !== 'closed'"
v-if="canEdit && row.openingConfirmed && row.status !== 'void' && row.status !== 'closed'"
class="mp-wv-btn"
type="button"
@click="confirmRow = row"
>
{{ row.openingConfirmed ? '复核' : '期初确认' }}
复核
</button>
</div>
</article>
@@ -119,7 +125,7 @@
} from '@/views/pl/quota-cert/cert-list-face'
import { useDictStore } from '@/store/modules/dict'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { certStatusLabels } from './face-model'
import { certCategoryLabel, certStatusLabels } from './face-model'
const props = defineProps<{
keyword: string
@@ -8,37 +8,21 @@
:close-on-click-overlay="!saving"
@update:show="onShow"
>
<van-nav-bar
:title="row?.openingConfirmed ? '复核证件' : '期初核对'"
left-text="关闭"
@click-left="!saving && emit('close')"
/>
<van-nav-bar title="复核证件" left-text="关闭" @click-left="!saving && emit('close')" />
<div v-if="row" class="mp-cert-confirm-sheet__body mp-cert-form">
<p class="plan-empty-hint">{{ row.certNo }} · {{ row.openingConfirmed ? '复核已启用的证件' : '核对历史用量后启用' }}</p>
<template v-if="!row.openingConfirmed">
<van-field v-model="opening.usedQty" label="历史已用量" label-align="top" type="number" :disabled="saving" />
<van-cell title="一次有效证已有使用记录" center>
<template #right-icon>
<van-switch v-model="opening.singleUseConsumed" :disabled="saving" size="20px" />
</template>
</van-cell>
<van-field v-model="opening.sourceNote" label="历史来源说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
<p class="plan-empty-hint">核对凭据</p>
<AttachmentUploadField v-model="opening.evidenceUrls" folder="pl/quota-cert" :disabled="saving" hint="" />
</template>
<p class="plan-empty-hint">{{ row.certNo }} · 复核已启用的证件</p>
<van-field v-model="opening.reason" label="核对说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
</div>
<footer>
<button class="mp-wv-btn" type="button" :disabled="saving" @click="emit('close')">取消</button>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" :disabled="saving" @click="submit">
{{ saving ? '正在提交…' : '确认启用' }}
{{ saving ? '正在提交…' : '确认复核' }}
</button>
</footer>
</van-popup>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
import { randomOperationId } from '@/utils/random-id'
import './cert-mobile.scss'
@@ -76,19 +60,15 @@
opening.reason = opening.reason.trim()
saving.value = true
try {
if (props.row.openingConfirmed) {
await quotaCertApi.verify(kind, props.row.id, {
operationId: opening.operationId,
reason: opening.reason
})
} else {
await quotaCertApi.confirm(kind, props.row.id, { ...opening })
}
ElMessage.success(props.row.openingConfirmed ? '证件已复核' : '期初已确认')
await quotaCertApi.verify(kind, props.row.id, {
operationId: opening.operationId,
reason: opening.reason
})
ElMessage.success('证件已复核')
emit('saved')
emit('close')
} catch {
ElMessage.error('核对没有提交,请重试')
ElMessage.error('复核没有提交,请重试')
} finally {
saving.value = false
}
@@ -8,7 +8,10 @@
<template v-else-if="plan">
<section class="mp-wv-section mp-cert-quota">
<p class="mp-wv-card__meta">当前车号 {{ plan.licensePlate || '待安排' }}</p>
<p v-if="plan.canSaveConfiguration === false" class="plan-empty-hint">
<p v-if="plan.certQuotaRuleVersion === 'customer_category'" class="plan-empty-hint">
新规则计划由系统按客户+品类自动选证,本页仅查看占用;安排不提供手工选证。
</p>
<p v-else-if="plan.canSaveConfiguration === false" class="plan-empty-hint">
已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。
</p>
<h3>全部装送段用证</h3>
@@ -122,8 +125,8 @@
</div>
</div>
<template v-if="face.kind === 'transport'">
<h3>证面引用</h3>
<p v-if="!face.cert.purchaseRefs?.length" class="plan-empty-hint">没有证面引用。</p>
<h3>关联购买证</h3>
<p v-if="!face.cert.purchaseRefs?.length" class="plan-empty-hint">没有关联购买证。</p>
<article
v-for="(refRow, index) in face.cert.purchaseRefs || []"
:key="refRow.id || index"
@@ -274,12 +277,32 @@
for (const [key, ids] of Object.entries(next.pairs)) pairs[key] = ids
}
function bound(row: QuotaSourceOption, kind: CertType) {
return (
const fromStop = (
plan.value?.stopProducts?.find(
(item) =>
item.sourceStopType === row.sourceStopType && item.sourceStopProductId === row.sourceStopProductId
)?.certificates || []
).filter((cert) => cert.certType === kind)
if (fromStop.length) return fromStop
// 新规则无合同绑证时,安排后从占用明细回填证号
const sourceId = `${row.sourceStopType}:${row.sourceStopProductId}`
const fromUsage: { certType: CertType; certId: string; certNo: string; quantityUnit: string }[] = []
for (const usage of plan.value?.usages || []) {
if (String(usage.certType || '') !== kind) continue
const items = Array.isArray(usage.items) ? usage.items : []
const hit = items.some((item: Record<string, unknown>) => {
const typed = `${String(item.sourceStopType || '')}:${String(item.sourceStopProductId || '')}`
return String(item.sourceId || '') === sourceId || typed === sourceId
})
if (!hit) continue
fromUsage.push({
certType: kind,
certId: String(usage.certId || ''),
certNo: String(usage.certNoSnapshot || ''),
quantityUnit: String(usage.quantityUnit || '')
})
}
return fromUsage
}
function display(cert: QuotaCert, key: string) {
if (key === 'status') return certStatusLabels[cert.status] || cert.status || '—'
@@ -1,129 +1,7 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
</p>
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article
v-for="row in rows"
:key="row.id"
class="mp-wv-card mp-wv-flat-card"
@click="open(row.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ row.contractNo || '未编号合同' }}</div>
<div class="mp-wv-card__sub">{{ row.contractName || '—' }}</div>
</div>
<span class="status-tag status-tag--info">{{ contractTypeLabel(row.contractType) }}</span>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ row.certCount }}</span>
</div>
</div>
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)">查看</button>
</div>
</article>
</van-list>
<van-empty
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
description="暂无合同用证数据"
/>
</main>
<CustomerTab :keyword="keyword" />
</template>
<script setup lang="ts">
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type ContractStatisticsSummary } from '@/api/pl/quota-cert'
import { contractTypeLabel } from '@/views/pl/quota-statistics/labels'
const props = defineProps<{ keyword: string }>()
const router = useRouter()
const rows = ref<ContractStatisticsSummary[]>([])
const loading = ref(true)
const finished = ref(false)
const error = ref('')
const allowed = ref(false)
const authReady = ref(false)
const page = ref(1)
let epoch = 0
let checking = true
function open(id: string) {
void router.push(`/m/pl/plan-cert/contract/${id}`)
}
async function init() {
const current = epoch
try {
const permissions = await fetchGetMenuCodes()
if (current !== epoch) return
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
checking = false
if (!allowed.value) {
finished.value = true
loading.value = false
return
}
await load()
} catch {
if (current !== epoch) return
checking = false
authReady.value = true
finished.value = true
loading.value = false
error.value = '权限加载失败,请重新打开页面'
}
}
function reload() {
if (!allowed.value) {
void init()
return
}
epoch++
page.value = 1
rows.value = []
loading.value = true
finished.value = false
error.value = ''
void load()
}
async function load() {
if (checking || !allowed.value) return
const current = epoch
loading.value = true
try {
const data = await quotaStatisticsApi.contracts({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
page.value += 1
} catch {
if (current === epoch) {
error.value = '统计加载失败,请重试'
finished.value = true
}
} finally {
if (current === epoch) loading.value = false
}
}
watch(
() => props.keyword,
() => {
if (!checking && allowed.value) reload()
}
)
void init()
import CustomerTab from './CustomerTab.vue'
defineProps<{ keyword: string }>()
</script>
@@ -0,0 +1,143 @@
<template>
<main class="mp-h5-list-content mp-plan-cert-list">
<p class="mp-customer-quota-tip">按客户展示购买证、运输证;证件关联客户,不再按合同绑定。</p>
<p v-if="error" class="mp-wv-empty" role="alert">
{{ error }}
<button class="mp-wv-btn" type="button" @click="reload">重试</button>
</p>
<div v-else-if="authReady && !allowed" class="mp-wv-empty">
<div class="mp-wv-empty__title">无证件查看权限</div>
</div>
<van-list v-else v-model:loading="loading" :finished="finished" finished-text="" @load="load">
<article
v-for="row in rows"
:key="row.id"
class="mp-wv-card mp-wv-flat-card"
@click="open(row.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ row.customerName || '未命名客户' }}</div>
<div class="mp-wv-card__sub">{{ row.customerNo || '—' }}</div>
</div>
</div>
<div class="mp-wv-card__kv">
<div class="mp-wv-card__kv-row">
<span>关联证件数</span><span>{{ row.certCount }}</span>
</div>
</div>
<div class="mp-wv-card-actions mp-wv-card-actions--single" @click.stop>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="open(row.id)"
>查看</button
>
</div>
</article>
</van-list>
<van-empty
v-if="authReady && allowed && !loading && finished && !error && !rows.length"
description="暂无客户用证数据"
/>
</main>
</template>
<script setup lang="ts">
import { fetchGetMenuCodes } from '@/api/auth'
import { quotaStatisticsApi, type CustomerStatisticsSummary } from '@/api/pl/quota-cert'
const props = defineProps<{ keyword: string }>()
const router = useRouter()
const rows = ref<CustomerStatisticsSummary[]>([])
const loading = ref(true)
const finished = ref(false)
const error = ref('')
const allowed = ref(false)
const authReady = ref(false)
const page = ref(1)
let epoch = 0
let checking = true
function open(id: string) {
void router.push(`/m/pl/plan-cert/customer/${id}`)
}
async function init() {
const current = epoch
try {
const permissions = await fetchGetMenuCodes()
if (current !== epoch) return
const codes = permissions?.menuCodes || []
allowed.value =
!!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list')
authReady.value = true
checking = false
if (!allowed.value) {
finished.value = true
loading.value = false
return
}
await load()
} catch {
if (current !== epoch) return
checking = false
authReady.value = true
finished.value = true
loading.value = false
error.value = '权限加载失败,请重新打开页面'
}
}
function reload() {
if (!allowed.value) {
void init()
return
}
epoch++
page.value = 1
rows.value = []
loading.value = true
finished.value = false
error.value = ''
void load()
}
async function load() {
if (checking || !allowed.value) return
const current = epoch
loading.value = true
try {
const data = await quotaStatisticsApi.customers({
page: page.value,
pageSize: 20,
keyword: props.keyword || undefined
})
if (current !== epoch) return
const list = data?.list || []
rows.value = page.value === 1 ? list : rows.value.concat(list)
finished.value = rows.value.length >= (data?.total || 0) || list.length < 20
page.value += 1
} catch {
if (current === epoch) {
error.value = '统计加载失败,请重试'
finished.value = true
}
} finally {
if (current === epoch) loading.value = false
}
}
watch(
() => props.keyword,
() => {
if (!checking && allowed.value) reload()
}
)
void init()
</script>
<style scoped>
.mp-customer-quota-tip {
margin: 8px 12px 0;
padding: 10px 12px;
border-radius: 8px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
</style>
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more