| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021 |
- """市场物料入库批次 CRUD"""
- from __future__ import annotations
- from datetime import date, datetime
- from decimal import Decimal
- from typing import Any, Dict, List, Optional, Tuple
- from sqlalchemy import func, or_
- from sqlalchemy.orm import Session
- from app.core.utils import generate_uuid
- from app.crud.material import _dec, _fmt_dt, _is_enabled_status, _norm_status
- from app.models.material import BizMaterial, BizMaterialStockBatch, BizMaterialUsage
- _SENTINEL_EXPIRE_DATE = date(9999, 12, 31)
- _DEFAULT_STOCK_IN_QTY = Decimal("1")
- def _fmt_date(d: Optional[date]) -> str:
- if not d or d >= _SENTINEL_EXPIRE_DATE:
- return ""
- return d.strftime("%Y-%m-%d")
- def _parse_date(v: Any, *, label: str = "入库日期") -> date:
- if isinstance(v, date) and not isinstance(v, datetime):
- return v
- s = str(v or "").strip()
- if not s:
- raise ValueError(f"{label}不能为空")
- if len(s) >= 10:
- s = s[:10]
- try:
- return datetime.strptime(s, "%Y-%m-%d").date()
- except ValueError as e:
- raise ValueError("入库日期格式无效,应为 YYYY-MM-DD") from e
- def _parse_expire_date(v: Any) -> date:
- if isinstance(v, date) and not isinstance(v, datetime):
- if v >= _SENTINEL_EXPIRE_DATE:
- return _SENTINEL_EXPIRE_DATE
- return v
- s = str(v or "").strip()
- if not s:
- return _SENTINEL_EXPIRE_DATE
- return _parse_date(v, label="过期日期")
- def _opt_str(v: Any, *, default: str = "") -> str:
- return str(v or "").strip() or default
- def _opt_decimal(v: Any, *, default: Decimal = Decimal("0")) -> Decimal:
- if v is None or str(v).strip() == "":
- return default
- try:
- d = Decimal(str(v))
- except Exception as e:
- raise ValueError("金额格式无效") from e
- if d < 0:
- raise ValueError("金额不能为负数")
- return d
- def _opt_stock_in_date(v: Any) -> date:
- if isinstance(v, date) and not isinstance(v, datetime):
- return v
- s = str(v or "").strip()
- if not s:
- return date.today()
- return _parse_date(v, label="入库日期")
- def _opt_stock_in_qty(v: Any) -> Decimal:
- if v is None or str(v).strip() == "":
- return _DEFAULT_STOCK_IN_QTY
- try:
- qty = Decimal(str(v))
- except Exception as e:
- raise ValueError("入库数量格式无效") from e
- if qty < 0:
- raise ValueError("入库数量不能为负数")
- return qty
- def _gen_stock_in_no(db: Session, tenant_no: str) -> str:
- for _ in range(10):
- suffix = generate_uuid()[:8].upper()
- sno = f"RK{datetime.now().strftime('%Y%m%d%H%M%S')}{suffix}"
- if not stock_in_no_exists(db, sno, tenant_no):
- return sno
- raise ValueError("入库编号自动生成失败,请稍后重试")
- def _resolve_stock_in_no(
- db: Session,
- tenant_no: str,
- stock_in_no: Any,
- *,
- exclude_id: Optional[str] = None,
- seen_nos: Optional[set] = None,
- ) -> str:
- s = str(stock_in_no or "").strip()
- if not s:
- s = _gen_stock_in_no(db, tenant_no)
- if seen_nos is not None:
- if s in seen_nos:
- raise ValueError(f"入库编号「{s}」在本单中重复")
- seen_nos.add(s)
- if stock_in_no_exists(db, s, tenant_no, exclude_id=exclude_id):
- raise ValueError(f"入库编号「{s}」已存在")
- return s
- def _require_str(v: Any, label: str) -> str:
- s = str(v or "").strip()
- if not s:
- raise ValueError(f"{label}不能为空")
- return s
- def _require_decimal(v: Any, label: str) -> Decimal:
- if v is None or str(v).strip() == "":
- raise ValueError(f"{label}不能为空")
- try:
- d = Decimal(str(v))
- except Exception as e:
- raise ValueError(f"{label}格式无效") from e
- if d < 0:
- raise ValueError(f"{label}不能为负数")
- return d
- def stock_in_no_exists(
- db: Session, stock_in_no: str, tenant_no: str, exclude_id: Optional[str] = None
- ) -> bool:
- s = (stock_in_no or "").strip()
- if not s:
- return False
- q = db.query(BizMaterialStockBatch).filter(
- BizMaterialStockBatch.stock_in_no == s,
- BizMaterialStockBatch.tenant_no == tenant_no,
- BizMaterialStockBatch.del_flag == 0,
- )
- if exclude_id:
- q = q.filter(BizMaterialStockBatch.id != exclude_id)
- return q.first() is not None
- def _batch_row_to_dict(b: BizMaterialStockBatch, material: Optional[BizMaterial] = None) -> Dict[str, Any]:
- mat = material
- warning_qty = _dec(mat.warning_qty) if mat else 0
- stock_qty = _dec(mat.stock_qty) if mat else _dec(b.remaining_qty)
- status = _norm_status(mat.status) if mat else "1"
- return {
- "id": b.id,
- "batchId": b.id,
- "materialId": b.material_id,
- "materialCode": b.material_code or "",
- "materialName": b.material_name or "",
- "category": (mat.category or "") if mat else "",
- "specModel": b.spec_model or "",
- "unit": b.unit or "",
- "stockInNo": b.stock_in_no or "",
- "stockInDate": _fmt_date(b.stock_in_date),
- "expireDate": _fmt_date(b.expire_date),
- "catalogNo": b.catalog_no or "",
- "batchNo": b.batch_no or "",
- "manufacturer": b.manufacturer or "",
- "unitPrice": float(b.unit_price) if b.unit_price is not None else 0,
- "amount": float(b.amount) if b.amount is not None else 0,
- "inQty": _dec(b.in_qty),
- "remainingQty": _dec(b.remaining_qty),
- "stockQty": stock_qty,
- "warningQty": warning_qty,
- "status": status,
- "remark": b.remark or "",
- "createTime": _fmt_dt(b.create_time),
- "updateTime": _fmt_dt(b.update_time),
- }
- def _usage_batch_snapshots(
- batch: BizMaterialStockBatch,
- *,
- out_qty: Optional[Decimal] = None,
- ) -> Dict[str, Any]:
- unit_price = batch.unit_price
- if out_qty is not None:
- amount = (Decimal(str(unit_price or 0)) * abs(out_qty)).quantize(Decimal("0.01"))
- else:
- amount = batch.amount
- return {
- "batch_id": batch.id,
- "stock_in_no": batch.stock_in_no,
- "catalog_no": batch.catalog_no,
- "batch_no": batch.batch_no,
- "manufacturer": batch.manufacturer,
- "unit_price": unit_price,
- "amount": amount,
- }
- def _get_material_for_update(
- db: Session, material_id: str, tenant_no: str
- ) -> BizMaterial:
- obj = (
- db.query(BizMaterial)
- .filter(
- BizMaterial.id == material_id,
- BizMaterial.tenant_no == tenant_no,
- BizMaterial.del_flag == 0,
- )
- .with_for_update()
- .first()
- )
- if not obj:
- raise ValueError("物料不存在")
- return obj
- def list_batches(
- db: Session,
- *,
- page: int = 1,
- page_size: int = 10,
- keyword: Optional[str] = None,
- material_id: Optional[str] = None,
- category: Optional[str] = None,
- status: Optional[str] = None,
- tenant_no: Optional[str] = None,
- user_info=None,
- ) -> Tuple[List[Dict[str, Any]], int]:
- def _filtered():
- q0 = (
- db.query(BizMaterialStockBatch, BizMaterial)
- .join(
- BizMaterial,
- BizMaterial.id == BizMaterialStockBatch.material_id,
- )
- .filter(
- BizMaterialStockBatch.del_flag == 0,
- BizMaterial.del_flag == 0,
- )
- )
- if tenant_no:
- q0 = q0.filter(BizMaterialStockBatch.tenant_no == tenant_no)
- kw = (keyword or "").strip()
- if kw:
- like = f"%{kw}%"
- q0 = q0.filter(
- or_(
- BizMaterialStockBatch.material_code.like(like),
- BizMaterialStockBatch.material_name.like(like),
- BizMaterialStockBatch.stock_in_no.like(like),
- BizMaterialStockBatch.batch_no.like(like),
- BizMaterialStockBatch.catalog_no.like(like),
- )
- )
- if material_id:
- q0 = q0.filter(BizMaterialStockBatch.material_id == material_id)
- cat = (category or "").strip()
- if cat:
- q0 = q0.filter(BizMaterial.category == cat)
- st = (status or "").strip()
- if st in ("0", "1"):
- q0 = q0.filter(BizMaterial.status == st)
- if user_info:
- from app.core.data_scope import apply_data_scope
- q0 = apply_data_scope(q0, BizMaterialStockBatch, user_info, db, tenant_no)
- return q0
- total = _filtered().order_by(None).with_entities(func.count(BizMaterialStockBatch.id)).scalar() or 0
- page = max(1, page)
- page_size = min(max(1, page_size), 100)
- rows = (
- _filtered()
- .order_by(
- BizMaterialStockBatch.stock_in_date.desc(),
- BizMaterialStockBatch.create_time.desc(),
- )
- .offset((page - 1) * page_size)
- .limit(page_size)
- .all()
- )
- return [_batch_row_to_dict(b, m) for b, m in rows], int(total)
- def batch_options(
- db: Session,
- tenant_no: str,
- *,
- keyword: Optional[str] = None,
- material_id: Optional[str] = None,
- only_available: bool = True,
- ) -> List[Dict[str, Any]]:
- q = (
- db.query(BizMaterialStockBatch, BizMaterial)
- .join(BizMaterial, BizMaterial.id == BizMaterialStockBatch.material_id)
- .filter(
- BizMaterialStockBatch.tenant_no == tenant_no,
- BizMaterialStockBatch.del_flag == 0,
- BizMaterial.del_flag == 0,
- BizMaterial.status == "1",
- )
- )
- if only_available:
- q = q.filter(BizMaterialStockBatch.remaining_qty > 0)
- if material_id:
- q = q.filter(BizMaterialStockBatch.material_id == material_id)
- kw = (keyword or "").strip()
- if kw:
- like = f"%{kw}%"
- q = q.filter(
- or_(
- BizMaterialStockBatch.stock_in_no.like(like),
- BizMaterialStockBatch.material_name.like(like),
- BizMaterialStockBatch.material_code.like(like),
- BizMaterialStockBatch.batch_no.like(like),
- BizMaterialStockBatch.catalog_no.like(like),
- )
- )
- rows = q.order_by(BizMaterialStockBatch.stock_in_date.asc()).all()
- out: List[Dict[str, Any]] = []
- for b, m in rows:
- unit = b.unit or ""
- rem = _dec(b.remaining_qty)
- mfr = (b.manufacturer or "").strip()
- cat = (b.catalog_no or "").strip()
- label = (
- f"{b.stock_in_no} / {b.material_name}"
- f" / 厂家:{mfr or '—'}"
- f" / 批号:{b.batch_no or '—'}"
- f" / 货号:{cat or '—'}"
- f" / 剩:{rem}{unit}"
- )
- unit_price = float(b.unit_price) if b.unit_price is not None else 0
- out.append(
- {
- "id": b.id,
- "batchId": b.id,
- "stockInNo": b.stock_in_no or "",
- "stockInDate": _fmt_date(b.stock_in_date),
- "expireDate": _fmt_date(b.expire_date),
- "materialId": b.material_id,
- "materialCode": b.material_code or "",
- "materialName": b.material_name or "",
- "specModel": b.spec_model or "",
- "unit": unit,
- "batchNo": b.batch_no or "",
- "catalogNo": b.catalog_no or "",
- "manufacturer": b.manufacturer or "",
- "unitPrice": unit_price,
- "amount": float(b.amount) if b.amount is not None else 0,
- "remainingQty": rem,
- "label": label,
- }
- )
- return out
- def _create_batch_and_usage(
- db: Session,
- *,
- material: BizMaterial,
- stock_in_no: str,
- stock_in_date: date,
- expire_date: date,
- catalog_no: str,
- batch_no: str,
- manufacturer: str,
- unit_price: Decimal,
- amount: Decimal,
- qty: Any,
- remark: Optional[str],
- operator_name: Optional[str],
- tenant_no: str,
- user_id: Optional[str],
- operate_time: datetime,
- commit: bool = False,
- ) -> BizMaterialStockBatch:
- qty = _opt_stock_in_qty(qty)
- if qty <= 0:
- raise ValueError("入库数量必须大于0")
- sno = _resolve_stock_in_no(db, tenant_no, stock_in_no)
- if not _is_enabled_status(material.status):
- raise ValueError(f"物料「{material.material_name}」已停用,无法入库")
- old_stock = material.stock_qty or Decimal("0")
- new_stock = old_stock + qty
- material.stock_qty = new_stock
- material.update_by = user_id
- material.update_time = datetime.now()
- db.add(material)
- batch = BizMaterialStockBatch(
- id=generate_uuid(),
- material_id=material.id,
- material_code=material.material_code,
- material_name=material.material_name,
- spec_model=material.spec_model,
- unit=material.unit,
- stock_in_no=sno,
- stock_in_date=_opt_stock_in_date(stock_in_date),
- expire_date=_parse_expire_date(expire_date),
- catalog_no=_opt_str(catalog_no),
- batch_no=_opt_str(batch_no),
- manufacturer=_opt_str(manufacturer),
- unit_price=_opt_decimal(unit_price),
- amount=_opt_decimal(amount),
- in_qty=qty,
- remaining_qty=qty,
- tenant_no=tenant_no,
- remark=(remark or "").strip() or None,
- create_by=user_id,
- create_dept_id=material.create_dept_id or "",
- create_time=operate_time,
- update_by=user_id,
- update_time=operate_time,
- del_flag=0,
- )
- db.add(batch)
- snaps = _usage_batch_snapshots(batch)
- usage = BizMaterialUsage(
- id=generate_uuid(),
- material_id=material.id,
- material_code=material.material_code,
- material_name=material.material_name,
- change_type="in",
- change_qty=qty,
- stock_before=old_stock,
- stock_after=new_stock,
- source_type="manual_in",
- tenant_no=tenant_no,
- operator_name=(operator_name or "").strip() or None,
- remark=(remark or "").strip() or None,
- create_by=user_id,
- create_dept_id=material.create_dept_id or "",
- create_time=operate_time,
- update_by=user_id,
- update_time=operate_time,
- del_flag=0,
- **snaps,
- )
- db.add(usage)
- if commit:
- db.commit()
- db.refresh(batch)
- return batch
- def stock_in_one(
- db: Session,
- material_id: str,
- *,
- stock_in_no: str,
- stock_in_date: Any,
- expire_date: Any,
- catalog_no: str,
- batch_no: str,
- manufacturer: str,
- unit_price: Any,
- amount: Any,
- qty: Any,
- remark: Optional[str] = None,
- operator_name: Optional[str] = None,
- tenant_no: str,
- user_id: Optional[str] = None,
- operate_time: Optional[datetime] = None,
- ) -> BizMaterialStockBatch:
- op_time = operate_time or datetime.now()
- material = _get_material_for_update(db, material_id, tenant_no)
- batch = _create_batch_and_usage(
- db,
- material=material,
- stock_in_no=stock_in_no,
- stock_in_date=stock_in_date,
- expire_date=expire_date,
- catalog_no=catalog_no,
- batch_no=batch_no,
- manufacturer=manufacturer,
- unit_price=unit_price,
- amount=amount,
- qty=qty,
- remark=remark,
- operator_name=operator_name,
- tenant_no=tenant_no,
- user_id=user_id,
- operate_time=op_time,
- commit=True,
- )
- return batch
- def batch_stock_in(
- db: Session,
- *,
- items: List[Dict[str, Any]],
- remark: Optional[str] = None,
- operator_name: Optional[str] = None,
- tenant_no: str,
- user_id: Optional[str] = None,
- operate_time: Optional[datetime] = None,
- ) -> int:
- if not items:
- raise ValueError("请至少添加一条入库明细")
- op_time = operate_time or datetime.now()
- shared_remark = (remark or "").strip() or None
- seen_nos: set = set()
- try:
- for it in items:
- material_id = (it.get("material_id") or it.get("materialId") or "").strip()
- if not material_id:
- raise ValueError("请选择物资")
- sno = _resolve_stock_in_no(
- db,
- tenant_no,
- it.get("stock_in_no") or it.get("stockInNo"),
- seen_nos=seen_nos,
- )
- qty_dec = _opt_stock_in_qty(it.get("qty"))
- if qty_dec <= 0:
- raise ValueError("入库数量必须大于0")
- line_remark = (it.get("remark") or "").strip() or shared_remark
- material = _get_material_for_update(db, material_id, tenant_no)
- _create_batch_and_usage(
- db,
- material=material,
- stock_in_no=sno,
- stock_in_date=it.get("stock_in_date") or it.get("stockInDate"),
- expire_date=it.get("expire_date") or it.get("expireDate"),
- catalog_no=it.get("catalog_no") or it.get("catalogNo"),
- batch_no=it.get("batch_no") or it.get("batchNo"),
- manufacturer=it.get("manufacturer"),
- unit_price=it.get("unit_price") or it.get("unitPrice"),
- amount=it.get("amount"),
- qty=qty_dec,
- remark=line_remark,
- operator_name=operator_name,
- tenant_no=tenant_no,
- user_id=user_id,
- operate_time=op_time,
- commit=False,
- )
- db.commit()
- return len(items)
- except Exception:
- db.rollback()
- raise
- def _stock_out_batch(
- db: Session,
- batch_id: str,
- *,
- qty: Decimal,
- receiver: Optional[str],
- purpose: Optional[str],
- remark: Optional[str],
- operator_name: Optional[str],
- usage_unit: Optional[str] = None,
- tenant_no: str,
- user_id: Optional[str],
- operate_time: datetime,
- ) -> None:
- if qty <= 0:
- raise ValueError("出库数量必须大于0")
- if not (receiver or "").strip():
- raise ValueError("请选择领用人")
- batch = (
- db.query(BizMaterialStockBatch)
- .filter(
- BizMaterialStockBatch.id == batch_id,
- BizMaterialStockBatch.tenant_no == tenant_no,
- BizMaterialStockBatch.del_flag == 0,
- )
- .with_for_update()
- .first()
- )
- if not batch:
- raise ValueError("入库批次不存在")
- material = _get_material_for_update(db, batch.material_id, tenant_no)
- if not _is_enabled_status(material.status):
- raise ValueError(f"物料「{material.material_name}」已停用,无法出库")
- rem = batch.remaining_qty or Decimal("0")
- if rem < qty:
- raise ValueError(
- f"批次「{batch.stock_in_no}」库存不足,当前剩余 {rem},需出库 {qty}"
- )
- batch.remaining_qty = rem - qty
- batch.update_by = user_id
- batch.update_time = datetime.now()
- db.add(batch)
- stock_before = material.stock_qty or Decimal("0")
- new_stock = stock_before - qty
- material.stock_qty = new_stock
- material.update_by = user_id
- material.update_time = datetime.now()
- db.add(material)
- snaps = _usage_batch_snapshots(batch, out_qty=qty)
- unit_label = (usage_unit or "").strip() or (batch.unit or "").strip() or None
- usage = BizMaterialUsage(
- id=generate_uuid(),
- material_id=material.id,
- material_code=material.material_code,
- material_name=material.material_name,
- change_type="use",
- change_qty=-qty,
- usage_unit=unit_label,
- stock_before=stock_before,
- stock_after=new_stock,
- source_type="manual_out",
- tenant_no=tenant_no,
- receiver=(receiver or "").strip() or None,
- operator_name=(operator_name or "").strip() or None,
- purpose=(purpose or "").strip() or None,
- remark=(remark or "").strip() or None,
- create_by=user_id,
- create_dept_id=material.create_dept_id or "",
- create_time=operate_time,
- update_by=user_id,
- update_time=operate_time,
- del_flag=0,
- **snaps,
- )
- db.add(usage)
- def batch_stock_out(
- db: Session,
- *,
- items: List[Dict[str, Any]],
- receiver: Optional[str] = None,
- purpose: Optional[str] = None,
- remark: Optional[str] = None,
- operator_name: Optional[str] = None,
- tenant_no: str,
- user_id: Optional[str] = None,
- operate_time: Optional[datetime] = None,
- ) -> int:
- if not items:
- raise ValueError("请至少添加一条出库明细")
- op_time = operate_time or datetime.now()
- shared_remark = (remark or "").strip() or None
- seen_batches: set = set()
- try:
- for it in items:
- batch_id = (it.get("batch_id") or it.get("batchId") or "").strip()
- if not batch_id:
- raise ValueError("请选择入库批次")
- if batch_id in seen_batches:
- raise ValueError("同一入库批次不可重复添加")
- seen_batches.add(batch_id)
- qty = it.get("qty")
- if qty is None or Decimal(str(qty)) <= 0:
- raise ValueError("出库数量必须大于0")
- qty_dec = Decimal(str(qty))
- line_remark = (it.get("remark") or "").strip() or shared_remark
- usage_unit = (it.get("usageUnit") or it.get("usage_unit") or "").strip() or None
- _stock_out_batch(
- db,
- batch_id,
- qty=qty_dec,
- receiver=receiver,
- purpose=purpose,
- remark=line_remark,
- operator_name=operator_name,
- usage_unit=usage_unit,
- tenant_no=tenant_no,
- user_id=user_id,
- operate_time=op_time,
- )
- db.commit()
- return len(items)
- except Exception:
- db.rollback()
- raise
- DISCARD_REASONS = frozenset({"过期", "质量问题", "污染/破损", "其他"})
- def _validate_discard_reason(discard_reason: Optional[str], remark: Optional[str]) -> str:
- reason = (discard_reason or "").strip()
- if reason not in DISCARD_REASONS:
- raise ValueError("请选择有效的废弃原因")
- if reason == "其他" and not (remark or "").strip():
- raise ValueError("选择「其他」时请填写备注说明")
- return reason
- def _discard_batch(
- db: Session,
- batch_id: str,
- *,
- qty: Decimal,
- discard_reason: str,
- remark: Optional[str],
- operator_name: Optional[str],
- usage_unit: Optional[str] = None,
- tenant_no: str,
- user_id: Optional[str],
- operate_time: datetime,
- ) -> None:
- if qty <= 0:
- raise ValueError("废弃数量必须大于0")
- batch = (
- db.query(BizMaterialStockBatch)
- .filter(
- BizMaterialStockBatch.id == batch_id,
- BizMaterialStockBatch.tenant_no == tenant_no,
- BizMaterialStockBatch.del_flag == 0,
- )
- .with_for_update()
- .first()
- )
- if not batch:
- raise ValueError("入库批次不存在")
- material = _get_material_for_update(db, batch.material_id, tenant_no)
- if not _is_enabled_status(material.status):
- raise ValueError(f"物料「{material.material_name}」已停用,无法废弃")
- rem = batch.remaining_qty or Decimal("0")
- if rem < qty:
- raise ValueError(
- f"批次「{batch.stock_in_no}」库存不足,当前剩余 {rem},需废弃 {qty}"
- )
- batch.remaining_qty = rem - qty
- batch.update_by = user_id
- batch.update_time = datetime.now()
- db.add(batch)
- stock_before = material.stock_qty or Decimal("0")
- new_stock = stock_before - qty
- material.stock_qty = new_stock
- material.update_by = user_id
- material.update_time = datetime.now()
- db.add(material)
- snaps = _usage_batch_snapshots(batch, out_qty=qty)
- unit_label = (usage_unit or "").strip() or (batch.unit or "").strip() or None
- usage = BizMaterialUsage(
- id=generate_uuid(),
- material_id=material.id,
- material_code=material.material_code,
- material_name=material.material_name,
- change_type="discard",
- change_qty=-qty,
- usage_unit=unit_label,
- stock_before=stock_before,
- stock_after=new_stock,
- source_type="manual_discard",
- tenant_no=tenant_no,
- purpose=discard_reason,
- operator_name=(operator_name or "").strip() or None,
- remark=(remark or "").strip() or None,
- create_by=user_id,
- create_dept_id=material.create_dept_id or "",
- create_time=operate_time,
- update_by=user_id,
- update_time=operate_time,
- del_flag=0,
- **snaps,
- )
- db.add(usage)
- def batch_discard(
- db: Session,
- *,
- items: List[Dict[str, Any]],
- discard_reason: Optional[str] = None,
- remark: Optional[str] = None,
- operator_name: Optional[str] = None,
- tenant_no: str,
- user_id: Optional[str] = None,
- operate_time: Optional[datetime] = None,
- ) -> int:
- if not items:
- raise ValueError("请至少添加一条废弃明细")
- op_time = operate_time or datetime.now()
- shared_remark = (remark or "").strip() or None
- reason = _validate_discard_reason(discard_reason, shared_remark)
- seen_batches: set = set()
- try:
- for it in items:
- batch_id = (it.get("batch_id") or it.get("batchId") or "").strip()
- if not batch_id:
- raise ValueError("请选择入库批次")
- if batch_id in seen_batches:
- raise ValueError("同一入库批次不可重复添加")
- seen_batches.add(batch_id)
- qty = it.get("qty")
- if qty is None or Decimal(str(qty)) <= 0:
- raise ValueError("废弃数量必须大于0")
- qty_dec = Decimal(str(qty))
- line_remark = (it.get("remark") or "").strip() or shared_remark
- if reason == "其他" and not line_remark:
- raise ValueError("选择「其他」时请填写备注说明")
- usage_unit = (it.get("usageUnit") or it.get("usage_unit") or "").strip() or None
- _discard_batch(
- db,
- batch_id,
- qty=qty_dec,
- discard_reason=reason,
- remark=line_remark,
- operator_name=operator_name,
- usage_unit=usage_unit,
- tenant_no=tenant_no,
- user_id=user_id,
- operate_time=op_time,
- )
- db.commit()
- return len(items)
- except Exception:
- db.rollback()
- raise
- def get_batch(db: Session, batch_id: str, tenant_no: str) -> Optional[BizMaterialStockBatch]:
- return (
- db.query(BizMaterialStockBatch)
- .filter(
- BizMaterialStockBatch.id == batch_id,
- BizMaterialStockBatch.tenant_no == tenant_no,
- BizMaterialStockBatch.del_flag == 0,
- )
- .first()
- )
- def update_batch(
- db: Session,
- batch_id: str,
- *,
- tenant_no: str,
- user_id: Optional[str] = None,
- stock_in_no: Optional[str] = None,
- stock_in_date: Optional[Any] = None,
- expire_date: Optional[Any] = None,
- catalog_no: Optional[str] = None,
- batch_no: Optional[str] = None,
- manufacturer: Optional[str] = None,
- unit_price: Optional[Any] = None,
- amount: Optional[Any] = None,
- remark: Optional[str] = None,
- material_code: Optional[str] = None,
- material_name: Optional[str] = None,
- category: Optional[str] = None,
- spec_model: Optional[str] = None,
- unit: Optional[str] = None,
- warning_qty: Optional[Any] = None,
- status: Optional[str] = None,
- ) -> BizMaterialStockBatch:
- from app.crud import material as mat_svc
- batch = get_batch(db, batch_id, tenant_no)
- if not batch:
- raise ValueError("入库批次不存在")
- material = _get_material_for_update(db, batch.material_id, tenant_no)
- mat_data: Dict[str, Any] = {}
- if material_code is not None:
- mat_data["material_code"] = material_code
- if material_name is not None:
- mat_data["material_name"] = material_name
- if category is not None:
- mat_data["category"] = (category or "").strip() or None
- if spec_model is not None:
- mat_data["spec_model"] = spec_model
- if unit is not None:
- mat_data["unit"] = unit
- if warning_qty is not None:
- mat_data["warning_qty"] = Decimal(str(warning_qty))
- if status is not None:
- mat_data["status"] = _norm_status(status)
- if mat_data:
- material = mat_svc.update_material_fields(
- db,
- batch.material_id,
- tenant_no=tenant_no,
- user_id=user_id,
- **mat_data,
- )
- if stock_in_no is not None:
- s = str(stock_in_no).strip()
- if s:
- if stock_in_no_exists(db, s, tenant_no, exclude_id=batch_id):
- raise ValueError(f"入库编号「{s}」已存在")
- batch.stock_in_no = s
- if stock_in_date is not None:
- s = str(stock_in_date).strip()
- if s:
- batch.stock_in_date = _parse_date(stock_in_date)
- if expire_date is not None:
- s = str(expire_date).strip()
- if s:
- batch.expire_date = _parse_expire_date(expire_date)
- else:
- batch.expire_date = _SENTINEL_EXPIRE_DATE
- if catalog_no is not None:
- batch.catalog_no = _opt_str(catalog_no)
- if batch_no is not None:
- batch.batch_no = _opt_str(batch_no)
- if manufacturer is not None:
- batch.manufacturer = _opt_str(manufacturer)
- if unit_price is not None:
- batch.unit_price = _opt_decimal(unit_price)
- if amount is not None:
- batch.amount = _opt_decimal(amount)
- if remark is not None:
- batch.remark = (remark or "").strip() or None
- batch.material_code = material.material_code
- batch.material_name = material.material_name
- batch.spec_model = material.spec_model
- batch.unit = material.unit
- batch.update_by = user_id
- batch.update_time = datetime.now()
- db.add(batch)
- in_usage = (
- db.query(BizMaterialUsage)
- .filter(
- BizMaterialUsage.batch_id == batch_id,
- BizMaterialUsage.tenant_no == tenant_no,
- BizMaterialUsage.del_flag == 0,
- BizMaterialUsage.change_type == "in",
- BizMaterialUsage.source_type == "manual_in",
- )
- .first()
- )
- if in_usage:
- in_usage.material_code = material.material_code
- in_usage.material_name = material.material_name
- in_usage.stock_in_no = batch.stock_in_no
- in_usage.catalog_no = batch.catalog_no
- in_usage.batch_no = batch.batch_no
- in_usage.manufacturer = batch.manufacturer
- in_usage.unit_price = batch.unit_price
- in_usage.amount = batch.amount
- in_usage.remark = batch.remark
- in_usage.update_by = user_id
- in_usage.update_time = datetime.now()
- db.add(in_usage)
- db.commit()
- db.refresh(batch)
- return batch
- def delete_batch(
- db: Session,
- batch_id: str,
- *,
- tenant_no: str,
- user_id: Optional[str] = None,
- ) -> None:
- batch = get_batch(db, batch_id, tenant_no)
- if not batch:
- raise ValueError("入库批次不存在")
- used = (
- db.query(BizMaterialUsage)
- .filter(
- BizMaterialUsage.batch_id == batch_id,
- BizMaterialUsage.tenant_no == tenant_no,
- BizMaterialUsage.del_flag == 0,
- BizMaterialUsage.change_type == "use",
- )
- .count()
- )
- if used > 0:
- raise ValueError("该批次已有领用或出库记录,无法删除")
- material = _get_material_for_update(db, batch.material_id, tenant_no)
- rem = batch.remaining_qty or Decimal("0")
- material.stock_qty = (material.stock_qty or Decimal("0")) - rem
- material.update_by = user_id
- material.update_time = datetime.now()
- db.add(material)
- now = datetime.now()
- batch.del_flag = 1
- batch.update_by = user_id
- batch.update_time = now
- db.add(batch)
- usages = (
- db.query(BizMaterialUsage)
- .filter(
- BizMaterialUsage.batch_id == batch_id,
- BizMaterialUsage.tenant_no == tenant_no,
- BizMaterialUsage.del_flag == 0,
- )
- .all()
- )
- for u in usages:
- u.del_flag = 1
- u.update_by = user_id
- u.update_time = now
- db.add(u)
- db.commit()
|