feat(qc): implement material library with CRUD, stock-in, usage tracking and task deduction

This commit is contained in:
zlwu committed 2026-05-20 06:36:50 +08:00
1 parent 4137c54588
commit e6072457fd
9 files changed
+1638 -22

No files matched your search

+177
View File
@@ -14,6 +14,8 @@ from app.crud import qc_task_result as result_svc
from app.crud import qc_task as task_svc
from app.crud import qc_third_party_company as tpc_svc
from app.crud import qc_third_party_item as tpi_svc
from app.crud import qc_material as mat_svc
from app.crud import qc_material_usage as mu_svc
from app.crud import qc_work_order as wo_svc
from app.crud.collection_protocol import total_pages
from app.api.sample_reception import _get_current_user_id, _get_tenant_no, _page_payload
@@ -373,6 +375,7 @@ class TaskCompleteWithResultBody(BaseModel):
results: List[Dict[str, Any]] = Field(default_factory=list, alias="results")
remark: Optional[str] = Field(None, alias="remark")
third_party_attachments: Optional[str] = Field(None, alias="thirdPartyAttachments")
material_usages: Optional[List[Dict[str, Any]]] = Field(None, alias="materialUsages")
@router.get("/qc-lab-test-items")
@@ -745,6 +748,18 @@ async def complete_task_with_result(
db, task_id, attachments=body.third_party_attachments,
tenant_no=tenant_no, user_id=user_id,
)
# 物料扣减(可选,仅当传了 materialUsages 时)
if body.material_usages:
task_obj_pre = task_svc.get_task(db, task_id, tenant_no=tenant_no)
task_no = task_obj_pre.task_no if task_obj_pre else ""
mu_svc.deduct_for_task(
db,
task_id,
task_no=task_no,
material_usages=body.material_usages,
tenant_no=tenant_no,
user_id=user_id,
)
obj = task_svc.apply_task_result(
db,
task_id,
@@ -788,3 +803,165 @@ async def update_qc_task_attachments(
return error_response(str(e))
except Exception as e:
return error_response(str(e))
# ---------- 质检物料库 ----------
class QcMaterialBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
material_code: str = Field(..., alias="materialCode")
material_name: str = Field(..., alias="materialName")
spec_model: Optional[str] = Field(None, alias="specModel")
unit: Optional[str] = None
warning_qty: Optional[float] = Field(0, alias="warningQty")
status: str = "1"
remark: Optional[str] = None
class QcMaterialUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
material_code: Optional[str] = Field(None, alias="materialCode")
material_name: Optional[str] = Field(None, alias="materialName")
spec_model: Optional[str] = Field(None, alias="specModel")
unit: Optional[str] = None
warning_qty: Optional[float] = Field(None, alias="warningQty")
status: Optional[str] = None
remark: Optional[str] = None
class StockInBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
qty: float = Field(..., gt=0)
remark: Optional[str] = None
@router.get("/qc-materials")
async def list_qc_materials(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100),
keyword: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = mat_svc.list_materials(
db, page=page, page_size=pageSize, keyword=keyword, status=status, tenant_no=tenant_no,
)
return success_response(_page_payload(items, total, page, pageSize))
except PermissionError as e:
return error_response(str(e), code=403)
except Exception as e:
return error_response(str(e))
@router.get("/qc-materials/options")
async def qc_material_options(
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
return success_response(mat_svc.options_list(db, tenant_no=tenant_no))
except Exception as e:
return error_response(str(e))
@router.post("/qc-materials")
async def create_qc_material(
body: QcMaterialBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
obj = mat_svc.create_material(db, body.model_dump(by_alias=True, exclude_none=True), tenant_no=tenant_no, user_id=user_id)
return success_response(obj, message="创建成功")
except ValueError as e:
return error_response(str(e))
except Exception as e:
return error_response(str(e))
@router.put("/qc-materials/{material_id}")
async def update_qc_material(
material_id: str,
body: QcMaterialUpdateBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
obj = mat_svc.update_material(db, material_id, body.model_dump(by_alias=True, exclude_none=True), tenant_no=tenant_no, user_id=user_id)
return success_response(obj, message="更新成功")
except ValueError as e:
return error_response(str(e))
except Exception as e:
return error_response(str(e))
@router.delete("/qc-materials/{material_id}")
async def delete_qc_material(
material_id: str,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
mat_svc.soft_delete_material(db, material_id, tenant_no=tenant_no, user_id=user_id)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(str(e))
except Exception as e:
return error_response(str(e))
@router.post("/qc-materials/{material_id}/stock-in")
async def stock_in_qc_material(
material_id: str,
body: StockInBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
obj = mat_svc.stock_in(db, material_id, qty=body.qty, remark=body.remark, tenant_no=tenant_no, user_id=user_id)
return success_response(obj, message="入库成功")
except ValueError as e:
return error_response(str(e))
except Exception as e:
return error_response(str(e))
# ---------- 物料变动记录 ----------
@router.get("/qc-material-usages")
async def list_qc_material_usages(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100),
materialId: Optional[str] = None,
changeType: Optional[str] = None,
startTime: Optional[str] = None,
endTime: Optional[str] = None,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterialUsage:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = mu_svc.list_usages(
db, tenant_no, page=page, page_size=pageSize,
material_id=materialId, change_type=changeType,
start_time=startTime, end_time=endTime,
)
return success_response(_page_payload(items, total, page, pageSize))
except PermissionError as e:
return error_response(str(e), code=403)
except Exception as e:
return error_response(str(e))
+313
View File
@@ -0,0 +1,313 @@
"""物料库 CRUD"""
from __future__ import annotations
from datetime import 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
from app.models.models import BizQcMaterial, BizQcMaterialUsage
def _dec(v: Any) -> float:
if v is None:
return 0.0
return float(v)
def _fmt_dt(t: Optional[datetime]) -> str:
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
def _row_to_dict(r: BizQcMaterial) -> Dict[str, Any]:
return {
"id": r.id,
"materialCode": r.material_code,
"materialName": r.material_name,
"specModel": r.spec_model or "",
"unit": r.unit or "",
"stockQty": _dec(r.stock_qty),
"warningQty": _dec(r.warning_qty),
"status": r.status or "1",
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
"createTime": _fmt_dt(r.create_time),
"updateTime": _fmt_dt(r.update_time),
}
def code_exists(
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
) -> bool:
c = (code or "").strip()
if not c:
return False
q = db.query(BizQcMaterial).filter(
BizQcMaterial.material_code == c,
BizQcMaterial.tenant_no == tenant_no,
BizQcMaterial.del_flag == 0,
)
if exclude_id:
q = q.filter(BizQcMaterial.id != exclude_id)
return q.first() is not None
def total_pages(db: Session, tenant_no: str, page_size: int = 10) -> int:
cnt = (
db.query(func.count(BizQcMaterial.id))
.filter(
BizQcMaterial.tenant_no == tenant_no,
BizQcMaterial.del_flag == 0,
)
.scalar()
or 0
)
ps = max(1, page_size)
return (cnt + ps - 1) // ps
def list_materials(
db: Session,
*,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
status: Optional[str] = None,
tenant_no: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
q0 = db.query(BizQcMaterial).filter(BizQcMaterial.del_flag == 0)
if tenant_no:
q0 = q0.filter(BizQcMaterial.tenant_no == tenant_no)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizQcMaterial.material_code.like(like),
BizQcMaterial.material_name.like(like),
)
)
st = (status or "").strip()
if st in ("0", "1"):
q0 = q0.filter(BizQcMaterial.status == st)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizQcMaterial.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizQcMaterial.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return [_row_to_dict(x) for x in rows], int(total)
def get_material(
db: Session, row_id: str, tenant_no: Optional[str] = None
) -> Optional[BizQcMaterial]:
q = db.query(BizQcMaterial).filter(
BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0
)
if tenant_no:
q = q.filter(BizQcMaterial.tenant_no == tenant_no)
return q.first()
def create_material(
db: Session,
*,
material_code: str,
material_name: str,
spec_model: Optional[str] = None,
unit: Optional[str] = None,
stock_qty: Optional[Decimal] = None,
warning_qty: Optional[Decimal] = None,
status: str = "1",
tenant_no: str,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
create_dept_id: str = "",
) -> BizQcMaterial:
code = (material_code or "").strip()
if not code:
raise ValueError("物料编码不能为空")
if code_exists(db, code, tenant_no):
raise ValueError("物料编码已存在")
name = (material_name or "").strip()
if not name:
raise ValueError("物料名称不能为空")
st = (status or "1").strip()
if st not in ("0", "1"):
raise ValueError("状态无效")
obj = BizQcMaterial(
id=generate_uuid(),
material_code=code,
material_name=name,
spec_model=(spec_model or "").strip() or None,
unit=(unit or "").strip() or None,
stock_qty=stock_qty if stock_qty is not None else 0,
warning_qty=warning_qty if warning_qty is not None else 0,
status=st,
tenant_no=tenant_no,
order_num=order_num if order_num is not None else 0,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_material(
db: Session,
row_id: str,
*,
material_code: Optional[str] = None,
material_name: Optional[str] = None,
spec_model: Optional[str] = None,
unit: Optional[str] = None,
warning_qty: Optional[Decimal] = None,
status: Optional[str] = None,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
) -> BizQcMaterial:
obj = get_material(db, row_id, tenant_no=tenant_no)
if not obj:
raise ValueError("物料不存在")
if material_code is not None:
code = material_code.strip()
if not code:
raise ValueError("物料编码不能为空")
if code != obj.material_code and code_exists(db, code, obj.tenant_no, exclude_id=row_id):
raise ValueError("物料编码已存在")
obj.material_code = code
if material_name is not None:
name = (material_name or "").strip()
if not name:
raise ValueError("物料名称不能为空")
obj.material_name = name
if spec_model is not None:
obj.spec_model = (spec_model or "").strip() or None
if unit is not None:
obj.unit = (unit or "").strip() or None
if warning_qty is not None:
obj.warning_qty = warning_qty
if status is not None:
st = status.strip()
if st not in ("0", "1"):
raise ValueError("状态无效")
obj.status = st
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = (remark or "").strip() or None
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def soft_delete_material(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
obj = get_material(db, row_id, tenant_no=tenant_no)
if not obj:
raise ValueError("物料不存在")
if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
raise ValueError("该物料仍有库存,无法删除")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
def stock_in(
db: Session,
row_id: str,
*,
qty: Decimal,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
) -> BizQcMaterial:
if qty is None or float(qty) <= 0:
raise ValueError("入库数量必须大于0")
# 行锁锁定物料行
obj = (
db.query(BizQcMaterial)
.filter(BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0)
.with_for_update()
.first()
)
if not obj:
raise ValueError("物料不存在")
old_stock = obj.stock_qty or 0
new_stock = old_stock + qty
obj.stock_qty = new_stock
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
# 写入使用记录
usage = BizQcMaterialUsage(
id=generate_uuid(),
material_id=obj.id,
material_code=obj.material_code,
material_name=obj.material_name,
change_type="in",
change_qty=qty,
stock_after=new_stock,
source_type="manual_in",
tenant_no=obj.tenant_no,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=obj.create_dept_id or "",
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(usage)
db.commit()
db.refresh(obj)
return obj
def options_list(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
rows = (
db.query(BizQcMaterial)
.filter(
BizQcMaterial.tenant_no == tenant_no,
BizQcMaterial.del_flag == 0,
BizQcMaterial.status == "1",
)
.order_by(BizQcMaterial.create_time.desc())
.all()
)
return [
{
"id": r.id,
"label": r.material_name,
"materialCode": r.material_code,
"materialName": r.material_name,
"specModel": r.spec_model or "",
"unit": r.unit or "",
"stockQty": _dec(r.stock_qty),
}
for r in rows
]
+140
View File
@@ -0,0 +1,140 @@
"""物料变动记录 CRUD"""
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
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage
def _row_to_dict(r: BizQcMaterialUsage) -> Dict[str, Any]:
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,
"stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
"qcTaskId": r.qc_task_id or "",
"qcTaskNo": r.qc_task_no or "",
"sourceType": r.source_type,
"remark": r.remark or "",
"createBy": r.create_by or "",
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
}
def list_usages(
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,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
q0 = db.query(BizQcMaterialUsage).filter(
BizQcMaterialUsage.tenant_no == tenant_no,
BizQcMaterialUsage.del_flag == 0,
)
if material_id:
q0 = q0.filter(BizQcMaterialUsage.material_id == material_id)
if change_type:
q0 = q0.filter(BizQcMaterialUsage.change_type == change_type)
if start_time:
q0 = q0.filter(BizQcMaterialUsage.create_time >= start_time)
if end_time:
q0 = q0.filter(BizQcMaterialUsage.create_time <= end_time + " 23:59:59")
return q0
total = _filtered().order_by(None).with_entities(func.count(BizQcMaterialUsage.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizQcMaterialUsage.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return [_row_to_dict(x) for x in rows], int(total)
def deduct_for_task(
db: Session,
task_id: str,
task_no: str,
material_usages: List[Dict[str, Any]],
tenant_no: str,
user_id: Optional[str] = None,
dept_id: str = "",
) -> None:
"""扣减物料库存并生成变动记录。调用方控制事务,本函数不 commit。"""
for item in material_usages:
mid = item.get("materialId") or ""
qty = item.get("qty")
if not mid:
raise HTTPException(status_code=400, detail="物料ID不能为空")
if qty is None or Decimal(str(qty)) <= 0:
raise HTTPException(status_code=400, detail="物料使用数量必须大于0")
qty_dec = Decimal(str(qty))
material = (
db.query(BizQcMaterial)
.with_for_update()
.filter(
BizQcMaterial.id == mid,
BizQcMaterial.tenant_no == tenant_no,
BizQcMaterial.del_flag == 0,
BizQcMaterial.status == "1",
)
.first()
)
if not material:
raise HTTPException(status_code=400, detail=f"物料不存在或已停用(ID: {mid})")
stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
if stock_qty < qty_dec:
raise HTTPException(
status_code=400,
detail=f"物料「{material.material_name}」库存不足,当前库存 {stock_qty},需使用 {qty_dec}",
)
new_stock = stock_qty - qty_dec
material.stock_qty = new_stock
material.update_by = user_id
material.update_time = datetime.now()
usage = BizQcMaterialUsage(
id=generate_uuid(),
material_id=mid,
material_code=material.material_code,
material_name=material.material_name,
change_type="use",
change_qty=-qty_dec,
stock_after=new_stock,
qc_task_id=task_id,
qc_task_no=task_no,
source_type="task_complete",
tenant_no=tenant_no,
remark="",
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(usage)