| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080 |
- """物料变动记录 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()
|