Files
shanxia2.0/backend/app/services/pl_stop_legal_document.py
T
wuzilongandCursor 8e9536332a feat: 保存运输证件、报销付款和开发流程的当前实现。
补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:42:33 +08:00

307 lines
16 KiB
Python

"""逐装送段产品维护证明;权限筛选先于汇总、分页,不参与额度或归档。"""
from datetime import date, datetime
from uuid import uuid4
from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy import and_, or_, func, case, literal
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope
from app.models.crm_models import CrmContract
from app.models.pl_models import PlPlan, PlPlanProduct, PlPlanLoad, PlPlanLoadProduct, PlPlanDelivery, PlPlanDeliveryProduct
from app.models.pl_quota_models import PlStopLegalDocument as Document
from app.schemas.pl.legal_use_document import StopLegalDocumentsBody
from app.services.pl_data_scope import apply_pl_scope
from app.services.pl_legal_use_document import _plan
from app.services.pl_quota_contract_service import _contract
from app.services.pl_plan_cert_perm import user_can_upload_plan_cert
from app.services.pl_quota_attachments import authorize_attachment, storage_base_url
SIDES = (('load', PlPlanLoadProduct, PlPlanLoad, 'load_id'),
('delivery', PlPlanDeliveryProduct, PlPlanDelivery, 'delivery_id'))
FOLLOW_STATUSES = {'drafting', 'wait_notice', 'pending_arrange', 'arranged', 'completed', 'cancelled'}
def _text(value):
return value.strip() if isinstance(value, str) else ''
def _product_names(detail):
names = []
for item in list(detail.get('items') or []) + list(detail.get('legacyProducts') or []):
name = (item.get('productName') or '').strip()
if name and name not in names:
names.append(name)
return '、'.join(names)
def _query(db, user, side, model, parent, field, contract_id=None):
contracts = db.query(CrmContract.id).filter_by(tenant_no=user.tenant_no, del_flag=0)
contracts = apply_crm_owner_scope(contracts, CrmContract, user.to_user_info(), db, user.tenant_no)
query = db.query(model, parent, PlPlanProduct, CrmContract, Document).join(
parent, and_(getattr(model, field) == parent.id, model.plan_id == parent.plan_id,
parent.tenant_no == user.tenant_no, parent.del_flag == 0)).join(
PlPlanProduct, and_(model.plan_product_id == PlPlanProduct.id, model.plan_id == PlPlanProduct.plan_id,
model.product_id == PlPlanProduct.product_id,
PlPlanProduct.tenant_no == user.tenant_no, PlPlanProduct.del_flag == 0,
PlPlanProduct.is_explosive == 1)).outerjoin(
CrmContract, and_(model.contract_id == CrmContract.id, CrmContract.tenant_no == user.tenant_no,
CrmContract.del_flag == 0)).outerjoin(
Document, and_(Document.plan_id == model.plan_id, Document.stop_type == side,
Document.stop_product_id == model.id, Document.tenant_no == user.tenant_no,
Document.del_flag == 0)).filter(
model.tenant_no == user.tenant_no, model.del_flag == 0,
or_(model.contract_id.is_(None), model.contract_id == '', model.contract_id.in_(contracts)))
if contract_id:
query = query.filter(model.contract_id == contract_id)
return query
def _unlinked_legacy(db, user):
"""无有效段的旧附件只在全局计划范围展示,不猜测合同归属。"""
query = db.query(PlPlanProduct).filter_by(tenant_no=user.tenant_no, del_flag=0, is_explosive=1).filter(
PlPlanProduct.legal_use_cert_url.isnot(None), PlPlanProduct.legal_use_cert_url != '')
for _, model, parent, field in SIDES:
linked = db.query(model.id).join(parent, getattr(model, field) == parent.id).filter(
model.plan_product_id == PlPlanProduct.id, model.plan_id == PlPlanProduct.plan_id,
model.tenant_no == user.tenant_no, model.del_flag == 0,
parent.tenant_no == user.tenant_no, parent.del_flag == 0, parent.plan_id == model.plan_id)
query = query.filter(~linked.exists())
return query
def _legacy_row(product):
return {'id': product.id, 'productId': product.product_id, 'productName': product.product_name,
'model': product.model, 'legalUseCertUrl': product.legal_use_cert_url}
def _certificate_urls(products):
urls = []
for product in products:
for value in (product.legal_use_cert_url or '').split(','):
value = value.strip()
if value and value not in urls:
urls.append(value)
return ','.join(urls)
def _plan_certificate(value, products):
"""一份运输计划共用一套合法使用证明,不按装送段分别计数。"""
rows = list(products.values())
value['planProductIds'] = sorted(product.id for product in rows)
value['legalUseCertUrl'] = _certificate_urls(rows)
filled = sum(1 for product in rows if (product.legal_use_cert_url or '').strip())
value['requiredCount'] = 1 if rows else 0
value['uploadedCount'] = 1 if rows and filled == len(rows) else 0
value['documentStatus'] = ('not_required' if not rows else
'uploaded' if value['uploadedCount'] else 'pending')
def _details(db, user, plans, contract_id=None, lock=False):
result = {p.id: {'id': p.id, 'planNo': p.plan_no, 'planDate': p.plan_date,
'licensePlate': p.license_plate, 'supplierName': p.supplier_name or '',
'customerName': p.customer_name or '', 'followStatus': p.follow_status,
'transportStatus': p.transport_status, 'archiveStatus': p.archive_status,
'items': [], 'legacyProducts': []} for p in plans}
legacy = {p.id: {} for p in plans}
scoped = {p.id: {} for p in plans}
for side, model, parent, field in SIDES:
query = _query(db, user, side, model, parent, field, contract_id).filter(model.plan_id.in_(result))
if lock:
query = query.with_for_update().populate_existing()
stop_order = (func.coalesce(func.nullif(parent.seq, 0), parent.order_num)
if side == 'delivery' else parent.order_num)
for row, stop, product, contract, document in query.order_by(stop_order, parent.id, model.order_num, model.id).all():
url = document.legal_use_cert_url if document else None
result[row.plan_id]['items'].append({
'stopType': side, 'stopId': stop.id, 'stopOrder': getattr(stop, 'seq', None) or stop.order_num or 1,
'stopProductId': row.id, 'productId': row.product_id, 'productName': row.product_name,
'model': row.model, 'unit': row.unit, 'contractId': row.contract_id,
'contractNo': contract.contract_no if contract else None, 'contractProductId': row.contract_product_id,
'partyName': stop.customer_name, 'address': stop.address, 'legalUseCertUrl': url,
'documentStatus': 'uploaded' if url else 'pending',
'uploadedBy': document.uploaded_by if document else None,
'uploadedTime': document.uploaded_time if document else None})
scoped[row.plan_id][product.id] = product
if product.legal_use_cert_url:
legacy[row.plan_id][product.id] = _legacy_row(product)
if not contract_id:
for product in _unlinked_legacy(db, user).filter(PlPlanProduct.plan_id.in_(result)).all():
scoped[product.plan_id][product.id] = product
legacy[product.plan_id][product.id] = _legacy_row(product)
for pid, value in result.items():
value['legacyProducts'] = list(legacy[pid].values())
_plan_certificate(value, scoped[pid])
value['productNames'] = _product_names(value)
return result
def get_documents(db, user, plan_id, contract_id=None):
plan = _plan(db, user, plan_id)
if contract_id:
_contract(db, user, contract_id)
return _details(db, user, [plan], contract_id)[plan_id]
def _plan_dates(start, end):
def parse(value):
if not isinstance(value, (str, date)):
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
raise HTTPException(400, '计划日期格式无效') from None
left, right = parse(start), parse(end)
if left and right and left > right:
raise HTTPException(400, '计划日期起始不能晚于结束')
return left, right
def _progress_filter(query, counts, progress):
text = ' '.join(str(progress or '').split())
if not text:
return query
uploaded = and_(counts.c.required > 0, counts.c.uploaded == counts.c.required)
pending = and_(counts.c.required > 0, or_(counts.c.uploaded.is_(None), counts.c.uploaded < counts.c.required))
empty = or_(counts.c.required.is_(None), counts.c.required == 0)
matched = [clause for label, clause in (('0 / 0', empty), ('0 / 1', pending), ('1 / 1', uploaded)) if text in label]
if not matched:
return query.filter(literal(False))
return query.filter(or_(*matched))
def _explosive_name_match(db, user, text, contract_id=None):
clauses = []
for side, model, parent, field in SIDES:
clauses.append(_query(db, user, side, model, parent, field, contract_id).filter(
model.plan_id == PlPlan.id,
PlPlanProduct.product_name.contains(text, autoescape=True)).exists())
if not contract_id:
clauses.append(_unlinked_legacy(db, user).filter(
PlPlanProduct.plan_id == PlPlan.id,
PlPlanProduct.product_name.contains(text, autoescape=True)).exists())
return or_(*clauses)
def list_documents(db, user, keyword=None, status=None, contract_id=None, page=1, page_size=20, plan_no=None,
plan_date_from=None, plan_date_to=None, license_plate=None, progress=None,
supplier_name=None, customer_name=None, product_name=None, follow_status_in=None):
if status not in (None, 'pending', 'uploaded') or page < 1 or not 1 <= page_size <= 100:
raise HTTPException(400, '无效资料状态或分页参数')
if contract_id:
_contract(db, user, contract_id)
queries = [_query(db, user, side, model, parent, field, contract_id).with_entities(
model.plan_id.label('plan_id'), PlPlanProduct.id.label('product_id'),
case((and_(PlPlanProduct.legal_use_cert_url.isnot(None), PlPlanProduct.legal_use_cert_url != ''), 1),
else_=0).label('uploaded'))
for side, model, parent, field in SIDES]
sources = queries[0].union_all(queries[1]).subquery()
per_product = db.query(sources.c.plan_id, sources.c.product_id,
func.max(sources.c.uploaded).label('uploaded')).group_by(
sources.c.plan_id, sources.c.product_id).subquery()
counts = db.query(per_product.c.plan_id, func.count().label('required'),
func.sum(per_product.c.uploaded).label('uploaded')).group_by(
per_product.c.plan_id).subquery()
query = db.query(PlPlan).outerjoin(counts, counts.c.plan_id == PlPlan.id).filter(
PlPlan.tenant_no == user.tenant_no, PlPlan.del_flag == 0)
if contract_id:
query = query.filter(counts.c.required > 0)
else:
query = query.filter(or_(counts.c.required > 0, PlPlan.id.in_(
_unlinked_legacy(db, user).with_entities(PlPlanProduct.plan_id))))
query = apply_pl_scope(query, PlPlan, user, db, user.tenant_no)
if keyword:
query = query.filter(PlPlan.plan_no.contains(keyword))
if isinstance(plan_no, str) and plan_no:
query = query.filter(PlPlan.plan_no.contains(plan_no))
start, end = _plan_dates(plan_date_from, plan_date_to)
if start:
query = query.filter(PlPlan.plan_date >= start)
if end:
query = query.filter(PlPlan.plan_date <= end)
plate = license_plate.strip() if isinstance(license_plate, str) else ''
if plate:
query = query.filter(PlPlan.license_plate.contains(plate, autoescape=True))
query = _progress_filter(query, counts, progress if isinstance(progress, str) else None)
for column, value in ((PlPlan.supplier_name, supplier_name), (PlPlan.customer_name, customer_name)):
text = _text(value)
if text:
query = query.filter(column.contains(text, autoescape=True))
product = _text(product_name)
if product:
query = query.filter(_explosive_name_match(db, user, product, contract_id))
follow_states = [value.strip() for value in _text(follow_status_in).split(',') if value.strip()]
if any(value not in FOLLOW_STATUSES for value in follow_states):
raise HTTPException(400, '未知跟进状态')
if follow_states:
query = query.filter(PlPlan.follow_status.in_(follow_states))
covered = and_(counts.c.required > 0, counts.c.uploaded == counts.c.required)
if status == 'pending':
query = query.filter(counts.c.required > 0, counts.c.uploaded < counts.c.required)
elif status == 'uploaded' and contract_id:
query = query.filter(covered)
elif status == 'uploaded':
query = query.filter(or_(covered, and_(
or_(counts.c.required.is_(None), counts.c.required == 0),
PlPlan.id.in_(_unlinked_legacy(db, user).with_entities(PlPlanProduct.plan_id)))))
total = query.count()
plans = query.order_by(PlPlan.create_time.desc(), PlPlan.id).offset((page - 1) * page_size).limit(page_size).all()
details = _details(db, user, plans, contract_id) if plans else {}
return {'list': [details[p.id] for p in plans], 'total': total, 'page': page, 'pageSize': page_size}
def save_documents(db, user, plan_id, data, contract_id=None):
if not user_can_upload_plan_cert(db, user):
raise HTTPException(403, '无权维护合法使用证明')
body = StopLegalDocumentsBody(**(data.model_dump(exclude_unset=True) if isinstance(data, BaseModel) else data))
plan = _plan(db, user, plan_id, lock=True)
if contract_id:
_contract(db, user, contract_id, lock=True)
if 'items' in body.model_fields_set and body.items is None:
raise HTTPException(400, '段产品集合不可为null')
details = _details(db, user, [plan], contract_id, lock=True)[plan_id]
valid = {(r['stopType'], r['stopProductId']) for r in details['items']}
# 包含软删除行以复用稳定唯一键;清空不会删除记录或创建信息。
records = {(r.stop_type, r.stop_product_id): r for r in db.query(Document).filter_by(
tenant_no=user.tenant_no, plan_id=plan_id).with_for_update().populate_existing().all()}
seen, changes = set(), []
for item in body.items or []:
key = (item.stop_type, item.stop_product_id)
if key in seen or key not in valid:
raise HTTPException(400, '段产品重复、不适用或不在可访问计划合同范围内')
seen.add(key)
if 'legal_use_cert_url' not in item.model_fields_set:
continue
row = records.get(key)
old = row.legal_use_cert_url if row and row.del_flag == 0 else ''
urls = list(dict.fromkeys(v.strip() for v in (item.legal_use_cert_url or '').split(',') if v.strip()))
for url in urls:
if url not in (old or '').split(','):
authorize_attachment(db, user, url, storage_base_url())
value = ','.join(urls)
if value != (old or ''):
changes.append((key, row, value))
for key, row, value in changes:
if row is None:
row = Document(id=str(uuid4()), tenant_no=user.tenant_no, plan_id=plan_id,
stop_type=key[0], stop_product_id=key[1], create_by=user.id,
create_dept_id=user.dept_id or '')
db.add(row)
row.del_flag = 0
row.legal_use_cert_url = value
row.update_by = user.id
row.uploaded_by = user.id if value else None
row.uploaded_time = datetime.now() if value else None
if changes:
from app.services.pl_plan_service import record_plan_change
record_plan_change(db, tenant_no=user.tenant_no, plan_id=plan_id, change_type='legal_use_document',
change_content='维护逐段合法使用证明:' + '、'.join(k[0] + ':' + k[1] for k, _, _ in changes),
user_id=user.id, user_name=getattr(user, 'name', None), dept_id=user.dept_id or '')
db.flush()
# 响应同样读取锁后的最新段证明,避免退回等待计划锁之前的事务快照。
return _details(db, user, [plan], contract_id, lock=True)[plan_id]