补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。 Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
16 KiB
Python
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]
|