Files
zkxw/backend/app/crud/production_material_prep.py
T
2026-06-13 19:47:22 +08:00

759 lines
25 KiB
Python

"""配置试剂配制单 CRUD"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.production_material import _dec, _fmt_dt, _is_enabled_status
from app.crud.production_material_stock_batch import (
_create_batch_and_usage,
_get_material_for_update,
_parse_date,
_parse_expire_date,
_require_str,
stock_in_no_exists,
_optional_str,
)
from app.models.models import SysUser
from app.models.production_material import (
BizProductionMaterial,
BizProductionMaterialPrep,
BizProductionMaterialPrepLine,
BizProductionMaterialUsage,
)
PREP_STATUSES = ("draft", "completed", "cancelled")
def _status_label(status: str) -> str:
if status == "completed":
return "已完成"
if status == "cancelled":
return "已作废"
return "草稿"
def _fmt_date(d: Optional[date]) -> str:
return d.strftime("%Y-%m-%d") if d else ""
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 _generate_prep_no(db: Session, tenant_no: str) -> str:
day = datetime.now().strftime("%Y%m%d")
like = f"PZ-{day}-%"
cnt = (
db.query(func.count(BizProductionMaterialPrep.id))
.filter(
BizProductionMaterialPrep.tenant_no == tenant_no,
BizProductionMaterialPrep.prep_no.like(like),
)
.scalar()
)
return f"PZ-{day}-{(cnt or 0) + 1:03d}"
def _generate_stock_in_no(db: Session, tenant_no: str) -> str:
day = datetime.now().strftime("%Y%m%d")
like = f"RK{day}%"
cnt = (
db.query(func.count(BizProductionMaterialPrep.id))
.filter(
BizProductionMaterialPrep.tenant_no == tenant_no,
BizProductionMaterialPrep.stock_in_no.like(like),
BizProductionMaterialPrep.del_flag == 0,
)
.scalar()
)
sno = f"RK{day}{(cnt or 0) + 1:04d}"
if stock_in_no_exists(db, sno, tenant_no):
sno = f"RK{day}{(cnt or 0) + 1:04d}P"
return sno
def _line_to_dict(row: BizProductionMaterialPrepLine) -> Dict[str, Any]:
return {
"id": row.id,
"prepId": row.prep_id,
"componentMaterialId": row.component_material_id,
"componentMaterialType": row.component_material_type or "",
"componentMaterialCode": row.component_material_code or "",
"componentMaterialName": row.component_material_name or "",
"requisitionUsageId": (row.requisition_usage_id or "").strip(),
"actualQty": _dec(row.actual_qty),
"usageUnit": row.usage_unit or "",
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
}
def _header_to_dict(
row: BizProductionMaterialPrep,
*,
create_by_name: str = "",
line_count: int = 0,
) -> Dict[str, Any]:
return {
"id": row.id,
"prepNo": row.prep_no or "",
"configuredMaterialId": row.configured_material_id,
"configuredMaterialCode": row.configured_material_code or "",
"configuredMaterialName": row.configured_material_name or "",
"prepTime": _fmt_dt(row.prep_time),
"outputQty": _dec(row.output_qty),
"outputUnit": row.output_unit or "",
"batchNo": row.batch_no or "",
"storageLocation": row.storage_location or "",
"expireDate": _fmt_date(row.expire_date),
"stockInNo": row.stock_in_no or "",
"outputBatchId": row.output_batch_id or "",
"status": row.status or "draft",
"statusLabel": _status_label(row.status or "draft"),
"lineCount": line_count,
"remark": row.remark or "",
"createBy": row.create_by or "",
"createByName": create_by_name or row.create_by or "",
"createTime": _fmt_dt(row.create_time),
"updateTime": _fmt_dt(row.update_time),
}
def _get_prep_row(db: Session, prep_id: str, tenant_no: str) -> BizProductionMaterialPrep:
row = (
db.query(BizProductionMaterialPrep)
.filter(
BizProductionMaterialPrep.id == prep_id,
BizProductionMaterialPrep.tenant_no == tenant_no,
BizProductionMaterialPrep.del_flag == 0,
)
.first()
)
if not row:
raise ValueError("配制单不存在")
return row
def _require_draft(row: BizProductionMaterialPrep) -> None:
if (row.status or "").strip() != "draft":
raise ValueError("仅草稿状态的配制单可编辑")
def _validate_requisition(
db: Session,
req_id: str,
*,
tenant_no: str,
component_material_id: str,
seen: set,
) -> BizProductionMaterialUsage:
if not req_id:
raise ValueError("请选择子物料领用记录")
if req_id in seen:
raise ValueError("配制明细中存在重复的领用记录")
seen.add(req_id)
req = (
db.query(BizProductionMaterialUsage)
.filter(
BizProductionMaterialUsage.id == req_id,
BizProductionMaterialUsage.tenant_no == tenant_no,
BizProductionMaterialUsage.del_flag == 0,
BizProductionMaterialUsage.source_type == "manual_out",
BizProductionMaterialUsage.change_type == "use",
)
.first()
)
if not req:
raise ValueError("领用记录不存在或已失效")
if (req.material_id or "").strip() != component_material_id:
raise ValueError("领用记录与子物料不匹配")
return req
def _parse_prep_lines(
db: Session,
lines: List[Dict[str, Any]],
*,
tenant_no: str,
) -> List[Dict[str, Any]]:
if not lines:
raise ValueError("请至少添加一条配制明细")
parsed: List[Dict[str, Any]] = []
seen_req: set = set()
for idx, it in enumerate(lines):
comp_id = (
it.get("componentMaterialId") or it.get("component_material_id") or ""
).strip()
if not comp_id:
raise ValueError("请选择子物料")
comp_type = (
it.get("componentMaterialType") or it.get("component_material_type") or ""
).strip().lower()
if comp_type not in ("reagent", "consumable"):
raise ValueError("子物料类型无效")
comp = (
db.query(BizProductionMaterial)
.filter(
BizProductionMaterial.id == comp_id,
BizProductionMaterial.tenant_no == tenant_no,
BizProductionMaterial.del_flag == 0,
)
.first()
)
if not comp:
raise ValueError("子物料不存在")
req_id = (
it.get("requisitionUsageId") or it.get("requisition_usage_id") or ""
).strip()
req = _validate_requisition(
db, req_id, tenant_no=tenant_no, component_material_id=comp_id, seen=seen_req
)
qty_raw = it.get("actualQty") if it.get("actualQty") is not None else it.get("actual_qty")
if qty_raw is None or Decimal(str(qty_raw)) <= 0:
raise ValueError("实际用量必须大于0")
actual_qty = Decimal(str(qty_raw))
usage_unit = (
it.get("usageUnit") or it.get("usage_unit") or comp.unit or ""
).strip()[:32]
parsed.append(
{
"idx": idx,
"component": comp,
"component_type": comp_type,
"requisition": req,
"actual_qty": actual_qty,
"usage_unit": usage_unit,
"remark": (it.get("remark") or "").strip()[:512] or None,
}
)
return parsed
def list_preps(
db: Session,
tenant_no: str,
*,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
status: Optional[str] = None,
configured_material_id: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
q0 = db.query(BizProductionMaterialPrep).filter(
BizProductionMaterialPrep.tenant_no == tenant_no,
BizProductionMaterialPrep.del_flag == 0,
)
st = (status or "").strip().lower()
if st in PREP_STATUSES:
q0 = q0.filter(BizProductionMaterialPrep.status == st)
cmid = (configured_material_id or "").strip()
if cmid:
q0 = q0.filter(BizProductionMaterialPrep.configured_material_id == cmid)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizProductionMaterialPrep.prep_no.like(like),
BizProductionMaterialPrep.configured_material_code.like(like),
BizProductionMaterialPrep.configured_material_name.like(like),
BizProductionMaterialPrep.batch_no.like(like),
BizProductionMaterialPrep.stock_in_no.like(like),
)
)
total = q0.count()
rows = (
q0.order_by(BizProductionMaterialPrep.prep_time.desc(), BizProductionMaterialPrep.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
prep_ids = [r.id for r in rows]
line_counts: Dict[str, int] = {}
if prep_ids:
cnt_rows = (
db.query(
BizProductionMaterialPrepLine.prep_id,
func.count(BizProductionMaterialPrepLine.id),
)
.filter(
BizProductionMaterialPrepLine.prep_id.in_(prep_ids),
BizProductionMaterialPrepLine.del_flag == 0,
)
.group_by(BizProductionMaterialPrepLine.prep_id)
.all()
)
line_counts = {pid: int(c) for pid, c in cnt_rows}
name_map = _user_nick_name_map(db, [r.create_by for r in rows if r.create_by])
items = [
_header_to_dict(
r,
create_by_name=name_map.get(r.create_by or "", ""),
line_count=line_counts.get(r.id, 0),
)
for r in rows
]
return items, total
def get_prep_detail(db: Session, prep_id: str, tenant_no: str) -> Dict[str, Any]:
row = _get_prep_row(db, prep_id, tenant_no)
lines = (
db.query(BizProductionMaterialPrepLine)
.filter(
BizProductionMaterialPrepLine.prep_id == prep_id,
BizProductionMaterialPrepLine.tenant_no == tenant_no,
BizProductionMaterialPrepLine.del_flag == 0,
)
.order_by(
BizProductionMaterialPrepLine.order_num.asc(),
BizProductionMaterialPrepLine.create_time.asc(),
)
.all()
)
name_map = _user_nick_name_map(db, [row.create_by] if row.create_by else [])
result = _header_to_dict(
row,
create_by_name=name_map.get(row.create_by or "", ""),
line_count=len(lines),
)
result["lines"] = [_line_to_dict(ln) for ln in lines]
return result
def create_prep(
db: Session,
*,
data: Dict[str, Any],
tenant_no: str,
user_id: Optional[str] = None,
dept_id: str = "",
) -> Dict[str, Any]:
configured_id = (
data.get("configuredMaterialId") or data.get("configured_material_id") or ""
).strip()
if not configured_id:
raise ValueError("请选择配置试剂")
configured = (
db.query(BizProductionMaterial)
.filter(
BizProductionMaterial.id == configured_id,
BizProductionMaterial.tenant_no == tenant_no,
BizProductionMaterial.del_flag == 0,
)
.first()
)
if not configured:
raise ValueError("配置试剂不存在")
if (configured.material_type or "").strip() != "configured_reagent":
raise ValueError("所选物料不是配置试剂")
prep_time_raw = data.get("prepTime") or data.get("prep_time")
prep_time = parse_datetime(prep_time_raw) if prep_time_raw else datetime.now()
output_qty_raw = data.get("outputQty") if data.get("outputQty") is not None else data.get("output_qty")
if output_qty_raw is None or Decimal(str(output_qty_raw)) <= 0:
raise ValueError("产出数量必须大于0")
output_qty = Decimal(str(output_qty_raw))
batch_no = _require_str(data.get("batchNo") or data.get("batch_no"), "产出批号")
expire_raw = data.get("expireDate") or data.get("expire_date")
expire_date = _parse_expire_date(expire_raw)
lines_raw = data.get("lines") or []
parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
now = datetime.now()
prep_id = generate_uuid()
prep_no = _generate_prep_no(db, tenant_no)
prep = BizProductionMaterialPrep(
id=prep_id,
prep_no=prep_no,
configured_material_id=configured_id,
configured_material_code=configured.material_code,
configured_material_name=configured.material_name,
prep_time=prep_time,
output_qty=output_qty,
output_unit=(data.get("outputUnit") or data.get("output_unit") or configured.unit or "").strip()[:32] or None,
batch_no=batch_no,
storage_location=_optional_str(data.get("storageLocation") or data.get("storage_location")),
expire_date=expire_date,
status="draft",
tenant_no=tenant_no,
remark=(data.get("remark") or "").strip()[:512] or None,
create_by=user_id,
create_dept_id=dept_id or configured.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(prep)
for pl in parsed_lines:
comp = pl["component"]
db.add(
BizProductionMaterialPrepLine(
id=generate_uuid(),
prep_id=prep_id,
component_material_id=comp.id,
component_material_type=pl["component_type"],
component_material_code=comp.material_code,
component_material_name=comp.material_name,
requisition_usage_id=pl["requisition"].id,
actual_qty=pl["actual_qty"],
usage_unit=pl["usage_unit"] or None,
tenant_no=tenant_no,
order_num=pl["idx"],
remark=pl["remark"],
create_by=user_id,
create_dept_id=dept_id or configured.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
db.commit()
return get_prep_detail(db, prep_id, tenant_no)
def update_prep(
db: Session,
prep_id: str,
*,
data: Dict[str, Any],
tenant_no: str,
user_id: Optional[str] = None,
dept_id: str = "",
) -> Dict[str, Any]:
prep = _get_prep_row(db, prep_id, tenant_no)
_require_draft(prep)
now = datetime.now()
if data.get("prepTime") or data.get("prep_time"):
prep.prep_time = parse_datetime(data.get("prepTime") or data.get("prep_time"))
if data.get("outputQty") is not None or data.get("output_qty") is not None:
output_qty_raw = data.get("outputQty") if data.get("outputQty") is not None else data.get("output_qty")
if Decimal(str(output_qty_raw)) <= 0:
raise ValueError("产出数量必须大于0")
prep.output_qty = Decimal(str(output_qty_raw))
if data.get("outputUnit") is not None or data.get("output_unit") is not None:
prep.output_unit = (
data.get("outputUnit") or data.get("output_unit") or ""
).strip()[:32] or None
if data.get("batchNo") or data.get("batch_no"):
prep.batch_no = _require_str(data.get("batchNo") or data.get("batch_no"), "产出批号")
if data.get("storageLocation") is not None or data.get("storage_location") is not None:
prep.storage_location = _optional_str(
data.get("storageLocation") if data.get("storageLocation") is not None else data.get("storage_location")
)
if data.get("expireDate") or data.get("expire_date"):
prep.expire_date = _parse_expire_date(data.get("expireDate") or data.get("expire_date"))
if data.get("remark") is not None:
prep.remark = (data.get("remark") or "").strip()[:512] or None
lines_raw = data.get("lines")
if lines_raw is not None:
parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
existing = (
db.query(BizProductionMaterialPrepLine)
.filter(
BizProductionMaterialPrepLine.prep_id == prep_id,
BizProductionMaterialPrepLine.del_flag == 0,
)
.all()
)
for ln in existing:
ln.del_flag = 1
ln.update_by = user_id
ln.update_time = now
db.add(ln)
for pl in parsed_lines:
comp = pl["component"]
db.add(
BizProductionMaterialPrepLine(
id=generate_uuid(),
prep_id=prep_id,
component_material_id=comp.id,
component_material_type=pl["component_type"],
component_material_code=comp.material_code,
component_material_name=comp.material_name,
requisition_usage_id=pl["requisition"].id,
actual_qty=pl["actual_qty"],
usage_unit=pl["usage_unit"] or None,
tenant_no=tenant_no,
order_num=pl["idx"],
remark=pl["remark"],
create_by=user_id,
create_dept_id=dept_id or prep.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
prep.update_by = user_id
prep.update_time = now
db.add(prep)
db.commit()
return get_prep_detail(db, prep_id, tenant_no)
def _write_prep_consume_usage(
db: Session,
*,
prep: BizProductionMaterialPrep,
line: BizProductionMaterialPrepLine,
req: BizProductionMaterialUsage,
tenant_no: str,
user_id: Optional[str],
dept_id: str,
operate_time: datetime,
) -> Decimal:
material = (
db.query(BizProductionMaterial)
.filter(
BizProductionMaterial.id == line.component_material_id,
BizProductionMaterial.tenant_no == tenant_no,
BizProductionMaterial.del_flag == 0,
)
.first()
)
if not material:
raise ValueError("子物料不存在")
actual_qty = line.actual_qty or Decimal("0")
stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
unit_price = req.unit_price
line_amount = Decimal("0")
if unit_price is not None and actual_qty > 0:
line_amount = (Decimal(str(unit_price)) * actual_qty).quantize(Decimal("0.01"))
usage = BizProductionMaterialUsage(
id=generate_uuid(),
material_id=material.id,
material_type=material.material_type,
material_code=material.material_code,
material_name=material.material_name,
batch_id=req.batch_id,
stock_in_no=req.stock_in_no,
catalog_no=req.catalog_no,
batch_no=req.batch_no,
manufacturer=req.manufacturer,
storage_location=req.storage_location,
unit_price=req.unit_price,
amount=line_amount if line_amount > 0 else req.amount,
change_type="use",
change_qty=-actual_qty,
usage_unit=line.usage_unit,
stock_before=stock_qty,
stock_after=stock_qty,
parent_usage_id=req.id,
prep_id=prep.id,
source_type="prep_consume",
tenant_no=tenant_no,
remark=f"配制单 {prep.prep_no}",
create_by=user_id,
create_dept_id=dept_id,
create_time=operate_time,
update_by=user_id,
update_time=operate_time,
del_flag=0,
)
db.add(usage)
return line_amount
def complete_prep(
db: Session,
prep_id: str,
*,
tenant_no: str,
user_id: Optional[str] = None,
dept_id: str = "",
) -> Dict[str, Any]:
prep = _get_prep_row(db, prep_id, tenant_no)
if (prep.status or "").strip() != "draft":
raise ValueError("仅草稿状态的配制单可确认配制")
lines = (
db.query(BizProductionMaterialPrepLine)
.filter(
BizProductionMaterialPrepLine.prep_id == prep_id,
BizProductionMaterialPrepLine.del_flag == 0,
)
.order_by(BizProductionMaterialPrepLine.order_num.asc())
.all()
)
if not lines:
raise ValueError("配制单无明细,无法确认")
configured = _get_material_for_update(db, prep.configured_material_id, tenant_no)
if (configured.material_type or "").strip() != "configured_reagent":
raise ValueError("配置试剂目录无效")
if not _is_enabled_status(configured.status):
raise ValueError(f"配置试剂「{configured.material_name}」已停用")
operate_time = datetime.now()
total_amount = Decimal("0")
try:
req_map: Dict[str, BizProductionMaterialUsage] = {}
for line in lines:
req_id = (line.requisition_usage_id or "").strip()
if not req_id:
raise ValueError("配制明细缺少领用记录")
if req_id not in req_map:
req = (
db.query(BizProductionMaterialUsage)
.filter(
BizProductionMaterialUsage.id == req_id,
BizProductionMaterialUsage.tenant_no == tenant_no,
BizProductionMaterialUsage.del_flag == 0,
BizProductionMaterialUsage.source_type == "manual_out",
)
.first()
)
if not req:
raise ValueError("领用记录不存在或已失效")
req_map[req_id] = req
amt = _write_prep_consume_usage(
db,
prep=prep,
line=line,
req=req_map[req_id],
tenant_no=tenant_no,
user_id=user_id,
dept_id=dept_id or prep.create_dept_id or "",
operate_time=operate_time,
)
total_amount += amt
stock_in_no = _generate_stock_in_no(db, tenant_no)
output_qty = prep.output_qty or Decimal("0")
if output_qty <= 0:
raise ValueError("产出数量必须大于0")
unit_price = Decimal("0")
amount = total_amount
if output_qty > 0 and total_amount > 0:
unit_price = (total_amount / output_qty).quantize(Decimal("0.01"))
amount = (unit_price * output_qty).quantize(Decimal("0.01"))
stock_in_date = prep.prep_time.date() if prep.prep_time else operate_time.date()
catalog_no = (configured.material_code or prep.batch_no or "—").strip() or "—"
batch = _create_batch_and_usage(
db,
material=configured,
stock_in_no=stock_in_no,
stock_in_date=stock_in_date,
expire_date=prep.expire_date,
catalog_no=catalog_no,
batch_no=prep.batch_no,
manufacturer="自配",
storage_location=prep.storage_location,
unit_price=unit_price,
amount=amount,
qty=output_qty,
remark=f"配制单 {prep.prep_no}",
tenant_no=tenant_no,
user_id=user_id,
operate_time=operate_time,
commit=False,
source_type="prep_in",
prep_id=prep.id,
)
prep.status = "completed"
prep.stock_in_no = stock_in_no
prep.output_batch_id = batch.id
prep.update_by = user_id
prep.update_time = operate_time
db.add(prep)
db.commit()
except Exception:
db.rollback()
raise
return get_prep_detail(db, prep_id, tenant_no)
def cancel_prep(
db: Session,
prep_id: str,
*,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
prep = _get_prep_row(db, prep_id, tenant_no)
_require_draft(prep)
now = datetime.now()
prep.status = "cancelled"
prep.update_by = user_id
prep.update_time = now
db.add(prep)
db.commit()
def delete_prep(
db: Session,
prep_id: str,
*,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
prep = _get_prep_row(db, prep_id, tenant_no)
_require_draft(prep)
now = datetime.now()
prep.del_flag = 1
prep.update_by = user_id
prep.update_time = now
db.add(prep)
lines = (
db.query(BizProductionMaterialPrepLine)
.filter(
BizProductionMaterialPrepLine.prep_id == prep_id,
BizProductionMaterialPrepLine.del_flag == 0,
)
.all()
)
for ln in lines:
ln.del_flag = 1
ln.update_by = user_id
ln.update_time = now
db.add(ln)
db.commit()