Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf47a26ba0 | ||
|
|
d5f3a79ea5 | ||
|
|
c5ae2d957c | ||
|
|
9f855423d8 | ||
|
|
a6fc0aa0f3 | ||
|
|
c460386583 | ||
|
|
e8cda33a3c | ||
|
|
b0c1bca683 | ||
|
|
208c93d192 | ||
|
|
168e909914 | ||
|
|
a327ccf69a | ||
|
|
cd94ebc56d | ||
|
|
aad74505c0 | ||
|
|
43e240ad6a | ||
|
|
53987bdf22 | ||
|
|
85f5f29895 | ||
|
|
8bffb1cbf5 | ||
|
|
a4d213231c | ||
|
|
7ebbaa2784 | ||
|
|
553c1ef316 | ||
|
|
f75d36e1ff | ||
|
|
a86ded9ea6 | ||
|
|
c8174ab3f2 | ||
|
|
01d607d766 | ||
|
|
fba478dcca | ||
|
|
c4852bd488 | ||
|
|
64f14814bd | ||
|
|
7f54923acf | ||
|
|
0f1446b5d7 | ||
|
|
c0fa3ccfd9 | ||
|
|
ed5784d1f7 | ||
|
|
a4a8c2de58 | ||
|
|
0a7eb97d14 | ||
|
|
efd7462e35 | ||
|
|
345149dd6b | ||
|
|
95a9c04fa6 | ||
|
|
a7938a3b80 | ||
|
|
738f76c486 | ||
|
|
9f65cd447f | ||
|
|
eae5654f01 | ||
|
|
71927bedef | ||
|
|
0baa6880f7 | ||
|
|
dd4fdf1e6e | ||
|
|
92a8b3f6da | ||
|
|
522ef36e7a | ||
|
|
bb75f98582 | ||
|
|
bf89a1f301 | ||
|
|
391e5f66f5 | ||
|
|
522b4be705 |
No files matched your search
@@ -115,4 +115,5 @@ api_key
|
||||
|
||||
# personal-dev:begin
|
||||
/.local/agent/
|
||||
/.local/secrets/
|
||||
# personal-dev:end
|
||||
@@ -4,7 +4,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.doclib_upload_route import DoclibRoute
|
||||
from app.schemas.doclib import DeleteFilesBody, FolderCreateBody, FolderUpdateBody, ShareBody
|
||||
from app.schemas.doclib import (
|
||||
BatchShareBody,
|
||||
DeleteFilesBody,
|
||||
FolderCreateBody,
|
||||
FolderUpdateBody,
|
||||
ShareBody,
|
||||
)
|
||||
from app.services.doclib_service import DoclibService
|
||||
from app.services.doclib_storage import download_document, upload_document
|
||||
|
||||
@@ -87,6 +93,11 @@ def delete_document_files(body: DeleteFilesBody, svc: DoclibService = Depends(se
|
||||
return success_response()
|
||||
|
||||
|
||||
@router.post('/web/doclib/files/batch-shares')
|
||||
def batch_share_documents(body: BatchShareBody, svc: DoclibService = Depends(service)):
|
||||
return success_response(svc.set_shares_batch(body.ids, body.userIds))
|
||||
|
||||
|
||||
@router.get('/web/doclib/files/{file_id}')
|
||||
def document_detail(file_id: str, svc: DoclibService = Depends(service)):
|
||||
return success_response(svc.get_file(file_id))
|
||||
|
||||
@@ -2206,6 +2206,8 @@ def create_plan(
|
||||
create_by=user.id,
|
||||
create_dept_id=user.dept_id,
|
||||
)
|
||||
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
|
||||
stamp_plan_rule_version(db, user, plan)
|
||||
db.add(plan)
|
||||
|
||||
# 添加产品明细
|
||||
@@ -2377,6 +2379,8 @@ def create_plan_full(
|
||||
create_by=user.id,
|
||||
create_dept_id=user.dept_id,
|
||||
)
|
||||
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
|
||||
stamp_plan_rule_version(db, user, plan)
|
||||
db.add(plan)
|
||||
db.flush()
|
||||
|
||||
@@ -2527,6 +2531,8 @@ def create_from_contract(
|
||||
create_by=user.id,
|
||||
create_dept_id=user.dept_id,
|
||||
)
|
||||
from app.services.pl_cert_quota_rule_config_service import stamp_plan_rule_version
|
||||
stamp_plan_rule_version(db, user, plan)
|
||||
db.add(plan)
|
||||
|
||||
# 查询合同产品明细
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ record_router = APIRouter(prefix="/vehicle-recharge-record", tags=["运输计划
|
||||
RECHARGE_TYPE_LABELS = {"etc": "ETC", "fuel": "油卡", "shanxia": "山峡", "bailian": "百链"}
|
||||
RECHARGE_APPLY_STATUS_LABELS = {
|
||||
"draft": "草稿",
|
||||
"pending": "待发放",
|
||||
"pending": "发放中",
|
||||
"in_approval": "审批中",
|
||||
"rejected": "已驳回",
|
||||
"disbursed": "已发放",
|
||||
@@ -113,6 +113,7 @@ def _apply_list_kwargs(params: dict) -> dict:
|
||||
license_plate=_param_get(params, "licensePlate", "license_plate"),
|
||||
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
|
||||
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
|
||||
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
|
||||
status=_param_get(params, "status"),
|
||||
status_in=_param_get(params, "statusIn", "status_in"),
|
||||
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
|
||||
@@ -139,6 +140,7 @@ def _disburse_list_kwargs(params: dict) -> dict:
|
||||
license_plate=_param_get(params, "licensePlate", "license_plate"),
|
||||
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
|
||||
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
|
||||
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
|
||||
apply_status=_param_get(params, "applyStatus", "apply_status"),
|
||||
apply_status_in=_param_get(params, "applyStatusIn", "apply_status_in"),
|
||||
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
|
||||
@@ -170,6 +172,7 @@ def list_applies(
|
||||
request: Request,
|
||||
q: Optional[str] = Query(default=None),
|
||||
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
|
||||
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
|
||||
status: Optional[str] = Query(default=None),
|
||||
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
|
||||
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
|
||||
@@ -185,6 +188,7 @@ def list_applies(
|
||||
request,
|
||||
q=q,
|
||||
rechargeType=recharge_type,
|
||||
rechargeTypeNe=recharge_type_ne,
|
||||
status=status,
|
||||
applyDateFrom=apply_date_from,
|
||||
applyDateTo=apply_date_to,
|
||||
@@ -233,6 +237,7 @@ RECHARGE_APPLY_EXPORT_COLUMNS = [
|
||||
class RechargeApplyExportBody(BaseModel):
|
||||
q: Optional[str] = None
|
||||
recharge_type: Optional[str] = Field(default=None, alias="rechargeType")
|
||||
recharge_type_ne: Optional[str] = Field(default=None, alias="rechargeTypeNe")
|
||||
status: Optional[str] = None
|
||||
apply_date_from: Optional[date] = Field(default=None, alias="applyDateFrom")
|
||||
apply_date_to: Optional[date] = Field(default=None, alias="applyDateTo")
|
||||
@@ -422,6 +427,7 @@ def delete_applies(
|
||||
def list_records(
|
||||
q: Optional[str] = Query(default=None),
|
||||
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
|
||||
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
|
||||
recharge_date_from: Optional[date] = Query(default=None, alias="rechargeDateFrom"),
|
||||
recharge_date_to: Optional[date] = Query(default=None, alias="rechargeDateTo"),
|
||||
gas_station: Optional[str] = Query(default=None, alias="gasStation"),
|
||||
@@ -438,6 +444,7 @@ def list_records(
|
||||
user,
|
||||
q=q,
|
||||
recharge_type=recharge_type,
|
||||
recharge_type_ne=recharge_type_ne,
|
||||
recharge_date_from=recharge_date_from,
|
||||
recharge_date_to=recharge_date_to,
|
||||
gas_station=gas_station,
|
||||
@@ -455,6 +462,7 @@ def list_disburse_board(
|
||||
request: Request,
|
||||
q: Optional[str] = Query(default=None),
|
||||
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
|
||||
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
|
||||
apply_status: Optional[str] = Query(default=None, alias="applyStatus"),
|
||||
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
|
||||
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
|
||||
@@ -470,6 +478,7 @@ def list_disburse_board(
|
||||
request,
|
||||
q=q,
|
||||
rechargeType=recharge_type,
|
||||
rechargeTypeNe=recharge_type_ne,
|
||||
applyStatus=apply_status,
|
||||
applyDateFrom=apply_date_from,
|
||||
applyDateTo=apply_date_to,
|
||||
@@ -513,6 +522,7 @@ RECHARGE_DISBURSE_EXPORT_COLUMNS = [
|
||||
class RechargeDisburseExportBody(BaseModel):
|
||||
q: Optional[str] = None
|
||||
recharge_type: Optional[str] = Field(default=None, alias="rechargeType")
|
||||
recharge_type_ne: Optional[str] = Field(default=None, alias="rechargeTypeNe")
|
||||
apply_status: Optional[str] = Field(default=None, alias="applyStatus")
|
||||
apply_date_from: Optional[date] = Field(default=None, alias="applyDateFrom")
|
||||
apply_date_to: Optional[date] = Field(default=None, alias="applyDateTo")
|
||||
|
||||
@@ -42,6 +42,7 @@ def _approval_list_kwargs(params: dict) -> dict:
|
||||
license_plate=_param_get(params, "licensePlate", "license_plate"),
|
||||
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
|
||||
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
|
||||
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
|
||||
status=_param_get(params, "status"),
|
||||
status_in=_param_get(params, "statusIn", "status_in"),
|
||||
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
|
||||
@@ -61,6 +62,7 @@ def _approval_list_kwargs(params: dict) -> dict:
|
||||
@approval_router.get("/pending", response_model=VehicleRechargeApplyListResponse)
|
||||
def list_pending(
|
||||
request: Request,
|
||||
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
|
||||
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
|
||||
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
@@ -71,6 +73,7 @@ def list_pending(
|
||||
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
|
||||
params = _merge_query(
|
||||
request,
|
||||
rechargeTypeNe=recharge_type_ne,
|
||||
applyDateFrom=apply_date_from,
|
||||
applyDateTo=apply_date_to,
|
||||
)
|
||||
@@ -90,6 +93,7 @@ def list_pending(
|
||||
@approval_router.get("/done", response_model=VehicleRechargeApplyListResponse)
|
||||
def list_done(
|
||||
request: Request,
|
||||
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
|
||||
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
|
||||
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
@@ -100,6 +104,7 @@ def list_done(
|
||||
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
|
||||
params = _merge_query(
|
||||
request,
|
||||
rechargeTypeNe=recharge_type_ne,
|
||||
applyDateFrom=apply_date_from,
|
||||
applyDateTo=apply_date_to,
|
||||
)
|
||||
|
||||
@@ -366,10 +366,12 @@ class PlPlan(Base):
|
||||
cert_configuration = Column(JSON, nullable=True, comment="用证来源配对、候选选择与逐证条件确认;不作为放行布尔状态")
|
||||
hazard_cert_ready_by = Column(String(64), nullable=True, comment="危化品证书确认人")
|
||||
hazard_cert_ready_time = Column(DateTime, nullable=True, comment="危化品证书确认时间")
|
||||
quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度")
|
||||
quota_cert_waived = Column(SmallInteger, nullable=False, default=0, comment="证书齐备:1 时安排和归档不校验、不扣购买证与运输证额度(新规则仅放行安排)")
|
||||
quota_cert_waived_by = Column(String(64), nullable=True, comment="证书齐备确认人ID")
|
||||
quota_cert_waived_by_name = Column(String(100), nullable=True, comment="证书齐备确认人姓名")
|
||||
quota_cert_waived_time = Column(DateTime, nullable=True, comment="证书齐备确认时间")
|
||||
cert_quota_rule_version = Column(String(32), nullable=True, comment="证件额度规则版本 legacy|customer_category;创建时打标")
|
||||
quota_cert_pending_fulfill = Column(SmallInteger, nullable=False, default=0, comment="新规则证书齐备待完善 0-否 1-是")
|
||||
erp_entered = Column(SmallInteger, nullable=False, default=0, comment="是否已录入ERP 0-否 1-是")
|
||||
cancel_reason = Column(String(500), nullable=True, comment="作废原因")
|
||||
cancel_time = Column(DateTime, nullable=True, comment="作废时间")
|
||||
@@ -536,6 +538,7 @@ class PlPlanLoad(Base):
|
||||
notes = Column(String(500), nullable=True, comment="注意事项")
|
||||
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
||||
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
|
||||
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
@@ -599,6 +602,7 @@ class PlPlanDelivery(Base):
|
||||
notes = Column(String(500), nullable=True, comment="注意事项")
|
||||
generate_dibang = Column(SmallInteger, nullable=False, default=0, comment="是否生成地磅单 0-否 1-是")
|
||||
dibang_direction = Column(String(20), nullable=True, comment="地磅方向 outbound-出库 inbound-入库 none-无")
|
||||
same_city = Column(SmallInteger, nullable=False, default=0, comment="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -28,5 +28,10 @@ class ShareBody(_Body):
|
||||
userIds: List[str] = Field(max_length=500)
|
||||
|
||||
|
||||
class BatchShareBody(_Body):
|
||||
ids: List[str] = Field(min_length=1, max_length=100)
|
||||
userIds: List[str] = Field(max_length=500)
|
||||
|
||||
|
||||
class DeleteFilesBody(_Body):
|
||||
ids: List[str] = Field(min_length=1, max_length=100)
|
||||
@@ -182,6 +182,7 @@ class PlanLoadBody(BaseModel):
|
||||
dibang_direction: Optional[str] = Field(
|
||||
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
|
||||
)
|
||||
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
products: list[PlanStopProductBody] = Field(default_factory=list, description="装货段产品明细")
|
||||
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="装货段合同列表")
|
||||
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
|
||||
@@ -203,6 +204,7 @@ class PlanDeliveryBody(BaseModel):
|
||||
dibang_direction: Optional[str] = Field(
|
||||
default=None, alias="dibangDirection", description="地磅方向 outbound-出库 inbound-入库 none-无"
|
||||
)
|
||||
same_city: int = Field(default=0, alias="sameCity", description="是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证")
|
||||
products: list[PlanStopProductBody] = Field(default_factory=list, description="送货段产品明细")
|
||||
contracts: Optional[list["PlanContractLink"]] = Field(default=None, description="送货段合同列表")
|
||||
remark: Optional[str] = Field(default=None, max_length=512, description="备注")
|
||||
@@ -411,10 +413,12 @@ class PlanRead(PlanBase, PlanResourceSnapshotMixin):
|
||||
hazard_cert_ready: int = Field(default=0, alias="hazardCertReady", description="危化品证书是否已人工确认齐备")
|
||||
hazard_cert_ready_by: Optional[str] = Field(default=None, alias="hazardCertReadyBy", description="危化品证书确认人")
|
||||
hazard_cert_ready_time: Optional[datetime] = Field(default=None, alias="hazardCertReadyTime", description="危化品证书确认时间")
|
||||
quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;为 1 时不校验、不扣购买证与运输证额度")
|
||||
quota_cert_waived: int = Field(default=0, alias="quotaCertWaived", description="证书齐备 0-否 1-是;新规则仅放行安排")
|
||||
quota_cert_waived_by: Optional[str] = Field(default=None, alias="quotaCertWaivedBy", description="证书齐备确认人ID")
|
||||
quota_cert_waived_by_name: Optional[str] = Field(default=None, alias="quotaCertWaivedByName", description="证书齐备确认人姓名")
|
||||
quota_cert_waived_time: Optional[datetime] = Field(default=None, alias="quotaCertWaivedTime", description="证书齐备确认时间")
|
||||
cert_quota_rule_version: Optional[str] = Field(default=None, alias="certQuotaRuleVersion", description="legacy|customer_category")
|
||||
quota_cert_pending_fulfill: int = Field(default=0, alias="quotaCertPendingFulfill", description="新规则齐备待完善 0否 1是")
|
||||
erp_entered: int = Field(default=0, alias="erpEntered", description="是否已录入ERP 0-否 1-是")
|
||||
cancel_reason: Optional[str] = Field(default=None, alias="cancelReason", description="作废原因")
|
||||
cancel_time: Optional[datetime] = Field(default=None, alias="cancelTime", description="作废时间")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class UsageSource(InternalModel):
|
||||
related_stop_products: List[str] = Field(default_factory=list)
|
||||
fingerprint: str
|
||||
snapshot: dict = Field(default_factory=dict)
|
||||
requires_transport: bool = True
|
||||
|
||||
@property
|
||||
def source_id(self):
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
RECHARGE_TYPES = {"etc", "fuel", "shanxia", "bailian"}
|
||||
APPLY_STATUSES = {"draft", "in_approval", "rejected", "disbursed", "cancelled"}
|
||||
APPLY_STATUSES = {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"}
|
||||
NODE_ACTION_TYPES = {"approve", "disburse"}
|
||||
APPROVAL_STATUSES = {"approved", "rejected", "disbursed"}
|
||||
|
||||
|
||||
@@ -228,23 +228,38 @@ class DoclibService:
|
||||
self.db.commit()
|
||||
return result
|
||||
|
||||
def set_shares(self, file_id, user_ids):
|
||||
def _apply_shares(self, file_id, user_ids):
|
||||
row = self._file(file_id, manage=True, lock=True)
|
||||
requested = set(user_ids) - {row.create_by}
|
||||
valid = {u.id for u in self._base(SysUser).filter(SysUser.id.in_(requested), SysUser.status == '1').all()}
|
||||
if requested != valid:
|
||||
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
||||
if requested:
|
||||
valid = {u.id for u in self._base(SysUser).filter(
|
||||
SysUser.id.in_(requested), SysUser.status == '1').all()}
|
||||
if requested != valid:
|
||||
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
||||
history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
|
||||
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id).populate_existing().with_for_update().all()}
|
||||
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id
|
||||
).populate_existing().with_for_update().all()}
|
||||
for uid, share in history.items():
|
||||
share.del_flag = 0 if uid in requested else 1
|
||||
share.update_by = self.uid
|
||||
for uid in requested - set(history):
|
||||
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
|
||||
row.update_by, row.update_time = self.uid, datetime.now()
|
||||
return row
|
||||
|
||||
def set_shares(self, file_id, user_ids):
|
||||
row = self._apply_shares(file_id, user_ids)
|
||||
self.db.commit()
|
||||
return self._serialize_files([row])[0]
|
||||
|
||||
def set_shares_batch(self, ids, user_ids):
|
||||
file_ids = sorted({str(i).strip() for i in ids if str(i).strip()})
|
||||
if not file_ids:
|
||||
raise HTTPException(400, '请选择要设置分享的文档')
|
||||
rows = [self._apply_shares(fid, user_ids) for fid in file_ids]
|
||||
self.db.commit()
|
||||
return self._serialize_files(rows)
|
||||
|
||||
def delete_files(self, ids):
|
||||
rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))]
|
||||
for row in rows:
|
||||
|
||||
@@ -25,6 +25,12 @@ def _split_csv(raw: Optional[str]) -> List[str]:
|
||||
return [s.strip() for s in str(raw).split(",") if s.strip()]
|
||||
|
||||
|
||||
def _append_ne_filter(filters: list, col, raw: Optional[str]) -> None:
|
||||
value = (raw or "").strip()
|
||||
if value:
|
||||
filters.append(col != value)
|
||||
|
||||
|
||||
def _append_enum_filter(
|
||||
filters: list,
|
||||
col,
|
||||
@@ -148,6 +154,7 @@ def build_recharge_apply_column_filters(
|
||||
license_plate: Optional[str] = None,
|
||||
recharge_type: Optional[str] = None,
|
||||
recharge_type_in: Optional[str] = None,
|
||||
recharge_type_ne: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
status_in: Optional[str] = None,
|
||||
apply_date_from: DateLike = None,
|
||||
@@ -175,6 +182,7 @@ def build_recharge_apply_column_filters(
|
||||
single=recharge_type,
|
||||
multi=recharge_type_in,
|
||||
)
|
||||
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
|
||||
_append_enum_filter(
|
||||
filters, PlVehicleRechargeApply.status, single=status, multi=status_in
|
||||
)
|
||||
@@ -223,6 +231,7 @@ def build_recharge_disburse_board_column_filters(
|
||||
license_plate: Optional[str] = None,
|
||||
recharge_type: Optional[str] = None,
|
||||
recharge_type_in: Optional[str] = None,
|
||||
recharge_type_ne: Optional[str] = None,
|
||||
apply_status: Optional[str] = None,
|
||||
apply_status_in: Optional[str] = None,
|
||||
apply_date_from: DateLike = None,
|
||||
@@ -256,6 +265,7 @@ def build_recharge_disburse_board_column_filters(
|
||||
single=recharge_type,
|
||||
multi=recharge_type_in,
|
||||
)
|
||||
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
|
||||
_append_enum_filter(
|
||||
filters,
|
||||
PlVehicleRechargeApply.status,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""证用品类与产品关联;差量保存关联行。"""
|
||||
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;清空请提交 []')
|
||||
existing = {
|
||||
r.id: r
|
||||
for r in db.query(PlCertCategoryProduct).filter_by(
|
||||
tenant_no=user.tenant_no, category_id=row.id, del_flag=0,
|
||||
).with_for_update().populate_existing().all()
|
||||
}
|
||||
seen = set()
|
||||
product_ids = set()
|
||||
for item in submitted:
|
||||
pid = item.get('productId') or item.get('product_id')
|
||||
if not pid:
|
||||
raise HTTPException(400, '产品关联缺少 productId')
|
||||
if pid in product_ids:
|
||||
raise HTTPException(400, '同一品类不能重复关联同一产品')
|
||||
product_ids.add(pid)
|
||||
rid = item.get('id')
|
||||
scope = item.get('scopeStatus') or item.get('scope_status') or 'active'
|
||||
if rid:
|
||||
link = existing.get(rid)
|
||||
if not link:
|
||||
raise HTTPException(400, '产品关联明细不属于当前品类')
|
||||
if rid in seen:
|
||||
raise HTTPException(400, '产品关联明细 ID 重复')
|
||||
seen.add(rid)
|
||||
link.product_id = pid
|
||||
link.scope_status = scope
|
||||
link.update_by = user.id
|
||||
else:
|
||||
link = PlCertCategoryProduct(
|
||||
**_audit(user),
|
||||
category_id=row.id,
|
||||
product_id=pid,
|
||||
scope_status=scope,
|
||||
)
|
||||
db.add(link)
|
||||
for rid, link in existing.items():
|
||||
if rid not in seen:
|
||||
link.del_flag = 1
|
||||
link.update_by = user.id
|
||||
db.flush()
|
||||
|
||||
return get_category(db, user, row.id)
|
||||
|
||||
|
||||
def delete_category(db: Session, user, category_id: str):
|
||||
row = db.query(PlCertCategory).filter_by(
|
||||
id=category_id, tenant_no=user.tenant_no, del_flag=0,
|
||||
).with_for_update().populate_existing().first()
|
||||
if not row:
|
||||
raise HTTPException(404, '证用品类不存在')
|
||||
from app.models.pl_quota_models import PlPurchaseCert
|
||||
used = db.query(PlPurchaseCert.id).filter_by(
|
||||
tenant_no=user.tenant_no, category_id=category_id, del_flag=0,
|
||||
).first()
|
||||
if used:
|
||||
raise HTTPException(400, '品类已被购买证使用,不能删除')
|
||||
row.del_flag = 1
|
||||
row.update_by = user.id
|
||||
for link in db.query(PlCertCategoryProduct).filter_by(
|
||||
tenant_no=user.tenant_no, category_id=category_id, del_flag=0,
|
||||
).with_for_update().all():
|
||||
link.del_flag = 1
|
||||
link.update_by = user.id
|
||||
db.flush()
|
||||
|
||||
|
||||
def category_ids_for_product(db: Session, tenant_no: str, product_id: str):
|
||||
rows = (
|
||||
db.query(PlCertCategoryProduct.category_id)
|
||||
.filter_by(tenant_no=tenant_no, product_id=product_id, del_flag=0, scope_status='active')
|
||||
.all()
|
||||
)
|
||||
return {r.category_id for r in rows}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""客户品类额度规则切点:系统信息配置;未配置则拦住安排。"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.pl_quota_models import PlCertQuotaRuleConfig
|
||||
|
||||
RULE_NOT_CONFIGURED = '请先在系统信息中配置客户品类额度生效时间后再安排运输'
|
||||
VERSION_LEGACY = 'legacy'
|
||||
VERSION_CUSTOMER_CATEGORY = 'customer_category'
|
||||
|
||||
|
||||
def get_config(db: Session, tenant_no: str) -> Optional[PlCertQuotaRuleConfig]:
|
||||
return (
|
||||
db.query(PlCertQuotaRuleConfig)
|
||||
.filter_by(tenant_no=tenant_no, del_flag=0)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_effective_at(db: Session, tenant_no: str):
|
||||
row = get_config(db, tenant_no)
|
||||
return row.effective_at if row else None
|
||||
|
||||
|
||||
def read_config(db: Session, tenant_no: str) -> dict:
|
||||
row = get_config(db, tenant_no)
|
||||
return {
|
||||
'effectiveAt': row.effective_at.isoformat(sep=' ', timespec='seconds') if row and row.effective_at else None,
|
||||
'configured': bool(row and row.effective_at),
|
||||
}
|
||||
|
||||
|
||||
def save_effective_at(db: Session, user, effective_at: Optional[datetime]) -> dict:
|
||||
row = get_config(db, user.tenant_no)
|
||||
if row is None:
|
||||
row = PlCertQuotaRuleConfig(
|
||||
id=str(uuid4()),
|
||||
tenant_no=user.tenant_no,
|
||||
create_by=user.id,
|
||||
create_dept_id=getattr(user, 'dept_id', None) or '',
|
||||
update_by=user.id,
|
||||
)
|
||||
db.add(row)
|
||||
row.effective_at = effective_at
|
||||
row.update_by = user.id
|
||||
db.flush()
|
||||
return read_config(db, user.tenant_no)
|
||||
|
||||
|
||||
def assert_cert_quota_rule_configured(db: Session, tenant_no: str) -> datetime:
|
||||
effective_at = get_effective_at(db, tenant_no)
|
||||
if effective_at is None:
|
||||
raise HTTPException(409, RULE_NOT_CONFIGURED)
|
||||
return effective_at
|
||||
|
||||
|
||||
def resolve_rule_version_for_create(db: Session, tenant_no: str, create_time: Optional[datetime] = None) -> str:
|
||||
"""计划创建时打标;未配置生效时间时仍标 legacy(安排会被拦住)。"""
|
||||
effective_at = get_effective_at(db, tenant_no)
|
||||
when = create_time or datetime.now()
|
||||
if effective_at is not None and when >= effective_at:
|
||||
return VERSION_CUSTOMER_CATEGORY
|
||||
return VERSION_LEGACY
|
||||
|
||||
|
||||
def stamp_plan_rule_version(db: Session, user, plan) -> str:
|
||||
version = resolve_rule_version_for_create(db, user.tenant_no, getattr(plan, 'create_time', None) or datetime.now())
|
||||
plan.cert_quota_rule_version = version
|
||||
return version
|
||||
|
||||
|
||||
def plan_uses_customer_category(plan) -> bool:
|
||||
return (getattr(plan, 'cert_quota_rule_version', None) or VERSION_LEGACY) == VERSION_CUSTOMER_CATEGORY
|
||||
@@ -164,7 +164,7 @@ def count_recharge_approval_pending(db: Session, tenant_no: str, user_id: str) -
|
||||
.filter(
|
||||
PlVehicleRechargeApply.tenant_no == tenant_no,
|
||||
PlVehicleRechargeApply.del_flag == 0,
|
||||
PlVehicleRechargeApply.status == "in_approval",
|
||||
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
|
||||
BxWorkflowNode.tenant_no == tenant_no,
|
||||
BxWorkflowNode.del_flag == 0,
|
||||
BxWorkflowNode.approver_id == user_id,
|
||||
|
||||
@@ -1152,6 +1152,7 @@ def _resolve_or_create_plan_load(
|
||||
existing.notes = load.notes
|
||||
existing.generate_dibang = generate_dibang
|
||||
existing.dibang_direction = dibang_direction
|
||||
existing.same_city = 1 if int(load.same_city or 0) == 1 else 0
|
||||
existing.remark = load.remark
|
||||
existing.del_flag = 0
|
||||
existing.update_by = user_id
|
||||
@@ -1173,6 +1174,7 @@ def _resolve_or_create_plan_load(
|
||||
notes=load.notes,
|
||||
generate_dibang=generate_dibang,
|
||||
dibang_direction=dibang_direction,
|
||||
same_city=1 if int(load.same_city or 0) == 1 else 0,
|
||||
tenant_no=tenant_no,
|
||||
remark=load.remark,
|
||||
create_by=user_id,
|
||||
@@ -1439,6 +1441,7 @@ def replace_plan_stops(
|
||||
stop.seq = body.seq or 1
|
||||
stop.generate_dibang, stop.dibang_direction = normalize_stop_dibang_fields(
|
||||
stop.customer_name, kind, body.generate_dibang, body.dibang_direction)
|
||||
stop.same_city = 1 if int(getattr(body, "same_city", 0) or 0) == 1 else 0
|
||||
stop.del_flag, stop.update_by, stop.update_time = 0, user_id, now
|
||||
model = PlPlanLoadProduct if kind == 'load' else PlPlanDeliveryProduct
|
||||
for idx, (row, product, linked) in enumerate(product_jobs or []):
|
||||
|
||||
@@ -37,6 +37,7 @@ class SourceDemand:
|
||||
product_id: str
|
||||
quantity: Decimal
|
||||
unit: str
|
||||
requires_transport: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -231,6 +232,8 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
|
||||
key = (row.permit_kind, row.source_id, row.permit_id)
|
||||
s = sources.get(row.source_id)
|
||||
p = groups.get(row.permit_kind, {}).get(row.permit_id)
|
||||
if s is not None and not s.requires_transport and row.permit_kind == 'transport':
|
||||
raise AllocationInputError('同市来源不接受运输证分配')
|
||||
sq, pq = _quantity(row.source_quantity), _quantity(row.permit_quantity)
|
||||
if key in manual or s is None or p is None or s.product_id not in p.product_ids:
|
||||
raise AllocationInputError('人工分配标识重复或不适用')
|
||||
@@ -249,12 +252,19 @@ def allocate_plan(demands: Sequence[SourceDemand], purchases: Sequence[PermitCan
|
||||
|
||||
routes = []
|
||||
for sid, s in sorted(sources.items()):
|
||||
for gid, g in sorted(groups['purchase'].items()):
|
||||
for tid, t in sorted(groups['transport'].items()):
|
||||
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t):
|
||||
routes.append((sid, gid, tid))
|
||||
if len(routes) > limits.max_variables:
|
||||
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
|
||||
if s.requires_transport:
|
||||
pairs = (
|
||||
(gid, tid)
|
||||
for gid, g in sorted(groups['purchase'].items())
|
||||
for tid, t in sorted(groups['transport'].items())
|
||||
if gid in t.purchase_permit_ids and eligible('purchase', s, g) and eligible('transport', s, t)
|
||||
)
|
||||
else:
|
||||
pairs = ((gid, None) for gid, g in sorted(groups['purchase'].items()) if eligible('purchase', s, g))
|
||||
for gid, tid in pairs:
|
||||
routes.append((sid, gid, tid))
|
||||
if len(routes) > limits.max_variables:
|
||||
raise AllocationIncomplete('分配计算未完成:匹配变量超限')
|
||||
a, b = [], []
|
||||
|
||||
def constrain(coefficients, bound, equal=False):
|
||||
@@ -405,6 +415,7 @@ def validate_allocation_pairing(
|
||||
if len(demands) > limits.max_sources or len(transports) > limits.max_permits:
|
||||
raise AllocationIncomplete('匹配校验未完成:输入规模超限')
|
||||
sources = _unique(demands, 'source_id')
|
||||
purchase_only = {sid for sid, row in sources.items() if not row.requires_transport}
|
||||
transport_map = _unique(transports, 'permit_id')
|
||||
for s in sources.values():
|
||||
_quantity(s.quantity)
|
||||
@@ -434,13 +445,16 @@ def validate_allocation_pairing(
|
||||
groups[kind][key] = row
|
||||
for sid, s in sources.items():
|
||||
total = sum(F(r.source_quantity) for (source_id, _), r in groups[kind].items() if source_id == sid)
|
||||
if total != F(s.quantity):
|
||||
expected = F(0) if kind == 'transport' and sid in purchase_only else F(s.quantity)
|
||||
if total != expected:
|
||||
raise AllocationPairingError('分配未覆盖实际来源量,需要人工调整;保留原占用')
|
||||
purchase_ids = {pid for _, pid in groups['purchase']}
|
||||
if len(purchase_ids) + len(transport_map) > limits.max_permits:
|
||||
raise AllocationIncomplete('匹配校验未完成:证件数量超限')
|
||||
routes = []
|
||||
for sid, gid in sorted(groups['purchase']):
|
||||
if sid in purchase_only:
|
||||
continue
|
||||
for source_id, tid in sorted(groups['transport']):
|
||||
if sid == source_id and gid in transport_map[tid].purchase_permit_ids:
|
||||
routes.append((sid, gid, tid))
|
||||
@@ -450,9 +464,13 @@ def validate_allocation_pairing(
|
||||
for kind, rows in groups.items():
|
||||
position = 1 if kind == 'purchase' else 2
|
||||
for (sid, pid), row in sorted(rows.items()):
|
||||
if sid in purchase_only:
|
||||
continue
|
||||
v = [int(r[0] == sid and r[position] == pid) for r in routes]
|
||||
a.extend([v, [-x for x in v]])
|
||||
b.extend([F(row.source_quantity), -F(row.source_quantity)])
|
||||
if not a:
|
||||
return
|
||||
try:
|
||||
_solve(a, b, [0] * len(routes), budget)
|
||||
except InsufficientQuota as exc:
|
||||
|
||||
@@ -120,10 +120,22 @@ def apply_plan_quota(db, user, plan, action, payload=None):
|
||||
evidenceUrls=getattr(payload, 'quota_evidence_urls', None),
|
||||
sourceNote=getattr(payload, 'quota_source_note', None))
|
||||
from app.services.pl_quota_waiver import assert_waived_sources, quota_cert_waived
|
||||
if quota_cert_waived(plan) and action in ('reserve', 'resize', 'settle', 'actual_adjust'):
|
||||
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
|
||||
# 新规则:齐备仅放行安排/改派预占;归档与实际调整必须真证结转。
|
||||
# 旧规则(legacy):齐备仍跳过安排与归档(祖父兼容)。
|
||||
skip_actions = ('reserve', 'resize')
|
||||
if not plan_uses_customer_category(plan):
|
||||
skip_actions = ('reserve', 'resize', 'settle', 'actual_adjust')
|
||||
if quota_cert_waived(plan) and action in skip_actions:
|
||||
if action in ('reserve', 'resize'):
|
||||
assert_waived_sources(db, user, plan, lock=True)
|
||||
return {'operationId': ledger_id, 'usageIds': [], 'waived': True}
|
||||
if (
|
||||
plan_uses_customer_category(plan)
|
||||
and action in ('settle', 'actual_adjust')
|
||||
and int(getattr(plan, 'quota_cert_pending_fulfill', 0) or 0) == 1
|
||||
):
|
||||
raise HTTPException(409, '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档')
|
||||
request, config = build_usage_request(
|
||||
db, user, plan.id, op, action, lock=True, check_conditions=explicit_configuration,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
# 用最终证面状态校验作废证不可扩大,不修改持久对象。
|
||||
|
||||
@@ -63,6 +63,28 @@ def _refs(db, user, cert_id, lock=False):
|
||||
return query.order_by(PlTransportCertPurchaseRef.create_time.desc()).all()
|
||||
|
||||
|
||||
def _transport_derived_categories(db, user, transport_cert_id):
|
||||
from app.models.pl_quota_models import PlPurchaseCert
|
||||
purchase_ids = [
|
||||
r.purchase_cert_id
|
||||
for r in _refs(db, user, transport_cert_id)
|
||||
if r.purchase_cert_id
|
||||
]
|
||||
if not purchase_ids:
|
||||
return []
|
||||
rows = (
|
||||
db.query(PlPurchaseCert.category_id)
|
||||
.filter(
|
||||
PlPurchaseCert.tenant_no == user.tenant_no,
|
||||
PlPurchaseCert.del_flag == 0,
|
||||
PlPurchaseCert.id.in_(purchase_ids),
|
||||
PlPurchaseCert.category_id.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return list(dict.fromkeys(r.category_id for r in rows if r.category_id))
|
||||
|
||||
|
||||
def get_cert(db, user, cert_type, cert_id, current=False):
|
||||
row = _get_row(db, user, cert_type, cert_id, lock=current)
|
||||
result = _row_to_dict(row)
|
||||
@@ -77,6 +99,7 @@ def get_cert(db, user, cert_type, cert_id, current=False):
|
||||
if cert_type == 'transport':
|
||||
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
|
||||
result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current)
|
||||
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
|
||||
return result
|
||||
|
||||
|
||||
@@ -94,7 +117,14 @@ def list_certs(db, user, cert_type, keyword=None, status=None):
|
||||
))
|
||||
if status:
|
||||
query = query.filter(model.status == status)
|
||||
return [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()]
|
||||
else:
|
||||
# 默认不展示已核销、已作废;列头漏斗显式传 status 时可查看
|
||||
query = query.filter(model.status.notin_(('closed', 'void')))
|
||||
result = [get_cert(db, user, cert_type, r.id) for r in query.order_by(model.create_time.desc()).all()]
|
||||
if cert_type == 'transport':
|
||||
for item in result:
|
||||
item['derivedCategoryIds'] = _transport_derived_categories(db, user, item['id'])
|
||||
return result
|
||||
|
||||
|
||||
def _audit(user):
|
||||
@@ -181,13 +211,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 +241,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))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""计划段次是否需要把计划单位换算成证面单位。"""
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from app.models.pl_models import (
|
||||
PlPlan, PlPlanDelivery, PlPlanDeliveryProduct, PlPlanLoad, PlPlanLoadProduct, PlPlanProduct,
|
||||
)
|
||||
@@ -63,12 +65,45 @@ def line_needs_conversion(unit, cert_units):
|
||||
)
|
||||
|
||||
|
||||
def plan_needs_unit_conversion(plan, lines, cert_units, snapshots):
|
||||
def _positive_decimal(value):
|
||||
try:
|
||||
number = Decimal(str(value).strip())
|
||||
except (InvalidOperation, AttributeError, TypeError):
|
||||
return False
|
||||
return number > 0
|
||||
|
||||
|
||||
def _saved_manual_keys(configuration):
|
||||
"""已填写完整人工换算的 (sourceId, permitKind, permitId)。"""
|
||||
rows = (configuration or {}).get('manualAllocations') or (configuration or {}).get('manual_allocations') or []
|
||||
keys = set()
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
source_id = row.get('sourceId') or row.get('source_id')
|
||||
kind = row.get('permitKind') or row.get('permit_kind')
|
||||
permit_id = row.get('permitId') or row.get('permit_id')
|
||||
source_qty = row.get('sourceQuantity') if 'sourceQuantity' in row else row.get('source_quantity')
|
||||
permit_qty = row.get('permitQuantity') if 'permitQuantity' in row else row.get('permit_quantity')
|
||||
if source_id and kind and permit_id and _positive_decimal(source_qty) and _positive_decimal(permit_qty):
|
||||
keys.add((str(source_id), str(kind), str(permit_id)))
|
||||
return keys
|
||||
|
||||
|
||||
def plan_needs_unit_conversion(plan, lines, cert_bindings, snapshots):
|
||||
"""证书齐备豁免或异单位段已保存完整人工换算时,不再进入待换算待办。"""
|
||||
if int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1:
|
||||
return False
|
||||
version = measurement_version(plan, snapshots)
|
||||
saved = _saved_manual_keys(plan.cert_configuration)
|
||||
for line in measured_lines(version, lines, plan.cert_configuration):
|
||||
units = cert_units.get(line['contractProductId'], [])
|
||||
if line_needs_conversion(line['unit'], units):
|
||||
return True
|
||||
bindings = cert_bindings.get(line['contractProductId'], [])
|
||||
source_id = f"{line['side']}:{line['id']}"
|
||||
for binding in bindings:
|
||||
if not line_needs_conversion(line['unit'], [binding['unit']]):
|
||||
continue
|
||||
if (source_id, binding['kind'], binding['certId']) not in saved:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -104,12 +139,14 @@ def _stop_lines(db, tenant_no, plan_ids):
|
||||
return grouped
|
||||
|
||||
|
||||
def _cert_units(db, tenant_no, contract_product_ids):
|
||||
units = {}
|
||||
def _cert_bindings(db, tenant_no, contract_product_ids):
|
||||
bindings = {}
|
||||
if not contract_product_ids:
|
||||
return units
|
||||
return bindings
|
||||
for kind, cert_model in (('purchase', PlPurchaseCert), ('transport', PlTransportCert)):
|
||||
rows = db.query(PlContractCertProduct.contract_product_id, cert_model.quantity_unit).join(
|
||||
rows = db.query(
|
||||
PlContractCertProduct.contract_product_id, cert_model.id, cert_model.quantity_unit,
|
||||
).join(
|
||||
PlContractCertLink, PlContractCertLink.id == PlContractCertProduct.contract_cert_link_id,
|
||||
).join(cert_model, cert_model.id == PlContractCertLink.cert_id).filter(
|
||||
PlContractCertLink.tenant_no == tenant_no, PlContractCertLink.del_flag == 0,
|
||||
@@ -119,9 +156,11 @@ def _cert_units(db, tenant_no, contract_product_ids):
|
||||
cert_model.tenant_no == tenant_no, cert_model.del_flag == 0,
|
||||
PlContractCertProduct.contract_product_id.in_(contract_product_ids),
|
||||
).all()
|
||||
for product_id, unit in rows:
|
||||
units.setdefault(product_id, []).append(unit)
|
||||
return units
|
||||
for product_id, cert_id, unit in rows:
|
||||
bindings.setdefault(product_id, []).append({
|
||||
'kind': kind, 'certId': cert_id, 'unit': unit,
|
||||
})
|
||||
return bindings
|
||||
|
||||
|
||||
def plans_needing_unit_conversion(db, tenant_no, plan_ids):
|
||||
@@ -137,7 +176,7 @@ def plans_needing_unit_conversion(db, tenant_no, plan_ids):
|
||||
product_ids = {
|
||||
line['contractProductId'] for rows in lines.values() for line in rows if line['contractProductId']
|
||||
}
|
||||
cert_units = _cert_units(db, tenant_no, product_ids)
|
||||
cert_bindings = _cert_bindings(db, tenant_no, product_ids)
|
||||
snapshots = {plan_id: [] for plan_id in ids}
|
||||
usage_rows = db.query(PlPlanCertUsage.plan_id, PlPlanCertUsage.confirmation_snapshot).filter(
|
||||
PlPlanCertUsage.tenant_no == tenant_no, PlPlanCertUsage.del_flag == 0,
|
||||
@@ -146,6 +185,6 @@ def plans_needing_unit_conversion(db, tenant_no, plan_ids):
|
||||
for plan_id, snapshot in usage_rows:
|
||||
snapshots[plan_id].append(snapshot)
|
||||
for plan in plans:
|
||||
if plan_needs_unit_conversion(plan, lines.get(plan.id, []), cert_units, snapshots.get(plan.id, [])):
|
||||
if plan_needs_unit_conversion(plan, lines.get(plan.id, []), cert_bindings, snapshots.get(plan.id, [])):
|
||||
needed.add(plan.id)
|
||||
return needed
|
||||
@@ -0,0 +1,223 @@
|
||||
"""新规则:按段次客户+品类整笔选证(不拆购买证)。"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models.pl_quota_models import (
|
||||
PlPurchaseCert,
|
||||
PlTransportCert,
|
||||
PlTransportCertPurchaseRef,
|
||||
PlCertQtyLog,
|
||||
)
|
||||
from app.schemas.pl.quota_planning import ManualQuantity
|
||||
from app.services.pl_cert_category_service import category_ids_for_product
|
||||
from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio
|
||||
|
||||
|
||||
def _available(cert):
|
||||
return Decimal(cert.total_qty) - Decimal(cert.used_qty) - Decimal(cert.reserved_qty)
|
||||
|
||||
|
||||
def _in_force(cert, on_date: date) -> bool:
|
||||
if cert.valid_from and on_date < cert.valid_from:
|
||||
return False
|
||||
if cert.valid_to and on_date > cert.valid_to:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _has_opening(db, user, cert_type, cert_id) -> bool:
|
||||
return (
|
||||
db.query(PlCertQtyLog.id)
|
||||
.filter_by(
|
||||
tenant_no=user.tenant_no,
|
||||
cert_type=cert_type,
|
||||
cert_id=cert_id,
|
||||
event_type='opening',
|
||||
del_flag=0,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _vehicle_ok(cert, vehicle_no: str) -> bool:
|
||||
plates = cert.vehicle_numbers_json or []
|
||||
if not isinstance(plates, list):
|
||||
return False
|
||||
plate = (vehicle_no or '').strip()
|
||||
if not plate:
|
||||
return False
|
||||
return plate in {str(p).strip() for p in plates if p}
|
||||
|
||||
|
||||
def _permit_qty_for(source_qty: Decimal, source_unit: str, cert_unit: str) -> Decimal:
|
||||
if same_quantity_unit(source_unit, cert_unit):
|
||||
return source_qty
|
||||
ratio = standard_conversion_ratio(source_unit, cert_unit)
|
||||
if ratio is None:
|
||||
raise HTTPException(409, f'来源单位「{source_unit}」与证件单位「{cert_unit}」无法自动换算,请调整单位或证件')
|
||||
return (source_qty * Decimal(str(ratio))).quantize(Decimal('0.000001'))
|
||||
|
||||
|
||||
def _customer_id_from_source(source) -> str:
|
||||
snap = source.snapshot or {}
|
||||
cid = snap.get('customerId') or snap.get('customer_id')
|
||||
if cid:
|
||||
return str(cid)
|
||||
raise HTTPException(409, '用证段次缺少客户,无法按客户选证')
|
||||
|
||||
|
||||
def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: date):
|
||||
"""返回 purchase_cert_ids, transport_cert_ids, manual_allocations。
|
||||
|
||||
每个来源必须由一张购买证整笔覆盖;异地再配一张关联且车号/余量够的运输证。
|
||||
"""
|
||||
if not sources:
|
||||
return [], [], []
|
||||
|
||||
purchase_ids = []
|
||||
transport_ids = []
|
||||
manuals = []
|
||||
|
||||
for source in sources:
|
||||
customer_id = _customer_id_from_source(source)
|
||||
categories = category_ids_for_product(db, user.tenant_no, source.product_id)
|
||||
if not categories:
|
||||
raise HTTPException(
|
||||
409,
|
||||
'产品未配置证用品类,请先在证用品类中关联系统产品后再安排运输',
|
||||
)
|
||||
|
||||
# MySQL ASC 会把 NULL 排最前;无到期证应排在末尾,优先消耗更早到期的证(D5)
|
||||
candidates = (
|
||||
db.query(PlPurchaseCert)
|
||||
.filter(
|
||||
PlPurchaseCert.tenant_no == user.tenant_no,
|
||||
PlPurchaseCert.del_flag == 0,
|
||||
PlPurchaseCert.status == 'active',
|
||||
PlPurchaseCert.customer_id == customer_id,
|
||||
PlPurchaseCert.category_id.in_(categories),
|
||||
)
|
||||
.order_by(
|
||||
PlPurchaseCert.valid_to.is_(None).asc(),
|
||||
PlPurchaseCert.valid_to.asc(),
|
||||
PlPurchaseCert.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
chosen_purchase = None
|
||||
chosen_transport = None
|
||||
purchase_permit_qty = None
|
||||
transport_permit_qty = None
|
||||
|
||||
for purchase in candidates:
|
||||
if not purchase.verified_by or not purchase.verified_time:
|
||||
continue
|
||||
if not _has_opening(db, user, 'purchase', purchase.id):
|
||||
continue
|
||||
if not _in_force(purchase, on_date):
|
||||
continue
|
||||
if purchase.opening_single_use_consumed:
|
||||
continue
|
||||
try:
|
||||
need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit)
|
||||
except HTTPException:
|
||||
continue
|
||||
if _available(purchase) < need:
|
||||
continue
|
||||
|
||||
if not source.requires_transport:
|
||||
chosen_purchase = purchase
|
||||
purchase_permit_qty = need
|
||||
break
|
||||
|
||||
refs = (
|
||||
db.query(PlTransportCertPurchaseRef)
|
||||
.filter_by(
|
||||
tenant_no=user.tenant_no,
|
||||
purchase_cert_id=purchase.id,
|
||||
del_flag=0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
transport_cert_ids = [r.transport_cert_id for r in refs if r.transport_cert_id]
|
||||
if not transport_cert_ids:
|
||||
continue
|
||||
transports = (
|
||||
db.query(PlTransportCert)
|
||||
.filter(
|
||||
PlTransportCert.tenant_no == user.tenant_no,
|
||||
PlTransportCert.del_flag == 0,
|
||||
PlTransportCert.status == 'active',
|
||||
PlTransportCert.customer_id == customer_id,
|
||||
PlTransportCert.id.in_(transport_cert_ids),
|
||||
)
|
||||
.order_by(
|
||||
PlTransportCert.valid_to.is_(None).asc(),
|
||||
PlTransportCert.valid_to.asc(),
|
||||
PlTransportCert.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for transport in transports:
|
||||
if not transport.verified_by or not transport.verified_time:
|
||||
continue
|
||||
if not _has_opening(db, user, 'transport', transport.id):
|
||||
continue
|
||||
if not _in_force(transport, on_date):
|
||||
continue
|
||||
if transport.opening_single_use_consumed:
|
||||
continue
|
||||
if not _vehicle_ok(transport, vehicle_no):
|
||||
continue
|
||||
try:
|
||||
t_need = _permit_qty_for(Decimal(source.quantity), source.unit, transport.quantity_unit)
|
||||
except HTTPException:
|
||||
continue
|
||||
if _available(transport) < t_need:
|
||||
continue
|
||||
chosen_purchase = purchase
|
||||
chosen_transport = transport
|
||||
purchase_permit_qty = need
|
||||
transport_permit_qty = t_need
|
||||
break
|
||||
if chosen_purchase and chosen_transport:
|
||||
break
|
||||
|
||||
if chosen_purchase is None:
|
||||
place = '同市' if not source.requires_transport else '异地'
|
||||
raise HTTPException(
|
||||
409,
|
||||
f'{place}段找不到余量足够、有效期内且匹配品类/车号的购买证'
|
||||
f'{"和运输证" if source.requires_transport else ""},请补充证件或标记证书齐备后再安排',
|
||||
)
|
||||
|
||||
purchase_ids.append(chosen_purchase.id)
|
||||
manuals.append(
|
||||
ManualQuantity(
|
||||
permit_kind='purchase',
|
||||
source_id=source.source_id,
|
||||
permit_id=chosen_purchase.id,
|
||||
source_quantity=Decimal(source.quantity),
|
||||
permit_quantity=purchase_permit_qty,
|
||||
)
|
||||
)
|
||||
if chosen_transport is not None:
|
||||
transport_ids.append(chosen_transport.id)
|
||||
manuals.append(
|
||||
ManualQuantity(
|
||||
permit_kind='transport',
|
||||
source_id=source.source_id,
|
||||
permit_id=chosen_transport.id,
|
||||
source_quantity=Decimal(source.quantity),
|
||||
permit_quantity=transport_permit_qty,
|
||||
)
|
||||
)
|
||||
|
||||
def unique(values):
|
||||
return list(dict.fromkeys(values))
|
||||
|
||||
return unique(purchase_ids), unique(transport_ids), manuals
|
||||
@@ -21,7 +21,13 @@ def allocation_diagnostics(demands, purchases, transports):
|
||||
grouped.append((code, source.unit))
|
||||
for kind, permits in [('purchase', purchases), ('transport', transports)]:
|
||||
for code, unit in grouped:
|
||||
sources = [s for s in demands if canonical_quantity_unit(s.unit) == code]
|
||||
sources = [
|
||||
s for s in demands
|
||||
if canonical_quantity_unit(s.unit) == code
|
||||
and (kind == 'purchase' or getattr(s, 'requires_transport', True))
|
||||
]
|
||||
if not sources:
|
||||
continue
|
||||
products = {s.product_id for s in sources}
|
||||
eligible = [p for p in permits if p.product_ids & products]
|
||||
demand = sum((s.quantity for s in sources), Decimal(0))
|
||||
@@ -67,6 +73,8 @@ def missing_purchase_link_message(demands, purchases, transports, cert_nos):
|
||||
notices = []
|
||||
seen = set()
|
||||
for source in demands:
|
||||
if not getattr(source, 'requires_transport', True):
|
||||
continue
|
||||
buys = [p for p in purchases if source.product_id in p.product_ids and same_quantity_unit(p.unit, source.unit)]
|
||||
moves = [t for t in transports if source.product_id in t.product_ids and same_quantity_unit(t.unit, source.unit)]
|
||||
if not buys or not moves:
|
||||
@@ -81,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)
|
||||
|
||||
@@ -93,16 +101,17 @@ def foreign_unit_message(demands, purchases, transports, cert_nos):
|
||||
for source in demands:
|
||||
buys = [p for p in purchases if source.product_id in p.product_ids]
|
||||
moves = [t for t in transports if source.product_id in t.product_ids]
|
||||
needs_transport = getattr(source, 'requires_transport', True)
|
||||
same_buys = [p for p in buys if _directly_usable(p, source.unit)]
|
||||
same_moves = [t for t in moves if _directly_usable(t, source.unit)]
|
||||
if same_buys and same_moves:
|
||||
if same_buys and (same_moves or not needs_transport):
|
||||
continue
|
||||
parts = []
|
||||
if not same_buys:
|
||||
text = _cert_unit_text('purchase', [p for p in buys if not _directly_usable(p, source.unit)], cert_nos)
|
||||
if text:
|
||||
parts.append(f'购买证 {text}')
|
||||
if not same_moves:
|
||||
if needs_transport and not same_moves:
|
||||
text = _cert_unit_text('transport', [t for t in moves if not _directly_usable(t, source.unit)], cert_nos)
|
||||
if text:
|
||||
parts.append(f'运输证 {text}')
|
||||
|
||||
@@ -35,8 +35,9 @@ def plan_fingerprint(sources):
|
||||
|
||||
|
||||
def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labels):
|
||||
"""按合同产品的可用绑定选出购买证、运输证。缺任一证时拒绝安排。"""
|
||||
purchase_ids, transport_ids, missing = [], [], []
|
||||
"""按合同产品的可用绑定选出购买证、运输证。同市段只要求购买证。"""
|
||||
purchase_ids, transport_ids = [], []
|
||||
missing_both, missing_purchase = [], []
|
||||
for source in sources:
|
||||
found = {'purchase': [], 'transport': []}
|
||||
for binding in bindings_by_contract.get(source.contract_id) or []:
|
||||
@@ -52,13 +53,22 @@ def classify_contract_cert_bindings(sources, bindings_by_contract, contract_labe
|
||||
if source.contract_product_id not in product_ids or kind not in found or not cert_id:
|
||||
continue
|
||||
found[kind].append(cert_id)
|
||||
if not found['purchase'] or not found['transport']:
|
||||
missing.append(contract_labels.get(source.contract_id) or '未编号合同')
|
||||
needs_transport = getattr(source, 'requires_transport', True)
|
||||
label = contract_labels.get(source.contract_id) or '未编号合同'
|
||||
if not found['purchase'] or (needs_transport and not found['transport']):
|
||||
(missing_both if needs_transport else missing_purchase).append(label)
|
||||
purchase_ids.extend(found['purchase'])
|
||||
transport_ids.extend(found['transport'])
|
||||
if missing:
|
||||
names = '、'.join(dict.fromkeys(missing))
|
||||
raise HTTPException(409, f'合同{names}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输')
|
||||
if needs_transport:
|
||||
transport_ids.extend(found['transport'])
|
||||
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 messages:
|
||||
raise HTTPException(409, ';'.join(messages))
|
||||
|
||||
def unique(values):
|
||||
return list(dict.fromkeys(values))
|
||||
@@ -121,28 +131,52 @@ def build_usage_request(db, user, plan_id, operation, action, *, lock=False, che
|
||||
sources, actuals = [], {}
|
||||
else:
|
||||
sources, actuals = extract_sources(db, user, plan_id, mapping, actual=action in ('settle', 'actual_adjust'), lock=lock, preview_vehicle=preview_vehicle, measurement_version=config.measurement_version)
|
||||
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
|
||||
customer_rule = plan_uses_customer_category(plan)
|
||||
if action in ('reserve', 'resize') and not explicit and sources:
|
||||
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources)
|
||||
source_ids = {source.source_id for source in sources}
|
||||
allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
|
||||
manual = [
|
||||
row for row in config.manual_allocations
|
||||
if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed
|
||||
]
|
||||
config = config.model_copy(update={
|
||||
'purchase_cert_ids': purchase_ids,
|
||||
'transport_cert_ids': transport_ids,
|
||||
'manual_allocations': manual,
|
||||
'conditions': [],
|
||||
})
|
||||
if customer_rule:
|
||||
# 新规则:禁止手工选证;按客户+品类整笔自动选证
|
||||
from app.services.pl_quota_customer_select import select_whole_cover_certs
|
||||
vehicle = (preview_vehicle or {}).get('licensePlate', plan.license_plate) or ''
|
||||
on_date = max(date.today(), plan.plan_date or date.today())
|
||||
purchase_ids, transport_ids, manuals = select_whole_cover_certs(
|
||||
db, user, sources, vehicle_no=vehicle, on_date=on_date,
|
||||
)
|
||||
config = config.model_copy(update={
|
||||
'purchase_cert_ids': purchase_ids,
|
||||
'transport_cert_ids': transport_ids,
|
||||
'manual_allocations': manuals,
|
||||
'conditions': [],
|
||||
})
|
||||
else:
|
||||
purchase_ids, transport_ids = resolve_contract_bound_certs(db, user, sources)
|
||||
source_ids = {source.source_id for source in sources}
|
||||
allowed = {('purchase', cert_id) for cert_id in purchase_ids} | {('transport', cert_id) for cert_id in transport_ids}
|
||||
manual = [
|
||||
row for row in config.manual_allocations
|
||||
if row.source_id in source_ids and (row.permit_kind, row.permit_id) in allowed
|
||||
]
|
||||
config = config.model_copy(update={
|
||||
'purchase_cert_ids': purchase_ids,
|
||||
'transport_cert_ids': transport_ids,
|
||||
'manual_allocations': manual,
|
||||
'conditions': [],
|
||||
})
|
||||
elif action in ('reserve', 'resize') and explicit and customer_rule:
|
||||
raise HTTPException(409, '新规则下安排仅支持自动选证,请勿手工指定证件;选不到请标记证书齐备')
|
||||
keys = [('purchase', cid) for cid in config.purchase_cert_ids] + [('transport', cid) for cid in config.transport_cert_ids]
|
||||
if len(set(keys)) != len(keys):
|
||||
raise HTTPException(400, '证件选择重复')
|
||||
if action in ('reserve', 'resize') or op.allow_supplement:
|
||||
certs = {key: _get_row(db, user, *key, lock=False) for key in sorted(keys)}
|
||||
if sources and not keys:
|
||||
raise HTTPException(409, '请先在合同中绑定购买证和运输证后再安排运输')
|
||||
if check_conditions and explicit:
|
||||
msg = (
|
||||
'找不到适用的购买证和运输证,请补充证件或标记证书齐备后再安排运输'
|
||||
if customer_rule else
|
||||
'请先在合同中绑定购买证和运输证后再安排运输'
|
||||
)
|
||||
raise HTTPException(409, msg)
|
||||
if check_conditions and explicit and not customer_rule:
|
||||
validate_conditions(config, certs, plan_fingerprint(sources))
|
||||
executed = bool(plan_execution_lock_reason(db, user.tenant_no, plan.id))
|
||||
request = UsageRequest(
|
||||
@@ -191,8 +225,11 @@ def preview_plan(db, user, plan_id, config, preview_vehicle=None):
|
||||
|
||||
def save_configuration(db, user, plan_id, config):
|
||||
from app.services.pl_plan_service import record_plan_change
|
||||
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
|
||||
config = config if isinstance(config, PlanQuotaConfiguration) else PlanQuotaConfiguration.model_validate(config)
|
||||
plan = _plan(db, user, plan_id, lock=True)
|
||||
if plan_uses_customer_category(plan):
|
||||
raise HTTPException(409, '新规则下安排仅自动选证,不支持手工保存用证配置')
|
||||
if plan.transport_status == 'arranged' or plan.archive_status == 'archived':
|
||||
raise HTTPException(409, '已安排计划请通过调整运输在同一事务重算用证')
|
||||
# 草稿允许缺少条件确认,但真实来源身份及选证权限必须有效。
|
||||
@@ -279,6 +316,7 @@ def get_plan_quota(db, user, plan_id):
|
||||
product = products[row.plan_product_id]
|
||||
stop = stops[side][getattr(row, 'load_id' if side == 'load' else 'delivery_id')]
|
||||
item = {'sourceStopId': stop.id, 'stopId': stop.id,
|
||||
'sameCity': int(getattr(stop, 'same_city', 0) or 0) == 1,
|
||||
'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
|
||||
'productId': row.product_id, 'productNo': row.product_no,
|
||||
'sourceStopType': side, 'sourceStopProductId': row.id,
|
||||
@@ -297,15 +335,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
|
||||
@@ -156,8 +156,12 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
|
||||
for stop_side, value in [(side, row)] + [(other_side, r) for r in related]:
|
||||
parent = stops[stop_side][getattr(value, SIDE[stop_side][2])]
|
||||
stop_facts.append({key: getattr(parent, key, None) for key in ('id', 'supplier_id', 'supplier_name', 'customer_id', 'customer_name', 'address')})
|
||||
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}
|
||||
'vehicle': plan.license_plate, 'trailer': plan.trailer_no, 'planType': plan.plan_type,
|
||||
'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:
|
||||
@@ -168,7 +172,7 @@ def extract_sources(db, user, plan_id, mapping=None, *, actual=False, lock=False
|
||||
plan_product_id=row.plan_product_id, contract_id=row.contract_id,
|
||||
contract_product_id=row.contract_product_id, product_id=row.product_id,
|
||||
quantity=row.plan_qty, unit=row.unit, related_stop_products=[r.id for r in related],
|
||||
fingerprint=fingerprint, snapshot=snapshot)
|
||||
fingerprint=fingerprint, snapshot=snapshot, requires_transport=not same_city)
|
||||
sources.append(source)
|
||||
if actual:
|
||||
quantity = getattr(row, actual_field)
|
||||
|
||||
@@ -197,6 +197,9 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
product=query(CrmContractProduct,id=s.contract_product_id,contract_id=s.contract_id,del_flag=0).first()
|
||||
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)
|
||||
for k in sorted(selected):
|
||||
kind,pid=k; c=certs[k]
|
||||
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
|
||||
@@ -206,25 +209,36 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
if not c.valid_from <= r.on_date <= c.valid_to: raise ValueError('证件不在有效日期内')
|
||||
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 [])): raise ValueError('车号不在运输证范围')
|
||||
# 合同上常有多张运输证。车号对不上的证不参与本次分配,只要还有一张覆盖该主车号即可安排。
|
||||
if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])):
|
||||
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('一次有效证件已使用或已占用')
|
||||
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('车号不在运输证范围')
|
||||
return sources,scopes,candidates
|
||||
|
||||
def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_preview=False):
|
||||
sources,scopes,candidates=_candidate_context(db,user,r,plan,certs,usages,items,selected,preview,lock_preview)
|
||||
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit) for s in sources.values()]
|
||||
demands=[SourceDemand(s.source_id,s.source_id,s.quantity,s.unit,s.requires_transport) for s in sources.values()]
|
||||
try:
|
||||
allocation=allocate_plan(demands,candidates['purchase'],candidates['transport'],manual_allocations=_manual(r.manual_allocations))
|
||||
except InsufficientQuota as error:
|
||||
@@ -244,6 +258,15 @@ def _reserve(db,user,r,plan,certs,usages,items,selected,preview=False,lock_previ
|
||||
# 零量也占用一次资格;纯计算器省略零流,账本补存一对真实适用证件。
|
||||
for sid,s in sorted(sources.items()):
|
||||
if s.quantity != 0: continue
|
||||
if not s.requires_transport:
|
||||
buys=[p for p in candidates['purchase'] if sid in p.product_ids and (
|
||||
same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None
|
||||
or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))]
|
||||
if not buys:
|
||||
raise ValueError('零量同市来源仍须有适用购买证')
|
||||
chosen=min(buys, key=lambda item: (item.expires_on, item.permit_id))
|
||||
rows.append(Allocation('purchase', sid, chosen.permit_id, ZERO, ZERO, s.unit, chosen.unit, 1))
|
||||
continue
|
||||
pairs=[(p,t) for p in candidates['purchase'] for t in candidates['transport']
|
||||
if sid in p.product_ids and sid in t.product_ids and p.permit_id in t.purchase_permit_ids
|
||||
and (same_quantity_unit(p.unit, s.unit) or standard_conversion_ratio(s.unit, p.unit) is not None or any(a.permit_kind=='purchase' and a.source_id==sid and a.permit_id==p.permit_id for a in r.manual_allocations))
|
||||
@@ -264,8 +287,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
|
||||
@@ -294,7 +319,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)
|
||||
|
||||
|
||||
@@ -430,12 +475,13 @@ def _settle(db,user,r,certs,usages,items,plan):
|
||||
if any(u.usage_status!='settled' if adjusting else u.usage_status not in ('reserved','in_transit','exception') for k,u in usages.items() if k in active):
|
||||
raise ValueError('用证状态不允许当前结转动作')
|
||||
groups={'purchase':[],'transport':[]}; item_map={}; demands={}
|
||||
transport_required={s.source_id: s.requires_transport for s in r.sources}
|
||||
for k,rows in active.items():
|
||||
for i in rows:
|
||||
sid=_key(i)
|
||||
a=Allocation(k[0],sid,k[1],i.allocated_source_qty,i.planned_cert_qty,i.source_unit,usages[k].quantity_unit,i.allocation_seq)
|
||||
groups[k[0]].append(a); item_map[(k[0],sid,k[1])]=i
|
||||
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit)
|
||||
demands[sid]=SourceDemand(sid,sid,r.actual_by_source.get(sid),i.source_unit,transport_required.get(sid, True))
|
||||
if not r.manual_actuals:
|
||||
groups, generated, conversion_error = _prepare_saved_conversion(groups, r.actual_by_source, certs)
|
||||
if conversion_error:
|
||||
|
||||
@@ -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}
|
||||
@@ -58,8 +58,8 @@ def _current_node(
|
||||
user: DbUserContext,
|
||||
tenant_no: str,
|
||||
) -> BxWorkflowNode:
|
||||
if apply.status != "in_approval" or not apply.current_node:
|
||||
raise HTTPException(status_code=400, detail="申请单不在审批中")
|
||||
if apply.status not in ("in_approval", "pending") or not apply.current_node:
|
||||
raise HTTPException(status_code=400, detail="申请单不在可审批/发放状态")
|
||||
node = (
|
||||
db.query(BxWorkflowNode)
|
||||
.filter(
|
||||
@@ -119,6 +119,7 @@ def list_pending(
|
||||
license_plate: Optional[str] = None,
|
||||
recharge_type: Optional[str] = None,
|
||||
recharge_type_in: Optional[str] = None,
|
||||
recharge_type_ne: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
status_in: Optional[str] = None,
|
||||
apply_date_from=None,
|
||||
@@ -144,7 +145,7 @@ def list_pending(
|
||||
.filter(
|
||||
PlVehicleRechargeApply.tenant_no == tenant_no,
|
||||
PlVehicleRechargeApply.del_flag == 0,
|
||||
PlVehicleRechargeApply.status == "in_approval",
|
||||
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
|
||||
BxWorkflowNode.tenant_no == tenant_no,
|
||||
BxWorkflowNode.del_flag == 0,
|
||||
BxWorkflowNode.approver_id == user.id,
|
||||
@@ -155,6 +156,7 @@ def list_pending(
|
||||
license_plate=license_plate,
|
||||
recharge_type=recharge_type,
|
||||
recharge_type_in=recharge_type_in,
|
||||
recharge_type_ne=recharge_type_ne,
|
||||
status=status,
|
||||
status_in=status_in,
|
||||
apply_date_from=apply_date_from,
|
||||
@@ -192,6 +194,7 @@ def list_done(
|
||||
license_plate: Optional[str] = None,
|
||||
recharge_type: Optional[str] = None,
|
||||
recharge_type_in: Optional[str] = None,
|
||||
recharge_type_ne: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
status_in: Optional[str] = None,
|
||||
apply_date_from=None,
|
||||
@@ -233,6 +236,7 @@ def list_done(
|
||||
license_plate=license_plate,
|
||||
recharge_type=recharge_type,
|
||||
recharge_type_in=recharge_type_in,
|
||||
recharge_type_ne=recharge_type_ne,
|
||||
status=status,
|
||||
status_in=status_in,
|
||||
apply_date_from=apply_date_from,
|
||||
@@ -290,7 +294,11 @@ def approve_apply(
|
||||
opinion=opinion,
|
||||
now=now,
|
||||
)
|
||||
apply.current_node = nodes[idx + 1].id
|
||||
next_node = nodes[idx + 1]
|
||||
apply.current_node = next_node.id
|
||||
next_action = (next_node.action_type or "approve").strip() or "approve"
|
||||
if next_action == "disburse":
|
||||
apply.status = "pending"
|
||||
apply.update_by = user.id
|
||||
apply.update_time = now
|
||||
sync_approval_notifications(
|
||||
|
||||
@@ -481,7 +481,7 @@ def _resolve_apply_for_record(
|
||||
detail="审批中的申请请在「ETC/油卡充值审批」中完成发放登记",
|
||||
)
|
||||
if apply.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="仅待发放申请可关联")
|
||||
raise HTTPException(status_code=400, detail="仅发放中申请可关联")
|
||||
existing = (
|
||||
db.query(PlVehicleRechargeRecord)
|
||||
.filter(
|
||||
@@ -513,6 +513,7 @@ def list_applies(
|
||||
license_plate: Optional[str] = None,
|
||||
recharge_type: Optional[str] = None,
|
||||
recharge_type_in: Optional[str] = None,
|
||||
recharge_type_ne: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
status_in: Optional[str] = None,
|
||||
apply_date_from: Optional[date] = None,
|
||||
@@ -555,6 +556,7 @@ def list_applies(
|
||||
license_plate=license_plate,
|
||||
recharge_type=recharge_type,
|
||||
recharge_type_in=recharge_type_in,
|
||||
recharge_type_ne=recharge_type_ne,
|
||||
status=status,
|
||||
status_in=status_in,
|
||||
apply_date_from=apply_date_from,
|
||||
@@ -854,6 +856,7 @@ def list_records(
|
||||
*,
|
||||
q: Optional[str] = None,
|
||||
recharge_type: Optional[str] = None,
|
||||
recharge_type_ne: Optional[str] = None,
|
||||
recharge_date_from: Optional[date] = None,
|
||||
recharge_date_to: Optional[date] = None,
|
||||
gas_station: Optional[str] = None,
|
||||
@@ -875,6 +878,10 @@ def list_records(
|
||||
)
|
||||
if recharge_type:
|
||||
query = query.filter(PlVehicleRechargeRecord.recharge_type == recharge_type)
|
||||
if recharge_type_ne and str(recharge_type_ne).strip():
|
||||
query = query.filter(
|
||||
PlVehicleRechargeRecord.recharge_type != str(recharge_type_ne).strip()
|
||||
)
|
||||
if recharge_date_from:
|
||||
query = query.filter(PlVehicleRechargeRecord.recharge_date >= recharge_date_from)
|
||||
if recharge_date_to:
|
||||
@@ -937,6 +944,7 @@ def list_disburse_board(
|
||||
license_plate: Optional[str] = None,
|
||||
recharge_type: Optional[str] = None,
|
||||
recharge_type_in: Optional[str] = None,
|
||||
recharge_type_ne: Optional[str] = None,
|
||||
apply_status: Optional[str] = None,
|
||||
apply_status_in: Optional[str] = None,
|
||||
apply_date_from: Optional[date] = None,
|
||||
@@ -997,6 +1005,7 @@ def list_disburse_board(
|
||||
license_plate=license_plate,
|
||||
recharge_type=recharge_type,
|
||||
recharge_type_in=recharge_type_in,
|
||||
recharge_type_ne=recharge_type_ne,
|
||||
apply_status=apply_status,
|
||||
apply_status_in=apply_status_in,
|
||||
apply_date_from=apply_date_from,
|
||||
@@ -1124,7 +1133,7 @@ def update_record(
|
||||
),
|
||||
nodes[-1] if nodes else None,
|
||||
)
|
||||
old_apply.status = "in_approval"
|
||||
old_apply.status = "pending"
|
||||
old_apply.current_node = disburse.id if disburse else None
|
||||
else:
|
||||
old_apply.status = "pending"
|
||||
@@ -1193,7 +1202,7 @@ def delete_records(
|
||||
),
|
||||
nodes[-1] if nodes else None,
|
||||
)
|
||||
apply.status = "in_approval"
|
||||
apply.status = "pending"
|
||||
apply.current_node = disburse.id if disburse else None
|
||||
else:
|
||||
apply.status = "pending"
|
||||
|
||||
@@ -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,465 @@
|
||||
"""客户+品类证件额度:切点、列表默认过滤、齐备语义、整笔选证。"""
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def test_assert_rule_not_configured_blocks_arrange():
|
||||
from app.services import pl_cert_quota_rule_config_service as cfg
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter_by.return_value.first.return_value = None
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
cfg.assert_cert_quota_rule_configured(db, 't1')
|
||||
assert exc.value.status_code == 409
|
||||
assert '系统信息' in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_resolve_rule_version_by_effective_at():
|
||||
from app.services import pl_cert_quota_rule_config_service as cfg
|
||||
|
||||
db = MagicMock()
|
||||
row = SimpleNamespace(effective_at=datetime(2026, 11, 14, 0, 0, 0))
|
||||
db.query.return_value.filter_by.return_value.first.return_value = row
|
||||
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 13)) == 'legacy'
|
||||
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 14)) == 'customer_category'
|
||||
db.query.return_value.filter_by.return_value.first.return_value = None
|
||||
assert cfg.resolve_rule_version_for_create(db, 't', datetime(2026, 11, 20)) == 'legacy'
|
||||
|
||||
|
||||
def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch):
|
||||
from app.services import pl_quota_business as biz
|
||||
|
||||
plan = SimpleNamespace(
|
||||
id='p1',
|
||||
quota_cert_waived=1,
|
||||
quota_cert_pending_fulfill=1,
|
||||
cert_quota_rule_version='customer_category',
|
||||
cert_configuration={},
|
||||
)
|
||||
called = {}
|
||||
|
||||
def fake_assert(*a, **k):
|
||||
called['assert'] = True
|
||||
|
||||
monkeypatch.setattr('app.services.pl_quota_waiver.assert_waived_sources', fake_assert)
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
|
||||
lambda p: True,
|
||||
)
|
||||
|
||||
result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'reserve', None)
|
||||
assert result.get('waived') is True
|
||||
assert called.get('assert') is True
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'settle', None)
|
||||
assert '待完善' in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_list_certs_default_excludes_closed_void(monkeypatch):
|
||||
from app.services import pl_quota_cert_service as svc
|
||||
|
||||
class Col:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
class FakeModel:
|
||||
__tablename__ = 'pl_purchase_cert'
|
||||
tenant_no = SimpleNamespace()
|
||||
del_flag = 0
|
||||
status = SimpleNamespace(notin_=lambda *a, **k: 'STATUS_FILTER')
|
||||
create_time = SimpleNamespace(desc=lambda: 'DESC')
|
||||
cert_no = seller_name = buyer_name = SimpleNamespace(contains=lambda *a, **k: True)
|
||||
__table__ = SimpleNamespace(columns=[Col('id'), Col('status')])
|
||||
|
||||
@staticmethod
|
||||
def id():
|
||||
return 'id'
|
||||
|
||||
class Query:
|
||||
def __init__(self):
|
||||
self.filters = []
|
||||
|
||||
def filter(self, *a, **k):
|
||||
self.filters.extend(a)
|
||||
return self
|
||||
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
q = Query()
|
||||
db = MagicMock()
|
||||
db.query.return_value = q
|
||||
monkeypatch.setattr(svc, '_model', lambda t: FakeModel)
|
||||
monkeypatch.setattr(svc, 'apply_pl_scope', lambda query, *a, **k: query)
|
||||
monkeypatch.setattr(svc, 'get_cert', lambda *a, **k: {})
|
||||
svc.list_certs(db, SimpleNamespace(tenant_no='t'), 'purchase')
|
||||
assert any(f == 'STATUS_FILTER' for f in q.filters)
|
||||
|
||||
|
||||
def test_whole_cover_skips_insufficient_earlier_purchase():
|
||||
"""规则 A:早到期 10 吨不够则跳过,选用晚到期够量的证。"""
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
from app.schemas.pl.quota_usage import UsageSource
|
||||
|
||||
source = UsageSource(
|
||||
source_stop_type='delivery',
|
||||
source_stop_id='s1',
|
||||
source_stop_product_id='sp1',
|
||||
plan_product_id='pp1',
|
||||
contract_id='c1',
|
||||
contract_product_id='cp1',
|
||||
product_id='prod1',
|
||||
quantity=Decimal('50'),
|
||||
unit='吨',
|
||||
fingerprint='x' * 64,
|
||||
snapshot={'customerId': 'cust1'},
|
||||
requires_transport=False,
|
||||
)
|
||||
|
||||
early = SimpleNamespace(
|
||||
id='buy-early', customer_id='cust1', category_id='cat1', status='active',
|
||||
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1),
|
||||
total_qty=Decimal('10'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨', opening_single_use_consumed=0,
|
||||
)
|
||||
late = SimpleNamespace(
|
||||
id='buy-late', customer_id='cust1', category_id='cat1', status='active',
|
||||
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 6, 1),
|
||||
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨', opening_single_use_consumed=0,
|
||||
)
|
||||
|
||||
class PurchaseQuery:
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [early, late]
|
||||
|
||||
class LogQuery:
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return SimpleNamespace(id='opening')
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def query(model):
|
||||
name = getattr(model, '__name__', str(model))
|
||||
if 'Purchase' in name or model is early.__class__:
|
||||
return PurchaseQuery()
|
||||
return LogQuery()
|
||||
|
||||
# route by model class name via side_effect
|
||||
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
|
||||
|
||||
def q_side(model):
|
||||
if model is PlPurchaseCert:
|
||||
return PurchaseQuery()
|
||||
if model is PlCertQtyLog:
|
||||
return LogQuery()
|
||||
return MagicMock()
|
||||
|
||||
db.query.side_effect = q_side
|
||||
user = SimpleNamespace(tenant_no='t')
|
||||
|
||||
import app.services.pl_quota_customer_select as mod
|
||||
orig_cat = mod.category_ids_for_product
|
||||
mod.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
|
||||
try:
|
||||
purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs(
|
||||
db, user, [source], vehicle_no='皖A12345', on_date=date(2026, 11, 14),
|
||||
)
|
||||
finally:
|
||||
mod.category_ids_for_product = orig_cat
|
||||
|
||||
assert purchase_ids == ['buy-late']
|
||||
assert transport_ids == []
|
||||
assert len(manuals) == 1
|
||||
assert manuals[0].permit_id == 'buy-late'
|
||||
assert manuals[0].permit_quantity == Decimal('50')
|
||||
|
||||
|
||||
def test_save_configuration_blocked_for_customer_category(monkeypatch):
|
||||
from app.services import pl_quota_planning as planning
|
||||
from app.schemas.pl.quota_planning import PlanQuotaConfiguration
|
||||
|
||||
plan = SimpleNamespace(
|
||||
id='p1',
|
||||
cert_quota_rule_version='customer_category',
|
||||
transport_status='pending_arrange',
|
||||
archive_status='none',
|
||||
cert_configuration={},
|
||||
)
|
||||
monkeypatch.setattr(planning, '_plan', lambda *a, **k: plan)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
planning.save_configuration(
|
||||
MagicMock(),
|
||||
SimpleNamespace(id='u', tenant_no='t', dept_id=''),
|
||||
'p1',
|
||||
PlanQuotaConfiguration(),
|
||||
)
|
||||
assert exc.value.status_code == 409
|
||||
assert '自动选证' in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_legacy_waive_still_skips_settle(monkeypatch):
|
||||
from app.services import pl_quota_business as biz
|
||||
|
||||
plan = SimpleNamespace(
|
||||
id='p1',
|
||||
quota_cert_waived=1,
|
||||
quota_cert_pending_fulfill=0,
|
||||
cert_quota_rule_version='legacy',
|
||||
cert_configuration={},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
|
||||
lambda p: False,
|
||||
)
|
||||
monkeypatch.setattr('app.services.pl_quota_waiver.assert_waived_sources', lambda *a, **k: None)
|
||||
result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'settle', None)
|
||||
assert result.get('waived') is True
|
||||
|
||||
|
||||
def test_null_valid_to_sorted_after_dated_certs():
|
||||
"""无到期日的证不能排在有到期日之前(MySQL NULL ASC 默认会错序)。"""
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
from app.schemas.pl.quota_usage import UsageSource
|
||||
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
|
||||
|
||||
source = UsageSource(
|
||||
source_stop_type='delivery',
|
||||
source_stop_id='s1',
|
||||
source_stop_product_id='sp1',
|
||||
plan_product_id='pp1',
|
||||
contract_id='c1',
|
||||
contract_product_id='cp1',
|
||||
product_id='prod1',
|
||||
quantity=Decimal('10'),
|
||||
unit='吨',
|
||||
fingerprint='z' * 64,
|
||||
snapshot={'customerId': 'cust1'},
|
||||
requires_transport=False,
|
||||
)
|
||||
unbounded = SimpleNamespace(
|
||||
id='buy-null', customer_id='cust1', category_id='cat1', status='active',
|
||||
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=None,
|
||||
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨', opening_single_use_consumed=0,
|
||||
)
|
||||
dated = SimpleNamespace(
|
||||
id='buy-dated', customer_id='cust1', category_id='cat1', status='active',
|
||||
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1),
|
||||
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨', opening_single_use_consumed=0,
|
||||
)
|
||||
|
||||
class PurchaseQuery:
|
||||
def __init__(self):
|
||||
self._order = None
|
||||
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *exprs):
|
||||
self._order = exprs
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
# 应先按 valid_to IS NULL 升序(非空在前),再按 valid_to ASC
|
||||
assert self._order is not None and len(self._order) >= 2
|
||||
return [dated, unbounded]
|
||||
|
||||
class LogQuery:
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return SimpleNamespace(id='opening')
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def q_side(model):
|
||||
if model is PlPurchaseCert:
|
||||
return PurchaseQuery()
|
||||
if model is PlCertQtyLog:
|
||||
return LogQuery()
|
||||
return MagicMock()
|
||||
|
||||
db.query.side_effect = q_side
|
||||
orig = sel.category_ids_for_product
|
||||
sel.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
|
||||
try:
|
||||
purchase_ids, _, manuals = sel.select_whole_cover_certs(
|
||||
db, SimpleNamespace(tenant_no='t'), [source], vehicle_no='皖A1', on_date=date(2026, 11, 14),
|
||||
)
|
||||
finally:
|
||||
sel.category_ids_for_product = orig
|
||||
assert purchase_ids == ['buy-dated']
|
||||
assert manuals[0].permit_id == 'buy-dated'
|
||||
|
||||
|
||||
def test_same_city_source_skips_transport_requirement():
|
||||
"""同市来源 requires_transport=False,整笔选证可不带运输证。"""
|
||||
from app.services import pl_quota_customer_select as sel
|
||||
from app.schemas.pl.quota_usage import UsageSource
|
||||
from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog
|
||||
|
||||
source = UsageSource(
|
||||
source_stop_type='delivery',
|
||||
source_stop_id='s1',
|
||||
source_stop_product_id='sp1',
|
||||
plan_product_id='pp1',
|
||||
contract_id='c1',
|
||||
contract_product_id='cp1',
|
||||
product_id='prod1',
|
||||
quantity=Decimal('20'),
|
||||
unit='吨',
|
||||
fingerprint='y' * 64,
|
||||
snapshot={'customerId': 'cust1', 'sameCity': 1},
|
||||
requires_transport=False,
|
||||
)
|
||||
purchase = SimpleNamespace(
|
||||
id='buy1', customer_id='cust1', category_id='cat1', status='active',
|
||||
verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 1, 1),
|
||||
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='吨', opening_single_use_consumed=0,
|
||||
)
|
||||
|
||||
class PurchaseQuery:
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [purchase]
|
||||
|
||||
class LogQuery:
|
||||
def filter_by(self, **k):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return SimpleNamespace(id='opening')
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def q_side(model):
|
||||
if model is PlPurchaseCert:
|
||||
return PurchaseQuery()
|
||||
if model is PlCertQtyLog:
|
||||
return LogQuery()
|
||||
return MagicMock()
|
||||
|
||||
db.query.side_effect = q_side
|
||||
orig = sel.category_ids_for_product
|
||||
sel.category_ids_for_product = lambda db, tenant, pid: {'cat1'}
|
||||
try:
|
||||
purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs(
|
||||
db, SimpleNamespace(tenant_no='t'), [source], vehicle_no='皖A1', on_date=date(2026, 11, 14),
|
||||
)
|
||||
finally:
|
||||
sel.category_ids_for_product = orig
|
||||
assert purchase_ids == ['buy1']
|
||||
assert transport_ids == []
|
||||
assert manuals[0].permit_id == 'buy1'
|
||||
|
||||
|
||||
def test_candidate_context_customer_rule_skips_contract_scope(monkeypatch):
|
||||
"""新规则账本候选不以合同绑证 scope 判定适用,否则预占会误报人工分配不适用。"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from app.services import pl_quota_usage_service as usage
|
||||
from app.schemas.pl.quota_usage import UsageSource
|
||||
|
||||
plan = SimpleNamespace(id='plan', cert_quota_rule_version='customer_category', plan_no='P1')
|
||||
source = UsageSource(
|
||||
source_stop_type='delivery',
|
||||
source_stop_id='s1',
|
||||
source_stop_product_id='sp1',
|
||||
plan_product_id='pp1',
|
||||
contract_id='c1',
|
||||
contract_product_id='cp1',
|
||||
product_id='prod1',
|
||||
quantity=Decimal('50'),
|
||||
unit='dun',
|
||||
fingerprint='f' * 64,
|
||||
snapshot={'customerId': 'cust1'},
|
||||
requires_transport=True,
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
sources=[source],
|
||||
on_date=date(2026, 11, 15),
|
||||
vehicle_no='皖A9TEST1',
|
||||
manual_allocations=[],
|
||||
)
|
||||
cert = SimpleNamespace(
|
||||
id='buy1', status='active', verified_time=date(2026, 11, 1), verified_by='u',
|
||||
valid_from=date(2026, 11, 1), valid_to=date(2027, 1, 1), valid_use_text='多次有效',
|
||||
total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'),
|
||||
quantity_unit='dun', vehicle_numbers_json=['皖A9TEST1'], opening_single_use_consumed=0,
|
||||
revision=1, cert_no='G1',
|
||||
)
|
||||
|
||||
class Q:
|
||||
def __init__(self, row=None, rows=None):
|
||||
self._row = row
|
||||
self._rows = rows or ([] if row is None else [row])
|
||||
|
||||
def first(self):
|
||||
return self._row
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
def fake_query(db, user, model, lock=False, **filters):
|
||||
name = getattr(model, '__name__', '')
|
||||
if name == 'PlCertQtyLog':
|
||||
return Q(SimpleNamespace(id='opening'))
|
||||
if name == 'PlTransportCertPurchaseRef':
|
||||
return Q(rows=[])
|
||||
if name in ('PlContractCertLink', 'PlContractCertProduct'):
|
||||
raise AssertionError('customer_category 不应查询合同绑证 scope')
|
||||
return Q()
|
||||
|
||||
monkeypatch.setattr(usage, '_query', fake_query)
|
||||
monkeypatch.setattr(usage, '_contract', lambda *a, **k: SimpleNamespace(status='executing', contract_type='sale'))
|
||||
monkeypatch.setattr(
|
||||
'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category',
|
||||
lambda p: True,
|
||||
)
|
||||
# product ownership check uses _query for CrmContractProduct
|
||||
def fake_query2(db, user, model, lock=False, **filters):
|
||||
name = getattr(model, '__name__', '')
|
||||
if name == 'CrmContractProduct':
|
||||
return Q(SimpleNamespace(product_id='prod1'))
|
||||
return fake_query(db, user, model, lock=lock, **filters)
|
||||
|
||||
monkeypatch.setattr(usage, '_query', fake_query2)
|
||||
|
||||
sources, scopes, candidates = usage._candidate_context(
|
||||
MagicMock(), SimpleNamespace(tenant_no='t', id='u'), request, plan,
|
||||
{('purchase', 'buy1'): cert}, {}, {}, {('purchase', 'buy1')},
|
||||
)
|
||||
assert 'delivery:sp1' in sources
|
||||
assert scopes[(('purchase', 'buy1'), 'delivery:sp1')] == (None, None)
|
||||
assert candidates['purchase'][0].product_ids == frozenset({'delivery:sp1'})
|
||||
@@ -75,6 +75,7 @@ def test_recharge_approval_badge_counts_current_approver_pending(monkeypatch):
|
||||
add_apply("a3", status="in_approval", current_node="node-other")
|
||||
add_apply("a4", status="rejected", current_node="node-me")
|
||||
add_apply("a5", status="draft", current_node=None)
|
||||
add_apply("a6", status="pending", current_node="node-me")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -89,4 +90,4 @@ def test_recharge_approval_badge_counts_current_approver_pending(monkeypatch):
|
||||
user_id="u-me",
|
||||
menu_codes=[MENU_CODE_RECHARGE_APPROVAL],
|
||||
)
|
||||
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 2
|
||||
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 3
|
||||
@@ -3,8 +3,8 @@ from decimal import Decimal as D
|
||||
import pytest
|
||||
from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, AllocationLimits, AllocationInputError, AllocationIncomplete, InsufficientQuota, SupplementalAllocationRequired, allocate_plan, settle_allocations)
|
||||
|
||||
def source(id='s', product='A', quantity='30', unit='t'):
|
||||
return SourceDemand(id, product, D(quantity), unit)
|
||||
def source(id='s', product='A', quantity='30', unit='t', requires_transport=True):
|
||||
return SourceDemand(id, product, D(quantity), unit, requires_transport)
|
||||
|
||||
def permit(id, quantity='30', products=('A',), refs=(), unit='t', expiry=1):
|
||||
return PermitCandidate(id, D(quantity), unit, date(2027, 1, expiry), frozenset(products), frozenset(refs))
|
||||
@@ -153,6 +153,40 @@ def test_zero_demand_and_empty_plan():
|
||||
assert allocate_plan([source(quantity='0')], [], []).transport_allocations == ()
|
||||
|
||||
|
||||
def test_same_city_demand_uses_purchase_certificate_only():
|
||||
result = allocate_plan([source('city', quantity='10', requires_transport=False)], [permit('g', '10')], [])
|
||||
assert quantities(result.purchase_allocations) == [('g', D(10), D(10))]
|
||||
assert result.transport_allocations == ()
|
||||
|
||||
|
||||
def test_same_city_shares_purchase_balance_and_skips_transport():
|
||||
city = source('city', 'A', '10', requires_transport=False)
|
||||
cross = source('cross', 'B', '10')
|
||||
result = allocate_plan(
|
||||
[city, cross],
|
||||
[permit('g', '30', ('A', 'B'))],
|
||||
[permit('t', '10', ('A', 'B'), ('g',))],
|
||||
)
|
||||
assert sum(row.source_quantity for row in result.purchase_allocations) == D(20)
|
||||
assert {(row.source_id, row.permit_id) for row in result.transport_allocations} == {('cross', 't')}
|
||||
with pytest.raises(InsufficientQuota):
|
||||
allocate_plan(
|
||||
[source('city', 'A', '10', requires_transport=False), source('cross', 'A', '10')],
|
||||
[permit('g', '15')],
|
||||
[permit('t', '10', refs=('g',))],
|
||||
)
|
||||
|
||||
|
||||
def test_same_city_rejects_a_transport_manual_row():
|
||||
with pytest.raises(AllocationInputError, match='同市'):
|
||||
allocate_plan(
|
||||
[source(requires_transport=False)],
|
||||
[permit('g', '30')],
|
||||
[permit('t', '30', refs=('g',))],
|
||||
manual_allocations=[ManualAllocation('transport', 's', 't', D(30), D(30))],
|
||||
)
|
||||
|
||||
|
||||
def test_one_purchase_and_two_transport_certificates():
|
||||
result = allocate_plan(
|
||||
[source()], [permit('g', '30')],
|
||||
@@ -208,6 +242,13 @@ def test_settlement_pairing_29_consistent_passes():
|
||||
assert validate_allocation_pairing([source(quantity='29')], actual_rows(plan.purchase_allocations), actual_rows(plan.transport_allocations), transports) is None
|
||||
|
||||
|
||||
def test_same_city_settlement_does_not_require_transport_pairing():
|
||||
from app.services.pl_quota_allocation import Allocation, validate_allocation_pairing
|
||||
demand = source('city', quantity='10', requires_transport=False)
|
||||
purchase = Allocation('purchase', 'city', 'g', D(10), D(10), 't', 't', 1)
|
||||
assert validate_allocation_pairing([demand], [purchase], [], []) is None
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -314,11 +314,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'},
|
||||
|
||||
@@ -23,47 +23,77 @@ def line(line_id, side, unit, contract_product_id='cp', customer='客户', toxic
|
||||
}
|
||||
|
||||
|
||||
def bind(unit, kind='purchase', cert_id='buy'):
|
||||
return {'kind': kind, 'certId': cert_id, 'unit': unit}
|
||||
|
||||
|
||||
def plan(**values):
|
||||
return SimpleNamespace(
|
||||
cert_configuration=values.get('configuration'),
|
||||
transport_status=values.get('transport_status', 'pending'),
|
||||
archive_status=values.get('archive_status', 'unarchived'),
|
||||
quota_cert_waived=values.get('quota_cert_waived', 0),
|
||||
)
|
||||
|
||||
|
||||
def test_same_label_and_code_do_not_need_conversion():
|
||||
current = plan()
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['dun']}, []) is False
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨', toxic=False)], {'cp': ['kg']}, []) is False
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': [bind('dun')]}, []) is False
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨', toxic=False)], {'cp': [bind('kg')]}, []) is False
|
||||
assert plan_needs_unit_conversion(
|
||||
current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': ['ping']}, [],
|
||||
current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': [bind('ping')]}, [],
|
||||
) is True
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['kg']}, []) is False
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '升')], {'cp': ['毫升']}, []) is False
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': [bind('kg')]}, []) is False
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', '升')], {'cp': [bind('毫升')]}, []) is False
|
||||
|
||||
|
||||
def test_unbound_and_unselected_legacy_sides_are_display_only():
|
||||
both = [line('L', 'load', '吨'), line('D', 'delivery', '吨')]
|
||||
arranged = plan(transport_status='arranged')
|
||||
assert plan_needs_unit_conversion(arranged, both, {'cp': ['kg']}, []) is False
|
||||
assert plan_needs_unit_conversion(arranged, both, {'cp': [bind('kg')]}, []) is False
|
||||
chosen = plan(transport_status='arranged', configuration={'sourceMapping': [
|
||||
{'sourceStopType': 'load', 'sourceStopProductId': 'L', 'relatedStopProductIds': ['D']},
|
||||
]})
|
||||
assert plan_needs_unit_conversion(chosen, both, {'cp': ['瓶']}, []) is True
|
||||
assert plan_needs_unit_conversion(chosen, both, {'cp': ['kg']}, []) is False
|
||||
assert plan_needs_unit_conversion(chosen, both, {'cp': [bind('瓶')]}, []) is True
|
||||
assert plan_needs_unit_conversion(chosen, both, {'cp': [bind('kg')]}, []) is False
|
||||
|
||||
|
||||
def test_new_plan_counts_either_stop_when_a_bound_certificate_uses_another_unit():
|
||||
current = plan()
|
||||
assert plan_needs_unit_conversion(
|
||||
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['kg']}, [],
|
||||
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': [bind('kg')]}, [],
|
||||
) is False
|
||||
assert plan_needs_unit_conversion(
|
||||
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['ping']}, [],
|
||||
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': [bind('ping')]}, [],
|
||||
) is True
|
||||
assert plan_needs_unit_conversion(current, [line('L', 'load', 'dun')], {}, []) is False
|
||||
|
||||
|
||||
def test_waived_or_saved_manual_conversion_clears_pending_flag():
|
||||
current = plan(quota_cert_waived=1)
|
||||
assert plan_needs_unit_conversion(
|
||||
current, [line('L', 'load', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
|
||||
) is False
|
||||
saved = plan(configuration={
|
||||
'manualAllocations': [{
|
||||
'permitKind': 'purchase', 'sourceId': 'delivery:D', 'permitId': 'buy',
|
||||
'sourceQuantity': '10', 'permitQuantity': '2.5', 'note': '1瓶=0.25升',
|
||||
}],
|
||||
})
|
||||
assert plan_needs_unit_conversion(
|
||||
saved, [line('D', 'delivery', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
|
||||
) is False
|
||||
incomplete = plan(configuration={
|
||||
'manualAllocations': [{
|
||||
'permitKind': 'purchase', 'sourceId': 'delivery:D', 'permitId': 'buy',
|
||||
'sourceQuantity': '10', 'permitQuantity': '', 'note': '',
|
||||
}],
|
||||
})
|
||||
assert plan_needs_unit_conversion(
|
||||
incomplete, [line('D', 'delivery', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
|
||||
) is True
|
||||
|
||||
|
||||
def test_list_and_badge_count_only_plans_with_a_real_unit_mismatch(monkeypatch):
|
||||
engine = create_engine('sqlite://')
|
||||
models = (
|
||||
|
||||
@@ -35,6 +35,17 @@ def test_sufficient_totals_do_not_claim_joint_feasibility():
|
||||
assert all('引用' in row['reason'] for row in rows)
|
||||
|
||||
|
||||
def test_same_city_shortfall_does_not_demand_a_transport_certificate():
|
||||
from app.services.pl_quota_diagnostics import allocation_diagnostics, insufficient_quantity_message
|
||||
rows = allocation_diagnostics(
|
||||
[SourceDemand('a', 'a', D('30'), '吨', False)],
|
||||
[permit('p', '10', ['a'])],
|
||||
[],
|
||||
)
|
||||
assert [row['certType'] for row in rows] == ['purchase']
|
||||
assert insufficient_quantity_message(rows) == '购买证剩余10.000吨,本次需要30.000吨。证件余量不足,不能安排运输,请先调整到有余量后再安排'
|
||||
|
||||
|
||||
def test_missing_purchase_link_names_both_certificates():
|
||||
from app.services.pl_quota_diagnostics import missing_purchase_link_message
|
||||
source = SourceDemand('a', 'a', D('30'), '吨')
|
||||
|
||||
@@ -69,6 +69,21 @@ def test_arrange_uses_active_contract_cert_bindings_and_names_the_gap():
|
||||
)
|
||||
|
||||
|
||||
def test_same_city_stop_binds_purchase_certificate_only():
|
||||
from app.services.pl_quota_planning import classify_contract_cert_bindings
|
||||
source = SimpleNamespace(contract_id='c1', contract_product_id='line-1', requires_transport=False)
|
||||
purchase = {
|
||||
'certType': 'purchase', 'certId': 'buy-1', 'linkStatus': 'active',
|
||||
'products': [{'contractProductId': 'line-1', 'scopeStatus': 'active'}],
|
||||
}
|
||||
labels = {'c1': 'HT-1'}
|
||||
assert classify_contract_cert_bindings([source], {'c1': [purchase]}, labels) == (['buy-1'], [])
|
||||
with pytest.raises(HTTPException) as error:
|
||||
classify_contract_cert_bindings([source], {'c1': []}, labels)
|
||||
assert error.value.status_code == 409
|
||||
assert error.value.detail == '合同HT-1的同市段尚未绑定购买证,请先到合同中绑定后再安排运输'
|
||||
|
||||
|
||||
def test_supplement_is_explicit_and_archive_public_schema_preserves_intent():
|
||||
from app.schemas.pl.plan import PlanArchiveBody
|
||||
from app.schemas.pl.quota_planning import PlanQuotaOperation
|
||||
|
||||
@@ -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)
|
||||
@@ -245,7 +245,25 @@ 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): run(c,vehicle_no='unknown')
|
||||
with pytest.raises(ValueError, match='车号不在运输证范围'): run(c,vehicle_no='unknown')
|
||||
|
||||
|
||||
def test_reserve_uses_transport_cert_that_lists_the_vehicle(case):
|
||||
"""合同上多张运输证时,只用登记了本次主车号的证,不因其他证没有该车号而拒绝安排。"""
|
||||
c = case
|
||||
_second_pair(c)
|
||||
c.db.get(c.m.PlPurchaseCert, 'p').total_qty = 100
|
||||
c.db.get(c.m.PlTransportCert, 't').total_qty = 100
|
||||
c.db.get(c.m.PlTransportCert, 't2').vehicle_numbers_json = ['OTHER']
|
||||
c.db.flush()
|
||||
run(c, purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
|
||||
assert c.db.get(c.m.PlTransportCert, 't').reserved_qty == 29
|
||||
assert c.db.get(c.m.PlTransportCert, 't2').reserved_qty == 0
|
||||
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='车号不在运输证范围'):
|
||||
run(c, operation_id='none', vehicle_no='NONE', purchase_cert_ids=['p', 'p2'], transport_cert_ids=['t', 't2'])
|
||||
|
||||
def test_no_cert_operation_is_still_idempotent(case):
|
||||
c=case
|
||||
|
||||
@@ -119,6 +119,20 @@ def test_maintenance_process_board_filters():
|
||||
assert "handler_id" in joined
|
||||
|
||||
|
||||
def test_recharge_apply_type_ne_filter():
|
||||
filters = build_recharge_apply_column_filters(recharge_type_ne="etc")
|
||||
assert len(filters) == 1
|
||||
sql = _compile(filters[0])
|
||||
assert "recharge_type" in sql
|
||||
assert "!=" in sql or "<>" in sql
|
||||
assert "etc" in sql
|
||||
|
||||
|
||||
def test_recharge_disburse_board_type_ne_filter():
|
||||
filters = build_recharge_disburse_board_column_filters(recharge_type_ne="etc")
|
||||
assert any("recharge_type" in _compile(f) for f in filters)
|
||||
|
||||
|
||||
def test_empty():
|
||||
assert build_recharge_apply_column_filters() == []
|
||||
assert build_recharge_disburse_board_column_filters() == []
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.schemas.pl.vehicle_recharge import APPLY_STATUSES, NODE_ACTION_TYPES, R
|
||||
|
||||
|
||||
def test_apply_statuses_include_workflow_states():
|
||||
assert {"draft", "in_approval", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
|
||||
assert {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
|
||||
assert {"approve", "disburse"} <= NODE_ACTION_TYPES
|
||||
assert {"etc", "fuel"} <= RECHARGE_TYPES
|
||||
|
||||
@@ -98,6 +98,40 @@ def _seed_workflow(db) -> tuple[str, str, str]:
|
||||
return wf_id, node_gm, node_fin
|
||||
|
||||
|
||||
def _seed_three_node_workflow(db) -> tuple[str, str, str, str]:
|
||||
wf_id = "wf-3"
|
||||
n1, n2, n3 = "node-a1", "node-a2", "node-dis"
|
||||
db.add(
|
||||
BxWorkflowDef(
|
||||
id=wf_id,
|
||||
biz_type="vehicle_recharge",
|
||||
wf_name="三级充值流程",
|
||||
status=1,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
for nid, name, order, uid, uname, action in (
|
||||
(n1, "一审", 1, "u-a1", "一审", "approve"),
|
||||
(n2, "二审", 2, "u-a2", "二审", "approve"),
|
||||
(n3, "发放", 3, "u-fin", "财务", "disburse"),
|
||||
):
|
||||
db.add(
|
||||
BxWorkflowNode(
|
||||
id=nid,
|
||||
wf_id=wf_id,
|
||||
node_name=name,
|
||||
node_order=order,
|
||||
approver_id=uid,
|
||||
approver_name=uname,
|
||||
action_type=action,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
return wf_id, n1, n2, n3
|
||||
|
||||
|
||||
def _patch_scope(monkeypatch, *modules) -> None:
|
||||
for mod in modules:
|
||||
monkeypatch.setattr(mod, "apply_pl_scope", lambda query, *_a, **_k: query)
|
||||
@@ -272,7 +306,7 @@ def test_approve_advances_to_next_node(monkeypatch):
|
||||
result = approval_svc.approve_apply(
|
||||
db, "t1", _user("u-gm", "总经理"), "apply-1", opinion="同意"
|
||||
)
|
||||
assert result.status == "in_approval"
|
||||
assert result.status == "pending"
|
||||
assert result.current_node == node_fin
|
||||
assert any(a.approval_status == "approved" for a in result.approvals)
|
||||
assert len(result.approval_flow) == 2
|
||||
@@ -285,6 +319,79 @@ def test_approve_advances_to_next_node(monkeypatch):
|
||||
assert result.approval_history[0].approval_status == "approved"
|
||||
|
||||
|
||||
def test_mid_approve_stays_in_approval_final_approve_sets_pending(monkeypatch):
|
||||
from app.services import pl_vehicle_recharge_approval_service as approval_svc
|
||||
from app.services import pl_vehicle_recharge_service as svc
|
||||
|
||||
db = _make_session()
|
||||
_seed_vehicle(db)
|
||||
wf_id, n1, n2, n3 = _seed_three_node_workflow(db)
|
||||
db.add(
|
||||
PlVehicleRechargeApply(
|
||||
id="apply-1",
|
||||
apply_no="YK2026092601",
|
||||
license_plate="鄂A00001",
|
||||
recharge_type="fuel",
|
||||
apply_date=date(2026, 9, 26),
|
||||
applicant_id="u-applicant",
|
||||
status="in_approval",
|
||||
wf_id=wf_id,
|
||||
current_node=n1,
|
||||
approval_round=1,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_patch_scope(monkeypatch, svc)
|
||||
|
||||
mid = approval_svc.approve_apply(db, "t1", _user("u-a1", "一审"), "apply-1", opinion="ok")
|
||||
assert mid.status == "in_approval"
|
||||
assert mid.current_node == n2
|
||||
|
||||
final = approval_svc.approve_apply(db, "t1", _user("u-a2", "二审"), "apply-1", opinion="ok")
|
||||
assert final.status == "pending"
|
||||
assert final.current_node == n3
|
||||
|
||||
|
||||
def test_disburse_accepts_pending_status(monkeypatch):
|
||||
from app.services import pl_vehicle_recharge_approval_service as approval_svc
|
||||
from app.services import pl_vehicle_recharge_service as svc
|
||||
from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
|
||||
|
||||
db = _make_session()
|
||||
_seed_vehicle(db)
|
||||
wf_id, _gm, node_fin = _seed_workflow(db)
|
||||
db.add(
|
||||
PlVehicleRechargeApply(
|
||||
id="apply-1",
|
||||
apply_no="ETC2026092601",
|
||||
license_plate="鄂A00001",
|
||||
recharge_type="etc",
|
||||
apply_date=date(2026, 9, 26),
|
||||
applicant_id="u-applicant",
|
||||
status="pending",
|
||||
wf_id=wf_id,
|
||||
current_node=node_fin,
|
||||
approval_round=1,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_patch_scope(monkeypatch, svc)
|
||||
payload = VehicleRechargeRecordCreate(
|
||||
rechargeDate=date(2026, 9, 26),
|
||||
rechargeType="etc",
|
||||
amount=Decimal("50.00"),
|
||||
applyId="apply-1",
|
||||
)
|
||||
result = approval_svc.disburse_apply(
|
||||
db, "t1", _user("u-fin", "财务"), "apply-1", payload
|
||||
)
|
||||
assert result.status == "disbursed"
|
||||
|
||||
|
||||
def test_reject_sets_rejected_and_appends_history(monkeypatch):
|
||||
from app.services import pl_vehicle_recharge_approval_service as approval_svc
|
||||
from app.services import pl_vehicle_recharge_service as svc
|
||||
@@ -508,3 +615,49 @@ def test_withdraw_rejects_non_in_approval(monkeypatch):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
svc.withdraw_apply(db, "t1", _user("u-applicant"), "a-wd3")
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
def test_delete_record_with_workflow_reverts_to_pending(monkeypatch):
|
||||
from app.services import pl_vehicle_recharge_service as svc
|
||||
|
||||
db = _make_session()
|
||||
_seed_vehicle(db)
|
||||
wf_id, _gm, node_fin = _seed_workflow(db)
|
||||
db.add(
|
||||
PlVehicleRechargeApply(
|
||||
id="apply-1",
|
||||
apply_no="ETC2026092601",
|
||||
license_plate="鄂A00001",
|
||||
recharge_type="etc",
|
||||
apply_date=date(2026, 9, 26),
|
||||
applicant_id="u-applicant",
|
||||
status="disbursed",
|
||||
wf_id=wf_id,
|
||||
current_node=None,
|
||||
approval_round=1,
|
||||
tenant_no="t1",
|
||||
create_by="u-applicant",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
PlVehicleRechargeRecord(
|
||||
id="rec-1",
|
||||
recharge_order_no="RO1",
|
||||
recharge_date=date(2026, 9, 26),
|
||||
recharge_type="etc",
|
||||
amount=Decimal("10.00"),
|
||||
apply_id="apply-1",
|
||||
license_plate="鄂A00001",
|
||||
tenant_no="t1",
|
||||
create_by="u-fin",
|
||||
create_dept_id="d1",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_patch_scope(monkeypatch, svc)
|
||||
svc.delete_records(db, "t1", _user("u-fin", "财务"), ["rec-1"])
|
||||
apply = db.query(PlVehicleRechargeApply).filter_by(id="apply-1").one()
|
||||
assert apply.status == "pending"
|
||||
assert apply.current_node == node_fin
|
||||
@@ -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,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,31 @@
|
||||
-- 装货段、送货段增加「是否同市」。同市的易制毒段只校验、扣减购买证。
|
||||
-- MySQL 5.7。可重复执行。历史数据默认 0,仍按购买证和运输证同时校验。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
SET @schema_name = DATABASE();
|
||||
|
||||
SET @load_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_load' AND COLUMN_NAME = 'same_city'
|
||||
);
|
||||
SET @load_sql = IF(
|
||||
@load_exists = 0,
|
||||
'ALTER TABLE pl_plan_load ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE load_stmt FROM @load_sql;
|
||||
EXECUTE load_stmt;
|
||||
DEALLOCATE PREPARE load_stmt;
|
||||
|
||||
SET @delivery_exists = (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'pl_plan_delivery' AND COLUMN_NAME = 'same_city'
|
||||
);
|
||||
SET @delivery_sql = IF(
|
||||
@delivery_exists = 0,
|
||||
'ALTER TABLE pl_plan_delivery ADD COLUMN same_city tinyint NOT NULL DEFAULT 0 COMMENT ''是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证'' AFTER dibang_direction',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE delivery_stmt FROM @delivery_sql;
|
||||
EXECUTE delivery_stmt;
|
||||
DEALLOCATE PREPARE delivery_stmt;
|
||||
@@ -0,0 +1,210 @@
|
||||
-- 油卡充值与 ETC 充值菜单拆分,并按已有油卡菜单同步角色。
|
||||
-- MySQL 5.7 / utf8mb4。可重复执行。仅租户 shanxia2.0 的有效菜单。
|
||||
-- 不移动 PL_VEH_FUEL_CARD,车油卡信息仍挂在油卡分组下。
|
||||
-- 不改 docs/sql/shanxia2.0.sql 全量快照;快照仍是本迁移之前的数据,不要为这条脚本重导整库。
|
||||
-- 审批菜单 PL_VEH_RECHARGE_APPROVAL 在快照与迁移中没有 F 按钮,本脚本不为 ETC 审批补按钮。
|
||||
SET NAMES utf8mb4;
|
||||
START TRANSACTION;
|
||||
|
||||
-- A. 原「ETC/油卡」分组改为油卡,path 让给下面新建的 ETC 分组。
|
||||
UPDATE `sys_menu`
|
||||
SET `menu_name` = '油卡充值管理',
|
||||
`path` = 'fuel-recharge',
|
||||
`remark` = '油卡充值分组',
|
||||
`update_time` = NOW()
|
||||
WHERE `id` = 'PL_GROUP_VEH_RECHARGE'
|
||||
AND `tenant_no` = 'shanxia2.0'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
UPDATE `sys_menu`
|
||||
SET `menu_name` = '油卡充值申请', `update_time` = NOW()
|
||||
WHERE `id` = 'PL_VEH_RECHARGE_APPLY'
|
||||
AND `tenant_no` = 'shanxia2.0'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
UPDATE `sys_menu`
|
||||
SET `menu_name` = '油卡充值审批', `update_time` = NOW()
|
||||
WHERE `id` = 'PL_VEH_RECHARGE_APPROVAL'
|
||||
AND `tenant_no` = 'shanxia2.0'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
UPDATE `sys_menu`
|
||||
SET `menu_name` = '油卡充值发放管理', `update_time` = NOW()
|
||||
WHERE `id` = 'PL_VEH_RECHARGE_RECORD'
|
||||
AND `tenant_no` = 'shanxia2.0'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
UPDATE `sys_menu`
|
||||
SET `menu_name` = '油卡充值审批人设置', `update_time` = NOW()
|
||||
WHERE `id` = 'PL_VEH_RECHARGE_WF'
|
||||
AND `tenant_no` = 'shanxia2.0'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
-- B. ETC 分组插在油卡分组原位置:先记下油卡 order_num,再把车辆管理下
|
||||
-- order_num >= 该值的有效同级(含油卡本身,以及加油登记、维保等)全部 +1,
|
||||
-- 然后用记下的序号插入 ETC。这样 ETC 紧挨在油卡之前,且不会和油卡同号。
|
||||
-- MySQL 5.7 的 UPDATE 不能在子查询里再读 sys_menu,所以序号先放进用户变量。
|
||||
-- 仅当 ETC 分组尚不存在、且车辆管理与油卡分组都有效时才顺延;重复执行不再 +1。
|
||||
-- 请在同一会话整文件执行,@oil_order 等用户变量不能跨连接。
|
||||
SET @etc_group_exists := (
|
||||
SELECT COUNT(*) FROM `sys_menu` WHERE `id` = 'PL_GROUP_VEH_ETC_RECHARGE'
|
||||
);
|
||||
SET @vehicle_exists := (
|
||||
SELECT COUNT(*) FROM `sys_menu`
|
||||
WHERE `id` = 'PL_GROUP_VEHICLE' AND `tenant_no` = 'shanxia2.0' AND `del_flag` = 0
|
||||
);
|
||||
SET @oil_order := (
|
||||
SELECT `order_num` FROM `sys_menu`
|
||||
WHERE `id` = 'PL_GROUP_VEH_RECHARGE' AND `tenant_no` = 'shanxia2.0' AND `del_flag` = 0
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
UPDATE `sys_menu`
|
||||
SET `order_num` = `order_num` + 1,
|
||||
`update_time` = NOW()
|
||||
WHERE `parent_id` = 'PL_GROUP_VEHICLE'
|
||||
AND `tenant_no` = 'shanxia2.0'
|
||||
AND `del_flag` = 0
|
||||
AND @etc_group_exists = 0
|
||||
AND @vehicle_exists = 1
|
||||
AND @oil_order IS NOT NULL
|
||||
AND `order_num` >= @oil_order;
|
||||
|
||||
INSERT INTO `sys_menu`
|
||||
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
|
||||
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`remark`,`create_by`,`create_dept_id`)
|
||||
SELECT 'PL_GROUP_VEH_ETC_RECHARGE', 'ETC充值管理', vehicle.id, 'M', 'etc-recharge',
|
||||
NULL, NULL, 1, '1', '1', vehicle.tenant_no, COALESCE(@oil_order, 1),
|
||||
'ETC充值分组', 'system', ''
|
||||
FROM `sys_menu` vehicle
|
||||
WHERE vehicle.id = 'PL_GROUP_VEHICLE'
|
||||
AND vehicle.tenant_no = 'shanxia2.0'
|
||||
AND vehicle.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` existing WHERE existing.id = 'PL_GROUP_VEH_ETC_RECHARGE'
|
||||
);
|
||||
|
||||
-- C. 四个页面菜单。审批人设置复用油卡流程权限,因此只按 id 判重,不能按 perms 判重。
|
||||
INSERT INTO `sys_menu`
|
||||
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
|
||||
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
|
||||
SELECT item.id, item.menu_name, parent.id, 'C', item.path, item.component, item.perms,
|
||||
1, '1', '1', parent.tenant_no, item.order_num, 'system', ''
|
||||
FROM `sys_menu` parent
|
||||
JOIN (
|
||||
SELECT 'PL_ETC_VEH_RECHARGE_APPLY' AS id, 'ETC充值申请' AS menu_name,
|
||||
'etc-vehicle-recharge-apply' AS path, 'pl/etc-vehicle-recharge-apply/index' AS component,
|
||||
'pl:etc-vehicle-recharge-apply:list' AS perms, 1 AS order_num
|
||||
UNION ALL
|
||||
SELECT 'PL_ETC_VEH_RECHARGE_APPROVAL', 'ETC充值审批',
|
||||
'etc-vehicle-recharge-approval', 'pl/etc-vehicle-recharge-approval/index',
|
||||
'pl:etc-vehicle-recharge-approval:list', 2
|
||||
UNION ALL
|
||||
SELECT 'PL_ETC_VEH_RECHARGE_RECORD', 'ETC发放管理',
|
||||
'etc-vehicle-recharge-record', 'pl/etc-vehicle-recharge-record/index',
|
||||
'pl:etc-vehicle-recharge-record:list', 3
|
||||
UNION ALL
|
||||
SELECT 'PL_ETC_VEH_RECHARGE_WF', 'ETC充值审批人设置',
|
||||
'etc-vehicle-recharge-workflow', 'pl/etc-vehicle-recharge-workflow/index',
|
||||
'pl:vehicle-recharge-workflow:list', 4
|
||||
) item
|
||||
WHERE parent.id = 'PL_GROUP_VEH_ETC_RECHARGE'
|
||||
AND parent.tenant_no = 'shanxia2.0'
|
||||
AND parent.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` existing WHERE existing.id = item.id
|
||||
);
|
||||
|
||||
-- D. 申请按钮,对照 PL_VRA_Q/A/E/R/C。
|
||||
INSERT INTO `sys_menu`
|
||||
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
|
||||
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
|
||||
SELECT btn.id, btn.menu_name, parent.id, 'F', '', '', btn.perms,
|
||||
1, '1', '1', parent.tenant_no, btn.order_num, 'system', ''
|
||||
FROM `sys_menu` parent
|
||||
JOIN (
|
||||
SELECT 'PL_ETC_VRA_Q' AS id, '查询' AS menu_name, 'pl:etc-vehicle-recharge-apply:query' AS perms, 1 AS order_num
|
||||
UNION ALL SELECT 'PL_ETC_VRA_A', '新增', 'pl:etc-vehicle-recharge-apply:add', 2
|
||||
UNION ALL SELECT 'PL_ETC_VRA_E', '编辑', 'pl:etc-vehicle-recharge-apply:edit', 3
|
||||
UNION ALL SELECT 'PL_ETC_VRA_R', '删除', 'pl:etc-vehicle-recharge-apply:remove', 4
|
||||
UNION ALL SELECT 'PL_ETC_VRA_C', '作废', 'pl:etc-vehicle-recharge-apply:cancel', 5
|
||||
) btn
|
||||
WHERE parent.id = 'PL_ETC_VEH_RECHARGE_APPLY'
|
||||
AND parent.tenant_no = 'shanxia2.0'
|
||||
AND parent.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` existing WHERE existing.id = btn.id
|
||||
);
|
||||
|
||||
-- E. 发放按钮,对照 PL_VRR_Q/A/E/R。审批侧没有对应 F 按钮,这里不补。
|
||||
INSERT INTO `sys_menu`
|
||||
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
|
||||
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
|
||||
SELECT btn.id, btn.menu_name, parent.id, 'F', '', '', btn.perms,
|
||||
1, '1', '1', parent.tenant_no, btn.order_num, 'system', ''
|
||||
FROM `sys_menu` parent
|
||||
JOIN (
|
||||
SELECT 'PL_ETC_VRR_Q' AS id, '查询' AS menu_name, 'pl:etc-vehicle-recharge-record:query' AS perms, 1 AS order_num
|
||||
UNION ALL SELECT 'PL_ETC_VRR_A', '新增', 'pl:etc-vehicle-recharge-record:add', 2
|
||||
UNION ALL SELECT 'PL_ETC_VRR_E', '编辑', 'pl:etc-vehicle-recharge-record:edit', 3
|
||||
UNION ALL SELECT 'PL_ETC_VRR_R', '删除', 'pl:etc-vehicle-recharge-record:remove', 4
|
||||
) btn
|
||||
WHERE parent.id = 'PL_ETC_VEH_RECHARGE_RECORD'
|
||||
AND parent.tenant_no = 'shanxia2.0'
|
||||
AND parent.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` existing WHERE existing.id = btn.id
|
||||
);
|
||||
|
||||
-- G. 角色同步。拥有油卡页面(del_flag=0)且角色仍有效的,授予对等 ETC 页面、
|
||||
-- 其下 F 按钮,以及最多三层祖先(ETC 分组、车辆管理、Web 根)。
|
||||
-- 同一条 INSERT 里用 DISTINCT,避免一个角色同时拥有多个油卡页面时把祖先插重。
|
||||
-- 已有任意 role_menu 行(含 del_flag=1)则跳过:不把软删除改回有效,也不另插一条把授权加回来。
|
||||
INSERT INTO `sys_role_menu`
|
||||
(`id`,`role_id`,`menu_id`,`tenant_no`,`create_by`,`create_dept_id`)
|
||||
SELECT UUID(), grant_row.role_id, grant_row.menu_id, grant_row.tenant_no, 'system', ''
|
||||
FROM (
|
||||
SELECT DISTINCT rm.role_id, target.id AS menu_id, rm.tenant_no
|
||||
FROM (
|
||||
SELECT 'PL_VEH_RECHARGE_APPLY' AS source_id, 'PL_ETC_VEH_RECHARGE_APPLY' AS target_id
|
||||
UNION ALL SELECT 'PL_VEH_RECHARGE_APPROVAL', 'PL_ETC_VEH_RECHARGE_APPROVAL'
|
||||
UNION ALL SELECT 'PL_VEH_RECHARGE_RECORD', 'PL_ETC_VEH_RECHARGE_RECORD'
|
||||
UNION ALL SELECT 'PL_VEH_RECHARGE_WF', 'PL_ETC_VEH_RECHARGE_WF'
|
||||
) mapping
|
||||
JOIN `sys_role_menu` rm
|
||||
ON rm.menu_id = mapping.source_id
|
||||
AND rm.tenant_no = 'shanxia2.0'
|
||||
AND rm.del_flag = 0
|
||||
JOIN `sys_role` r
|
||||
ON r.id = rm.role_id
|
||||
AND r.tenant_no = rm.tenant_no
|
||||
AND r.del_flag = 0
|
||||
AND r.status = '1'
|
||||
JOIN `sys_menu` child
|
||||
ON child.id = mapping.target_id
|
||||
AND child.tenant_no = rm.tenant_no
|
||||
AND child.del_flag = 0
|
||||
LEFT JOIN `sys_menu` p1
|
||||
ON p1.id = child.parent_id AND p1.tenant_no = rm.tenant_no AND p1.del_flag = 0
|
||||
LEFT JOIN `sys_menu` p2
|
||||
ON p2.id = p1.parent_id AND p2.tenant_no = rm.tenant_no AND p2.del_flag = 0
|
||||
LEFT JOIN `sys_menu` p3
|
||||
ON p3.id = p2.parent_id AND p3.tenant_no = rm.tenant_no AND p3.del_flag = 0
|
||||
LEFT JOIN `sys_menu` btn
|
||||
ON btn.parent_id = child.id
|
||||
AND btn.tenant_no = rm.tenant_no
|
||||
AND btn.del_flag = 0
|
||||
AND btn.menu_type = 'F'
|
||||
JOIN `sys_menu` target
|
||||
ON target.tenant_no = rm.tenant_no
|
||||
AND target.del_flag = 0
|
||||
AND target.id IN (child.id, p1.id, p2.id, p3.id, btn.id)
|
||||
) grant_row
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_role_menu` existing
|
||||
WHERE existing.role_id = grant_row.role_id
|
||||
AND existing.menu_id = grant_row.menu_id
|
||||
AND existing.tenant_no = grant_row.tenant_no
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -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,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';
|
||||
@@ -26711,6 +26711,7 @@ CREATE TABLE `pl_plan_delivery` (
|
||||
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
||||
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
||||
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
|
||||
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
@@ -26794,6 +26795,7 @@ CREATE TABLE `pl_plan_load` (
|
||||
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
||||
`generate_dibang` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否生成地磅单 0-否 1-是',
|
||||
`dibang_direction` varchar(20) DEFAULT NULL COMMENT '地磅方向 outbound-出库 inbound-入库 none-无',
|
||||
`same_city` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否同市 0-否 1-是;为 1 时本段易制毒只校验购买证',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# 文档库多选批量设置分享 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 文档库 `/#/doclib/enter` 支持多选文档后一次设置分享人员(整份覆盖、交集预选、整批事务)。
|
||||
|
||||
**Architecture:** 后端新增 `POST /web/doclib/files/batch-shares`,从现有 `set_shares` 抽出无 commit 的 `_apply_shares`,批量一次 commit;前端放宽 `actionsFor.share`、扩展 `ShareDialog` 为多文档并用 `shareUserIntersection` 预选,统一调用批量 API。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / TypeScript / Element Plus;`node:test` + `tsx`(web-admin)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-12-doclib-batch-share-design.md`(工单 #25)
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `backend/app/schemas/doclib.py` | 新增 `BatchShareBody` |
|
||||
| `backend/app/services/doclib_service.py` | `_apply_shares`、`set_shares` 复用、`set_shares_batch` |
|
||||
| `backend/app/api/doclib.py` | 注册 `POST .../batch-shares` |
|
||||
| `web-admin/src/api/doclib.ts` | `saveDoclibBatchShares` |
|
||||
| `web-admin/src/views/doclib/enter/model.ts` | `actionsFor.share` 多选 + `shareUserIntersection` |
|
||||
| `web-admin/src/views/doclib/enter/model.test.ts` | 前端纯函数回归 |
|
||||
| `web-admin/src/views/doclib/enter/api.test.ts` | 批量 API 适配器断言 |
|
||||
| `web-admin/src/views/doclib/enter/ShareDialog.vue` | 多文档摘要、交集预选、批量保存 |
|
||||
| `web-admin/src/views/doclib/enter/index.vue` | 工具栏传入全部 `selected` |
|
||||
|
||||
说明:仓库内暂无 backend doclib pytest;本计划以服务层行为对照现有 `set_shares`/`delete_files` 实现,并用前端单测锁定契约。若执行时已有可用测试 DB,可追加服务层用例,但不阻塞前端交付。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 前端纯函数 — 多选可分享 + 分享人交集
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/doclib/enter/model.ts`
|
||||
- Modify: `web-admin/src/views/doclib/enter/model.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(更新 actionsFor + 新增交集)**
|
||||
|
||||
在 `model.test.ts` 将首个测试改为多选可分享语义,并新增交集测试:
|
||||
|
||||
```ts
|
||||
test('可管理文档可多选分享,混合选择不能分享', () => {
|
||||
assert.deepEqual(actionsFor([{ canManage: false }]), {
|
||||
share: false,
|
||||
download: true,
|
||||
delete: false
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: false }]), {
|
||||
share: false,
|
||||
download: true,
|
||||
delete: false
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }]), {
|
||||
share: true,
|
||||
download: true,
|
||||
delete: true
|
||||
})
|
||||
assert.deepEqual(actionsFor([{ canManage: true }, { canManage: true }]), {
|
||||
share: true,
|
||||
download: true,
|
||||
delete: true
|
||||
})
|
||||
assert.deepEqual(actionsFor([]), { share: false, download: false, delete: false })
|
||||
})
|
||||
|
||||
test('分享人初始勾选取所选文档交集', async () => {
|
||||
const { shareUserIntersection } = await import('./model')
|
||||
const docs = [
|
||||
{ shares: [{ id: 'a' }, { id: 'b' }] },
|
||||
{ shares: [{ id: 'b' }, { id: 'c' }] }
|
||||
]
|
||||
assert.deepEqual(shareUserIntersection(docs), ['b'])
|
||||
assert.deepEqual(shareUserIntersection([{ shares: [{ id: 'a' }] }, { shares: [] }]), [])
|
||||
assert.deepEqual(shareUserIntersection([]), [])
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run(在 `web-admin/`,使用本仓 `node_modules`):
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL(`share` 仍要求 `length === 1`,且无 `shareUserIntersection`)
|
||||
|
||||
- [ ] **Step 3: 最小实现**
|
||||
|
||||
`model.ts`:
|
||||
|
||||
```ts
|
||||
export function actionsFor(rows: Array<{ canManage: boolean }>) {
|
||||
return {
|
||||
share: rows.length > 0 && rows.every((row) => row.canManage),
|
||||
download: rows.length > 0,
|
||||
delete: rows.length > 0 && rows.every((row) => row.canManage)
|
||||
}
|
||||
}
|
||||
|
||||
export function shareUserIntersection(
|
||||
documents: Array<{ shares?: Array<{ id: string }> | null }>
|
||||
): string[] {
|
||||
if (!documents.length) return []
|
||||
let ids = new Set(
|
||||
(documents[0].shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
|
||||
)
|
||||
for (const doc of documents.slice(1)) {
|
||||
const next = new Set(
|
||||
(doc.shares || []).map((person) => String(person.id || '').trim()).filter(Boolean)
|
||||
)
|
||||
ids = new Set([...ids].filter((id) => next.has(id)))
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/doclib/enter/model.ts web-admin/src/views/doclib/enter/model.test.ts
|
||||
git commit -m "feat(doclib): 允许多选可管理文档分享并计算分享人交集。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 前端 API — batch-shares 适配器
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/doclib.ts`
|
||||
- Modify: `web-admin/src/views/doclib/enter/api.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
在 `api.test.ts` 追加:
|
||||
|
||||
```ts
|
||||
test('批量分享提交文档 id 列表与人员 id', async () => {
|
||||
const { api, calls } = apiAdapter()
|
||||
await api.saveDoclibBatchShares(['id/a', 'id/b'], ['u1', 'u2'])
|
||||
assert.equal(calls[0].method, 'post')
|
||||
assert.equal(calls[0].config.url, '/web/doclib/files/batch-shares')
|
||||
assert.deepEqual(calls[0].config.data, { ids: ['id/a', 'id/b'], userIds: ['u1', 'u2'] })
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL(无 `saveDoclibBatchShares`)
|
||||
|
||||
- [ ] **Step 3: 最小实现**
|
||||
|
||||
在 `doclib.ts` 的 `saveDoclibShares` 旁增加:
|
||||
|
||||
```ts
|
||||
export const saveDoclibBatchShares = (ids: string[], userIds: string[]) =>
|
||||
request.post<DoclibDocument[]>({
|
||||
url: `${base}/files/batch-shares`,
|
||||
data: { ids, userIds }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/doclib.ts web-admin/src/views/doclib/enter/api.test.ts
|
||||
git commit -m "feat(doclib): 增加批量设置分享 API 客户端。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 — BatchShareBody + set_shares_batch
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/doclib.py`
|
||||
- Modify: `backend/app/services/doclib_service.py`
|
||||
- Modify: `backend/app/api/doclib.py`
|
||||
|
||||
- [ ] **Step 1: 增加 schema**
|
||||
|
||||
在 `ShareBody` 后增加:
|
||||
|
||||
```python
|
||||
class BatchShareBody(_Body):
|
||||
ids: List[str] = Field(min_length=1, max_length=100)
|
||||
userIds: List[str] = Field(max_length=500)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重构服务层**
|
||||
|
||||
将 `set_shares` 拆为 `_apply_shares`(不 commit)与包装方法;新增 `set_shares_batch`:
|
||||
|
||||
```python
|
||||
def _apply_shares(self, file_id, user_ids):
|
||||
row = self._file(file_id, manage=True, lock=True)
|
||||
requested = set(user_ids) - {row.create_by}
|
||||
if requested:
|
||||
valid = {u.id for u in self._base(SysUser).filter(
|
||||
SysUser.id.in_(requested), SysUser.status == '1').all()}
|
||||
if requested != valid:
|
||||
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
||||
else:
|
||||
valid = set()
|
||||
history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
|
||||
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id
|
||||
).populate_existing().with_for_update().all()}
|
||||
for uid, share in history.items():
|
||||
share.del_flag = 0 if uid in requested else 1
|
||||
share.update_by = self.uid
|
||||
for uid in requested - set(history):
|
||||
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
|
||||
row.update_by, row.update_time = self.uid, datetime.now()
|
||||
return row
|
||||
|
||||
def set_shares(self, file_id, user_ids):
|
||||
row = self._apply_shares(file_id, user_ids)
|
||||
self.db.commit()
|
||||
return self._serialize_files([row])[0]
|
||||
|
||||
def set_shares_batch(self, ids, user_ids):
|
||||
file_ids = sorted({str(i).strip() for i in ids if str(i).strip()})
|
||||
if not file_ids:
|
||||
raise HTTPException(400, '请选择要设置分享的文档')
|
||||
rows = [self._apply_shares(fid, user_ids) for fid in file_ids]
|
||||
self.db.commit()
|
||||
return self._serialize_files(rows)
|
||||
```
|
||||
|
||||
注意:空 `requested` 时跳过用户表查询(避免 `IN ()`),与「清空分享」语义一致。原 `set_shares` 在 `requested` 为空时 `SysUser.id.in_(set())` 行为依赖方言,重构时一并修掉。
|
||||
|
||||
- [ ] **Step 3: 注册路由**
|
||||
|
||||
`doclib.py` 增加 import `BatchShareBody`,并在 `batch-delete` 旁:
|
||||
|
||||
```python
|
||||
@router.post('/web/doclib/files/batch-shares')
|
||||
def batch_share_documents(body: BatchShareBody, svc: DoclibService = Depends(service)):
|
||||
return success_response(svc.set_shares_batch(body.ids, body.userIds))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 静态核对**
|
||||
|
||||
确认:
|
||||
- 单文档 `PUT .../shares` 仍指向 `set_shares`
|
||||
- 批量路径仅一次 `commit`
|
||||
- `_apply_shares` 任一步抛错时不调用 `commit`
|
||||
|
||||
可选:在已激活 venv 下对改动文件做语法检查
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -c "from app.schemas.doclib import BatchShareBody; from app.services.doclib_service import DoclibService; print('ok')"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/doclib.py backend/app/services/doclib_service.py backend/app/api/doclib.py
|
||||
git commit -m "feat(doclib): 增加批量设置分享接口并复用单文档写入逻辑。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ShareDialog + 列表页接入
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/doclib/enter/ShareDialog.vue`
|
||||
- Modify: `web-admin/src/views/doclib/enter/index.vue`
|
||||
|
||||
- [ ] **Step 1: 改 ShareDialog props 与保存**
|
||||
|
||||
- Props:`documents: DoclibDocument[]`(由页面传入;可去掉单 `document` 或保留 computed 取 `documents[0]` 兼容内部逻辑)。
|
||||
- 打开时:`userIds = shareUserIntersection(documents)`;摘要展示名称列表(超过 5 个截断并显示「等 N 个」)。
|
||||
- `options` 排除所有 `documents` 的 `ownerId`。
|
||||
- `save`:若无文档或存在 `!canManage` 则 return;调用 `saveDoclibBatchShares(ids, userIds)`;`emit('saved', updated)`(`updated` 为数组时由父组件刷新)。
|
||||
|
||||
示意:
|
||||
|
||||
```ts
|
||||
const props = defineProps<{ modelValue: boolean; documents: DoclibDocument[] }>()
|
||||
// watch open:
|
||||
userIds.value = shareUserIntersection(props.documents)
|
||||
// save:
|
||||
const ids = props.documents.map((d) => d.id)
|
||||
const updated = await saveDoclibBatchShares(ids, userIds.value)
|
||||
emit('saved', updated)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 index.vue**
|
||||
|
||||
- `shareDocuments` 替代 `shareDocument`(`ref<DoclibDocument[]>`)。
|
||||
- `openShare(rows: DoclibDocument | DoclibDocument[])` 规范化为数组。
|
||||
- 工具栏:`@click="openShare(selected)"`。
|
||||
- 详情:`@click="openShare(detail)"`。
|
||||
- `<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />`
|
||||
- `shareSaved`:若返回数组则按 id 更新详情;始终 `refresh()`。
|
||||
|
||||
- [ ] **Step 3: 跑前端相关测试**
|
||||
|
||||
```bash
|
||||
cd web-admin
|
||||
./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/doclib/enter/ShareDialog.vue web-admin/src/views/doclib/enter/index.vue
|
||||
git commit -m "feat(doclib): 文档库支持多选文档后批量设置分享。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 回归核对与交付准备
|
||||
|
||||
- [ ] **Step 1: 再跑前端测试套件**
|
||||
|
||||
```bash
|
||||
cd web-admin && ./node_modules/.bin/tsx --test src/views/doclib/enter/model.test.ts src/views/doclib/enter/api.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 对照验收清单(手工或浏览器)**
|
||||
|
||||
1. 多选可管理 → 可开分享;混入不可管理 → 按钮禁用
|
||||
2. 初始勾选为交集
|
||||
3. 保存后各文档分享人一致
|
||||
4. 详情单文档分享仍可用
|
||||
|
||||
- [ ] **Step 3: 更新工单 #25 评论**:分支、提交、测试命令与结果、未跑项(真机)
|
||||
|
||||
- [ ] **Step 4: 按用户授权 push 任务分支**(`push_task_branch` 已开;`create_pr` 未开则不建 PR)
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage self-check
|
||||
|
||||
| Spec 要求 | Task |
|
||||
| --- | --- |
|
||||
| `actionsFor` 多选可管理可分享 | Task 1 |
|
||||
| 交集预选 | Task 1 + 4 |
|
||||
| `batch-shares` API + 事务 | Task 3 |
|
||||
| `_apply_shares` 复用单路径 | Task 3 |
|
||||
| 前端批量客户端 | Task 2 |
|
||||
| ShareDialog / 工具栏 | Task 4 |
|
||||
| 单测 | Task 1–2、5 |
|
||||
| 整份覆盖 / 空列表收回 | Task 3–4(沿用 `_apply_shares`) |
|
||||
| 非目标:小程序 / 只追加 / 部分成功 | 无任务(刻意不做) |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
无 TBD /「类似 Task N」/ 空测试步骤。
|
||||
@@ -0,0 +1,413 @@
|
||||
# 证件到期提醒跳转编辑弹窗 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:** 在证件到期提醒列表增加「维护证件」操作列,按车辆/人员及自有/三方跳到基础信息库对应页并自动打开编辑弹窗。
|
||||
|
||||
**Architecture:** 纯函数根据主体类型与归属字段解析目标 path;提醒页 get 详情后 `router.push({ path, query: { editId } })`;车辆/自有车辆/人员页监听 `editId` 调用现有 `handleEdit`,成功后 `replace` 清除 query。后端不动。
|
||||
|
||||
**Tech Stack:** Vue 3 / TypeScript / vue-router / Element Plus;`node:test` + `tsx`。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-12-document-reminder-edit-jump-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `web-admin/src/utils/pl-document-reminder-jump.ts` | 解析目标 path;错误文案常量 |
|
||||
| `web-admin/src/utils/pl-document-reminder-jump.test.ts` | 路径解析单测 |
|
||||
| `web-admin/src/composables/useRouteEditIdOpen.ts` | 监听 `editId`、调用 opener、清 query |
|
||||
| `web-admin/src/views/pl/inventory/document-reminder.vue` | 操作列 + 跳转 |
|
||||
| `web-admin/src/views/pl/vehicle/index.vue` | 接入 `editId` 打开编辑 |
|
||||
| `web-admin/src/views/pl/self-vehicle/index.vue` | 同上 |
|
||||
| `web-admin/src/views/pl/personnel/index.vue` | 同上(覆盖 self / carrier 两路由) |
|
||||
|
||||
说明:提交步骤在已获用户授权时执行;否则暂存改动待授权再 commit。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 路径解析纯函数 + 单测
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/utils/pl-document-reminder-jump.ts`
|
||||
- Create: `web-admin/src/utils/pl-document-reminder-jump.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```ts
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
resolveDocumentReminderEditPath,
|
||||
DOCUMENT_REMINDER_JUMP_UNSUPPORTED
|
||||
} from './pl-document-reminder-jump'
|
||||
|
||||
describe('证件提醒跳转路径', () => {
|
||||
it('车辆按 vehicleType 分流自有与三方', () => {
|
||||
assert.equal(
|
||||
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'self' }),
|
||||
'/vehicle-mgmt/base-info/self-vehicle'
|
||||
)
|
||||
assert.equal(
|
||||
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'carrier' }),
|
||||
'/vehicle-mgmt/base-info/vehicle'
|
||||
)
|
||||
assert.equal(
|
||||
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: '' }),
|
||||
'/vehicle-mgmt/base-info/vehicle'
|
||||
)
|
||||
})
|
||||
|
||||
it('人员按 personnelAffiliation 分流', () => {
|
||||
assert.equal(
|
||||
resolveDocumentReminderEditPath({
|
||||
ownerType: 'personnel',
|
||||
personnelAffiliation: 'self'
|
||||
}),
|
||||
'/vehicle-mgmt/base-info/personnel'
|
||||
)
|
||||
assert.equal(
|
||||
resolveDocumentReminderEditPath({
|
||||
ownerType: 'personnel',
|
||||
personnelAffiliation: 'carrier'
|
||||
}),
|
||||
'/vehicle-mgmt/base-info/personnel-carrier'
|
||||
)
|
||||
assert.equal(
|
||||
resolveDocumentReminderEditPath({
|
||||
ownerType: 'personnel',
|
||||
personnelAffiliation: ''
|
||||
}),
|
||||
'/vehicle-mgmt/base-info/personnel'
|
||||
)
|
||||
})
|
||||
|
||||
it('不支持的主体返回 null', () => {
|
||||
assert.equal(resolveDocumentReminderEditPath({ ownerType: 'carrier' }), null)
|
||||
assert.equal(resolveDocumentReminderEditPath({ ownerType: '' }), null)
|
||||
assert.equal(DOCUMENT_REMINDER_JUMP_UNSUPPORTED, '暂不支持该主体类型的证件维护跳转')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-document-reminder-jump.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL(模块不存在)
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
```ts
|
||||
export const DOCUMENT_REMINDER_JUMP_UNSUPPORTED = '暂不支持该主体类型的证件维护跳转'
|
||||
|
||||
export type ReminderJumpInput = {
|
||||
ownerType?: string | null
|
||||
vehicleType?: string | null
|
||||
personnelAffiliation?: string | null
|
||||
}
|
||||
|
||||
export function resolveDocumentReminderEditPath(input: ReminderJumpInput): string | null {
|
||||
const ownerType = String(input.ownerType || '').trim()
|
||||
if (ownerType === 'vehicle') {
|
||||
return String(input.vehicleType || '').trim() === 'self'
|
||||
? '/vehicle-mgmt/base-info/self-vehicle'
|
||||
: '/vehicle-mgmt/base-info/vehicle'
|
||||
}
|
||||
if (ownerType === 'personnel') {
|
||||
return String(input.personnelAffiliation || '').trim() === 'carrier'
|
||||
? '/vehicle-mgmt/base-info/personnel-carrier'
|
||||
: '/vehicle-mgmt/base-info/personnel'
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测确认通过**
|
||||
|
||||
同 Step 2,Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit(需授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/utils/pl-document-reminder-jump.ts \
|
||||
web-admin/src/utils/pl-document-reminder-jump.test.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add document reminder jump path resolver
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `useRouteEditIdOpen` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/composables/useRouteEditIdOpen.ts`
|
||||
|
||||
- [ ] **Step 1: 实现 composable**
|
||||
|
||||
```ts
|
||||
import { watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 监听 route.query.editId,调用 openById;结束后清除 editId,避免刷新重复弹窗。
|
||||
* openById 应自行处理加载失败提示。
|
||||
*/
|
||||
export function useRouteEditIdOpen(openById: (id: string) => void | Promise<void>) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
async function consumeEditId() {
|
||||
const raw = route.query.editId
|
||||
const id = Array.isArray(raw) ? String(raw[0] || '') : String(raw || '')
|
||||
if (!id.trim()) return
|
||||
try {
|
||||
await openById(id.trim())
|
||||
} finally {
|
||||
if (route.query.editId == null) return
|
||||
const query = { ...route.query }
|
||||
delete query.editId
|
||||
await router.replace({ path: route.path, query })
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.editId,
|
||||
() => {
|
||||
void consumeEditId()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit(需授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/composables/useRouteEditIdOpen.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add useRouteEditIdOpen for deep-link edit dialogs
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 提醒列表操作列与跳转
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/pl/inventory/document-reminder.vue`
|
||||
|
||||
- [ ] **Step 1: 模板 — 在 `</ListTable>` 前增加操作列**
|
||||
|
||||
在状态/备注列之后、`</ListTable>` 之前插入:
|
||||
|
||||
```vue
|
||||
<el-table-column label="操作" fixed="right" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:loading="maintainLoadingId === row.id"
|
||||
@click="handleMaintainCert(row)"
|
||||
>
|
||||
<el-icon><EditPen /></el-icon>维护证件
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: script — 导入与跳转逻辑**
|
||||
|
||||
1. 图标:`import { CircleCheck, Filter, EditPen } from '@element-plus/icons-vue'`
|
||||
2. API:`import { inventoryApi, vehicleApi, personnelApi, type DocumentReminder } from '@/api/pl'`
|
||||
3. `import { useRouter } from 'vue-router'`(若尚未)
|
||||
4. `import { pickBxData } from '@/utils/http/bx'`
|
||||
5. `import { resolveDocumentReminderEditPath, DOCUMENT_REMINDER_JUMP_UNSUPPORTED } from '@/utils/pl-document-reminder-jump'`
|
||||
6. `import type { VehicleDetail, PersonnelDetail } from '@/api/pl'`(若类型从同文件导出则合并 import)
|
||||
|
||||
```ts
|
||||
const router = useRouter()
|
||||
const maintainLoadingId = ref<string | null>(null)
|
||||
|
||||
async function handleMaintainCert(row: DocumentReminder) {
|
||||
const ownerId = String(row.ownerId || '').trim()
|
||||
const ownerType = String(row.ownerType || '').trim()
|
||||
if (!ownerId || !ownerType) {
|
||||
ElMessage.warning('缺少主体信息,无法跳转维护')
|
||||
return
|
||||
}
|
||||
maintainLoadingId.value = row.id
|
||||
try {
|
||||
let path: string | null = null
|
||||
if (ownerType === 'vehicle') {
|
||||
const res = await vehicleApi.get(ownerId)
|
||||
const detail = pickBxData<VehicleDetail>(res) || (res as any)?.data || res
|
||||
const vehicleType = String((detail as VehicleDetail)?.vehicleType || '')
|
||||
path = resolveDocumentReminderEditPath({ ownerType, vehicleType })
|
||||
} else if (ownerType === 'personnel') {
|
||||
const res = await personnelApi.get(ownerId)
|
||||
const detail = pickBxData<PersonnelDetail>(res) || (res as any)?.data || res
|
||||
const personnelAffiliation = String(
|
||||
(detail as PersonnelDetail)?.personnelAffiliation || ''
|
||||
)
|
||||
path = resolveDocumentReminderEditPath({ ownerType, personnelAffiliation })
|
||||
} else {
|
||||
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
|
||||
return
|
||||
}
|
||||
if (!path) {
|
||||
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
|
||||
return
|
||||
}
|
||||
await router.push({ path, query: { editId: ownerId } })
|
||||
} catch {
|
||||
ElMessage.error('无法打开主体档案,请确认数据存在且有权限')
|
||||
} finally {
|
||||
maintainLoadingId.value = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
确保页面已导入 `ElMessage`(项目若自动导入则无需手写)。
|
||||
|
||||
- [ ] **Step 3: Commit(需授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pl/inventory/document-reminder.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add maintain-cert action on document reminder list
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 车辆 / 自有车辆页响应 `editId`
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/pl/vehicle/index.vue`
|
||||
- Modify: `web-admin/src/views/pl/self-vehicle/index.vue`
|
||||
|
||||
- [ ] **Step 1: vehicle/index.vue**
|
||||
|
||||
在 `handleEdit` 定义之后加入:
|
||||
|
||||
```ts
|
||||
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
|
||||
|
||||
useRouteEditIdOpen(async (id) => {
|
||||
await handleEdit({ id } as Vehicle)
|
||||
})
|
||||
```
|
||||
|
||||
确认 `handleEdit` 仅依赖 `row.id` 拉详情(现有实现用 `vehicleApi.get(row.id)`)。若 TypeScript 抱怨,使用 `as Vehicle` 或最小对象即可。
|
||||
|
||||
- [ ] **Step 2: self-vehicle/index.vue**
|
||||
|
||||
同样在 `handleEdit` 之后:
|
||||
|
||||
```ts
|
||||
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
|
||||
|
||||
useRouteEditIdOpen(async (id) => {
|
||||
await handleEdit({ id } as Vehicle)
|
||||
})
|
||||
```
|
||||
|
||||
(若自有车页 Vehicle 类型同名,用该页已有类型。)
|
||||
|
||||
- [ ] **Step 3: Commit(需授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pl/vehicle/index.vue \
|
||||
web-admin/src/views/pl/self-vehicle/index.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): open vehicle edit dialog from editId query
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 人员页响应 `editId`
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/pl/personnel/index.vue`
|
||||
|
||||
- [ ] **Step 1: 接入 composable**
|
||||
|
||||
在 `handleEdit` 之后:
|
||||
|
||||
```ts
|
||||
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
|
||||
|
||||
useRouteEditIdOpen(async (id) => {
|
||||
await handleEdit({ id } as Personnel)
|
||||
})
|
||||
```
|
||||
|
||||
该组件同时挂 `personnel` 与 `personnel-carrier`,两路由都会响应 `editId`。提醒页已按 affiliation 选对 path;若进错页,`handleEdit`/列表数据范围会失败——保持现有错误提示即可,必要时在 `openById` 外包一层 try/catch 用 `ElMessage.error`。
|
||||
|
||||
- [ ] **Step 2: Commit(需授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pl/personnel/index.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): open personnel edit dialog from editId query
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 验证
|
||||
|
||||
**Files:** 无必须改动。
|
||||
|
||||
- [ ] **Step 1: 单元测试**
|
||||
|
||||
```bash
|
||||
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-document-reminder-jump.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: 手工冒烟(需登录)**
|
||||
|
||||
1. 打开 `/#/pl/document-reminder-module/document-reminder`
|
||||
2. 点车辆行「维护证件」→ 进入对应自有/三方车辆页且编辑弹窗打开,证件可编辑
|
||||
3. 点人员行同理
|
||||
4. 刷新目标页不重复弹窗(query 已清)
|
||||
5. 原「处理」按钮仍可用
|
||||
|
||||
- [ ] **Step 3: 记录未跑项**
|
||||
|
||||
若无法登录 UI,在交付说明写明仅完成单测与代码接入,UI 冒烟待用户验证。
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage
|
||||
|
||||
| 设计项 | 任务 |
|
||||
| --- | --- |
|
||||
| 操作列「维护证件」 | Task 3 |
|
||||
| 按类型/归属分流 path | Task 1 + 3 |
|
||||
| `editId` 打开编辑并清 query | Task 2 + 4 + 5 |
|
||||
| 后端不动 / 不自动 resolve | 全程遵守 |
|
||||
| 仅 Web | 不改 miniprogram |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
无 TBD;路径、文件、测试命令、代码块已钉死。
|
||||
@@ -0,0 +1,543 @@
|
||||
# ETC / 油卡充值管理拆分 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 端将混合的「ETC/油卡充值管理」拆成并列的【ETC充值管理】与【油卡充值管理】;前端复制页面并按类型过滤,后端共用接口并新增 `rechargeTypeNe`;审批流程与审批人配置保持一套。
|
||||
|
||||
**Architecture:** 后端在现有 list/export/approval/board 过滤链增加 `recharge_type_ne`;油卡前端固定传 `rechargeTypeNe=etc`,ETC 前端固定传 `rechargeType=etc` 并复制四套页面。原分组 path 改为 `fuel-recharge`,新建 `etc-recharge` 分组;顶部 `PlRechargeSummary` 仅油卡路径显示。工作流 `biz_type=vehicle_recharge` 不拆。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy;Vue 3 / TypeScript / Element Plus;pytest;`node:test` + `tsx`;MySQL 菜单迁移 SQL。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-12-pl-etc-fuel-recharge-split-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `backend/app/services/pl/vehicle_recharge_list_filters.py` | `recharge_type_ne` → `col != value` |
|
||||
| `backend/app/services/pl_vehicle_recharge_service.py` | `list_applies` / `list_disburse_board`(及 list_records 若已接类型)透传 ne |
|
||||
| `backend/app/services/pl_vehicle_recharge_approval_service.py` | pending/done 透传 ne |
|
||||
| `backend/app/api/pl/vehicle_recharge.py` | Query/export body 增加 `rechargeTypeNe` |
|
||||
| `backend/app/api/pl/vehicle_recharge_approval.py` | pending/done 增加 `rechargeTypeNe` |
|
||||
| `backend/tests/test_pl_vehicle_ops_list_filters.py` | ne 过滤单测 |
|
||||
| `web-admin/src/utils/pl-recharge-summary.ts` | 仅 `fuel-recharge` 显示统计 |
|
||||
| `web-admin/src/utils/pl-recharge-summary.test.ts` | 路由断言更新 |
|
||||
| `web-admin/src/views/pl/vehicle-recharge-{apply,approval,record,workflow}/` | 油卡侧:排除 etc、文案、默认类型 |
|
||||
| `web-admin/src/views/pl/etc-vehicle-recharge-{apply,approval,record,workflow}/` | ETC 复制页:固定 etc、无油卡字段、新 table-key |
|
||||
| `web-admin/src/router/modules/pl.ts` | 分组 path、标题、ETC 子路由、旧 URL redirect |
|
||||
| `docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql` | 改名、改 path、新建 ETC 菜单、角色同步 |
|
||||
| `web-admin/src/components/core/layouts/art-page-content/index.vue` | 确认 summary 仍指向油卡申请 menuCode(一般无需改逻辑) |
|
||||
|
||||
**说明:**
|
||||
|
||||
- 「车油卡信息」`PL_VEH_FUEL_CARD` 继续留在油卡分组下(现挂在原充值分组),不迁入 ETC。
|
||||
- 写入接口本期不按域拆分;类型由前端固定。
|
||||
- 提交前须有用户明确授权再 `git commit`;下列 Commit 步骤在已授权时执行。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 `rechargeTypeNe` 过滤
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/pl/vehicle_recharge_list_filters.py`
|
||||
- Modify: `backend/tests/test_pl_vehicle_ops_list_filters.py`
|
||||
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`
|
||||
- Modify: `backend/app/services/pl_vehicle_recharge_approval_service.py`
|
||||
- Modify: `backend/app/api/pl/vehicle_recharge.py`
|
||||
- Modify: `backend/app/api/pl/vehicle_recharge_approval.py`
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
在 `test_pl_vehicle_ops_list_filters.py` 追加:
|
||||
|
||||
```python
|
||||
def test_recharge_apply_type_ne_filter():
|
||||
filters = build_recharge_apply_column_filters(recharge_type_ne="etc")
|
||||
assert len(filters) == 1
|
||||
sql = _compile(filters[0])
|
||||
assert "recharge_type" in sql
|
||||
assert "!=" in sql or "<>" in sql
|
||||
assert "etc" in sql
|
||||
|
||||
|
||||
def test_recharge_disburse_board_type_ne_filter():
|
||||
filters = build_recharge_disburse_board_column_filters(recharge_type_ne="etc")
|
||||
assert any("recharge_type" in _compile(f) for f in filters)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
pytest tests/test_pl_vehicle_ops_list_filters.py::test_recharge_apply_type_ne_filter tests/test_pl_vehicle_ops_list_filters.py::test_recharge_disburse_board_type_ne_filter -v
|
||||
```
|
||||
|
||||
Expected: FAIL(未知参数或无 ne 条件)
|
||||
|
||||
- [ ] **Step 3: 实现 filter 层**
|
||||
|
||||
在 `vehicle_recharge_list_filters.py` 增加:
|
||||
|
||||
```python
|
||||
def _append_ne_filter(filters: list, col, raw: Optional[str]) -> None:
|
||||
value = (raw or "").strip()
|
||||
if value:
|
||||
filters.append(col != value)
|
||||
```
|
||||
|
||||
`build_recharge_apply_column_filters` / `build_recharge_disburse_board_column_filters` 增加参数 `recharge_type_ne: Optional[str] = None`,在现有 `_append_enum_filter(... recharge_type ...)` 之后调用:
|
||||
|
||||
```python
|
||||
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
|
||||
```
|
||||
|
||||
(发放看板同样用 `PlVehicleRechargeApply.recharge_type`。)
|
||||
|
||||
- [ ] **Step 4: 透传 service + API**
|
||||
|
||||
对以下调用链增加同名可选参数并向下传递(alias `rechargeTypeNe`):
|
||||
|
||||
1. `list_applies`(service + `_apply_list_kwargs` / export 解析 + `list_applies` Query)
|
||||
2. `list_disburse_board`(及 export)
|
||||
3. `list_records`(若当前已支持 `recharge_type`,一并加 ne;否则仅保证 apply/board/approval)
|
||||
4. approval `list_pending` / `list_done`(`vehicle_recharge_approval.py` 的 `_param_get` 与 Query)
|
||||
|
||||
示例(API Query):
|
||||
|
||||
```python
|
||||
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
|
||||
```
|
||||
|
||||
export body Field 同样加 `rechargeTypeNe`。
|
||||
|
||||
- [ ] **Step 5: 跑测确认通过**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
pytest tests/test_pl_vehicle_ops_list_filters.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit(需用户授权)**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/pl/vehicle_recharge_list_filters.py \
|
||||
backend/app/services/pl_vehicle_recharge_service.py \
|
||||
backend/app/services/pl_vehicle_recharge_approval_service.py \
|
||||
backend/app/api/pl/vehicle_recharge.py \
|
||||
backend/app/api/pl/vehicle_recharge_approval.py \
|
||||
backend/tests/test_pl_vehicle_ops_list_filters.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(pl): add rechargeTypeNe filter for vehicle recharge lists
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 顶部统计仅油卡路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/utils/pl-recharge-summary.ts`
|
||||
- Modify: `web-admin/src/utils/pl-recharge-summary.test.ts`
|
||||
|
||||
- [ ] **Step 1: 更新失败测试**
|
||||
|
||||
替换 `isRechargeSummaryRoute` 相关用例为:
|
||||
|
||||
```ts
|
||||
it('只对油卡充值业务子页显示,ETC 与其它车辆页不显示', () => {
|
||||
assert.equal(
|
||||
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-record'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-apply'),
|
||||
false
|
||||
)
|
||||
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle-fuel-card'), false)
|
||||
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle'), false)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-recharge-summary.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL(旧路径仍为 true / 新 fuel 路径未匹配)
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
```ts
|
||||
export function isRechargeSummaryRoute(path: string) {
|
||||
return /\/fuel-recharge\/vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(
|
||||
path
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
确认 `art-page-content` 仍用 `menuCode === 'pl:vehicle-recharge-apply:list'` 解析跳转(油卡申请 perms 不变),一般无需改文件。
|
||||
|
||||
- [ ] **Step 4: 跑测确认通过**
|
||||
|
||||
同 Step 2,Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit(需用户授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/utils/pl-recharge-summary.ts web-admin/src/utils/pl-recharge-summary.test.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(web-admin): show recharge summary only on fuel-recharge routes
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 油卡侧原页面改造(排除 ETC)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/index.vue`
|
||||
- Modify: `web-admin/src/views/pl/vehicle-recharge-approval/index.vue`
|
||||
- Modify: `web-admin/src/views/pl/vehicle-recharge-record/index.vue`
|
||||
- Modify: `web-admin/src/views/pl/vehicle-recharge-workflow/index.vue`
|
||||
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts`(若枚举选项在此定义)
|
||||
|
||||
- [ ] **Step 1: 申请页强制排除 etc**
|
||||
|
||||
在 `buildListQueryParams`(约 L715)末尾增加:
|
||||
|
||||
```ts
|
||||
params.rechargeTypeNe = 'etc'
|
||||
return params
|
||||
```
|
||||
|
||||
若用户列筛选选了 `rechargeType` 且包含 `etc`,以 ne 为准仍排除 etc(后端 `!=` 与 `IN` 同时生效时需确认 SQLAlchemy 为 AND)。为避免矛盾:油卡页 `enumOptionsFor('rechargeType')` **过滤掉** `etc` 选项;表单 `<el-option>` 同样不渲染 `etc`。
|
||||
|
||||
默认新建类型:
|
||||
|
||||
```ts
|
||||
// handleAdd / formData 初始
|
||||
formData.rechargeType = 'fuel'
|
||||
```
|
||||
|
||||
文案:文件头注释、导出 filename、提示里的「ETC/油卡」改为「油卡」(审批跳转文案等同理)。
|
||||
|
||||
- [ ] **Step 2: 审批页 / 发放页同样传 `rechargeTypeNe=etc`**
|
||||
|
||||
在各自组装 list/export/board 参数处增加 `rechargeTypeNe: 'etc'`;充值类型筛选项去掉 etc;文案改为油卡。
|
||||
|
||||
发放页中类似:
|
||||
|
||||
```ts
|
||||
ElMessage.warning('审批中的申请请到「油卡充值审批」菜单完成发放登记')
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 审批人设置页**
|
||||
|
||||
顶部 `wf-info-bar` 旁或下方增加一行提示(简短即可):
|
||||
|
||||
```vue
|
||||
<span class="form-tip">ETC 与油卡共用同一套审批人配置</span>
|
||||
```
|
||||
|
||||
不改 `biz_type` / API。
|
||||
|
||||
- [ ] **Step 4: 本地点验(开发服)**
|
||||
|
||||
打开 `/#/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply`(路由 Task 5 完成前可暂用旧 path 带 `rechargeTypeNe` 联调)。确认列表无 etc 行;新建无 ETC 选项。
|
||||
|
||||
- [ ] **Step 5: Commit(需用户授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pl/vehicle-recharge-apply \
|
||||
web-admin/src/views/pl/vehicle-recharge-approval \
|
||||
web-admin/src/views/pl/vehicle-recharge-record \
|
||||
web-admin/src/views/pl/vehicle-recharge-workflow
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(web-admin): restrict legacy recharge pages to non-ETC types
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 复制并改造 ETC 四套页面
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-apply/`(含 `index.vue`、`columnFilterDefs.ts`)
|
||||
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-approval/index.vue`
|
||||
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue`
|
||||
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-workflow/index.vue`
|
||||
|
||||
- [ ] **Step 1: 复制目录**
|
||||
|
||||
```bash
|
||||
cd web-admin/src/views/pl
|
||||
cp -R vehicle-recharge-apply etc-vehicle-recharge-apply
|
||||
cp -R vehicle-recharge-approval etc-vehicle-recharge-approval
|
||||
cp -R vehicle-recharge-record etc-vehicle-recharge-record
|
||||
cp -R vehicle-recharge-workflow etc-vehicle-recharge-workflow
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ETC 申请页改造清单(在复制后的文件上改)**
|
||||
|
||||
1. 注释与标题文案 →「ETC充值申请」等。
|
||||
2. `buildListQueryParams`:删除 `rechargeTypeNe`;固定 `params.rechargeType = 'etc'`(若用户又选了类型筛选,仍强制 etc,或隐藏类型筛选项)。
|
||||
3. 表单:充值类型固定 `etc`,可用只读展示或隐藏 select 并在提交时写死;去掉油卡卡号相关表单项与 `useVehicleFuelCard` 展示(ETC 不需要)。
|
||||
4. `table-key`、`usePersistedListColumns` 的 localStorage key、导出列定义 key → 加 `etc-` 前缀,例如 `pl-etc-vehicle-recharge-apply`、`pl-etc-vehicle-recharge-apply-list-columns-v2`。
|
||||
5. `columnFilterDefs` 中若 import 路径指向旧目录,改为相对本目录或 `@/views/pl/etc-vehicle-recharge-apply/columnFilterDefs`。
|
||||
6. 权限指令 / `menuCode` 若页面内有硬编码,改为 ETC 新 perms(与菜单 SQL 一致,见 Task 6):`pl:etc-vehicle-recharge-apply:*`。
|
||||
7. 统计刷新:可保留 `mittBus.emit('rechargeSummaryRefresh')`(无害);ETC 路由本就不显示 summary。
|
||||
|
||||
- [ ] **Step 3: ETC 审批 / 发放页**
|
||||
|
||||
- 列表参数固定 `rechargeType=etc`。
|
||||
- 文案「ETC充值审批」「ETC发放管理」。
|
||||
- 新 `table-key` / 列偏好 key。
|
||||
- 发放页跳转提示改为「ETC充值审批」。
|
||||
- 隐藏或固定类型筛选为 etc;无油卡卡号列亦可保留显示为 —。
|
||||
|
||||
- [ ] **Step 4: ETC 审批人设置页**
|
||||
|
||||
- 仍调用现有工作流 API / `vehicle_recharge` biz_type(与油卡页相同 service 调用)。
|
||||
- 增加共用说明文案。
|
||||
- `table-key` 改为 `pl-etc-vehicle-recharge-workflow`(仅前端偏好隔离)。
|
||||
- 页面内 perms 复用 `pl:vehicle-recharge-workflow:*`(与设计一致)。
|
||||
|
||||
- [ ] **Step 5: Commit(需用户授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pl/etc-vehicle-recharge-apply \
|
||||
web-admin/src/views/pl/etc-vehicle-recharge-approval \
|
||||
web-admin/src/views/pl/etc-vehicle-recharge-record \
|
||||
web-admin/src/views/pl/etc-vehicle-recharge-workflow
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add dedicated ETC vehicle recharge pages
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 静态路由与 redirect
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/router/modules/pl.ts`
|
||||
|
||||
- [ ] **Step 1: 原分组改为油卡**
|
||||
|
||||
将 `PL_GROUP_VEH_RECHARGE`:
|
||||
|
||||
- `path: 'etc-recharge'` → `'fuel-recharge'`
|
||||
- `meta.title` → `'油卡充值管理'`
|
||||
- 子菜单 title:`油卡充值申请` / `油卡充值审批` / `油卡充值发放管理` / `油卡充值审批人设置`
|
||||
- **保留** `PL_VEH_FUEL_CARD`(车油卡信息)在此分组下
|
||||
- component 仍指向原 `pl/vehicle-recharge-*`
|
||||
|
||||
- [ ] **Step 2: 新增 ETC 分组(插在油卡分组旁)**
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'PL_GROUP_VEH_ETC_RECHARGE',
|
||||
name: 'PlVehicleEtcRechargeGroup',
|
||||
path: 'etc-recharge',
|
||||
meta: { title: 'ETC充值管理', keepAlive: false },
|
||||
children: [
|
||||
{
|
||||
id: 'PL_ETC_VEH_RECHARGE_APPLY',
|
||||
name: 'PlEtcVehicleRechargeApply',
|
||||
path: 'etc-vehicle-recharge-apply',
|
||||
component: '/pl/etc-vehicle-recharge-apply/index',
|
||||
meta: {
|
||||
title: 'ETC充值申请',
|
||||
keepAlive: false,
|
||||
menuCode: 'pl:etc-vehicle-recharge-apply:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'PL_ETC_VEH_RECHARGE_APPROVAL',
|
||||
name: 'PlEtcVehicleRechargeApproval',
|
||||
path: 'etc-vehicle-recharge-approval',
|
||||
component: '/pl/etc-vehicle-recharge-approval/index',
|
||||
meta: {
|
||||
title: 'ETC充值审批',
|
||||
keepAlive: false,
|
||||
menuCode: 'pl:etc-vehicle-recharge-approval:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'PL_ETC_VEH_RECHARGE_RECORD',
|
||||
name: 'PlEtcVehicleRechargeRecord',
|
||||
path: 'etc-vehicle-recharge-record',
|
||||
component: '/pl/etc-vehicle-recharge-record/index',
|
||||
meta: {
|
||||
title: 'ETC发放管理',
|
||||
keepAlive: false,
|
||||
menuCode: 'pl:etc-vehicle-recharge-record:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'PL_ETC_VEH_RECHARGE_WF',
|
||||
name: 'PlEtcVehicleRechargeWorkflow',
|
||||
path: 'etc-vehicle-recharge-workflow',
|
||||
component: '/pl/etc-vehicle-recharge-workflow/index',
|
||||
meta: {
|
||||
title: 'ETC充值审批人设置',
|
||||
keepAlive: false,
|
||||
menuCode: 'pl:vehicle-recharge-workflow:list'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新隐藏 redirect**
|
||||
|
||||
所有原指向 `/vehicle-mgmt/etc-recharge/vehicle-recharge-*` 的 redirect,改为:
|
||||
|
||||
`/vehicle-mgmt/fuel-recharge/vehicle-recharge-*`
|
||||
|
||||
(书签落到油卡侧。)
|
||||
|
||||
- [ ] **Step 4: Commit(需用户授权)**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/router/modules/pl.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): split etc and fuel recharge router groups
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 菜单迁移 SQL + 角色同步
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql`
|
||||
|
||||
- [ ] **Step 1: 编写可重复执行的迁移**
|
||||
|
||||
要点(MySQL 5.7,`SET NAMES utf8mb4;` + 事务):
|
||||
|
||||
1. 更新原分组与子菜单名称:
|
||||
- `PL_GROUP_VEH_RECHARGE`:`menu_name='油卡充值管理'`,`path='fuel-recharge'`,remark 更新
|
||||
- 原子菜单名改为「油卡充值申请/审批/发放管理/审批人设置」
|
||||
2. 插入 ETC 分组(`parent_id=PL_GROUP_VEHICLE`,`path='etc-recharge'`,`order_num` 建议小于油卡分组以便并列靠前,或紧挨油卡):
|
||||
- id: `PL_GROUP_VEH_ETC_RECHARGE`
|
||||
3. 插入 4 个 C 菜单(component / path / perms 与 Task 5 一致):
|
||||
- Apply/Approval/Record:新 perms `pl:etc-vehicle-recharge-*:list`
|
||||
- WF:`perms='pl:vehicle-recharge-workflow:list'`(复用)
|
||||
4. 为 Apply/Approval/Record 复制对应 F 按钮菜单(对照现有 `PL_VRA_*` / `PL_VRR_*` / 审批按钮),id 前缀 `PL_ETC_…`,perms 换新前缀。
|
||||
5. 角色同步:对已拥有对应油卡菜单(如 `PL_VEH_RECHARGE_APPLY`)的角色,插入 ETC 对等菜单及祖先(模式参考 `docs/sql/migrations/2026-10-25-pl-vehicle-fuel-card.sql` 的 `sys_role_menu` 段);`NOT EXISTS` 防重复;不恢复管理员已软删授权。
|
||||
6. WF 菜单:对已有 `PL_VEH_RECHARGE_WF` 授权的角色补 `PL_ETC_VEH_RECHARGE_WF` + 分组祖先。
|
||||
|
||||
- [ ] **Step 2: 在本地库执行并抽查**
|
||||
|
||||
```bash
|
||||
# 使用项目既有 mysql 方式执行该 migration;执行后:
|
||||
# SELECT id,menu_name,path,component,perms FROM sys_menu
|
||||
# WHERE id LIKE 'PL_%RECHARGE%' OR parent_id IN ('PL_GROUP_VEH_RECHARGE','PL_GROUP_VEH_ETC_RECHARGE');
|
||||
```
|
||||
|
||||
Expected: 两组菜单并存;油卡 path=`fuel-recharge`;ETC 指向 `pl/etc-vehicle-recharge-*`。
|
||||
|
||||
- [ ] **Step 3: Commit(需用户授权)**
|
||||
|
||||
```bash
|
||||
git add docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(sql): split ETC and fuel recharge menus with role sync
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 联调验收与回归
|
||||
|
||||
**Files:** 无必须改动;按需修漏网文案 / 参数。
|
||||
|
||||
- [ ] **Step 1: 后端过滤回归**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
pytest tests/test_pl_vehicle_ops_list_filters.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: 前端 summary 单测**
|
||||
|
||||
```bash
|
||||
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-recharge-summary.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: 类型检查(可选但推荐)**
|
||||
|
||||
```bash
|
||||
cd web-admin && pnpm build:typecheck
|
||||
```
|
||||
|
||||
Expected: 与本改动相关的错误为 0
|
||||
|
||||
- [ ] **Step 4: 手工验收对照设计「验收」清单**
|
||||
|
||||
1. 侧栏并列【ETC充值管理】【油卡充值管理】,ETC 4 子菜单;油卡含原 4 项 +「车油卡信息」。
|
||||
2. ETC 列表仅 etc;新建固定 ETC;无顶部统计。
|
||||
3. 油卡列表无 etc;有顶部统计;卡片跳转进油卡申请。
|
||||
4. 两边审批人设置改同一套节点。
|
||||
5. 旧 URL `/#/vehicle-mgmt/etc-recharge/vehicle-recharge-apply` redirect 到 fuel 路径。
|
||||
6. 小程序未改。
|
||||
|
||||
- [ ] **Step 5: 若有修复,再提交(需用户授权)**
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage(自检)
|
||||
|
||||
| 设计要求 | 任务 |
|
||||
| --- | --- |
|
||||
| 并列两模块 + 子菜单 | Task 5–6 |
|
||||
| 油卡排除 etc、界面现状 | Task 3 |
|
||||
| ETC 复制页 + 固定 etc + 无统计 | Task 2、4 |
|
||||
| 后端不整拆 + `rechargeTypeNe` | Task 1 |
|
||||
| 审批/审批人保持一套 + 双入口 | Task 3–4 workflow |
|
||||
| 仅 Web | 全程不改 miniprogram |
|
||||
| 旧 URL redirect | Task 5 |
|
||||
| 角色默认同步 | Task 6 |
|
||||
| 车油卡信息归属油卡组 | Task 5–6 明确保留 |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
无 TBD/TODO;油卡/ETC 路径、菜单 id、perms、测试命令已钉死。
|
||||
@@ -0,0 +1,498 @@
|
||||
# 充值申请「发放中」状态 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:** 末级审批通过进入发放节点后,申请状态变为 `pending`,Web 展示为「发放中」;待办/徽章/发放登记与撤销回退一并兼容。
|
||||
|
||||
**Architecture:** 油卡/ETC 共用后端状态机。`approve_apply` 在下一节点为 `disburse` 时写入 `pending`;`_current_node` / `list_pending` / 菜单徽章同时接受 `in_approval` 与 `pending`;撤销发放有流程时回退 `pending`。前端仅改充值申请文案与筛选项(尿素等其它业务不动)。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy、pytest、Vue 3 + Element Plus(web-admin)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-12-pl-recharge-pending-disburse-status-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| `backend/app/schemas/pl/vehicle_recharge.py` | `APPLY_STATUSES` 纳入 `pending` |
|
||||
| `backend/app/services/pl_vehicle_recharge_approval_service.py` | 审批推进切 `pending`;待办/当前节点准入 |
|
||||
| `backend/app/services/pl_vehicle_recharge_service.py` | 撤销/换绑回退;关联提示文案 |
|
||||
| `backend/app/services/pl_menu_badge_service.py` | 徽章计数含 `pending` |
|
||||
| `backend/app/api/pl/vehicle_recharge.py` | 导出状态标签「发放中」 |
|
||||
| `backend/tests/test_pl_vehicle_recharge_workflow.py` | 状态机单测 |
|
||||
| `backend/tests/test_pl_menu_badge_recharge_approval.py` | 徽章含 pending |
|
||||
| `web-admin/src/utils/pl-display.ts` | 充值申请 `pending` → 发放中 |
|
||||
| `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts` | 筛选项 |
|
||||
| `web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts` | 筛选项 |
|
||||
| `web-admin/src/views/pl/vehicle-recharge-record/index.vue` | 撤销确认文案 |
|
||||
| `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue` | 撤销确认文案 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schema + 审批推进写 pending(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/pl/vehicle_recharge.py`
|
||||
- Modify: `backend/app/services/pl_vehicle_recharge_approval_service.py`
|
||||
- Modify: `backend/tests/test_pl_vehicle_recharge_workflow.py`
|
||||
|
||||
- [ ] **Step 1: 改现有断言并加失败用例**
|
||||
|
||||
在 `test_apply_statuses_include_workflow_states` 中让集合包含 `pending`:
|
||||
|
||||
```python
|
||||
assert {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
|
||||
```
|
||||
|
||||
将 `test_approve_advances_to_next_node` 中通过后断言改为(默认种子流程下一节点是发放):
|
||||
|
||||
```python
|
||||
assert result.status == "pending"
|
||||
assert result.current_node == node_fin
|
||||
```
|
||||
|
||||
新增三级流程用例(一级通过仍审批中,二级通过变发放中):
|
||||
|
||||
```python
|
||||
def _seed_three_node_workflow(db) -> tuple[str, str, str, str]:
|
||||
wf_id = "wf-3"
|
||||
n1, n2, n3 = "node-a1", "node-a2", "node-dis"
|
||||
db.add(
|
||||
BxWorkflowDef(
|
||||
id=wf_id,
|
||||
biz_type="vehicle_recharge",
|
||||
wf_name="三级充值流程",
|
||||
status=1,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
for nid, name, order, uid, uname, action in (
|
||||
(n1, "一审", 1, "u-a1", "一审", "approve"),
|
||||
(n2, "二审", 2, "u-a2", "二审", "approve"),
|
||||
(n3, "发放", 3, "u-fin", "财务", "disburse"),
|
||||
):
|
||||
db.add(
|
||||
BxWorkflowNode(
|
||||
id=nid,
|
||||
wf_id=wf_id,
|
||||
node_name=name,
|
||||
node_order=order,
|
||||
approver_id=uid,
|
||||
approver_name=uname,
|
||||
action_type=action,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
return wf_id, n1, n2, n3
|
||||
|
||||
|
||||
def test_mid_approve_stays_in_approval_final_approve_sets_pending(monkeypatch):
|
||||
from app.services import pl_vehicle_recharge_approval_service as approval_svc
|
||||
from app.services import pl_vehicle_recharge_service as svc
|
||||
|
||||
db = _make_session()
|
||||
_seed_vehicle(db)
|
||||
wf_id, n1, n2, n3 = _seed_three_node_workflow(db)
|
||||
db.add(
|
||||
PlVehicleRechargeApply(
|
||||
id="apply-1",
|
||||
apply_no="YK2026092601",
|
||||
license_plate="鄂A00001",
|
||||
recharge_type="fuel",
|
||||
apply_date=date(2026, 9, 26),
|
||||
applicant_id="u-applicant",
|
||||
status="in_approval",
|
||||
wf_id=wf_id,
|
||||
current_node=n1,
|
||||
approval_round=1,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_patch_scope(monkeypatch, svc)
|
||||
|
||||
mid = approval_svc.approve_apply(db, "t1", _user("u-a1", "一审"), "apply-1", opinion="ok")
|
||||
assert mid.status == "in_approval"
|
||||
assert mid.current_node == n2
|
||||
|
||||
final = approval_svc.approve_apply(db, "t1", _user("u-a2", "二审"), "apply-1", opinion="ok")
|
||||
assert final.status == "pending"
|
||||
assert final.current_node == n3
|
||||
```
|
||||
|
||||
新增:`pending` 状态下可发放:
|
||||
|
||||
```python
|
||||
def test_disburse_accepts_pending_status(monkeypatch):
|
||||
from app.services import pl_vehicle_recharge_approval_service as approval_svc
|
||||
from app.services import pl_vehicle_recharge_service as svc
|
||||
from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
|
||||
|
||||
db = _make_session()
|
||||
_seed_vehicle(db)
|
||||
wf_id, _gm, node_fin = _seed_workflow(db)
|
||||
db.add(
|
||||
PlVehicleRechargeApply(
|
||||
id="apply-1",
|
||||
apply_no="ETC2026092601",
|
||||
license_plate="鄂A00001",
|
||||
recharge_type="etc",
|
||||
apply_date=date(2026, 9, 26),
|
||||
applicant_id="u-applicant",
|
||||
status="pending",
|
||||
wf_id=wf_id,
|
||||
current_node=node_fin,
|
||||
approval_round=1,
|
||||
tenant_no="t1",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_patch_scope(monkeypatch, svc)
|
||||
payload = VehicleRechargeRecordCreate(
|
||||
rechargeDate=date(2026, 9, 26),
|
||||
rechargeType="etc",
|
||||
amount=Decimal("50.00"),
|
||||
applyId="apply-1",
|
||||
)
|
||||
result = approval_svc.disburse_apply(
|
||||
db, "t1", _user("u-fin", "财务"), "apply-1", payload
|
||||
)
|
||||
assert result.status == "disbursed"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
Run(在 `backend/`,已激活项目 venv):
|
||||
|
||||
```bash
|
||||
pytest tests/test_pl_vehicle_recharge_workflow.py::test_apply_statuses_include_workflow_states \
|
||||
tests/test_pl_vehicle_recharge_workflow.py::test_approve_advances_to_next_node \
|
||||
tests/test_pl_vehicle_recharge_workflow.py::test_mid_approve_stays_in_approval_final_approve_sets_pending \
|
||||
tests/test_pl_vehicle_recharge_workflow.py::test_disburse_accepts_pending_status -v
|
||||
```
|
||||
|
||||
Expected: FAIL(`pending` 不在集合 / 状态仍为 `in_approval` / `申请单不在审批中`)
|
||||
|
||||
- [ ] **Step 3: 最小实现**
|
||||
|
||||
`vehicle_recharge.py` schema:
|
||||
|
||||
```python
|
||||
APPLY_STATUSES = {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"}
|
||||
```
|
||||
|
||||
`pl_vehicle_recharge_approval_service.py` 中 `_current_node`:
|
||||
|
||||
```python
|
||||
if apply.status not in ("in_approval", "pending") or not apply.current_node:
|
||||
raise HTTPException(status_code=400, detail="申请单不在可审批/发放状态")
|
||||
```
|
||||
|
||||
`list_pending` 过滤:
|
||||
|
||||
```python
|
||||
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
|
||||
```
|
||||
|
||||
`approve_apply` 在推进节点后:
|
||||
|
||||
```python
|
||||
next_node = nodes[idx + 1]
|
||||
apply.current_node = next_node.id
|
||||
next_action = (next_node.action_type or "approve").strip() or "approve"
|
||||
if next_action == "disburse":
|
||||
apply.status = "pending"
|
||||
```
|
||||
|
||||
(勿改中间 approve 的 status。)
|
||||
|
||||
- [ ] **Step 4: 再跑 Step 2 命令**
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/pl/vehicle_recharge.py \
|
||||
backend/app/services/pl_vehicle_recharge_approval_service.py \
|
||||
backend/tests/test_pl_vehicle_recharge_workflow.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(pl): set recharge apply to pending when entering disburse node
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 徽章计数含 pending
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/pl_menu_badge_service.py`
|
||||
- Modify: `backend/tests/test_pl_menu_badge_recharge_approval.py`
|
||||
|
||||
- [ ] **Step 1: 扩展徽章测试**
|
||||
|
||||
在现有 `add_apply` 种子后增加:
|
||||
|
||||
```python
|
||||
add_apply("a6", status="pending", current_node="node-me")
|
||||
```
|
||||
|
||||
并将断言改为:
|
||||
|
||||
```python
|
||||
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 3
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
pytest tests/test_pl_menu_badge_recharge_approval.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL(仍为 2)
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
`count_recharge_approval_pending` 中:
|
||||
|
||||
```python
|
||||
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 再跑测**
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/pl_menu_badge_service.py \
|
||||
backend/tests/test_pl_menu_badge_recharge_approval.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(pl): include pending recharge applies in approval menu badge
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 撤销发放回退为 pending + 文案「发放中」
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(`delete_records` / 换绑旧申请回退两处:约 1136、1205 行,有 `wf_id` 时现为 `in_approval`)
|
||||
- Modify: `backend/app/api/pl/vehicle_recharge.py`(`RECHARGE_APPLY_STATUS_LABELS`)
|
||||
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(`仅待发放申请可关联` → `仅发放中申请可关联`)
|
||||
|
||||
- [ ] **Step 1: 写回退单测(可放在 workflow 测试文件)**
|
||||
|
||||
```python
|
||||
def test_delete_record_with_workflow_reverts_to_pending(monkeypatch):
|
||||
from app.services import pl_vehicle_recharge_service as svc
|
||||
|
||||
db = _make_session()
|
||||
_seed_vehicle(db)
|
||||
wf_id, _gm, node_fin = _seed_workflow(db)
|
||||
db.add(
|
||||
PlVehicleRechargeApply(
|
||||
id="apply-1",
|
||||
apply_no="ETC2026092601",
|
||||
license_plate="鄂A00001",
|
||||
recharge_type="etc",
|
||||
apply_date=date(2026, 9, 26),
|
||||
applicant_id="u-applicant",
|
||||
status="disbursed",
|
||||
wf_id=wf_id,
|
||||
current_node=None,
|
||||
approval_round=1,
|
||||
tenant_no="t1",
|
||||
create_by="u-applicant",
|
||||
create_dept_id="d1",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
PlVehicleRechargeRecord(
|
||||
id="rec-1",
|
||||
recharge_order_no="RO1",
|
||||
recharge_date=date(2026, 9, 26),
|
||||
recharge_type="etc",
|
||||
amount=Decimal("10.00"),
|
||||
apply_id="apply-1",
|
||||
license_plate="鄂A00001",
|
||||
tenant_no="t1",
|
||||
create_by="u-fin",
|
||||
create_dept_id="d1",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_patch_scope(monkeypatch, svc)
|
||||
svc.delete_records(db, "t1", _user("u-fin", "财务"), ["rec-1"])
|
||||
apply = db.query(PlVehicleRechargeApply).filter_by(id="apply-1").one()
|
||||
assert apply.status == "pending"
|
||||
assert apply.current_node == node_fin
|
||||
```
|
||||
|
||||
若 `PlVehicleRechargeRecord` 构造缺必填字段,按模型补齐后保持断言不变。
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
pytest tests/test_pl_vehicle_recharge_workflow.py::test_delete_record_with_workflow_reverts_to_pending -v
|
||||
```
|
||||
|
||||
Expected: FAIL(仍为 `in_approval`)
|
||||
|
||||
- [ ] **Step 3: 实现回退与文案**
|
||||
|
||||
两处有 wf 回退改为:
|
||||
|
||||
```python
|
||||
old_apply.status = "pending" # 或 apply.status = "pending"
|
||||
```
|
||||
|
||||
API 标签:
|
||||
|
||||
```python
|
||||
"pending": "发放中",
|
||||
```
|
||||
|
||||
服务提示:
|
||||
|
||||
```python
|
||||
detail="仅发放中申请可关联",
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑回退测 + 相关 workflow 测**
|
||||
|
||||
```bash
|
||||
pytest tests/test_pl_vehicle_recharge_workflow.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/pl_vehicle_recharge_service.py \
|
||||
backend/app/api/pl/vehicle_recharge.py \
|
||||
backend/tests/test_pl_vehicle_recharge_workflow.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(pl): revert disbursed recharge apply to pending and rename label
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Web 文案与筛选项
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/utils/pl-display.ts`(仅 `getPlRechargeApplyStatusLabel`,**不要**改 `getPlUreaApplyStatusLabel`)
|
||||
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts`
|
||||
- Modify: `web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts`
|
||||
- Modify: `web-admin/src/views/pl/vehicle-recharge-record/index.vue`
|
||||
- Modify: `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue`
|
||||
|
||||
- [ ] **Step 1: 改充值状态文案**
|
||||
|
||||
```typescript
|
||||
pending: '发放中',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 筛选项插入 pending(建议放在审批中之后)**
|
||||
|
||||
油卡与 ETC 的 `RECHARGE_STATUS_OPTIONS`:
|
||||
|
||||
```typescript
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'in_approval', label: '审批中' },
|
||||
{ value: 'pending', label: '发放中' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
{ value: 'disbursed', label: '已发放' },
|
||||
{ value: 'cancelled', label: '已作废' }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 撤销确认文案**
|
||||
|
||||
两处 record 页:
|
||||
|
||||
```typescript
|
||||
'确定撤销该发放登记吗?关联申请将回退为发放中。'
|
||||
```
|
||||
|
||||
全文检索 `web-admin` 内充值相关「待发放」字样,仅改充值申请/发放语境;跳过尿素、运费、备用金。
|
||||
|
||||
- [ ] **Step 4: 静态核对**
|
||||
|
||||
```bash
|
||||
# 在 web-admin/
|
||||
rg "待发放" src/utils/pl-display.ts src/views/pl/vehicle-recharge-apply src/views/pl/etc-vehicle-recharge-apply src/views/pl/vehicle-recharge-record src/views/pl/etc-vehicle-recharge-record
|
||||
```
|
||||
|
||||
Expected: 上述路径无残留「待发放」(尿素等其它目录可仍有)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/utils/pl-display.ts \
|
||||
web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts \
|
||||
web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts \
|
||||
web-admin/src/views/pl/vehicle-recharge-record/index.vue \
|
||||
web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): show recharge pending status as 发放中
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 回归验证
|
||||
|
||||
**Files:** 无新文件
|
||||
|
||||
- [ ] **Step 1: 后端相关测试全跑**
|
||||
|
||||
```bash
|
||||
pytest tests/test_pl_vehicle_recharge_workflow.py \
|
||||
tests/test_pl_menu_badge_recharge_approval.py \
|
||||
tests/test_pl_vehicle_recharge_summary.py -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: 手工冒烟清单(记结果,未跑勿称通过)**
|
||||
|
||||
1. 两级审批 + 发放节点:一审后「审批中」;二审后「发放中」;发放后「已发放」
|
||||
2. 发放节点人审批待办仍可见并发放成功
|
||||
3. 申请列表可用「发放中」筛选
|
||||
4. 油卡、ETC 各至少一单(或同后端断言后单侧冒烟 + 说明)
|
||||
|
||||
- [ ] **Step 3: 若用户要求再 push;默认本任务不 push**
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage check
|
||||
|
||||
| Spec 项 | Task |
|
||||
| --- | --- |
|
||||
| 末级 approve → pending | Task 1 |
|
||||
| 中间仍 in_approval | Task 1 |
|
||||
| 待办/准入认 pending | Task 1 |
|
||||
| 徽章含 pending | Task 2 |
|
||||
| 撤销回退 pending | Task 3 |
|
||||
| 文案「发放中」 | Task 3–4 |
|
||||
| 筛选项 | Task 4 |
|
||||
| 不做历史刷库 / 不改尿素文案 | 明确不做 |
|
||||
| 回归 | Task 5 |
|
||||
@@ -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,115 @@
|
||||
# 文档库多选批量设置分享(工单 #25)
|
||||
|
||||
工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/25
|
||||
|
||||
## 背景与问题
|
||||
|
||||
Web 文档库管理(`/#/doclib/enter`)工具栏「设置分享」当前仅在恰好选中 1 份且可管理的文档时可用(`actionsFor.share`),弹窗与接口均为单文档。用户需要多选文档后统一设置分享人员。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| 保存语义 | 整份覆盖:弹窗所选 `userIds` 写入每一份所选文档,替换原分享人;空列表表示全部收回 |
|
||||
| 弹窗初始勾选 | 所选文档现有分享人的交集;无共同人员则为空 |
|
||||
| 修改端 | Web 管理端 + 后端批量 API |
|
||||
| 失败策略 | 整批事务:全部成功或全部回滚 |
|
||||
| 实现方案 | 新增 `batch-shares` + 扩展现有 `ShareDialog`(方案 A) |
|
||||
|
||||
## 范围
|
||||
|
||||
**包含**
|
||||
|
||||
- `web-admin`:`doclib/enter` 列表工具栏、`ShareDialog`、`actionsFor`、API 封装与单测
|
||||
- `backend`:批量分享路由、schema、服务层事务与单文件写入逻辑复用
|
||||
|
||||
**不包含**
|
||||
|
||||
- 小程序 / 移动 H5
|
||||
- 「只追加不收回」、部分成功返回清单
|
||||
- 文件夹级分享
|
||||
|
||||
## 后端设计
|
||||
|
||||
### 接口
|
||||
|
||||
`POST /web/doclib/files/batch-shares`
|
||||
|
||||
请求体(`extra=forbid`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["file-id-1", "file-id-2"],
|
||||
"userIds": ["user-id-a", "user-id-b"]
|
||||
}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `ids`:`min_length=1`,`max_length=100`(对齐 `DeleteFilesBody`)
|
||||
- `userIds`:`max_length=500`(对齐 `ShareBody`)
|
||||
- 去重后的 `ids` 均须当前用户可管理;任一份不可管理则 4xx,整批不写
|
||||
- 人员须同租户且启用;自动排除各文档上传人(与现 `set_shares` 一致:`requested - {create_by}`)
|
||||
- 非法 / 停用 / 跨租户人员 → 400,整批回滚
|
||||
|
||||
响应:成功后返回更新后的文档列表(字段与现有单文档分享成功返回一致的序列化)。
|
||||
|
||||
### 事务与复用
|
||||
|
||||
- 现有 `DoclibService.set_shares` 在单文件路径末尾 `commit`。批量路径须在**全部文件写入完成前不 commit**。
|
||||
- 抽取内部方法(名称示例:`_apply_shares(file_id, user_ids)`)完成单文件差量更新(含 lock);`set_shares` = `_apply_shares` + `commit` + 返回序列化;`set_shares_batch` = 校验全部可管理 → 校验人员 → 对每个 id 调用 `_apply_shares` → 一次 `commit` → 返回列表。
|
||||
- 任一步抛错依赖会话回滚,不留下部分文档已改的状态。
|
||||
|
||||
路由注册位置:与现有 `PUT /web/doclib/files/{file_id}/shares`、`POST .../batch-delete` 同模块(`backend/app/api/doclib.py`)。
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 工具栏与权限
|
||||
|
||||
- `actionsFor.share`:`rows.length > 0 && rows.every((row) => row.canManage)`(与删除多选门槛一致,取消「仅 1 条」限制)。
|
||||
- 工具栏「设置分享」对 `selected` 全量打开弹窗,不再只取 `selected[0]`。
|
||||
- 详情抽屉单文档「设置分享」仍传入单元素数组或单文档,行为不变。
|
||||
|
||||
### ShareDialog
|
||||
|
||||
- Props 改为支持多文档(例如 `documents: DoclibDocument[]`);打开时:
|
||||
- 摘要列出所选文档名称(数量较多时可截断并注明总数)
|
||||
- `userIds` 初始 = 分享人 id **交集**
|
||||
- 候选人员:交集中的人员 ∪ 远程搜索结果;排除各文档上传人(多文档时排除所有相关 `ownerId`)
|
||||
- 保存:统一调用批量接口 `saveDoclibBatchShares(ids, userIds)`(单文档也走批量,少分支);成功提示后关闭并刷新列表 / 更新详情。
|
||||
|
||||
### 纯函数(便于单测)
|
||||
|
||||
- `shareUserIntersection(documents): string[]` — 交集;空文档列表返回 `[]`
|
||||
- 可选:`canShareSelection(rows)` 与 `actionsFor` 共用语义
|
||||
|
||||
### API
|
||||
|
||||
在 `web-admin/src/api/doclib.ts` 增加:
|
||||
|
||||
`saveDoclibBatchShares(ids: string[], userIds: string[])` → `POST .../files/batch-shares`
|
||||
|
||||
单文档 `saveDoclibShares` 可保留供兼容/测试,页面路径以批量为准。
|
||||
|
||||
## UI 规范
|
||||
|
||||
- 沿用现有 `doclib-dialog dialog-form-layout`、Fresh Mint、P12 类弹窗;不新造布局与主色。
|
||||
- 按钮图标规则与现页一致(Close / Check 等已有用法)。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 多选可管理文档后可打开「设置分享」;含不可管理项时按钮禁用。
|
||||
2. 打开时勾选为交集;无共同分享人时为空。
|
||||
3. 保存后所选文档分享人员均等于弹窗所选集合(含清空)。
|
||||
4. 故意混入无权限文档(若后端被直接调用)→ 整批不改。
|
||||
5. 详情页单文档分享仍可用。
|
||||
6. 单测覆盖交集函数与 `actionsFor.share`;后端/服务层覆盖批量成功与权限失败回滚(按项目既有测试方式)。
|
||||
|
||||
## 风险
|
||||
|
||||
- `set_shares` 重构时须保证单文档路径回归(commit 时机、上传人排除、软删恢复历史 share 行)。
|
||||
- 批量上限 100 与删除一致;超大选区依赖现有表格多选,不另做分片 UI。
|
||||
|
||||
## 状态
|
||||
|
||||
设计已获用户确认(会话中「按照推荐」方案 A,「OK,请继续」批准落盘)。待用户审阅本文件后进入实现计划。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 证件到期提醒本页维护证件弹窗(Web)
|
||||
|
||||
## 背景与问题
|
||||
|
||||
Web【证件到期提醒】展示车辆、人员证件过期信息。用户需要补录/更新证件时,若整页跳到【基础信息库】再返回,连续处理多条很麻烦。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| 交互(方案 A) | 在提醒页点「维护证件」,**本页弹出**与基础信息库同款编辑弹窗;关闭后仍停在提醒列表 |
|
||||
| 车辆弹窗 | 复用 `PlVehicleFormDialog`;按 `vehicleType` 传 `self` / `carrier` |
|
||||
| 人员弹窗 | 复用 `PlPersonnelFormDialog`;按 `personnelAffiliation` 传 `self` / `carrier` |
|
||||
| 打开前 | `get` 主体详情以判定归属;缺 ID / 失败则提示,不弹窗 |
|
||||
| 保存后 | 刷新提醒列表与菜单角标;**不**自动将提醒标为已处理 |
|
||||
| 整页跳转 | 提醒页「维护证件」**不再** `router.push` 离开本页 |
|
||||
| `editId` 深链 | 车辆/人员档案页已有的 `editId` 能力可保留(书签/外链),与本页弹窗无关 |
|
||||
| 后端 | 不改 |
|
||||
| 范围 | 仅 Web |
|
||||
|
||||
## 范围
|
||||
|
||||
**包含**
|
||||
|
||||
- `document-reminder.vue`:操作列改为打开本页 `PlVehicleFormDialog` / `PlPersonnelFormDialog`
|
||||
- `pl-document-reminder-jump.ts`:增加表单模式解析(或复用归属判定);路径函数可保留给深链/兼容
|
||||
|
||||
**不包含**
|
||||
|
||||
- 小程序
|
||||
- 保存后自动 resolve 提醒
|
||||
- 抽离档案列表页内联大表单(已有独立 dialog 组件则直接复用)
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 提醒页
|
||||
|
||||
1. 模板挂载:
|
||||
|
||||
```vue
|
||||
<PlVehicleFormDialog
|
||||
v-model="vehicleFormVisible"
|
||||
:vehicle-type="vehicleFormType"
|
||||
:vehicle-id="editingOwnerId"
|
||||
@saved="handleOwnerFormSaved"
|
||||
/>
|
||||
<PlPersonnelFormDialog
|
||||
v-model="personnelFormVisible"
|
||||
:affiliation="personnelFormAffiliation"
|
||||
:personnel-id="editingOwnerId"
|
||||
@saved="handleOwnerFormSaved"
|
||||
/>
|
||||
```
|
||||
|
||||
2. `handleMaintainCert`:校验 → get → `resolveDocumentReminderFormMode` → 设置 `editingOwnerId` 与 type/affiliation → `vehicleFormVisible` 或 `personnelFormVisible = true`。
|
||||
3. `@saved`:`fetchData()` + `refreshMenuBadges()`;成功提示可选。
|
||||
4. 文案:「缺少主体信息,无法维护」等,不再提「跳转」。
|
||||
|
||||
### 模式解析
|
||||
|
||||
```ts
|
||||
type ReminderFormMode =
|
||||
| { kind: 'vehicle'; vehicleType: 'self' | 'carrier' }
|
||||
| { kind: 'personnel'; affiliation: 'self' | 'carrier' }
|
||||
| null
|
||||
```
|
||||
|
||||
- vehicle + vehicleType self → self,否则 carrier
|
||||
- personnel + affiliation carrier → carrier,否则 self
|
||||
|
||||
## 验收
|
||||
|
||||
1. 提醒页点「维护证件」不离开当前路由,弹出编辑框(含证件)。
|
||||
2. 关窗后列表仍在,可立刻点下一条。
|
||||
3. 保存后列表/角标刷新;提醒状态仍须手动「处理」。
|
||||
4. 车辆自有/三方、人员山峡/三方弹窗标题与校验与基础信息库一致。
|
||||
|
||||
## 状态
|
||||
|
||||
用户确认方案 A(本页弹窗)。实现于 `V3.0`。
|
||||
@@ -0,0 +1,177 @@
|
||||
# ETC / 油卡充值管理菜单拆分(Web)
|
||||
|
||||
## 背景与问题
|
||||
|
||||
Web【车辆管理】下现有【ETC/油卡充值管理】(路由前缀 `/vehicle-mgmt/etc-recharge/`)将 ETC 与油卡(及山峡、百链等非 ETC 类型)混在同一套申请 / 审批 / 发放 / 审批人设置页面中。布局层在匹配充值路由时还会展示 `PlRechargeSummary` 顶部统计,该统计面向油卡场景,不适合挂在独立 ETC 模块上。
|
||||
|
||||
用户希望:ETC 与油卡分成两个并列模块,前端可独立演进;后端表与接口暂不整模块拆分;审批流程与审批人配置继续共用现有 `biz_type=vehicle_recharge`。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| 产品结构 | 并列【ETC充值管理】与【油卡充值管理】(原名改名) |
|
||||
| ETC 子菜单 | 申请、审批、发放管理、审批人设置 |
|
||||
| 油卡侧 | 界面保持现状;列表等不再展示 `rechargeType=etc` |
|
||||
| ETC 顶部统计 | ETC 分组展示 `PlRechargeSummary`(仅 ETC 信息);油卡分组仅展示加油卡信息(验收调整) |
|
||||
| 前端实现 | 复制现有 4 个页面目录再改,便于后续各自演进 |
|
||||
| 后端实现 | 不拆服务/表;列表与写入靠类型过滤/约束;小改即可 |
|
||||
| 审批流程 / 审批人 | 保持现状,仍一套 `vehicle_recharge`;两边菜单都进同一配置(方案 A) |
|
||||
| 修改端 | 仅 Web 管理端;小程序不改 |
|
||||
| 非 ETC 类型归属 | `fuel` / `shanxia` / `bailian` 等非 `etc` 均留在油卡模块 |
|
||||
|
||||
## 范围
|
||||
|
||||
**包含**
|
||||
|
||||
- `web-admin`:菜单与路由、页面复制与类型固化、顶部统计路由判定、文案与筛选
|
||||
- `backend`:列表/导出/看板/审批查询落实类型过滤(见后端设计);不拆接口与表
|
||||
- `docs/sql/migrations/`:菜单改名、新建 ETC 分组与子菜单、角色菜单同步;旧 path 由路由 redirect 兼容
|
||||
|
||||
**不包含**
|
||||
|
||||
- 小程序 / 移动 H5
|
||||
- 拆分审批流程 `biz_type`、复制工作流定义、在途单迁移
|
||||
- 拆分数据表或新建独立后端模块
|
||||
- 本期不要求油卡与 ETC 审批人可分别配置(后续若需要再单独立项)
|
||||
|
||||
## 菜单与路由
|
||||
|
||||
### 目标结构(均挂在【车辆管理】`PL_GROUP_VEHICLE` 下)
|
||||
|
||||
1. **【ETC充值管理】**(新建分组)
|
||||
- ETC充值申请
|
||||
- ETC充值审批
|
||||
- ETC发放管理
|
||||
- ETC充值审批人设置
|
||||
2. **【油卡充值管理】**(原 `PL_GROUP_VEH_RECHARGE` 改名)
|
||||
- 油卡充值申请 / 审批 / 发放管理 / 审批人设置(原子菜单改名)
|
||||
|
||||
### 路径约定
|
||||
|
||||
| 模块 | 分组 path | 说明 |
|
||||
| --- | --- | --- |
|
||||
| ETC | `etc-recharge` | 语义与产品名一致;组件指向**复制后的** ETC 页面 |
|
||||
| 油卡 | `fuel-recharge` | 原分组 path 由 `etc-recharge` 改为 `fuel-recharge` |
|
||||
|
||||
完整路径(菜单 SQL 与此对齐):
|
||||
|
||||
- ETC:`/#/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-{apply|approval|record|workflow}`
|
||||
- 油卡:`/#/vehicle-mgmt/fuel-recharge/vehicle-recharge-{apply|approval|record|workflow}`
|
||||
|
||||
### 兼容
|
||||
|
||||
- `web-admin/src/router/modules/pl.ts` 中现有指向 `/vehicle-mgmt/etc-recharge/vehicle-recharge-*` 的 redirect,改为指向 **油卡** 新路径 `/vehicle-mgmt/fuel-recharge/vehicle-recharge-*`(保持书签可用且落到油卡侧)。
|
||||
- 若另有文档/外链写死旧地址,实现计划中列出并改 redirect 或文档。
|
||||
|
||||
### 权限
|
||||
|
||||
- 油卡:保留现有菜单 id 与 `perms`(如 `pl:vehicle-recharge-apply:list`),仅改展示名与分组 path/component 若需要。
|
||||
- ETC:新建菜单 id 与独立 `perms`(如 `pl:etc-vehicle-recharge-apply:list` 等),便于日后分开授权。
|
||||
- 迁移 SQL:对已拥有原充值相关菜单的角色,同步挂上对应 ETC 菜单,保证上线后默认两边都可用;之后可由管理员单独收回。
|
||||
- 审批人设置:ETC / 油卡各一条菜单,component 分别指向复制页与原页,均调用同一工作流 API / `biz_type=vehicle_recharge`。ETC 菜单的 `perms` **复用**现有 `pl:vehicle-recharge-workflow:*`,避免配置权被拆成两套。
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 页面复制
|
||||
|
||||
在 `web-admin/src/views/pl/` 复制:
|
||||
|
||||
| 原路径 | ETC 复制目标(示例名) |
|
||||
| --- | --- |
|
||||
| `vehicle-recharge-apply/` | `etc-vehicle-recharge-apply/` |
|
||||
| `vehicle-recharge-approval/` | `etc-vehicle-recharge-approval/` |
|
||||
| `vehicle-recharge-record/` | `etc-vehicle-recharge-record/` |
|
||||
| `vehicle-recharge-workflow/` | `etc-vehicle-recharge-workflow/`(复制后仅改文案/说明;仍调用同一工作流 API) |
|
||||
|
||||
油卡侧继续使用原目录,做「排除 etc」与文案改名。审批人设置菜单的 `component` 分别指向上述两套页面,避免一边改提示文案影响另一边;业务数据仍是同一 `biz_type`。
|
||||
|
||||
`columnFilterDefs`、本地 `table-key` / 列偏好 localStorage key:ETC 页使用**新 key**,避免与油卡列配置互相污染。
|
||||
|
||||
API 封装:优先继续共用 `web-admin/src/api/pl` 现有 `vehicleRecharge*Api`,在页面请求参数中带类型约束;不为本期再复制一套 API 模块,除非出现仅 ETC 需要的入参。
|
||||
|
||||
### 类型与交互
|
||||
|
||||
**ETC 页**
|
||||
|
||||
- 列表 / 导出 / 待办 / 已办 / 发放看板:固定只查 `rechargeType=etc`(或后端等价过滤)。
|
||||
- 新增/编辑:充值类型固定为 `etc`,不提供油卡/山峡/百链选项;隐藏油卡卡号等仅油卡字段。
|
||||
- 文案、导出文件名、空态、跳转提示改为「ETC…」。
|
||||
- **不展示**顶部 `PlRechargeSummary`。
|
||||
|
||||
**油卡页**
|
||||
|
||||
- 列表 / 导出 / 审批 / 发放:**排除** `rechargeType=etc`;保留 `fuel` / `shanxia` / `bailian` 等现有非 ETC 类型与筛选(筛选中去掉或禁用单独再选 ETC,避免空结果误导)。
|
||||
- 顶部统计:**保留**(仍走现有 summary 组件与接口)。
|
||||
- 其余交互、按钮、审批发放流程与现网一致。
|
||||
|
||||
### 顶部统计路由
|
||||
|
||||
`isRechargeSummaryRoute`(`web-admin/src/utils/pl-recharge-summary.ts`)及 `art-page-content` 中 `showRechargeSummary` / `rechargeApplyPath`:
|
||||
|
||||
- 仅对**油卡**充值相关 path 为 true(例如匹配 `fuel-recharge` 下的 apply/approval/record/workflow)。
|
||||
- ETC 路径一律不展示。
|
||||
- `rechargeApplyPath` 解析菜单 code 时指向油卡申请菜单,保证统计卡片跳转仍进入油卡申请页。
|
||||
|
||||
相关单测(`pl-recharge-summary.test.ts`)同步改断言。
|
||||
|
||||
### 审批人设置(方案 A)
|
||||
|
||||
- 两菜单入口都打开同一业务配置(`biz_type=vehicle_recharge`)。
|
||||
- UI 可增加简短说明:「ETC 与油卡共用同一套审批人配置」,降低「改了一边另一边不变」的误解。
|
||||
- 流程显示名可暂保持「ETC/油卡充值审批流程」,本期不强制改名。
|
||||
|
||||
## 后端设计
|
||||
|
||||
### 查询
|
||||
|
||||
现有列表已支持 `rechargeType` / `rechargeTypeIn`(见 `vehicle_recharge` API 与 `vehicle_recharge_list_filters`)。本期落实:
|
||||
|
||||
- ETC 前端固定传 `rechargeType=etc`。
|
||||
- 油卡「非 etc」过滤:**采用新增可选查询参数 `rechargeTypeNe=etc`**(filter 层 `!=`),避免字典新增类型时维护白名单;保留现有 `rechargeType` / `rechargeTypeIn` 兼容。
|
||||
- 审批 pending/done、发放看板、导出与列表使用同一套类型约束,避免一侧漏过滤。
|
||||
|
||||
### 写入约束
|
||||
|
||||
- 本期**不**引入域请求头,**不**按菜单拆 create/update 接口。
|
||||
- 类型正确性由前端保证:ETC 页只提交 `etc`;油卡页表单选项与默认值不含 `etc`。
|
||||
- 后端 create/update 维持现有字段校验即可;防串改列为后续增强,不阻塞本期验收。
|
||||
|
||||
### 审批与工作流
|
||||
|
||||
- `_BIZ_TYPE_VEHICLE_RECHARGE = "vehicle_recharge"` 不变。
|
||||
- 不新增 workflow 定义,不做在途迁移。
|
||||
- summary 接口可保持现行为(油卡统计);不强制为 ETC 提供第二套 summary。
|
||||
|
||||
## 数据与兼容
|
||||
|
||||
- 表结构不改;历史 `etc` 单据仅在 ETC 菜单可见,非 etc 仅在油卡菜单可见。
|
||||
- 小程序仍使用原混合入口与文案;可能仍能看到/提交两类数据,与 Web 分栏并存,接受为已知差异,后续另项处理。
|
||||
|
||||
## UI 规范
|
||||
|
||||
- 沿用现页模式(复杂业务列表)与 Fresh Mint;不新造布局与主色。
|
||||
- 复制页保持与原页相同的 panel / 工具栏 / 表格 / 弹窗结构;仅改类型范围、文案与统计显隐。
|
||||
- 菜单标题变更后,若申请页有 `panelTitleBarTextRules` 注册需求,按 Web AGENTS 补齐完整文案。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 侧栏可见并列【ETC充值管理】【油卡充值管理】,各含 4 个子菜单(文案正确)。
|
||||
2. ETC 申请/审批/发放仅出现 `rechargeType=etc` 数据;新建只能选/固定 ETC。
|
||||
3. 油卡对应页面不出现 ETC 数据;`fuel` / `shanxia` / `bailian` 仍可见可操作(在权限与数据允许时)。
|
||||
4. ETC 各子路由无顶部充值统计;油卡各子路由仍有统计,且卡片跳转进油卡申请。
|
||||
5. 两边「审批人设置」打开后编辑的是同一套节点;保存一侧,另一侧刷新可见。
|
||||
6. 旧 URL `/vehicle-mgmt/etc-recharge/vehicle-recharge-apply` 等 redirect 到油卡新路径且可用。
|
||||
7. 原已授权充值菜单的角色,上线后默认可进 ETC 新菜单(迁移 SQL 生效后)。
|
||||
8. 小程序行为与菜单未改。
|
||||
|
||||
## 风险
|
||||
|
||||
- 前端双份页面:同类 Bug 可能需改两处;接受为独立演进的代价。
|
||||
- 审批人双入口同配置:需文案提示,否则易误解为已拆分。
|
||||
- 小程序仍混合:与 Web 不一致,需在发布说明中写明。
|
||||
- `rechargeTypeNe` 需在 list/export/approval/board 全路径接通,漏接会导致某一页仍混入 ETC。
|
||||
|
||||
## 状态
|
||||
|
||||
会话中已确认:复制前端、后端不整拆、审批配置保持现状且双菜单进同一配置(A)、仅 Web。实现计划:`docs/superpowers/plans/2026-11-12-pl-etc-fuel-recharge-split.md`。
|
||||
@@ -0,0 +1,124 @@
|
||||
# 充值申请「发放中」状态(审批通过后)
|
||||
|
||||
## 背景与问题
|
||||
|
||||
车辆充值(油卡 / ETC 共用后端)走审批流程时,单据从提交起一直是 `in_approval`(审批中),直到发放登记才变为 `disbursed`(已发放)。
|
||||
|
||||
用户配置多级审批人后,期望:
|
||||
|
||||
- 中间审批级等待处理:仍显示「审批中」
|
||||
- **末级审批人通过、进入发放节点后**:显示「发放中」
|
||||
- 发放登记完成后:显示「已发放」
|
||||
|
||||
系统里已有 `pending` 枚举(原前端文案「待发放」),但有工作流时主路径几乎不会写入该状态;发放撤销在有流程时还会回退为 `in_approval`。申请列表筛选项也未暴露该状态。
|
||||
|
||||
**文案约定(已确认)**:充值申请 `pending` 的对外名称统一为 **「发放中」**(不再用「待发放」)。状态码仍为 `pending`,不改库字段。尿素 / 运费等其它业务的「待发放」文案不在本期范围。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| 切换时机 | 末级 `approve` 节点通过后,若下一节点为 `disburse`(发放登记),将 `status` 设为 `pending` |
|
||||
| 展示文案 | `pending` → **发放中**(Web `getPlRechargeApplyStatusLabel`、导出/后端 `RECHARGE_APPLY_STATUS_LABELS`、筛选项、相关提示语) |
|
||||
| 中间审批 | 仍保持 `in_approval` |
|
||||
| 发放完成 | 仍为 `disbursed` |
|
||||
| 撤销发放 | 有工作流时回退为 `pending`(停在发放节点),不再回退为 `in_approval`;提示改为「回退为发放中」 |
|
||||
| 无工作流路径 | 维持现有 `pending` 语义不变,仅文案改为「发放中」 |
|
||||
| 历史数据 | 本期**不做**批量刷库;已停在发放节点但仍为 `in_approval` 的单据继续可在审批待办发放,列表可能仍显示「审批中」直至被处理 |
|
||||
| 范围 | 油卡 + ETC 共用后端一并生效;Web 列表文案/筛选项同步;小程序不单独改(若共用展示映射则自然带上) |
|
||||
| 不另拆 | 不新建独立「发放中」菜单;发放仍在现有「充值审批」待办中对发放节点操作 |
|
||||
|
||||
## 目标状态机(有工作流)
|
||||
|
||||
```text
|
||||
draft / rejected
|
||||
└─提交─► in_approval # 当前节点为 approve*
|
||||
│
|
||||
├─中间 approve 通过──► in_approval(推进 current_node)
|
||||
│
|
||||
└─末级 approve 通过且下一节点为 disburse
|
||||
─► pending # 发放中;current_node = 发放节点
|
||||
│
|
||||
└─发放登记─► disbursed
|
||||
|
||||
任意审批节点驳回 ─► rejected
|
||||
撤回(规则不变)─► draft
|
||||
撤销发放登记 ─► pending(有 wf)或 pending(无 wf,保持现状)
|
||||
```
|
||||
|
||||
\* 流程末节点必须仍是 `disburse`(现有约束:`approve_apply` 不得收尾)。
|
||||
|
||||
## 后端改动要点
|
||||
|
||||
### 1. 状态常量与文案
|
||||
|
||||
- `APPLY_STATUSES` 正式纳入 `pending`(schema / 单测断言同步)
|
||||
- `RECHARGE_APPLY_STATUS_LABELS["pending"]`:`待发放` → `发放中`
|
||||
- 关联申请错误提示等用户可见「待发放」字样同步改为「发放中」
|
||||
|
||||
### 2. `approve_apply`
|
||||
|
||||
通过当前 `approve` 节点后:
|
||||
|
||||
- `apply.current_node = nodes[idx + 1].id`(不变)
|
||||
- 若下一节点 `action_type == "disburse"`:`apply.status = "pending"`
|
||||
- 否则:保持 `in_approval`
|
||||
|
||||
### 3. 待办与发放登记准入
|
||||
|
||||
当前 `_current_node` / `list_pending` / 菜单徽章只认 `status == "in_approval"`。改为认:
|
||||
|
||||
`status in ("in_approval", "pending")`
|
||||
|
||||
且仍要求 `current_node` 存在、当前用户为该节点审批人。这样「发放中」单据继续出现在审批待办,发放人可完成登记。
|
||||
|
||||
错误文案可区分「不在可处理状态」,但行为上两者同等可办。
|
||||
|
||||
### 4. 撤销 / 换绑发放记录回退
|
||||
|
||||
`delete_records` / 换绑旧申请回退时:有 `wf_id` 时改为 `status = "pending"` + `current_node = 发放节点`(与文案「回退为发放中」一致);无流程仍 `pending`。
|
||||
|
||||
### 5. 发放登记页关联申请
|
||||
|
||||
现有 `_resolve_apply_for_record`:拒绝 `in_approval`、仅允许 `pending`。与新状态机一致——走审批发放应走审批菜单的 `disburse_apply`;记录页手动关联仍只认 `pending` 单。无需为发放节点旁路放开记录页。
|
||||
|
||||
### 6. 看板 / 汇总
|
||||
|
||||
`pl_vehicle_recharge_summary_service` 已将 `pending` 与 `in_approval` 计入 pending 类统计,保持即可;回归确认油卡看板数字仍合理。
|
||||
|
||||
## 前端改动要点
|
||||
|
||||
- `getPlRechargeApplyStatusLabel`:`pending` → `发放中`(仅充值申请映射;尿素等其它模块不动)
|
||||
- 油卡 / ETC 申请列表列头筛选项:增加 `{ value: 'pending', label: '发放中' }`
|
||||
- 发放页撤销确认等「回退为待发放」→「回退为发放中」
|
||||
- 发放页提示「审批中请到审批菜单发放」逻辑可保留(`in_approval`);`pending` 仍可按现有关联逻辑处理
|
||||
- 审批页「待我审批」Tab 不改名(含审批 + 发放节点待办)
|
||||
|
||||
## 测试与验收
|
||||
|
||||
**后端单测(优先)**
|
||||
|
||||
- 两级 approve + 末节点 disburse:一级通过后仍 `in_approval`;二级通过后 `pending` 且 `current_node` 为发放节点
|
||||
- `list_pending` / 徽章计数包含 `pending` 且当前节点审批人匹配的单据
|
||||
- `disburse_apply` 在 `pending` 状态下成功 → `disbursed`
|
||||
- 撤销发放回退为 `pending`(有流程)
|
||||
- 导出/标签映射:`pending` 文案为「发放中」
|
||||
|
||||
**手工验收**
|
||||
|
||||
- 申请列表:二级通过后状态显示「发放中」,可按「发放中」筛选
|
||||
- 发放节点处理人在审批待办仍能看到并完成发放 →「已发放」
|
||||
- 油卡与 ETC 模块各走一单冒烟
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 历史 `in_approval` + 已在发放节点的数据迁移脚本
|
||||
- 拆分发放独立菜单 / 独立待办 API
|
||||
- 修改流程配置 UI(仍要求末节点为发放登记)
|
||||
- 小程序专项改造
|
||||
- 尿素 / 运费 / 备用金等其它业务「待发放」文案改名
|
||||
|
||||
## 风险
|
||||
|
||||
- 若漏改徽章或 `list_pending` 状态条件,会出现「列表已是发放中但待办/角标消失」;实现时与 `approve_apply` 同批改、同批测。
|
||||
- 历史停在发放节点的 `in_approval` 单据列表文案仍为「审批中」,验收时需知悉;若后续需要可另开刷数任务。
|
||||
@@ -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>` 可能仍可用)→ 失败提示引导改用下载;必要时再评估代理拉取。
|
||||
- 少数未走公共组件的页面可能暂无按钮 → 后续按反馈补齐。
|
||||
@@ -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",
|
||||
|
||||
Generated
+1469
-19
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,7 @@
|
||||
# 单包工程也须声明 packages,否则 pnpm 9+ 报 “packages field missing or empty”
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@tailwindcss/oxide': true
|
||||
@@ -5,4 +9,5 @@ allowBuilds:
|
||||
core-js: false
|
||||
es5-ext: false
|
||||
esbuild: true
|
||||
protobufjs: false
|
||||
vue-demi: true
|
||||
@@ -3,6 +3,9 @@ import test from 'node:test'
|
||||
import {
|
||||
buyPartyName,
|
||||
certFaceFields,
|
||||
certKindLabel,
|
||||
certKindTagType,
|
||||
compareCertUploadTime,
|
||||
expiryWarning,
|
||||
salePartyName
|
||||
} from '../src/views/pl/quota-cert/cert-list-face'
|
||||
@@ -49,3 +52,19 @@ test('only certificates expiring within seven days show a red warning label', ()
|
||||
assert.equal(expiryWarning('2026-11-12', today), null)
|
||||
assert.equal(expiryWarning('', today), null)
|
||||
})
|
||||
|
||||
test('merged certificate lists follow upload time, then id, descending', () => {
|
||||
assert.equal(certKindLabel('transport'), '运输证')
|
||||
assert.equal(certKindLabel('purchase'), '购买证')
|
||||
assert.equal(certKindTagType('transport'), 'warning')
|
||||
assert.equal(certKindTagType('purchase'), 'success')
|
||||
const rows = [
|
||||
{ id: 'b', createTime: '2026-11-01 10:00:00' },
|
||||
{ id: 'a', createTime: '2026-11-05 10:00:00' },
|
||||
{ id: 'c', createTime: '2026-11-05 10:00:00' }
|
||||
].sort(compareCertUploadTime)
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.id),
|
||||
['c', 'a', 'b']
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* #26 回归:
|
||||
* 1) ArtSvgIcon 的 el-icon 不得带 Tailwind `inline`(生产 CSS 会覆盖 display)
|
||||
* 2) 运输归档列展示含 planNo,且表格列受 isColumnVisible 控制
|
||||
*/
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse } from '@vue/compiler-sfc'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
test('ArtSvgIcon: el-icon branch must not use Tailwind inline class', () => {
|
||||
const src = readFileSync(join(root, 'src/components/core/base/art-svg-icon/index.vue'), 'utf8')
|
||||
const { descriptor } = parse(src)
|
||||
const template = descriptor.template?.content || ''
|
||||
assert.match(template, /<el-icon\b[^>]*class="[^"]*art-svg-icon/)
|
||||
assert.doesNotMatch(
|
||||
template,
|
||||
/<el-icon\b[^>]*class="[^"]*\binline\b/,
|
||||
'el-icon must not include Tailwind inline (breaks display:inline-flex in prod CSS order)'
|
||||
)
|
||||
})
|
||||
|
||||
test('plan-archive: planNo is in column defs and table visibility gate', () => {
|
||||
const src = readFileSync(join(root, 'src/views/pl/plan-archive/index.vue'), 'utf8')
|
||||
assert.match(src, /prop:\s*'planNo',\s*label:\s*'计划编号'/)
|
||||
assert.match(src, /pl-plan-archive-list-columns-v17/)
|
||||
assert.match(
|
||||
src,
|
||||
/v-if="isColumnVisible\('planNo'\)"[\s\S]*?prop="planNo"/,
|
||||
'planNo table column must be gated by isColumnVisible'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
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/ContractTab.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, /客户\+证用品类/)
|
||||
assert.match(contractTab, /仅供历史查看/)
|
||||
})
|
||||
|
||||
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,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/)
|
||||
})
|
||||
@@ -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
|
||||
),
|
||||
'证件余量不足'
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ export const saveDoclibShares = (id: string, userIds: string[]) =>
|
||||
url: `${base}/files/${encodeURIComponent(id)}/shares`,
|
||||
data: { userIds }
|
||||
})
|
||||
export const saveDoclibBatchShares = (ids: string[], userIds: string[]) =>
|
||||
request.post<DoclibDocument[]>({
|
||||
url: `${base}/files/batch-shares`,
|
||||
data: { ids, userIds }
|
||||
})
|
||||
export const deleteDoclibFiles = (ids: string[]) =>
|
||||
request.post({ url: `${base}/files/batch-delete`, data: { ids } })
|
||||
export const downloadDoclibFile = (id: string) =>
|
||||
|
||||
@@ -480,6 +480,8 @@ export interface PlanLoad {
|
||||
notes?: string
|
||||
generateDibang?: number
|
||||
dibangDirection?: string
|
||||
/** 1 时本段易制毒只校验购买证 */
|
||||
sameCity?: number
|
||||
remark?: string
|
||||
products?: PlanStopProduct[]
|
||||
contracts?: PlanContractLink[]
|
||||
@@ -502,6 +504,8 @@ export interface PlanDelivery {
|
||||
notes?: string
|
||||
generateDibang?: number
|
||||
dibangDirection?: string
|
||||
/** 1 时本段易制毒只校验购买证 */
|
||||
sameCity?: number
|
||||
remark?: string
|
||||
deliveryMethod?: string
|
||||
products?: PlanStopProduct[]
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface CertBody {
|
||||
routeText?: string | null
|
||||
consigneeName?: string | null
|
||||
applicantName?: string | null
|
||||
customerId?: string | null
|
||||
categoryId?: string | null
|
||||
contractBindings?: CertificateContractBinding[]
|
||||
purchaseRefs?: PurchaseReference[]
|
||||
}
|
||||
@@ -61,6 +63,8 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
|
||||
usagePolicy: 'once' | 'multiple' | 'needs_confirmation'
|
||||
createTime?: string
|
||||
certType?: CertType
|
||||
derivedCategoryIds?: string[]
|
||||
customerName?: string | null
|
||||
}
|
||||
export interface ScopeProduct {
|
||||
id?: string
|
||||
@@ -285,6 +289,8 @@ export interface QuotaSourceOption {
|
||||
contractId: string
|
||||
contractProductId: string
|
||||
partyName?: string
|
||||
/** 本段勾选同市时只要求购买证 */
|
||||
sameCity?: boolean
|
||||
}
|
||||
export interface QuotaSourcePair {
|
||||
sourceStopType: 'load' | 'delivery'
|
||||
@@ -325,6 +331,7 @@ export interface PlanQuota {
|
||||
canSaveConfiguration?: boolean
|
||||
arrangeBlockMessage?: string
|
||||
quotaCertWaived?: boolean
|
||||
certQuotaRuleVersion?: 'legacy' | 'customer_category' | string
|
||||
configuration: Partial<QuotaConfiguration>
|
||||
sourceOptions: QuotaSourceOption[]
|
||||
usages: Record<string, any>[]
|
||||
@@ -391,7 +398,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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -452,9 +452,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,8 +1602,13 @@
|
||||
|
||||
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
|
||||
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
|
||||
if (title !== '用证没有完成') {
|
||||
await ElMessageBox.alert(html, title, {
|
||||
const pendingFulfill =
|
||||
message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善')
|
||||
if (title !== '用证没有完成' || pendingFulfill) {
|
||||
const guide = pendingFulfill
|
||||
? `${html}<p class="pl-arrange-block-text">新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 已标记待完善证书」上传证件并执行完善并关闭后再归档。</p>`
|
||||
: html
|
||||
await ElMessageBox.alert(guide, title, {
|
||||
type: 'warning',
|
||||
confirmButtonText: '知道了',
|
||||
dangerouslyUseHTMLString: true,
|
||||
@@ -1606,7 +1617,8 @@
|
||||
}).catch(() => undefined)
|
||||
return undefined
|
||||
}
|
||||
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。标记后这次归档不再校验,也不扣减这两证的额度。</p>`
|
||||
// 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度
|
||||
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「证书齐备 → 已标记待完善证书」。</p>`
|
||||
try {
|
||||
await ElMessageBox.confirm(readyHtml, title, {
|
||||
type: 'warning',
|
||||
|
||||
@@ -162,6 +162,15 @@
|
||||
@update:model-value="patchDelivery(index, { notes: $event })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否同市" class="form-col-span-2">
|
||||
<el-checkbox
|
||||
:model-value="delivery.sameCity === 1"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(checked) => patchDelivery(index, { sameCity: checked ? 1 : 0 })"
|
||||
>
|
||||
同市只需购买证
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="地磅单" class="form-col-span-2">
|
||||
<div class="pl-plan-dibang-row">
|
||||
<el-checkbox
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -714,6 +714,7 @@
|
||||
mergePlanProductsFromLoadMirror,
|
||||
shouldRetainPlanProductsWhileLoadEmpty,
|
||||
resolveStopDibangDefaults,
|
||||
toxicStopCertBindingMessage,
|
||||
sumStopProductsPlanQty,
|
||||
type PlanDeliveryForm,
|
||||
type PlanLoadForm,
|
||||
@@ -2619,19 +2620,7 @@
|
||||
}
|
||||
|
||||
function toxicContractCertBindingMessage(rows: QuotaSourceOption[]) {
|
||||
const missing = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
if (isPlInternalCustomerName(row.partyName)) continue
|
||||
const certificates = row.certificates || []
|
||||
const ready =
|
||||
certificates.some((cert) => cert.certType === 'purchase') &&
|
||||
certificates.some((cert) => cert.certType === 'transport')
|
||||
if (ready) continue
|
||||
const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId
|
||||
missing.set(key, row.contractNo || '未编号合同')
|
||||
}
|
||||
if (!missing.size) return ''
|
||||
return `合同${[...missing.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输`
|
||||
return toxicStopCertBindingMessage(rows)
|
||||
}
|
||||
|
||||
async function performArrangeAfterSave(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -110,6 +110,15 @@
|
||||
@update:model-value="patch({ notes: $event })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否同市" class="form-col-span-2">
|
||||
<el-checkbox
|
||||
:model-value="modelValue.sameCity === 1"
|
||||
:disabled="readonly"
|
||||
@update:model-value="patch({ sameCity: $event ? 1 : 0 })"
|
||||
>
|
||||
同市只需购买证
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="地磅单" class="form-col-span-2">
|
||||
<div class="pl-plan-dibang-row">
|
||||
<el-checkbox
|
||||
|
||||
@@ -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">
|
||||
@@ -78,7 +85,15 @@
|
||||
<section v-if="faceTabs.length" class="quota-face">
|
||||
<h3 class="dialog-section-title">关联证件证面</h3>
|
||||
<el-tabs v-model="faceTab" class="quota-face-tabs">
|
||||
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :label="tab.label" :name="tab.key">
|
||||
<el-tab-pane v-for="tab in faceTabs" :key="tab.key" :name="tab.key">
|
||||
<template #label>
|
||||
<span class="quota-kind-line">
|
||||
<el-tag size="small" :type="certKindTagType(tab.kind)">{{
|
||||
certKindLabel(tab.kind)
|
||||
}}</el-tag>
|
||||
<span>{{ tab.certNo }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<p v-if="!tab.cert" class="quota-note">没有读到这张证的证面档案。</p>
|
||||
<template v-else>
|
||||
<section
|
||||
@@ -90,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"
|
||||
@@ -110,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>
|
||||
@@ -209,12 +224,9 @@
|
||||
<div class="conversion-grid">
|
||||
<section v-for="row in group.rows" :key="conversionKey(row)" class="conversion-card">
|
||||
<div class="conversion-card-head">
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="row.cert.certType === 'purchase' ? 'success' : 'primary'"
|
||||
>{{ row.cert.certType === 'purchase' ? '购买证' : '运输证' }}</el-tag
|
||||
>
|
||||
<el-tag size="small" :type="certKindTagType(row.cert.certType)">{{
|
||||
certKindLabel(row.cert.certType)
|
||||
}}</el-tag>
|
||||
<strong>{{ row.cert.certNo }}</strong>
|
||||
</div>
|
||||
<label>换算系数</label>
|
||||
@@ -286,7 +298,12 @@
|
||||
>
|
||||
</div>
|
||||
<div v-for="item in selected" :key="item.kind + item.cert.id" class="view-section">
|
||||
<h4>{{ item.kind === 'purchase' ? '购买证' : '运输证' }} · {{ item.cert.certNo }}</h4>
|
||||
<h4 class="quota-kind-line">
|
||||
<el-tag size="small" :type="certKindTagType(item.kind)">{{
|
||||
certKindLabel(item.kind)
|
||||
}}</el-tag>
|
||||
<span>{{ item.cert.certNo }}</span>
|
||||
</h4>
|
||||
<p
|
||||
>{{ item.cert.buyerName || item.cert.shipperName }} →
|
||||
{{ item.cert.sellerName || item.cert.consigneeName }};有效期
|
||||
@@ -393,10 +410,12 @@
|
||||
:key="row.certType + row.unit"
|
||||
class="view-section"
|
||||
>
|
||||
<strong
|
||||
>{{ row.certType === 'purchase' ? '购买证' : '运输证' }} · 需求
|
||||
{{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong
|
||||
>
|
||||
<div class="quota-kind-line">
|
||||
<el-tag size="small" :type="certKindTagType(row.certType)">{{
|
||||
certKindLabel(row.certType)
|
||||
}}</el-tag>
|
||||
<strong>需求 {{ qtyText(row.demand) }} {{ unitText(row.unit) }}</strong>
|
||||
</div>
|
||||
<p v-if="row.availableUpperBound !== null">
|
||||
适用余额至多 {{ qtyText(row.availableUpperBound) }}
|
||||
{{ unitText(row.unit) }};缺口至少 {{ qtyText(row.gapLowerBound) }}
|
||||
@@ -408,7 +427,13 @@
|
||||
}}</p>
|
||||
</div>
|
||||
<el-table :data="allocations" stripe
|
||||
><el-table-column prop="kind" label="证种" min-width="90" /><el-table-column
|
||||
><el-table-column label="证种" min-width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag size="small" :type="row.kind === '运输证' ? 'warning' : 'success'">{{
|
||||
row.kind
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
><el-table-column
|
||||
prop="certNo"
|
||||
label="证号"
|
||||
min-width="160" /><el-table-column
|
||||
@@ -493,6 +518,7 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { getProductUnitLabel } from '@/utils/crm-product-display'
|
||||
import { certKindLabel, certKindTagType } from '@/views/pl/quota-cert/cert-list-face'
|
||||
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
@@ -631,7 +657,7 @@
|
||||
kind,
|
||||
cert,
|
||||
certNo: number,
|
||||
label: `${kind === 'purchase' ? '购买证' : '运输证'} ${number}`
|
||||
label: `${certKindLabel(kind)} ${number}`
|
||||
})
|
||||
}
|
||||
for (const row of plan.value?.stopProducts || []) {
|
||||
@@ -731,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)
|
||||
@@ -1059,6 +1105,14 @@
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.quota-kind-line {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quota-dialog :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user