"""库存管理业务逻辑""" import uuid from datetime import date, datetime from typing import Optional, List from sqlalchemy.orm import Session from sqlalchemy import func, text from fastapi import HTTPException from app.models.inventory import ( MaterialCategory, Material, InventoryBatch, SerialDevice, InventoryTransaction, InventoryCheck ) def _gen_no(prefix: str) -> str: return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}" # ── 物料分类 ────────────────────────────────────────────────────────────────── def get_categories(db: Session) -> List[MaterialCategory]: return db.query(MaterialCategory).order_by(MaterialCategory.id).all() def create_category(db: Session, name: str, code: str, description: Optional[str]) -> MaterialCategory: if db.query(MaterialCategory).filter(MaterialCategory.code == code).first(): raise HTTPException(status_code=400, detail="分类代码已存在") cat = MaterialCategory(name=name, code=code, description=description) db.add(cat) db.commit() db.refresh(cat) return cat # ── 物料 ────────────────────────────────────────────────────────────────────── def get_materials(db: Session, skip: int, limit: int, category_id: Optional[int], keyword: Optional[str]): q = db.query(Material) if category_id: q = q.filter(Material.category_id == category_id) if keyword: q = q.filter(Material.name.contains(keyword) | Material.model.contains(keyword)) total = q.count() items = q.order_by(Material.id).offset(skip).limit(limit).all() result = [] for m in items: total_qty = db.query(func.sum(InventoryBatch.quantity)).filter( InventoryBatch.material_id == m.id ).scalar() or 0 avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter( InventoryBatch.material_id == m.id ).scalar() or 0 item = { "id": m.id, "category_id": m.category_id, "category_name": m.category.name if m.category else None, "name": m.name, "model": m.model, "specification": m.specification, "brand": m.brand, "unit": m.unit, "safe_quantity": m.safe_quantity, "notes": m.notes, "total_quantity": total_qty, "available_quantity": avail_qty, "created_at": m.created_at, } result.append(item) return {"total": total, "items": result} def create_material(db: Session, data: dict) -> Material: m = Material(**data) db.add(m) db.commit() db.refresh(m) return m def update_material(db: Session, material_id: int, data: dict) -> Material: m = db.query(Material).filter(Material.id == material_id).first() if not m: raise HTTPException(status_code=404, detail="物料不存在") for k, v in data.items(): if v is not None: setattr(m, k, v) db.commit() db.refresh(m) return m def delete_material(db: Session, material_id: int): m = db.query(Material).filter(Material.id == material_id).first() if not m: raise HTTPException(status_code=404, detail="物料不存在") has_stock = db.query(InventoryBatch).filter( InventoryBatch.material_id == material_id, InventoryBatch.available_quantity > 0, ).first() if has_stock: raise HTTPException(status_code=400, detail="该物料仍有库存,请先出库后再删除") db.delete(m) db.commit() # ── 采购入库 ────────────────────────────────────────────────────────────────── def purchase_in(db: Session, data: dict, operator_id: int) -> dict: material_id = data["material_id"] m = db.query(Material).filter(Material.id == material_id).first() if not m: raise HTTPException(status_code=404, detail="物料不存在") serial_nos = data.pop("serial_nos", None) or [] quantity = data["quantity"] batch = InventoryBatch( material_id=material_id, batch_no=data["batch_no"], quantity=quantity, available_quantity=quantity, supplier=data.get("supplier"), purchase_date=data.get("purchase_date"), purchase_price=data.get("purchase_price"), location=data.get("location"), status="in_stock", ) db.add(batch) db.flush() # 高价值设备:逐个创建序列号记录 for sn in serial_nos: sd = SerialDevice( material_id=material_id, batch_id=batch.id, serial_no=sn, status="in_stock", ) db.add(sd) txn = InventoryTransaction( transaction_no=_gen_no("PI"), transaction_type="purchase_in", material_id=material_id, batch_id=batch.id, quantity=quantity, from_status=None, to_status="in_stock", operator_id=operator_id, notes=data.get("notes"), ) db.add(txn) db.commit() return {"message": "入库成功", "batch_id": batch.id, "transaction_no": txn.transaction_no} # ── 领用出库 ────────────────────────────────────────────────────────────────── def allocate_out(db: Session, data: dict, operator_id: int) -> dict: serial_device_id = data.get("serial_device_id") batch_id = data.get("batch_id") quantity = data.get("quantity", 1) if serial_device_id: sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first() if not sd: raise HTTPException(status_code=404, detail="设备不存在") if sd.status != "in_stock": raise HTTPException(status_code=400, detail=f"设备当前状态为 {sd.status},无法领用") batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first() if batch: batch.available_quantity = max(0, batch.available_quantity - 1) sd.status = "allocated" sd.installed_info = data.get("installation_info") txn = InventoryTransaction( transaction_no=_gen_no("AO"), transaction_type="allocate_out", material_id=sd.material_id, batch_id=sd.batch_id, serial_device_id=sd.id, quantity=1, from_status="in_stock", to_status="allocated", operator_id=operator_id, project_name=data.get("project_name"), installation_info=data.get("installation_info"), notes=data.get("notes"), ) db.add(txn) db.commit() return {"message": "领用成功", "transaction_no": txn.transaction_no} elif batch_id: batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first() if not batch: raise HTTPException(status_code=404, detail="批次不存在") if batch.available_quantity < quantity: raise HTTPException(status_code=400, detail="库存不足") batch.available_quantity -= quantity # 方案三:领用时补录设备标识,存入 installation_info device_info = data.get("installation_info") or {} if isinstance(device_info, str): device_info = {} mac = data.get("mac_address", "").strip() if data.get("mac_address") else "" sn = data.get("serial_no", "").strip() if data.get("serial_no") else "" asset = data.get("asset_no", "").strip() if data.get("asset_no") else "" if mac or sn or asset: device_info = {k: v for k, v in {"mac_address": mac, "serial_no": sn, "asset_no": asset}.items() if v} txn = InventoryTransaction( transaction_no=_gen_no("AO"), transaction_type="allocate_out", material_id=batch.material_id, batch_id=batch_id, quantity=quantity, from_status="in_stock", to_status="allocated", operator_id=operator_id, project_name=data.get("project_name"), installation_info=device_info if device_info else None, notes=data.get("notes"), ) db.add(txn) db.commit() return {"message": "领用成功", "transaction_no": txn.transaction_no} raise HTTPException(status_code=400, detail="需要指定序列号设备或批次") # ── 退库 ────────────────────────────────────────────────────────────────────── def return_in(db: Session, serial_device_id: int, return_type: str, notes: Optional[str], operator_id: int) -> dict: sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first() if not sd: raise HTTPException(status_code=404, detail="设备不存在") from_status = sd.status if return_type == "scrap": to_status = "scrapped" elif return_type == "repair": to_status = "repairing" else: to_status = "in_stock" # 归还时恢复批次可用数量 if sd.batch_id: batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first() if batch: batch.available_quantity += 1 sd.status = to_status txn = InventoryTransaction( transaction_no=_gen_no("RI"), transaction_type="return_in" if return_type != "scrap" else "scrap_out", material_id=sd.material_id, batch_id=sd.batch_id, serial_device_id=sd.id, quantity=1, from_status=from_status, to_status=to_status, operator_id=operator_id, notes=notes, ) db.add(txn) db.commit() return {"message": "退库成功", "transaction_no": txn.transaction_no} # ── 批次查询 ────────────────────────────────────────────────────────────────── def get_batches_by_material(db: Session, material_id: int) -> list: batches = db.query(InventoryBatch).filter( InventoryBatch.material_id == material_id, InventoryBatch.available_quantity > 0, ).order_by(InventoryBatch.id.desc()).all() return [ { "id": b.id, "batch_no": b.batch_no, "quantity": b.quantity, "available_quantity": b.available_quantity, "supplier": b.supplier, "location": b.location, } for b in batches ] # ── 序列号设备查询 ──────────────────────────────────────────────────────────── def get_serial_devices(db: Session, skip: int, limit: int, material_id: Optional[int], status: Optional[str], keyword: Optional[str]): q = db.query(SerialDevice) if material_id: q = q.filter(SerialDevice.material_id == material_id) if status: q = q.filter(SerialDevice.status == status) if keyword: q = q.filter( SerialDevice.serial_no.contains(keyword) | SerialDevice.mac_address.contains(keyword) | SerialDevice.asset_no.contains(keyword) ) total = q.count() items = q.order_by(SerialDevice.id.desc()).offset(skip).limit(limit).all() result = [] for sd in items: result.append({ "id": sd.id, "material_id": sd.material_id, "material_name": sd.material.name if sd.material else None, "batch_id": sd.batch_id, "serial_no": sd.serial_no, "mac_address": sd.mac_address, "asset_no": sd.asset_no, "status": sd.status, "current_location": sd.current_location, "installed_info": sd.installed_info, "notes": sd.notes, "created_at": sd.created_at, "updated_at": sd.updated_at, }) return {"total": total, "items": result} def get_transaction_detail(db: Session, transaction_id: int) -> dict: t = db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first() if not t: raise HTTPException(status_code=404, detail="记录不存在") m = db.query(Material).filter(Material.id == t.material_id).first() batch = db.query(InventoryBatch).filter(InventoryBatch.id == t.batch_id).first() if t.batch_id else None sd = db.query(SerialDevice).filter(SerialDevice.id == t.serial_device_id).first() if t.serial_device_id else None return { "id": t.id, "transaction_no": t.transaction_no, "transaction_type": t.transaction_type, "material_id": t.material_id, "material_name": m.name if m else None, "material_model": m.model if m else None, "material_brand": m.brand if m else None, "quantity": t.quantity, "from_status": t.from_status, "to_status": t.to_status, "operator_id": t.operator_id, "project_name": t.project_name, "installation_info": t.installation_info, "notes": t.notes, "created_at": t.created_at, "batch": { "id": batch.id, "batch_no": batch.batch_no, "supplier": batch.supplier, "purchase_date": str(batch.purchase_date) if batch.purchase_date else None, "purchase_price": str(batch.purchase_price) if batch.purchase_price else None, "location": batch.location, } if batch else None, "serial_device": { "id": sd.id, "serial_no": sd.serial_no, "mac_address": sd.mac_address, "asset_no": sd.asset_no, } if sd else None, } def get_transactions(db: Session, skip: int, limit: int, transaction_type: Optional[str], material_id: Optional[int]): q = db.query(InventoryTransaction) if transaction_type: q = q.filter(InventoryTransaction.transaction_type == transaction_type) if material_id: q = q.filter(InventoryTransaction.material_id == material_id) total = q.count() items = q.order_by(InventoryTransaction.id.desc()).offset(skip).limit(limit).all() result = [] for t in items: m = db.query(Material).filter(Material.id == t.material_id).first() result.append({ "id": t.id, "transaction_no": t.transaction_no, "transaction_type": t.transaction_type, "material_id": t.material_id, "material_name": m.name if m else None, "quantity": t.quantity, "from_status": t.from_status, "to_status": t.to_status, "operator_id": t.operator_id, "project_name": t.project_name, "installation_info": t.installation_info, "notes": t.notes, "created_at": t.created_at, }) return {"total": total, "items": result} # ── 库存总览 ────────────────────────────────────────────────────────────────── def get_summary(db: Session) -> dict: total_materials = db.query(Material).count() total_qty = db.query(func.sum(InventoryBatch.quantity)).scalar() or 0 avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).scalar() or 0 allocated_qty = total_qty - avail_qty # 低库存物料数 low_stock = db.execute(text(""" SELECT COUNT(*) FROM ( SELECT m.id, m.safe_quantity, COALESCE(SUM(b.available_quantity), 0) AS avail FROM materials m LEFT JOIN inventory_batches b ON b.material_id = m.id GROUP BY m.id, m.safe_quantity HAVING COALESCE(SUM(b.available_quantity), 0) <= m.safe_quantity AND m.safe_quantity > 0 ) t """)).scalar() or 0 # 按分类统计 rows = db.execute(text(""" SELECT c.name, COUNT(m.id) AS material_count, COALESCE(SUM(b.available_quantity), 0) AS available FROM material_categories c LEFT JOIN materials m ON m.category_id = c.id LEFT JOIN inventory_batches b ON b.material_id = m.id GROUP BY c.id, c.name ORDER BY c.id """)).fetchall() category_stats = [{"name": r[0], "material_count": r[1], "available": r[2]} for r in rows] return { "total_materials": total_materials, "total_quantity": total_qty, "available_quantity": avail_qty, "allocated_quantity": allocated_qty, "low_stock_count": low_stock, "category_stats": category_stats, } # ── 盘点 ────────────────────────────────────────────────────────────────────── def create_check(db: Session, data: dict, checker_id: int) -> InventoryCheck: material_id = data["material_id"] batch_id = data.get("batch_id") if batch_id: batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first() book_qty = batch.available_quantity if batch else 0 else: book_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter( InventoryBatch.material_id == material_id ).scalar() or 0 actual_qty = data["actual_quantity"] diff = actual_qty - book_qty check = InventoryCheck( check_no=_gen_no("CK"), check_date=data["check_date"], checker_id=checker_id, material_id=material_id, batch_id=batch_id, book_quantity=book_qty, actual_quantity=actual_qty, difference=diff, reason=data.get("reason"), adjusted=False, ) db.add(check) db.commit() db.refresh(check) return check def adjust_check(db: Session, check_id: int, operator_id: int) -> dict: check = db.query(InventoryCheck).filter(InventoryCheck.id == check_id).first() if not check: raise HTTPException(status_code=404, detail="盘点记录不存在") if check.adjusted: raise HTTPException(status_code=400, detail="已调整过") if check.batch_id: batch = db.query(InventoryBatch).filter(InventoryBatch.id == check.batch_id).first() if batch: batch.available_quantity = check.actual_quantity else: # 调整第一个批次(简化处理) batch = db.query(InventoryBatch).filter( InventoryBatch.material_id == check.material_id ).first() if batch: batch.available_quantity = check.actual_quantity txn = InventoryTransaction( transaction_no=_gen_no("ADJ"), transaction_type="adjust", material_id=check.material_id, batch_id=check.batch_id, quantity=abs(check.difference or 0), from_status="in_stock", to_status="in_stock", operator_id=operator_id, notes=f"盘点调整:{check.check_no},差异 {check.difference}", ) db.add(txn) check.adjusted = True db.commit() return {"message": "调整成功"} def get_checks(db: Session, skip: int, limit: int, material_id: Optional[int]): q = db.query(InventoryCheck) if material_id: q = q.filter(InventoryCheck.material_id == material_id) total = q.count() items = q.order_by(InventoryCheck.id.desc()).offset(skip).limit(limit).all() result = [] for c in items: m = db.query(Material).filter(Material.id == c.material_id).first() result.append({ "id": c.id, "check_no": c.check_no, "check_date": c.check_date, "material_id": c.material_id, "material_name": m.name if m else None, "batch_id": c.batch_id, "book_quantity": c.book_quantity, "actual_quantity": c.actual_quantity, "difference": c.difference, "reason": c.reason, "adjusted": c.adjusted, "created_at": c.created_at, }) return {"total": total, "items": result}