"""市场物料变动记录 CRUD""" from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.models.material import BizMaterialStockBatch, BizMaterialUsage from app.models.models import SysUser def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]: receiver, purpose = "", "" for part in (remark or "").split(";"): seg = part.strip() if seg.startswith("领用人:"): receiver = seg[len("领用人:") :].strip() elif seg.startswith("用途:"): purpose = seg[len("用途:") :].strip() return receiver, purpose def _resolve_receiver_purpose(r: BizMaterialUsage) -> 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: BizMaterialUsage) -> 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]: 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: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> 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: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> str: if batch and batch.stock_in_date: return batch.stock_in_date.strftime("%Y-%m-%d") return "" def _unit_price_for_row(r: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> 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: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> 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: BizMaterialUsage, *, create_by_name: str = "", batch: Optional[BizMaterialStockBatch] = None, 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) 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, "receiver": receiver, "receiverName": receiver_name or receiver, "operatorName": (getattr(r, "operator_name", None) or "").strip(), "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 _batch_map(db: Session, batch_ids: List[str]) -> Dict[str, BizMaterialStockBatch]: ids = [x for x in batch_ids if x] if not ids: return {} rows = ( db.query(BizMaterialStockBatch) .filter(BizMaterialStockBatch.id.in_(ids), BizMaterialStockBatch.del_flag == 0) .all() ) return {r.id: r for r in rows} 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, ): q0 = db.query(BizMaterialUsage).filter( BizMaterialUsage.tenant_no == tenant_no, BizMaterialUsage.del_flag == 0, ) if material_id: q0 = q0.filter(BizMaterialUsage.material_id == material_id) ct = (change_type or "").strip() if ct == "out": q0 = q0.filter( BizMaterialUsage.change_type == "use", BizMaterialUsage.source_type == "manual_out", ) elif ct == "in": q0 = q0.filter(BizMaterialUsage.change_type == "in") elif ct == "discard": q0 = q0.filter(BizMaterialUsage.change_type == "discard") sno = (stock_in_no or "").strip() if sno: q0 = q0.filter(BizMaterialUsage.stock_in_no.like(f"%{sno}%")) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q0 = q0.filter( or_( BizMaterialUsage.material_code.like(like), BizMaterialUsage.material_name.like(like), BizMaterialUsage.stock_in_no.like(like), BizMaterialUsage.batch_no.like(like), BizMaterialUsage.catalog_no.like(like), ) ) if start_time: q0 = q0.filter(BizMaterialUsage.create_time >= start_time) if end_time: q0 = q0.filter(BizMaterialUsage.create_time <= end_time + " 23:59:59") if user_info: from app.core.data_scope import apply_data_scope q0 = apply_data_scope(q0, BizMaterialUsage, user_info, db, tenant_no) return q0 def _paginate_usages(db: Session, q0, *, page: int, page_size: int) -> Tuple[List[Dict[str, Any]], int]: total = q0.order_by(None).with_entities(func.count(BizMaterialUsage.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q0.order_by(BizMaterialUsage.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) return [ _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 "" ), ) for x in rows ], 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, page=page, page_size=page_size) 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(BizMaterialUsage).filter( BizMaterialUsage.tenant_no == tenant_no, BizMaterialUsage.del_flag == 0, BizMaterialUsage.change_type == "use", BizMaterialUsage.source_type == "manual_out", BizMaterialUsage.requisition_status == "open", ) if user_info: from app.core.data_scope import apply_data_scope q0 = apply_data_scope(q0, BizMaterialUsage, user_info, db, tenant_no) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q0 = q0.filter( or_( BizMaterialUsage.material_code.like(like), BizMaterialUsage.material_name.like(like), BizMaterialUsage.stock_in_no.like(like), BizMaterialUsage.batch_no.like(like), BizMaterialUsage.catalog_no.like(like), BizMaterialUsage.receiver.like(like), ) ) total = q0.order_by(None).with_entities(func.count(BizMaterialUsage.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 200) rows = ( q0.order_by(BizMaterialUsage.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) 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 "" 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": 0, "receiver": rcv, "receiverName": rcv_name, "operatorName": (getattr(r, "operator_name", None) or "").strip(), "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(BizMaterialUsage) .filter( BizMaterialUsage.id == usage_id, BizMaterialUsage.tenant_no == tenant_no, BizMaterialUsage.del_flag == 0, BizMaterialUsage.change_type == "use", BizMaterialUsage.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(BizMaterialUsage).filter(BizMaterialUsage.id == usage_id), BizMaterialUsage, 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()