283 lines
9.7 KiB
Python
283 lines
9.7 KiB
Python
"""耗材变动记录 CRUD(biz_consumable_stock_log)"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import func, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.utils import generate_uuid
|
||
from app.models.models import BizConsumable, BizConsumableStockLog, SysUser
|
||
|
||
|
||
def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
|
||
ids = list({x for x in user_ids if x})
|
||
if not ids:
|
||
return {}
|
||
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
|
||
return {u.id: (u.nick_name or "").strip() for u in rows}
|
||
|
||
|
||
def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
|
||
receiver, purpose = "", ""
|
||
for part in (remark or "").split(";"):
|
||
seg = part.strip()
|
||
if seg.startswith("领用人:"):
|
||
receiver = seg[len("领用人:") :].strip()
|
||
elif seg.startswith("用途:"):
|
||
purpose = seg[len("用途:") :].strip()
|
||
return receiver, purpose
|
||
|
||
|
||
def _resolve_receiver_purpose(r: BizConsumableStockLog) -> Tuple[str, str]:
|
||
receiver = (getattr(r, "receiver", None) or "").strip()
|
||
purpose = (getattr(r, "purpose", None) or "").strip()
|
||
if receiver or purpose:
|
||
return receiver, purpose
|
||
if (r.source_type or "") == "manual_out":
|
||
return _parse_receiver_purpose_from_remark(r.remark)
|
||
return "", ""
|
||
|
||
|
||
def _display_remark(r: BizConsumableStockLog) -> str:
|
||
remark = (r.remark or "").strip()
|
||
if (r.source_type or "") != "manual_out" or not remark:
|
||
return remark
|
||
parts: List[str] = []
|
||
for part in remark.split(";"):
|
||
seg = part.strip()
|
||
if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
|
||
continue
|
||
if seg.startswith("备注"):
|
||
seg = seg[2:].strip() or seg
|
||
parts.append(seg)
|
||
return ";".join(parts) if parts else ""
|
||
|
||
|
||
def _row_to_dict(r: BizConsumableStockLog, *, create_by_name: str = "") -> Dict[str, Any]:
|
||
create_by = r.create_by or ""
|
||
receiver, purpose = _resolve_receiver_purpose(r)
|
||
create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
|
||
is_manual_out = (r.source_type or "") == "manual_out"
|
||
return {
|
||
"id": r.id,
|
||
"materialId": r.material_id,
|
||
"materialCode": r.material_code or "",
|
||
"materialName": r.material_name or "",
|
||
"changeType": r.change_type,
|
||
"changeQty": float(r.change_qty) if r.change_qty is not None else 0,
|
||
"stockBefore": float(r.stock_before) if r.stock_before is not None else 0,
|
||
"stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
|
||
"stepId": r.step_id or "",
|
||
"stepName": r.step_name or "",
|
||
"taskId": r.task_id or "",
|
||
"taskNo": r.task_no or "",
|
||
"receiver": receiver,
|
||
"purpose": purpose,
|
||
"sourceType": r.source_type or "",
|
||
"useTime": create_time if is_manual_out else "",
|
||
"remark": _display_remark(r),
|
||
"createBy": create_by,
|
||
"createByName": create_by_name or create_by,
|
||
"createTime": create_time,
|
||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||
}
|
||
|
||
|
||
def list_stock_logs(
|
||
db: Session,
|
||
tenant_no: str,
|
||
*,
|
||
page: int = 1,
|
||
page_size: int = 10,
|
||
material_id: Optional[str] = None,
|
||
change_type: Optional[str] = None,
|
||
start_time: Optional[str] = None,
|
||
end_time: Optional[str] = None,
|
||
keyword: Optional[str] = None,
|
||
user_info=None,
|
||
) -> Tuple[List[Dict[str, Any]], int]:
|
||
def _filtered():
|
||
q0 = db.query(BizConsumableStockLog).filter(
|
||
BizConsumableStockLog.tenant_no == tenant_no,
|
||
BizConsumableStockLog.del_flag == 0,
|
||
)
|
||
if material_id:
|
||
q0 = q0.filter(BizConsumableStockLog.material_id == material_id)
|
||
ct = (change_type or "").strip()
|
||
if ct == "out":
|
||
q0 = q0.filter(
|
||
BizConsumableStockLog.change_type == "use",
|
||
BizConsumableStockLog.source_type == "manual_out",
|
||
)
|
||
elif ct == "use":
|
||
q0 = q0.filter(
|
||
BizConsumableStockLog.change_type == "use",
|
||
BizConsumableStockLog.source_type == "step_save",
|
||
)
|
||
elif ct == "in":
|
||
q0 = q0.filter(BizConsumableStockLog.change_type == "in")
|
||
if start_time:
|
||
q0 = q0.filter(BizConsumableStockLog.create_time >= start_time)
|
||
if end_time:
|
||
q0 = q0.filter(BizConsumableStockLog.create_time <= end_time + " 23:59:59")
|
||
kw = (keyword or "").strip()
|
||
if kw:
|
||
like = f"%{kw}%"
|
||
q0 = q0.filter(
|
||
or_(
|
||
BizConsumableStockLog.material_code.like(like),
|
||
BizConsumableStockLog.material_name.like(like),
|
||
)
|
||
)
|
||
if user_info:
|
||
from app.core.data_scope import apply_data_scope
|
||
q0 = apply_data_scope(q0, BizConsumableStockLog, user_info, db, tenant_no)
|
||
return q0
|
||
|
||
total = _filtered().order_by(None).with_entities(func.count(BizConsumableStockLog.id)).scalar() or 0
|
||
page = max(1, page)
|
||
page_size = min(max(1, page_size), 100)
|
||
rows = (
|
||
_filtered()
|
||
.order_by(BizConsumableStockLog.create_time.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
|
||
return [
|
||
_row_to_dict(x, create_by_name=name_map.get(x.create_by or "", ""))
|
||
for x in rows
|
||
], int(total)
|
||
|
||
|
||
def revert_step_stock_changes(
|
||
db: Session,
|
||
step_id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
) -> None:
|
||
"""撤销步骤此前 step_save 扣减,便于重新填报。"""
|
||
rows = (
|
||
db.query(BizConsumableStockLog)
|
||
.filter(
|
||
BizConsumableStockLog.step_id == step_id,
|
||
BizConsumableStockLog.tenant_no == tenant_no,
|
||
BizConsumableStockLog.del_flag == 0,
|
||
BizConsumableStockLog.source_type == "step_save",
|
||
BizConsumableStockLog.change_type == "use",
|
||
)
|
||
.all()
|
||
)
|
||
if not rows:
|
||
return
|
||
now = datetime.now()
|
||
for r in rows:
|
||
restore_qty = abs(r.change_qty) if r.change_qty is not None else Decimal("0")
|
||
if restore_qty <= 0:
|
||
r.del_flag = 1
|
||
r.update_by = user_id
|
||
r.update_time = now
|
||
continue
|
||
material = (
|
||
db.query(BizConsumable)
|
||
.with_for_update()
|
||
.filter(
|
||
BizConsumable.id == r.material_id,
|
||
BizConsumable.tenant_no == tenant_no,
|
||
BizConsumable.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if material:
|
||
material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
|
||
material.update_by = user_id
|
||
material.update_time = now
|
||
r.del_flag = 1
|
||
r.update_by = user_id
|
||
r.update_time = now
|
||
|
||
|
||
def deduct_for_step(
|
||
db: Session,
|
||
step_id: str,
|
||
step_name: str,
|
||
task_id: str,
|
||
task_no: str,
|
||
consumable_items: List[Dict[str, Any]],
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
dept_id: str = "",
|
||
) -> None:
|
||
"""扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。"""
|
||
for item in consumable_items:
|
||
code = (item.get("consumable_code") or "").strip()
|
||
actual_usage = item.get("actual_usage")
|
||
if not code:
|
||
continue
|
||
# 跳过空/零/非数字的 actual_usage
|
||
if actual_usage is None or str(actual_usage).strip() == "":
|
||
continue
|
||
try:
|
||
usage_val = Decimal(str(actual_usage))
|
||
except Exception:
|
||
continue
|
||
if usage_val <= 0:
|
||
continue
|
||
|
||
# 按 consumable_code 查找耗材(跳过目录中不存在的自定义材料)
|
||
material = (
|
||
db.query(BizConsumable)
|
||
.with_for_update()
|
||
.filter(
|
||
BizConsumable.consumable_code == code,
|
||
BizConsumable.tenant_no == tenant_no,
|
||
BizConsumable.del_flag == 0,
|
||
BizConsumable.status == "1",
|
||
)
|
||
.first()
|
||
)
|
||
if not material:
|
||
continue
|
||
|
||
stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
|
||
if stock_before < usage_val:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"耗材「{material.consumable_name}」库存不足,当前库存 {stock_before},需使用 {usage_val}",
|
||
)
|
||
|
||
new_stock = stock_before - usage_val
|
||
material.stock_qty = new_stock
|
||
material.update_by = user_id
|
||
material.update_time = datetime.now()
|
||
|
||
log = BizConsumableStockLog(
|
||
id=generate_uuid(),
|
||
material_id=material.id,
|
||
material_code=material.consumable_code,
|
||
material_name=material.consumable_name,
|
||
change_type="use",
|
||
change_qty=-usage_val,
|
||
stock_before=stock_before,
|
||
stock_after=new_stock,
|
||
source_type="step_save",
|
||
step_id=step_id,
|
||
step_name=(step_name or "").strip() or None,
|
||
task_id=task_id,
|
||
task_no=task_no,
|
||
tenant_no=tenant_no,
|
||
remark=str(item.get("remark") or "").strip()[:512] or None,
|
||
create_by=user_id,
|
||
create_dept_id=dept_id,
|
||
create_time=datetime.now(),
|
||
update_by=user_id,
|
||
update_time=datetime.now(),
|
||
del_flag=0,
|
||
)
|
||
db.add(log)
|