"""物料变动记录 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 and_, func, or_ from sqlalchemy.orm import Session from app.core.utils import generate_uuid from app.models.qc_material import BizQcMaterial, BizQcMaterialStockBatch, BizQcMaterialUsage from app.models.models import SysUser from app.crud.qc_material_stock_batch import _usage_batch_snapshots def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]: """兼容旧数据:领用人/用途曾拼接在 remark 中。""" 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: BizQcMaterialUsage) -> 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: BizQcMaterialUsage) -> 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 _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 _receiver_display_map(db: Session, receivers: List[str]) -> Dict[str, str]: """领用人字段可能是用户 ID 或昵称。""" keys = list({(x or "").strip() for x in receivers if (x or "").strip()}) if not keys: return {} by_id = _user_nick_name_map(db, keys) out: Dict[str, str] = {} for k in keys: out[k] = by_id.get(k) or k return out def _usage_unit_label(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> str: u = (getattr(r, "usage_unit", None) or "").strip() if u: return u if batch and (batch.unit or "").strip(): return (batch.unit or "").strip() return "" def _stock_in_date_for_row(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> str: if batch and batch.stock_in_date: return batch.stock_in_date.strftime("%Y-%m-%d") return "" def _unit_price_for_row(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> Optional[float]: if r.unit_price is not None: return float(r.unit_price) if batch and batch.unit_price is not None: return float(batch.unit_price) return None def _amount_for_row(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> Optional[float]: is_in = (r.change_type or "") == "in" if is_in: if r.amount is not None: return float(r.amount) if batch and batch.amount is not None: return float(batch.amount) return None unit_price = _unit_price_for_row(r, batch) change_qty = float(r.change_qty) if r.change_qty is not None else 0 qty = abs(change_qty) if unit_price is None or qty <= 0: return None return round(unit_price * qty, 2) def _row_to_dict( r: BizQcMaterialUsage, *, create_by_name: str = "", batch: Optional[BizQcMaterialStockBatch] = None, receiver_name: str = "", requisition_receiver: str = "", requisition_receiver_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" stock_after = float(r.stock_after) if r.stock_after is not None else 0 change_qty = float(r.change_qty) if r.change_qty is not None else 0 stock_before = ( float(r.stock_before) if r.stock_before is not None else stock_after - change_qty ) usage_unit = _usage_unit_label(r, batch) req_receiver = requisition_receiver or receiver req_receiver_name = requisition_receiver_name or receiver_name or req_receiver return { "id": r.id, "materialId": r.material_id, "materialCode": r.material_code or "", "materialName": r.material_name or "", "batchId": r.batch_id or "", "stockInNo": r.stock_in_no or "", "stockInDate": _stock_in_date_for_row(r, batch), "catalogNo": r.catalog_no or "", "batchNo": r.batch_no or "", "manufacturer": r.manufacturer or "", "unitPrice": _unit_price_for_row(r, batch), "amount": _amount_for_row(r, batch), "changeType": r.change_type, "changeQty": change_qty, "usageUnit": usage_unit, "stockBefore": stock_before, "stockAfter": stock_after, "qcTaskId": r.qc_task_id or "", "qcTaskNo": r.qc_task_no or "", "parentUsageId": (r.parent_usage_id or "").strip(), "requisitionUsageId": (r.parent_usage_id or "").strip(), "receiver": receiver, "receiverName": receiver_name or receiver, "requisitionReceiver": req_receiver, "requisitionReceiverName": req_receiver_name, "purpose": purpose, "sourceType": r.source_type or "", "requisitionStatus": (getattr(r, "requisition_status", None) or "open").strip() or "open", "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 _linked_qc_task_no_map( db: Session, tenant_no: str, parent_usage_ids: List[str], ) -> Dict[str, str]: """领用记录(manual_out)关联的质检任务号,聚合为 parent_usage_id → 任务号列表。""" ids = [x for x in parent_usage_ids if x] if not ids: return {} rows = ( db.query(BizQcMaterialUsage.parent_usage_id, BizQcMaterialUsage.qc_task_no) .filter( BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.parent_usage_id.in_(ids), BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "task_complete", ) .order_by(BizQcMaterialUsage.create_time.asc()) .all() ) grouped: Dict[str, List[str]] = {} for pid, tno in rows: pid = (pid or "").strip() tno = (tno or "").strip() if not pid or not tno: continue grouped.setdefault(pid, []) if tno not in grouped[pid]: grouped[pid].append(tno) return {pid: "、".join(nos) for pid, nos in grouped.items()} def _batch_map(db: Session, batch_ids: List[str]) -> Dict[str, BizQcMaterialStockBatch]: ids = [x for x in batch_ids if x] if not ids: return {} rows = ( db.query(BizQcMaterialStockBatch) .filter(BizQcMaterialStockBatch.id.in_(ids), BizQcMaterialStockBatch.del_flag == 0) .all() ) return {r.id: r for r in rows} def _mine_usage_filter(user_id: str, nick_name: str): """当前登录人的领用记录:手动出库(领用人)或任务使用(填报人)。""" uid = (user_id or "").strip() nn = (nick_name or "").strip() manual_conds = [BizQcMaterialUsage.source_type == "manual_out"] receiver_match: List[Any] = [] if nn: receiver_match.append(BizQcMaterialUsage.receiver == nn) receiver_match.append(BizQcMaterialUsage.remark.like(f"%领用人:{nn}%")) if uid: receiver_match.append(BizQcMaterialUsage.receiver == uid) if receiver_match: manual_conds.append(or_(*receiver_match)) else: manual_conds.append(BizQcMaterialUsage.id == "") task_conds = [ BizQcMaterialUsage.source_type == "task_complete", BizQcMaterialUsage.create_by == uid, ] return and_( BizQcMaterialUsage.change_type == "use", or_(and_(*manual_conds), and_(*task_conds)), ) def _list_usages_query( db: Session, tenant_no: str, *, material_id: Optional[str] = None, change_type: Optional[str] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, keyword: Optional[str] = None, stock_in_no: Optional[str] = None, user_info=None, mine_user_id: Optional[str] = None, mine_nick_name: Optional[str] = None, ): q0 = db.query(BizQcMaterialUsage).filter( BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, ) if mine_user_id is not None: q0 = q0.filter(_mine_usage_filter(mine_user_id, mine_nick_name or "")) if material_id: q0 = q0.filter(BizQcMaterialUsage.material_id == material_id) ct = (change_type or "").strip() if ct == "out": q0 = q0.filter( BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "manual_out", ) elif ct == "use": q0 = q0.filter( BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "task_complete", ) elif ct == "in": q0 = q0.filter(BizQcMaterialUsage.change_type == "in") elif ct == "discard": q0 = q0.filter(BizQcMaterialUsage.change_type == "discard") elif ct == "adjust": q0 = q0.filter(BizQcMaterialUsage.change_type == "adjust") # 任务消耗子流水(parent_usage_id 有值)仅在「任务使用」筛选下展示,避免与领用行重复 if ct != "use": q0 = q0.filter( or_( BizQcMaterialUsage.source_type != "task_complete", BizQcMaterialUsage.parent_usage_id.is_(None), BizQcMaterialUsage.parent_usage_id == "", ) ) sno = (stock_in_no or "").strip() if sno: q0 = q0.filter(BizQcMaterialUsage.stock_in_no.like(f"%{sno}%")) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q0 = q0.filter( or_( BizQcMaterialUsage.material_code.like(like), BizQcMaterialUsage.material_name.like(like), BizQcMaterialUsage.stock_in_no.like(like), BizQcMaterialUsage.batch_no.like(like), BizQcMaterialUsage.catalog_no.like(like), ) ) 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") if user_info and mine_user_id is None: from app.core.data_scope import apply_data_scope q0 = apply_data_scope(q0, BizQcMaterialUsage, user_info, db, tenant_no) return q0 def _paginate_usages( db: Session, q0, *, tenant_no: str, page: int, page_size: int, ) -> Tuple[List[Dict[str, Any]], int]: total = q0.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 = ( q0.order_by(BizQcMaterialUsage.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id]) name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by]) receivers = [] for x in rows: rcv, _ = _resolve_receiver_purpose(x) if rcv: receivers.append(rcv) receiver_map = _receiver_display_map(db, receivers) manual_out_ids = [x.id for x in rows if (x.source_type or "") == "manual_out"] linked_task_no_map = _linked_qc_task_no_map(db, tenant_no, manual_out_ids) out: List[Dict[str, Any]] = [] for x in rows: row_dict = _row_to_dict( x, create_by_name=name_map.get(x.create_by or "", ""), batch=bmap.get(x.batch_id or ""), receiver_name=receiver_map.get( (_resolve_receiver_purpose(x)[0] or ""), _resolve_receiver_purpose(x)[0] or "" ), ) if (x.source_type or "") == "manual_out": linked = linked_task_no_map.get(x.id, "") if linked: row_dict["qcTaskNo"] = linked out.append(row_dict) return out, int(total) 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, keyword: Optional[str] = None, stock_in_no: Optional[str] = None, user_info=None, ) -> Tuple[List[Dict[str, Any]], int]: q0 = _list_usages_query( db, tenant_no, material_id=material_id, change_type=change_type, start_time=start_time, end_time=end_time, keyword=keyword, stock_in_no=stock_in_no, user_info=user_info, ) return _paginate_usages(db, q0, tenant_no=tenant_no, page=page, page_size=page_size) def list_my_usages( db: Session, tenant_no: str, *, user_id: str, nick_name: 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, stock_in_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: """当前登录人的物料领用记录(手动出库 + 任务使用)。""" q0 = _list_usages_query( db, tenant_no, material_id=material_id, change_type=change_type, start_time=start_time, end_time=end_time, keyword=keyword, stock_in_no=stock_in_no, mine_user_id=user_id, mine_nick_name=nick_name, ) return _paginate_usages(db, q0, tenant_no=tenant_no, page=page, page_size=page_size) def list_usages_by_task( db: Session, task_id: str, tenant_no: str, *, user_info=None, ) -> List[Dict[str, Any]]: """查询质检任务关联的物料使用记录(task_complete)。""" q = db.query(BizQcMaterialUsage).filter( BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.qc_task_id == task_id, BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "task_complete", ) if user_info: from app.core.data_scope import apply_data_scope q = apply_data_scope(q, BizQcMaterialUsage, user_info, db, tenant_no) rows = q.order_by(BizQcMaterialUsage.create_time.asc()).all() bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id]) parent_ids = [x.parent_usage_id for x in rows if (x.parent_usage_id or "").strip()] parent_map: Dict[str, BizQcMaterialUsage] = {} if parent_ids: parents = ( db.query(BizQcMaterialUsage) .filter( BizQcMaterialUsage.id.in_(parent_ids), BizQcMaterialUsage.del_flag == 0, ) .all() ) parent_map = {p.id: p for p in parents} parent_receivers = [] for p in parent_map.values(): rcv, _ = _resolve_receiver_purpose(p) if rcv: parent_receivers.append(rcv) parent_receiver_map = _receiver_display_map(db, parent_receivers) out: List[Dict[str, Any]] = [] for x in rows: parent = parent_map.get((x.parent_usage_id or "").strip()) req_rcv, _ = _resolve_receiver_purpose(parent) if parent else ("", "") req_rcv_name = parent_receiver_map.get(req_rcv, req_rcv) if req_rcv else "" rcv, _ = _resolve_receiver_purpose(x) out.append( _row_to_dict( x, batch=bmap.get(x.batch_id or ""), receiver_name=parent_receiver_map.get(rcv, rcv) if not parent else req_rcv_name, requisition_receiver=req_rcv, requisition_receiver_name=req_rcv_name, ) ) return out def list_linked_task_usages( db: Session, parent_usage_id: str, tenant_no: str, *, user_info=None, ) -> List[Dict[str, Any]]: """查询领用记录关联的质检任务物料消耗(parent_usage_id → task_complete)。""" pid = (parent_usage_id or "").strip() if not pid: return [] parent = ( db.query(BizQcMaterialUsage) .filter( BizQcMaterialUsage.id == pid, BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, ) .first() ) if not parent: raise HTTPException(status_code=404, detail="领用记录不存在") if (parent.source_type or "") != "manual_out": return [] q = db.query(BizQcMaterialUsage).filter( BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.parent_usage_id == pid, BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "task_complete", ) if user_info: from app.core.data_scope import apply_data_scope q = apply_data_scope(q, BizQcMaterialUsage, user_info, db, tenant_no) rows = q.order_by(BizQcMaterialUsage.create_time.desc()).all() bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id]) 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 "", ""), batch=bmap.get(x.batch_id or ""), requisition_receiver_name="", ) for x in rows ] def revert_task_material_usages( db: Session, task_id: str, tenant_no: str, user_id: Optional[str] = None, ) -> None: """撤销任务此前 task_complete 扣减,便于重新填报物料使用。""" rows = ( db.query(BizQcMaterialUsage) .filter( BizQcMaterialUsage.qc_task_id == task_id, BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.source_type == "task_complete", BizQcMaterialUsage.change_type == "use", ) .all() ) if not rows: return now = datetime.now() for r in rows: if (r.parent_usage_id or "").strip(): r.del_flag = 1 r.update_by = user_id r.update_time = now continue 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 batch_id = (r.batch_id or "").strip() if batch_id: batch = ( db.query(BizQcMaterialStockBatch) .with_for_update() .filter( BizQcMaterialStockBatch.id == batch_id, BizQcMaterialStockBatch.tenant_no == tenant_no, BizQcMaterialStockBatch.del_flag == 0, ) .first() ) if batch: batch.remaining_qty = (batch.remaining_qty or Decimal("0")) + restore_qty batch.update_by = user_id batch.update_time = now material = ( db.query(BizQcMaterial) .with_for_update() .filter( BizQcMaterial.id == r.material_id, BizQcMaterial.tenant_no == tenant_no, BizQcMaterial.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 list_requisition_options( db: Session, tenant_no: str, *, keyword: Optional[str] = None, limit: int = 50, user_info=None, ) -> List[Dict[str, Any]]: """领用记录选项(manual_out),供质检任务关联物料使用。""" q0 = db.query(BizQcMaterialUsage).filter( BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "manual_out", BizQcMaterialUsage.requisition_status == "open", ) if user_info: from app.core.data_scope import apply_data_scope q0 = apply_data_scope(q0, BizQcMaterialUsage, user_info, db, tenant_no) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q0 = q0.filter( or_( BizQcMaterialUsage.material_code.like(like), BizQcMaterialUsage.material_name.like(like), BizQcMaterialUsage.stock_in_no.like(like), BizQcMaterialUsage.batch_no.like(like), BizQcMaterialUsage.catalog_no.like(like), BizQcMaterialUsage.receiver.like(like), ) ) limit = min(max(1, limit), 100) rows = q0.order_by(BizQcMaterialUsage.create_time.desc()).limit(limit).all() bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id]) receivers = [] for r in rows: rcv, _ = _resolve_receiver_purpose(r) if rcv: receivers.append(rcv) receiver_map = _receiver_display_map(db, receivers) options: List[Dict[str, Any]] = [] for r in rows: batch = bmap.get(r.batch_id or "") rcv, _ = _resolve_receiver_purpose(r) rcv_name = receiver_map.get(rcv, rcv) if rcv else "" qty_abs = abs(float(r.change_qty or 0)) unit = _usage_unit_label(r, batch) use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "" unit_suffix = unit or "" label = ( f"{use_time} {r.material_name or ''} / {r.stock_in_no or ''} " f"/ 批号:{r.batch_no or '—'} / 领用:{qty_abs}{unit_suffix} / 领用人:{rcv_name or '—'}" ).strip() options.append( { "id": r.id, "label": label, "materialId": r.material_id, "materialName": r.material_name or "", "stockInNo": r.stock_in_no or "", "batchNo": r.batch_no or "", "batchId": r.batch_id or "", "receiver": rcv, "receiverName": rcv_name, "requisitionQty": qty_abs, "usageUnit": unit, "useTime": use_time, } ) return options def _record_task_usage_from_requisition( db: Session, task_id: str, task_no: str, item: Dict[str, Any], tenant_no: str, user_id: Optional[str] = None, dept_id: str = "", ) -> None: req_id = ( item.get("requisitionUsageId") or item.get("requisition_usage_id") or item.get("parentUsageId") or item.get("parent_usage_id") or "" ).strip() if not req_id: raise HTTPException(status_code=400, detail="请选择领用记录") qty = item.get("qty") if qty is None or Decimal(str(qty)) <= 0: raise HTTPException(status_code=400, detail="物料使用数量必须大于0") qty_dec = Decimal(str(qty)) req = ( db.query(BizQcMaterialUsage) .filter( BizQcMaterialUsage.id == req_id, BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.source_type == "manual_out", BizQcMaterialUsage.change_type == "use", ) .first() ) if not req: raise HTTPException(status_code=400, detail="领用记录不存在或已失效") material = ( db.query(BizQcMaterial) .filter( BizQcMaterial.id == req.material_id, BizQcMaterial.tenant_no == tenant_no, BizQcMaterial.del_flag == 0, ) .first() ) if not material: raise HTTPException(status_code=400, detail="关联物料不存在") usage_unit = ( str(item.get("usageUnit") or item.get("usage_unit") or "").strip() or _usage_unit_label(req, None) or "" )[:32] stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0") usage_remark = str(item.get("remark") or "").strip()[:512] now = datetime.now() usage = BizQcMaterialUsage( id=generate_uuid(), material_id=req.material_id, material_code=req.material_code or material.material_code, material_name=req.material_name or 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, unit_price=req.unit_price, amount=req.amount, change_type="use", change_qty=-qty_dec, usage_unit=usage_unit or None, stock_before=stock_qty, stock_after=stock_qty, qc_task_id=task_id, qc_task_no=task_no, parent_usage_id=req_id, source_type="task_complete", tenant_no=tenant_no, remark=usage_remark, create_by=user_id, create_dept_id=dept_id, create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(usage) def _deduct_task_usage_legacy_batch( db: Session, task_id: str, task_no: str, item: Dict[str, Any], tenant_no: str, user_id: Optional[str] = None, dept_id: str = "", ) -> None: """旧版:按入库批次扣减库存(兼容历史数据)。""" batch_id = (item.get("batchId") or item.get("batch_id") or "").strip() qty = item.get("qty") if not batch_id: raise HTTPException(status_code=400, detail="入库批次不能为空") if qty is None or Decimal(str(qty)) <= 0: raise HTTPException(status_code=400, detail="物料使用数量必须大于0") qty_dec = Decimal(str(qty)) batch = ( db.query(BizQcMaterialStockBatch) .with_for_update() .filter( BizQcMaterialStockBatch.id == batch_id, BizQcMaterialStockBatch.tenant_no == tenant_no, BizQcMaterialStockBatch.del_flag == 0, ) .first() ) if not batch: raise HTTPException(status_code=400, detail=f"入库批次不存在(ID: {batch_id})") rem = batch.remaining_qty or Decimal("0") if rem < qty_dec: raise HTTPException( status_code=400, detail=f"批次「{batch.stock_in_no}」库存不足,当前剩余 {rem},需使用 {qty_dec}", ) material = ( db.query(BizQcMaterial) .with_for_update() .filter( BizQcMaterial.id == batch.material_id, BizQcMaterial.tenant_no == tenant_no, BizQcMaterial.del_flag == 0, BizQcMaterial.status == "1", ) .first() ) if not material: raise HTTPException(status_code=400, detail="物料不存在或已停用") batch.remaining_qty = rem - qty_dec batch.update_by = user_id batch.update_time = datetime.now() db.add(batch) 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}」汇总库存不足", ) new_stock = stock_qty - qty_dec material.stock_qty = new_stock material.update_by = user_id material.update_time = datetime.now() db.add(material) usage_remark = str(item.get("remark") or "").strip()[:512] usage_unit = str(item.get("usageUnit") or item.get("usage_unit") or "").strip()[:32] or None snaps = _usage_batch_snapshots(batch, out_qty=qty_dec) usage = BizQcMaterialUsage( id=generate_uuid(), material_id=material.id, material_code=material.material_code, material_name=material.material_name, change_type="use", change_qty=-qty_dec, usage_unit=usage_unit, stock_before=stock_qty, stock_after=new_stock, qc_task_id=task_id, qc_task_no=task_no, source_type="task_complete", tenant_no=tenant_no, remark=usage_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, **snaps, ) db.add(usage) 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。""" revert_task_material_usages(db, task_id, tenant_no, user_id=user_id) seen_requisition: set = set() seen_batch: set = set() for item in material_usages: req_id = ( item.get("requisitionUsageId") or item.get("requisition_usage_id") or item.get("parentUsageId") or "" ).strip() if req_id: if req_id in seen_requisition: raise HTTPException(status_code=400, detail="物料列表中存在重复领用记录,请检查") seen_requisition.add(req_id) _record_task_usage_from_requisition( db, task_id, task_no, item, tenant_no, user_id=user_id, dept_id=dept_id, ) continue batch_id = (item.get("batchId") or item.get("batch_id") or "").strip() if not batch_id: raise HTTPException(status_code=400, detail="请选择领用记录") if batch_id in seen_batch: raise HTTPException(status_code=400, detail="物料列表中存在重复入库批次,请检查") seen_batch.add(batch_id) _deduct_task_usage_legacy_batch( db, task_id, task_no, item, tenant_no, user_id=user_id, dept_id=dept_id, ) def _task_consumed_qty_map( db: Session, tenant_no: str, parent_usage_ids: List[str], ) -> Dict[str, float]: ids = [x for x in parent_usage_ids if x] if not ids: return {} rows = ( db.query( BizQcMaterialUsage.parent_usage_id, func.coalesce(func.sum(func.abs(BizQcMaterialUsage.change_qty)), 0), ) .filter( BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.parent_usage_id.in_(ids), BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "task_complete", ) .group_by(BizQcMaterialUsage.parent_usage_id) .all() ) return {pid: float(qty or 0) for pid, qty in rows if pid} def _fmt_date_val(v) -> str: if v is None: return "" if hasattr(v, "strftime"): return v.strftime("%Y-%m-%d") return str(v)[:10] def list_stock_check_requisitions( db: Session, tenant_no: str, *, page: int = 1, page_size: int = 50, keyword: Optional[str] = None, user_info=None, ) -> Tuple[List[Dict[str, Any]], int]: """盘点登记弹窗:待处理领用记录(未标记已使用完)。""" q0 = db.query(BizQcMaterialUsage).filter( BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "manual_out", BizQcMaterialUsage.requisition_status == "open", ) if user_info: from app.core.data_scope import apply_data_scope q0 = apply_data_scope(q0, BizQcMaterialUsage, user_info, db, tenant_no) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q0 = q0.filter( or_( BizQcMaterialUsage.material_code.like(like), BizQcMaterialUsage.material_name.like(like), BizQcMaterialUsage.stock_in_no.like(like), BizQcMaterialUsage.batch_no.like(like), BizQcMaterialUsage.catalog_no.like(like), BizQcMaterialUsage.receiver.like(like), ) ) total = q0.order_by(None).with_entities(func.count(BizQcMaterialUsage.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 200) rows = ( q0.order_by(BizQcMaterialUsage.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id]) name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by]) receivers = [] for r in rows: rcv, _ = _resolve_receiver_purpose(r) if rcv: receivers.append(rcv) receiver_map = _receiver_display_map(db, receivers) consumed_map = _task_consumed_qty_map(db, tenant_no, [x.id for x in rows]) out: List[Dict[str, Any]] = [] for r in rows: batch = bmap.get(r.batch_id or "") rcv, purpose = _resolve_receiver_purpose(r) rcv_name = receiver_map.get(rcv, rcv) if rcv else "" qty_abs = abs(float(r.change_qty or 0)) unit = _usage_unit_label(r, batch) use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "" task_consumed = consumed_map.get(r.id, 0) out.append( { "id": r.id, "materialId": r.material_id, "materialCode": r.material_code or "", "materialName": r.material_name or "", "specModel": (batch.spec_model if batch else "") or "", "batchId": r.batch_id or "", "stockInNo": r.stock_in_no or "", "stockInDate": _stock_in_date_for_row(r, batch), "expireDate": _fmt_date_val(batch.expire_date if batch else None), "catalogNo": r.catalog_no or "", "batchNo": r.batch_no or "", "manufacturer": r.manufacturer or "", "unitPrice": _unit_price_for_row(r, batch), "unit": unit, "requisitionQty": qty_abs, "taskConsumedQty": task_consumed, "receiver": rcv, "receiverName": rcv_name, "purpose": purpose, "useTime": use_time, "requisitionStatus": (r.requisition_status or "open").strip() or "open", "createByName": name_map.get(r.create_by or "", r.create_by or ""), } ) return out, int(total) def mark_requisition_fully_used( db: Session, usage_id: str, *, tenant_no: str, user_id: Optional[str] = None, user_info=None, ) -> None: row = ( db.query(BizQcMaterialUsage) .filter( BizQcMaterialUsage.id == usage_id, BizQcMaterialUsage.tenant_no == tenant_no, BizQcMaterialUsage.del_flag == 0, BizQcMaterialUsage.change_type == "use", BizQcMaterialUsage.source_type == "manual_out", ) .first() ) if not row: raise ValueError("领用记录不存在或不可标记") if user_info: from app.core.data_scope import apply_data_scope scoped = apply_data_scope( db.query(BizQcMaterialUsage).filter(BizQcMaterialUsage.id == usage_id), BizQcMaterialUsage, user_info, db, tenant_no, ) if not scoped.first(): raise PermissionError("无权操作该领用记录") if (row.requisition_status or "open") == "closed": return now = datetime.now() row.requisition_status = "closed" row.update_by = user_id row.update_time = now db.add(row) db.commit()