Files
shanxia2.0/backend/app/services/pl_dispatch_calendar_service.py
wuzilong fcbf8a9b65 feat: 为现场日历增加单据类型并同步派车记录,减少重复录入。
按起止地关键词生成现场出入库计划,同步来源修改和删除,保留历史记录不补录。调整卡片及复制文本的单据类型格式,并记录测试库和生产库迁移结果。
2026-10-30 14:04:24 +08:00

260 lines
11 KiB
Python

"""手工派车 CRUD:租户、角色数据范围和关联主数据校验。"""
import re
from datetime import date
from functools import wraps
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
from app.core.db_auth import DbUserContext
from app.core.utils import generate_uuid
from app.models.crm_models import CrmProduct
from app.models.pl_dispatch_calendar import PlDispatchCalendar
from app.models.pl_models import PlVehicle
from app.schemas.pl.dispatch_calendar import CreateBody, UpdateBody
from app.services.pl_data_scope import apply_pl_scope
CALENDAR_KINDS = ("acid", "alkali", "other", "site")
def _calendar_kind(kind: Optional[str]) -> str:
value = kind or "acid"
if value not in CALENDAR_KINDS:
raise HTTPException(status_code=422, detail="日历类型须为 acid、alkali、other 或 site")
return value
def _row_to_dict(row: PlDispatchCalendar) -> dict:
return {
"id": row.id,
"dispatchDate": row.dispatch_date,
"vehicleId": row.vehicle_id,
"licensePlate": row.license_plate,
"productId": row.product_id,
"productName": row.product_name,
"origin": row.origin,
"destination": row.destination,
"quantity": row.quantity,
"calendarKind": row.calendar_kind or "acid",
"documentType": row.document_type,
"sourceRecordId": row.source_record_id,
"remark": row.remark,
"createTime": row.create_time,
"updateTime": row.update_time,
}
def _query(db: Session, tenant_no: str, user: DbUserContext, model=PlDispatchCalendar):
query = db.query(model).filter(model.tenant_no == tenant_no, model.del_flag == 0)
return apply_pl_scope(query, model, user, db, tenant_no)
def _month_bounds(month: str):
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", month or ""):
raise HTTPException(status_code=422, detail="月份须为 YYYY-MM")
year, number = map(int, month.split("-"))
if not 1900 <= year <= 2100 or not 1 <= number <= 12:
raise HTTPException(status_code=422, detail="月份须在 1900-01 至 2100-12 范围内")
return date(year, number, 1), date(year + (number == 12), number % 12 + 1, 1)
def list_records(db: Session, tenant_no: str, user: DbUserContext, *, month: str,
vehicle_id: Optional[str] = None, calendar_kind: Optional[str] = None,
page: int = 1, page_size: int = 500) -> dict:
start, end = _month_bounds(month)
query = _query(db, tenant_no, user).filter(
PlDispatchCalendar.dispatch_date >= start, PlDispatchCalendar.dispatch_date < end,
PlDispatchCalendar.calendar_kind == _calendar_kind(calendar_kind),
)
if vehicle_id:
query = query.filter(PlDispatchCalendar.vehicle_id == vehicle_id)
total = query.count()
rows = query.order_by(PlDispatchCalendar.create_time.desc(), PlDispatchCalendar.id.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {"list": [_row_to_dict(row) for row in rows], "total": total}
def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str,
*, for_update: bool = False) -> PlDispatchCalendar:
query = _query(db, tenant_no, user).filter(PlDispatchCalendar.id == record_id)
if for_update:
# 来源行锁串行化同一来源的更新/删除;刷新会话内缓存,避免并发覆盖关联状态。
query = query.populate_existing().with_for_update()
row = query.first()
if row is None:
raise HTTPException(status_code=404, detail="派车记录不存在或无权访问")
return row
def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> dict:
vehicle = _query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id == body.vehicle_id).first()
if vehicle is None:
raise HTTPException(status_code=422, detail="车辆不存在、已删除或无权访问,请重新选择")
product = (
_query(db, tenant_no, user, CrmProduct)
.filter(CrmProduct.id == body.product_id, CrmProduct.status == "on")
.first()
)
if product is None:
raise HTTPException(status_code=422, detail="产品不存在、已下架、已删除或无权访问,请重新选择")
return {"license_plate": vehicle.license_plate, "product_name": product.product_name}
def _site_document_type(origin: str, destination: str) -> Optional[str]:
outbound = any(word in (origin or "") for word in ("山峡", "云栋"))
inbound = any(word in (destination or "") for word in ("山峡", "云栋"))
if outbound and inbound:
return "出入库计划"
if outbound:
return "出库计划"
if inbound:
return "入库计划"
return None
def _sync_site_record(db: Session, tenant_no: str, user: DbUserContext,
source: PlDispatchCalendar) -> None:
"""在来源事务和行锁内维护唯一现场记录;不为历史来源补录。"""
if not source.site_sync_enabled or source.calendar_kind not in ("acid", "alkali", "other"):
return
# 包含已软删除关联行,以便重新命中时恢复原 ID 和创建信息。
query = db.query(PlDispatchCalendar).filter(
PlDispatchCalendar.tenant_no == tenant_no,
PlDispatchCalendar.source_record_id == source.id,
PlDispatchCalendar.calendar_kind == "site",
)
target = apply_pl_scope(query, PlDispatchCalendar, user, db, tenant_no).populate_existing().with_for_update().first()
document_type = None if source.del_flag == 1 else _site_document_type(source.origin, source.destination)
if document_type is None:
if target is not None and target.del_flag != 1:
target.del_flag = 1
target.update_by = user.id
return
if target is None:
target = PlDispatchCalendar(
id=generate_uuid(), tenant_no=tenant_no, source_record_id=source.id,
calendar_kind="site", site_sync_enabled=0, create_by=source.create_by,
create_dept_id=source.create_dept_id,
)
db.add(target)
for field in ("dispatch_date", "vehicle_id", "license_plate", "product_id", "product_name",
"origin", "destination", "quantity", "remark"):
setattr(target, field, getattr(source, field))
target.document_type = document_type
target.del_flag = 0
target.update_by = user.id
def _ensure_manual_record(row: PlDispatchCalendar) -> None:
if row.source_record_id:
raise HTTPException(status_code=409, detail="此现场记录由派车日历自动同步,请在来源派车日历修改或删除")
def _retry_deadlock(action):
"""MySQL 间隙锁竞争可能选择当前事务为死锁牺牲者,回滚后完整重试。"""
@wraps(action)
def run(db: Session, *args, **kwargs):
for attempt in range(3):
try:
return action(db, *args, **kwargs)
except OperationalError as exc:
db.rollback()
code = exc.orig.args[0] if getattr(exc.orig, "args", ()) else None
if code != 1213 or attempt == 2:
raise
return run
@_retry_deadlock
def create_record(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> PlDispatchCalendar:
snapshots = _resolve_snapshots(db, tenant_no, user, body)
row = PlDispatchCalendar(
id=generate_uuid(), tenant_no=tenant_no, create_by=user.id,
create_dept_id=user.dept_id or "", update_by=user.id,
site_sync_enabled=1 if body.calendar_kind in ("acid", "alkali", "other") else 0,
**body.model_dump(), **snapshots,
)
try:
db.add(row)
db.flush()
_sync_site_record(db, tenant_no, user, row)
db.commit()
except Exception:
db.rollback()
raise
# ORM 默认值已在 flush/commit 时赋值;提交后不再查询,避免重试已提交的写入。
return row
@_retry_deadlock
def update_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str,
body: UpdateBody) -> PlDispatchCalendar:
try:
row = get_record(db, tenant_no, user, record_id, for_update=True)
_ensure_manual_record(row)
if body.calendar_kind != (row.calendar_kind or "acid"):
raise HTTPException(status_code=422, detail="不能把派车计划改到另一本日历")
values = {**body.model_dump(), **_resolve_snapshots(db, tenant_no, user, body)}
# 兼容未提交新字段的旧客户端;显式空值仍可清空。
if "document_type" not in body.model_fields_set:
values.pop("document_type", None)
for key, value in values.items():
setattr(row, key, value)
row.update_by = user.id
_sync_site_record(db, tenant_no, user, row)
db.commit()
except Exception:
db.rollback()
raise
return row
@_retry_deadlock
def delete_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str) -> int:
try:
row = get_record(db, tenant_no, user, record_id, for_update=True)
_ensure_manual_record(row)
row.del_flag = 1
row.update_by = user.id
_sync_site_record(db, tenant_no, user, row)
db.commit()
except Exception:
db.rollback()
raise
return 1
def _list_options(db: Session, tenant_no: str, user: DbUserContext, model, name_field,
alias: str, q: Optional[str], page: int, page_size: int) -> dict:
query = _query(db, tenant_no, user, model)
keyword = (q or "").strip()
if keyword:
query = query.filter(name_field.contains(keyword, autoescape=True))
total = query.count()
rows = query.order_by(model.create_time.desc(), model.id.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {"list": [{"id": row.id, alias: getattr(row, name_field.key)} for row in rows], "total": total}
def list_vehicles(db: Session, tenant_no: str, user: DbUserContext, *, q: Optional[str] = None,
page: int = 1, page_size: int = 500) -> dict:
return _list_options(db, tenant_no, user, PlVehicle, PlVehicle.license_plate,
"licensePlate", q, page, page_size)
def list_products(db: Session, tenant_no: str, user: DbUserContext, *, q: Optional[str] = None,
page: int = 1, page_size: int = 500) -> dict:
query = _query(db, tenant_no, user, CrmProduct).filter(CrmProduct.status == "on")
keyword = (q or "").strip()
if keyword:
query = query.filter(CrmProduct.product_name.contains(keyword, autoescape=True))
total = query.count()
rows = query.order_by(CrmProduct.create_time.desc(), CrmProduct.id.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {"list": [{"id": row.id, "productName": row.product_name} for row in rows], "total": total}