material_stock_batch.py 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
  1. """市场物料入库批次 CRUD"""
  2. from __future__ import annotations
  3. from datetime import date, datetime
  4. from decimal import Decimal
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from sqlalchemy import func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.crud.material import _dec, _fmt_dt, _is_enabled_status, _norm_status
  10. from app.models.material import BizMaterial, BizMaterialStockBatch, BizMaterialUsage
  11. _SENTINEL_EXPIRE_DATE = date(9999, 12, 31)
  12. _DEFAULT_STOCK_IN_QTY = Decimal("1")
  13. def _fmt_date(d: Optional[date]) -> str:
  14. if not d or d >= _SENTINEL_EXPIRE_DATE:
  15. return ""
  16. return d.strftime("%Y-%m-%d")
  17. def _parse_date(v: Any, *, label: str = "入库日期") -> date:
  18. if isinstance(v, date) and not isinstance(v, datetime):
  19. return v
  20. s = str(v or "").strip()
  21. if not s:
  22. raise ValueError(f"{label}不能为空")
  23. if len(s) >= 10:
  24. s = s[:10]
  25. try:
  26. return datetime.strptime(s, "%Y-%m-%d").date()
  27. except ValueError as e:
  28. raise ValueError("入库日期格式无效,应为 YYYY-MM-DD") from e
  29. def _parse_expire_date(v: Any) -> date:
  30. if isinstance(v, date) and not isinstance(v, datetime):
  31. if v >= _SENTINEL_EXPIRE_DATE:
  32. return _SENTINEL_EXPIRE_DATE
  33. return v
  34. s = str(v or "").strip()
  35. if not s:
  36. return _SENTINEL_EXPIRE_DATE
  37. return _parse_date(v, label="过期日期")
  38. def _opt_str(v: Any, *, default: str = "") -> str:
  39. return str(v or "").strip() or default
  40. def _opt_decimal(v: Any, *, default: Decimal = Decimal("0")) -> Decimal:
  41. if v is None or str(v).strip() == "":
  42. return default
  43. try:
  44. d = Decimal(str(v))
  45. except Exception as e:
  46. raise ValueError("金额格式无效") from e
  47. if d < 0:
  48. raise ValueError("金额不能为负数")
  49. return d
  50. def _opt_stock_in_date(v: Any) -> date:
  51. if isinstance(v, date) and not isinstance(v, datetime):
  52. return v
  53. s = str(v or "").strip()
  54. if not s:
  55. return date.today()
  56. return _parse_date(v, label="入库日期")
  57. def _opt_stock_in_qty(v: Any) -> Decimal:
  58. if v is None or str(v).strip() == "":
  59. return _DEFAULT_STOCK_IN_QTY
  60. try:
  61. qty = Decimal(str(v))
  62. except Exception as e:
  63. raise ValueError("入库数量格式无效") from e
  64. if qty < 0:
  65. raise ValueError("入库数量不能为负数")
  66. return qty
  67. def _gen_stock_in_no(db: Session, tenant_no: str) -> str:
  68. for _ in range(10):
  69. suffix = generate_uuid()[:8].upper()
  70. sno = f"RK{datetime.now().strftime('%Y%m%d%H%M%S')}{suffix}"
  71. if not stock_in_no_exists(db, sno, tenant_no):
  72. return sno
  73. raise ValueError("入库编号自动生成失败,请稍后重试")
  74. def _resolve_stock_in_no(
  75. db: Session,
  76. tenant_no: str,
  77. stock_in_no: Any,
  78. *,
  79. exclude_id: Optional[str] = None,
  80. seen_nos: Optional[set] = None,
  81. ) -> str:
  82. s = str(stock_in_no or "").strip()
  83. if not s:
  84. s = _gen_stock_in_no(db, tenant_no)
  85. if seen_nos is not None:
  86. if s in seen_nos:
  87. raise ValueError(f"入库编号「{s}」在本单中重复")
  88. seen_nos.add(s)
  89. if stock_in_no_exists(db, s, tenant_no, exclude_id=exclude_id):
  90. raise ValueError(f"入库编号「{s}」已存在")
  91. return s
  92. def _require_str(v: Any, label: str) -> str:
  93. s = str(v or "").strip()
  94. if not s:
  95. raise ValueError(f"{label}不能为空")
  96. return s
  97. def _require_decimal(v: Any, label: str) -> Decimal:
  98. if v is None or str(v).strip() == "":
  99. raise ValueError(f"{label}不能为空")
  100. try:
  101. d = Decimal(str(v))
  102. except Exception as e:
  103. raise ValueError(f"{label}格式无效") from e
  104. if d < 0:
  105. raise ValueError(f"{label}不能为负数")
  106. return d
  107. def stock_in_no_exists(
  108. db: Session, stock_in_no: str, tenant_no: str, exclude_id: Optional[str] = None
  109. ) -> bool:
  110. s = (stock_in_no or "").strip()
  111. if not s:
  112. return False
  113. q = db.query(BizMaterialStockBatch).filter(
  114. BizMaterialStockBatch.stock_in_no == s,
  115. BizMaterialStockBatch.tenant_no == tenant_no,
  116. BizMaterialStockBatch.del_flag == 0,
  117. )
  118. if exclude_id:
  119. q = q.filter(BizMaterialStockBatch.id != exclude_id)
  120. return q.first() is not None
  121. def _batch_row_to_dict(b: BizMaterialStockBatch, material: Optional[BizMaterial] = None) -> Dict[str, Any]:
  122. mat = material
  123. warning_qty = _dec(mat.warning_qty) if mat else 0
  124. stock_qty = _dec(mat.stock_qty) if mat else _dec(b.remaining_qty)
  125. status = _norm_status(mat.status) if mat else "1"
  126. return {
  127. "id": b.id,
  128. "batchId": b.id,
  129. "materialId": b.material_id,
  130. "materialCode": b.material_code or "",
  131. "materialName": b.material_name or "",
  132. "category": (mat.category or "") if mat else "",
  133. "specModel": b.spec_model or "",
  134. "unit": b.unit or "",
  135. "stockInNo": b.stock_in_no or "",
  136. "stockInDate": _fmt_date(b.stock_in_date),
  137. "expireDate": _fmt_date(b.expire_date),
  138. "catalogNo": b.catalog_no or "",
  139. "batchNo": b.batch_no or "",
  140. "manufacturer": b.manufacturer or "",
  141. "unitPrice": float(b.unit_price) if b.unit_price is not None else 0,
  142. "amount": float(b.amount) if b.amount is not None else 0,
  143. "inQty": _dec(b.in_qty),
  144. "remainingQty": _dec(b.remaining_qty),
  145. "stockQty": stock_qty,
  146. "warningQty": warning_qty,
  147. "status": status,
  148. "remark": b.remark or "",
  149. "createTime": _fmt_dt(b.create_time),
  150. "updateTime": _fmt_dt(b.update_time),
  151. }
  152. def _usage_batch_snapshots(
  153. batch: BizMaterialStockBatch,
  154. *,
  155. out_qty: Optional[Decimal] = None,
  156. ) -> Dict[str, Any]:
  157. unit_price = batch.unit_price
  158. if out_qty is not None:
  159. amount = (Decimal(str(unit_price or 0)) * abs(out_qty)).quantize(Decimal("0.01"))
  160. else:
  161. amount = batch.amount
  162. return {
  163. "batch_id": batch.id,
  164. "stock_in_no": batch.stock_in_no,
  165. "catalog_no": batch.catalog_no,
  166. "batch_no": batch.batch_no,
  167. "manufacturer": batch.manufacturer,
  168. "unit_price": unit_price,
  169. "amount": amount,
  170. }
  171. def _get_material_for_update(
  172. db: Session, material_id: str, tenant_no: str
  173. ) -> BizMaterial:
  174. obj = (
  175. db.query(BizMaterial)
  176. .filter(
  177. BizMaterial.id == material_id,
  178. BizMaterial.tenant_no == tenant_no,
  179. BizMaterial.del_flag == 0,
  180. )
  181. .with_for_update()
  182. .first()
  183. )
  184. if not obj:
  185. raise ValueError("物料不存在")
  186. return obj
  187. def list_batches(
  188. db: Session,
  189. *,
  190. page: int = 1,
  191. page_size: int = 10,
  192. keyword: Optional[str] = None,
  193. material_id: Optional[str] = None,
  194. category: Optional[str] = None,
  195. status: Optional[str] = None,
  196. tenant_no: Optional[str] = None,
  197. user_info=None,
  198. ) -> Tuple[List[Dict[str, Any]], int]:
  199. def _filtered():
  200. q0 = (
  201. db.query(BizMaterialStockBatch, BizMaterial)
  202. .join(
  203. BizMaterial,
  204. BizMaterial.id == BizMaterialStockBatch.material_id,
  205. )
  206. .filter(
  207. BizMaterialStockBatch.del_flag == 0,
  208. BizMaterial.del_flag == 0,
  209. )
  210. )
  211. if tenant_no:
  212. q0 = q0.filter(BizMaterialStockBatch.tenant_no == tenant_no)
  213. kw = (keyword or "").strip()
  214. if kw:
  215. like = f"%{kw}%"
  216. q0 = q0.filter(
  217. or_(
  218. BizMaterialStockBatch.material_code.like(like),
  219. BizMaterialStockBatch.material_name.like(like),
  220. BizMaterialStockBatch.stock_in_no.like(like),
  221. BizMaterialStockBatch.batch_no.like(like),
  222. BizMaterialStockBatch.catalog_no.like(like),
  223. )
  224. )
  225. if material_id:
  226. q0 = q0.filter(BizMaterialStockBatch.material_id == material_id)
  227. cat = (category or "").strip()
  228. if cat:
  229. q0 = q0.filter(BizMaterial.category == cat)
  230. st = (status or "").strip()
  231. if st in ("0", "1"):
  232. q0 = q0.filter(BizMaterial.status == st)
  233. if user_info:
  234. from app.core.data_scope import apply_data_scope
  235. q0 = apply_data_scope(q0, BizMaterialStockBatch, user_info, db, tenant_no)
  236. return q0
  237. total = _filtered().order_by(None).with_entities(func.count(BizMaterialStockBatch.id)).scalar() or 0
  238. page = max(1, page)
  239. page_size = min(max(1, page_size), 100)
  240. rows = (
  241. _filtered()
  242. .order_by(
  243. BizMaterialStockBatch.stock_in_date.desc(),
  244. BizMaterialStockBatch.create_time.desc(),
  245. )
  246. .offset((page - 1) * page_size)
  247. .limit(page_size)
  248. .all()
  249. )
  250. return [_batch_row_to_dict(b, m) for b, m in rows], int(total)
  251. def batch_options(
  252. db: Session,
  253. tenant_no: str,
  254. *,
  255. keyword: Optional[str] = None,
  256. material_id: Optional[str] = None,
  257. only_available: bool = True,
  258. ) -> List[Dict[str, Any]]:
  259. q = (
  260. db.query(BizMaterialStockBatch, BizMaterial)
  261. .join(BizMaterial, BizMaterial.id == BizMaterialStockBatch.material_id)
  262. .filter(
  263. BizMaterialStockBatch.tenant_no == tenant_no,
  264. BizMaterialStockBatch.del_flag == 0,
  265. BizMaterial.del_flag == 0,
  266. BizMaterial.status == "1",
  267. )
  268. )
  269. if only_available:
  270. q = q.filter(BizMaterialStockBatch.remaining_qty > 0)
  271. if material_id:
  272. q = q.filter(BizMaterialStockBatch.material_id == material_id)
  273. kw = (keyword or "").strip()
  274. if kw:
  275. like = f"%{kw}%"
  276. q = q.filter(
  277. or_(
  278. BizMaterialStockBatch.stock_in_no.like(like),
  279. BizMaterialStockBatch.material_name.like(like),
  280. BizMaterialStockBatch.material_code.like(like),
  281. BizMaterialStockBatch.batch_no.like(like),
  282. BizMaterialStockBatch.catalog_no.like(like),
  283. )
  284. )
  285. rows = q.order_by(BizMaterialStockBatch.stock_in_date.asc()).all()
  286. out: List[Dict[str, Any]] = []
  287. for b, m in rows:
  288. unit = b.unit or ""
  289. rem = _dec(b.remaining_qty)
  290. mfr = (b.manufacturer or "").strip()
  291. cat = (b.catalog_no or "").strip()
  292. label = (
  293. f"{b.stock_in_no} / {b.material_name}"
  294. f" / 厂家:{mfr or '—'}"
  295. f" / 批号:{b.batch_no or '—'}"
  296. f" / 货号:{cat or '—'}"
  297. f" / 剩:{rem}{unit}"
  298. )
  299. unit_price = float(b.unit_price) if b.unit_price is not None else 0
  300. out.append(
  301. {
  302. "id": b.id,
  303. "batchId": b.id,
  304. "stockInNo": b.stock_in_no or "",
  305. "stockInDate": _fmt_date(b.stock_in_date),
  306. "expireDate": _fmt_date(b.expire_date),
  307. "materialId": b.material_id,
  308. "materialCode": b.material_code or "",
  309. "materialName": b.material_name or "",
  310. "specModel": b.spec_model or "",
  311. "unit": unit,
  312. "batchNo": b.batch_no or "",
  313. "catalogNo": b.catalog_no or "",
  314. "manufacturer": b.manufacturer or "",
  315. "unitPrice": unit_price,
  316. "amount": float(b.amount) if b.amount is not None else 0,
  317. "remainingQty": rem,
  318. "label": label,
  319. }
  320. )
  321. return out
  322. def _create_batch_and_usage(
  323. db: Session,
  324. *,
  325. material: BizMaterial,
  326. stock_in_no: str,
  327. stock_in_date: date,
  328. expire_date: date,
  329. catalog_no: str,
  330. batch_no: str,
  331. manufacturer: str,
  332. unit_price: Decimal,
  333. amount: Decimal,
  334. qty: Any,
  335. remark: Optional[str],
  336. operator_name: Optional[str],
  337. tenant_no: str,
  338. user_id: Optional[str],
  339. operate_time: datetime,
  340. commit: bool = False,
  341. ) -> BizMaterialStockBatch:
  342. qty = _opt_stock_in_qty(qty)
  343. if qty <= 0:
  344. raise ValueError("入库数量必须大于0")
  345. sno = _resolve_stock_in_no(db, tenant_no, stock_in_no)
  346. if not _is_enabled_status(material.status):
  347. raise ValueError(f"物料「{material.material_name}」已停用,无法入库")
  348. old_stock = material.stock_qty or Decimal("0")
  349. new_stock = old_stock + qty
  350. material.stock_qty = new_stock
  351. material.update_by = user_id
  352. material.update_time = datetime.now()
  353. db.add(material)
  354. batch = BizMaterialStockBatch(
  355. id=generate_uuid(),
  356. material_id=material.id,
  357. material_code=material.material_code,
  358. material_name=material.material_name,
  359. spec_model=material.spec_model,
  360. unit=material.unit,
  361. stock_in_no=sno,
  362. stock_in_date=_opt_stock_in_date(stock_in_date),
  363. expire_date=_parse_expire_date(expire_date),
  364. catalog_no=_opt_str(catalog_no),
  365. batch_no=_opt_str(batch_no),
  366. manufacturer=_opt_str(manufacturer),
  367. unit_price=_opt_decimal(unit_price),
  368. amount=_opt_decimal(amount),
  369. in_qty=qty,
  370. remaining_qty=qty,
  371. tenant_no=tenant_no,
  372. remark=(remark or "").strip() or None,
  373. create_by=user_id,
  374. create_dept_id=material.create_dept_id or "",
  375. create_time=operate_time,
  376. update_by=user_id,
  377. update_time=operate_time,
  378. del_flag=0,
  379. )
  380. db.add(batch)
  381. snaps = _usage_batch_snapshots(batch)
  382. usage = BizMaterialUsage(
  383. id=generate_uuid(),
  384. material_id=material.id,
  385. material_code=material.material_code,
  386. material_name=material.material_name,
  387. change_type="in",
  388. change_qty=qty,
  389. stock_before=old_stock,
  390. stock_after=new_stock,
  391. source_type="manual_in",
  392. tenant_no=tenant_no,
  393. operator_name=(operator_name or "").strip() or None,
  394. remark=(remark or "").strip() or None,
  395. create_by=user_id,
  396. create_dept_id=material.create_dept_id or "",
  397. create_time=operate_time,
  398. update_by=user_id,
  399. update_time=operate_time,
  400. del_flag=0,
  401. **snaps,
  402. )
  403. db.add(usage)
  404. if commit:
  405. db.commit()
  406. db.refresh(batch)
  407. return batch
  408. def stock_in_one(
  409. db: Session,
  410. material_id: str,
  411. *,
  412. stock_in_no: str,
  413. stock_in_date: Any,
  414. expire_date: Any,
  415. catalog_no: str,
  416. batch_no: str,
  417. manufacturer: str,
  418. unit_price: Any,
  419. amount: Any,
  420. qty: Any,
  421. remark: Optional[str] = None,
  422. operator_name: Optional[str] = None,
  423. tenant_no: str,
  424. user_id: Optional[str] = None,
  425. operate_time: Optional[datetime] = None,
  426. ) -> BizMaterialStockBatch:
  427. op_time = operate_time or datetime.now()
  428. material = _get_material_for_update(db, material_id, tenant_no)
  429. batch = _create_batch_and_usage(
  430. db,
  431. material=material,
  432. stock_in_no=stock_in_no,
  433. stock_in_date=stock_in_date,
  434. expire_date=expire_date,
  435. catalog_no=catalog_no,
  436. batch_no=batch_no,
  437. manufacturer=manufacturer,
  438. unit_price=unit_price,
  439. amount=amount,
  440. qty=qty,
  441. remark=remark,
  442. operator_name=operator_name,
  443. tenant_no=tenant_no,
  444. user_id=user_id,
  445. operate_time=op_time,
  446. commit=True,
  447. )
  448. return batch
  449. def batch_stock_in(
  450. db: Session,
  451. *,
  452. items: List[Dict[str, Any]],
  453. remark: Optional[str] = None,
  454. operator_name: Optional[str] = None,
  455. tenant_no: str,
  456. user_id: Optional[str] = None,
  457. operate_time: Optional[datetime] = None,
  458. ) -> int:
  459. if not items:
  460. raise ValueError("请至少添加一条入库明细")
  461. op_time = operate_time or datetime.now()
  462. shared_remark = (remark or "").strip() or None
  463. seen_nos: set = set()
  464. try:
  465. for it in items:
  466. material_id = (it.get("material_id") or it.get("materialId") or "").strip()
  467. if not material_id:
  468. raise ValueError("请选择物资")
  469. sno = _resolve_stock_in_no(
  470. db,
  471. tenant_no,
  472. it.get("stock_in_no") or it.get("stockInNo"),
  473. seen_nos=seen_nos,
  474. )
  475. qty_dec = _opt_stock_in_qty(it.get("qty"))
  476. if qty_dec <= 0:
  477. raise ValueError("入库数量必须大于0")
  478. line_remark = (it.get("remark") or "").strip() or shared_remark
  479. material = _get_material_for_update(db, material_id, tenant_no)
  480. _create_batch_and_usage(
  481. db,
  482. material=material,
  483. stock_in_no=sno,
  484. stock_in_date=it.get("stock_in_date") or it.get("stockInDate"),
  485. expire_date=it.get("expire_date") or it.get("expireDate"),
  486. catalog_no=it.get("catalog_no") or it.get("catalogNo"),
  487. batch_no=it.get("batch_no") or it.get("batchNo"),
  488. manufacturer=it.get("manufacturer"),
  489. unit_price=it.get("unit_price") or it.get("unitPrice"),
  490. amount=it.get("amount"),
  491. qty=qty_dec,
  492. remark=line_remark,
  493. operator_name=operator_name,
  494. tenant_no=tenant_no,
  495. user_id=user_id,
  496. operate_time=op_time,
  497. commit=False,
  498. )
  499. db.commit()
  500. return len(items)
  501. except Exception:
  502. db.rollback()
  503. raise
  504. def _stock_out_batch(
  505. db: Session,
  506. batch_id: str,
  507. *,
  508. qty: Decimal,
  509. receiver: Optional[str],
  510. purpose: Optional[str],
  511. remark: Optional[str],
  512. operator_name: Optional[str],
  513. usage_unit: Optional[str] = None,
  514. tenant_no: str,
  515. user_id: Optional[str],
  516. operate_time: datetime,
  517. ) -> None:
  518. if qty <= 0:
  519. raise ValueError("出库数量必须大于0")
  520. if not (receiver or "").strip():
  521. raise ValueError("请选择领用人")
  522. batch = (
  523. db.query(BizMaterialStockBatch)
  524. .filter(
  525. BizMaterialStockBatch.id == batch_id,
  526. BizMaterialStockBatch.tenant_no == tenant_no,
  527. BizMaterialStockBatch.del_flag == 0,
  528. )
  529. .with_for_update()
  530. .first()
  531. )
  532. if not batch:
  533. raise ValueError("入库批次不存在")
  534. material = _get_material_for_update(db, batch.material_id, tenant_no)
  535. if not _is_enabled_status(material.status):
  536. raise ValueError(f"物料「{material.material_name}」已停用,无法出库")
  537. rem = batch.remaining_qty or Decimal("0")
  538. if rem < qty:
  539. raise ValueError(
  540. f"批次「{batch.stock_in_no}」库存不足,当前剩余 {rem},需出库 {qty}"
  541. )
  542. batch.remaining_qty = rem - qty
  543. batch.update_by = user_id
  544. batch.update_time = datetime.now()
  545. db.add(batch)
  546. stock_before = material.stock_qty or Decimal("0")
  547. new_stock = stock_before - qty
  548. material.stock_qty = new_stock
  549. material.update_by = user_id
  550. material.update_time = datetime.now()
  551. db.add(material)
  552. snaps = _usage_batch_snapshots(batch, out_qty=qty)
  553. unit_label = (usage_unit or "").strip() or (batch.unit or "").strip() or None
  554. usage = BizMaterialUsage(
  555. id=generate_uuid(),
  556. material_id=material.id,
  557. material_code=material.material_code,
  558. material_name=material.material_name,
  559. change_type="use",
  560. change_qty=-qty,
  561. usage_unit=unit_label,
  562. stock_before=stock_before,
  563. stock_after=new_stock,
  564. source_type="manual_out",
  565. tenant_no=tenant_no,
  566. receiver=(receiver or "").strip() or None,
  567. operator_name=(operator_name or "").strip() or None,
  568. purpose=(purpose or "").strip() or None,
  569. remark=(remark or "").strip() or None,
  570. create_by=user_id,
  571. create_dept_id=material.create_dept_id or "",
  572. create_time=operate_time,
  573. update_by=user_id,
  574. update_time=operate_time,
  575. del_flag=0,
  576. **snaps,
  577. )
  578. db.add(usage)
  579. def batch_stock_out(
  580. db: Session,
  581. *,
  582. items: List[Dict[str, Any]],
  583. receiver: Optional[str] = None,
  584. purpose: Optional[str] = None,
  585. remark: Optional[str] = None,
  586. operator_name: Optional[str] = None,
  587. tenant_no: str,
  588. user_id: Optional[str] = None,
  589. operate_time: Optional[datetime] = None,
  590. ) -> int:
  591. if not items:
  592. raise ValueError("请至少添加一条出库明细")
  593. op_time = operate_time or datetime.now()
  594. shared_remark = (remark or "").strip() or None
  595. seen_batches: set = set()
  596. try:
  597. for it in items:
  598. batch_id = (it.get("batch_id") or it.get("batchId") or "").strip()
  599. if not batch_id:
  600. raise ValueError("请选择入库批次")
  601. if batch_id in seen_batches:
  602. raise ValueError("同一入库批次不可重复添加")
  603. seen_batches.add(batch_id)
  604. qty = it.get("qty")
  605. if qty is None or Decimal(str(qty)) <= 0:
  606. raise ValueError("出库数量必须大于0")
  607. qty_dec = Decimal(str(qty))
  608. line_remark = (it.get("remark") or "").strip() or shared_remark
  609. usage_unit = (it.get("usageUnit") or it.get("usage_unit") or "").strip() or None
  610. _stock_out_batch(
  611. db,
  612. batch_id,
  613. qty=qty_dec,
  614. receiver=receiver,
  615. purpose=purpose,
  616. remark=line_remark,
  617. operator_name=operator_name,
  618. usage_unit=usage_unit,
  619. tenant_no=tenant_no,
  620. user_id=user_id,
  621. operate_time=op_time,
  622. )
  623. db.commit()
  624. return len(items)
  625. except Exception:
  626. db.rollback()
  627. raise
  628. DISCARD_REASONS = frozenset({"过期", "质量问题", "污染/破损", "其他"})
  629. def _validate_discard_reason(discard_reason: Optional[str], remark: Optional[str]) -> str:
  630. reason = (discard_reason or "").strip()
  631. if reason not in DISCARD_REASONS:
  632. raise ValueError("请选择有效的废弃原因")
  633. if reason == "其他" and not (remark or "").strip():
  634. raise ValueError("选择「其他」时请填写备注说明")
  635. return reason
  636. def _discard_batch(
  637. db: Session,
  638. batch_id: str,
  639. *,
  640. qty: Decimal,
  641. discard_reason: str,
  642. remark: Optional[str],
  643. operator_name: Optional[str],
  644. usage_unit: Optional[str] = None,
  645. tenant_no: str,
  646. user_id: Optional[str],
  647. operate_time: datetime,
  648. ) -> None:
  649. if qty <= 0:
  650. raise ValueError("废弃数量必须大于0")
  651. batch = (
  652. db.query(BizMaterialStockBatch)
  653. .filter(
  654. BizMaterialStockBatch.id == batch_id,
  655. BizMaterialStockBatch.tenant_no == tenant_no,
  656. BizMaterialStockBatch.del_flag == 0,
  657. )
  658. .with_for_update()
  659. .first()
  660. )
  661. if not batch:
  662. raise ValueError("入库批次不存在")
  663. material = _get_material_for_update(db, batch.material_id, tenant_no)
  664. if not _is_enabled_status(material.status):
  665. raise ValueError(f"物料「{material.material_name}」已停用,无法废弃")
  666. rem = batch.remaining_qty or Decimal("0")
  667. if rem < qty:
  668. raise ValueError(
  669. f"批次「{batch.stock_in_no}」库存不足,当前剩余 {rem},需废弃 {qty}"
  670. )
  671. batch.remaining_qty = rem - qty
  672. batch.update_by = user_id
  673. batch.update_time = datetime.now()
  674. db.add(batch)
  675. stock_before = material.stock_qty or Decimal("0")
  676. new_stock = stock_before - qty
  677. material.stock_qty = new_stock
  678. material.update_by = user_id
  679. material.update_time = datetime.now()
  680. db.add(material)
  681. snaps = _usage_batch_snapshots(batch, out_qty=qty)
  682. unit_label = (usage_unit or "").strip() or (batch.unit or "").strip() or None
  683. usage = BizMaterialUsage(
  684. id=generate_uuid(),
  685. material_id=material.id,
  686. material_code=material.material_code,
  687. material_name=material.material_name,
  688. change_type="discard",
  689. change_qty=-qty,
  690. usage_unit=unit_label,
  691. stock_before=stock_before,
  692. stock_after=new_stock,
  693. source_type="manual_discard",
  694. tenant_no=tenant_no,
  695. purpose=discard_reason,
  696. operator_name=(operator_name or "").strip() or None,
  697. remark=(remark or "").strip() or None,
  698. create_by=user_id,
  699. create_dept_id=material.create_dept_id or "",
  700. create_time=operate_time,
  701. update_by=user_id,
  702. update_time=operate_time,
  703. del_flag=0,
  704. **snaps,
  705. )
  706. db.add(usage)
  707. def batch_discard(
  708. db: Session,
  709. *,
  710. items: List[Dict[str, Any]],
  711. discard_reason: Optional[str] = None,
  712. remark: Optional[str] = None,
  713. operator_name: Optional[str] = None,
  714. tenant_no: str,
  715. user_id: Optional[str] = None,
  716. operate_time: Optional[datetime] = None,
  717. ) -> int:
  718. if not items:
  719. raise ValueError("请至少添加一条废弃明细")
  720. op_time = operate_time or datetime.now()
  721. shared_remark = (remark or "").strip() or None
  722. reason = _validate_discard_reason(discard_reason, shared_remark)
  723. seen_batches: set = set()
  724. try:
  725. for it in items:
  726. batch_id = (it.get("batch_id") or it.get("batchId") or "").strip()
  727. if not batch_id:
  728. raise ValueError("请选择入库批次")
  729. if batch_id in seen_batches:
  730. raise ValueError("同一入库批次不可重复添加")
  731. seen_batches.add(batch_id)
  732. qty = it.get("qty")
  733. if qty is None or Decimal(str(qty)) <= 0:
  734. raise ValueError("废弃数量必须大于0")
  735. qty_dec = Decimal(str(qty))
  736. line_remark = (it.get("remark") or "").strip() or shared_remark
  737. if reason == "其他" and not line_remark:
  738. raise ValueError("选择「其他」时请填写备注说明")
  739. usage_unit = (it.get("usageUnit") or it.get("usage_unit") or "").strip() or None
  740. _discard_batch(
  741. db,
  742. batch_id,
  743. qty=qty_dec,
  744. discard_reason=reason,
  745. remark=line_remark,
  746. operator_name=operator_name,
  747. usage_unit=usage_unit,
  748. tenant_no=tenant_no,
  749. user_id=user_id,
  750. operate_time=op_time,
  751. )
  752. db.commit()
  753. return len(items)
  754. except Exception:
  755. db.rollback()
  756. raise
  757. def get_batch(db: Session, batch_id: str, tenant_no: str) -> Optional[BizMaterialStockBatch]:
  758. return (
  759. db.query(BizMaterialStockBatch)
  760. .filter(
  761. BizMaterialStockBatch.id == batch_id,
  762. BizMaterialStockBatch.tenant_no == tenant_no,
  763. BizMaterialStockBatch.del_flag == 0,
  764. )
  765. .first()
  766. )
  767. def update_batch(
  768. db: Session,
  769. batch_id: str,
  770. *,
  771. tenant_no: str,
  772. user_id: Optional[str] = None,
  773. stock_in_no: Optional[str] = None,
  774. stock_in_date: Optional[Any] = None,
  775. expire_date: Optional[Any] = None,
  776. catalog_no: Optional[str] = None,
  777. batch_no: Optional[str] = None,
  778. manufacturer: Optional[str] = None,
  779. unit_price: Optional[Any] = None,
  780. amount: Optional[Any] = None,
  781. remark: Optional[str] = None,
  782. material_code: Optional[str] = None,
  783. material_name: Optional[str] = None,
  784. category: Optional[str] = None,
  785. spec_model: Optional[str] = None,
  786. unit: Optional[str] = None,
  787. warning_qty: Optional[Any] = None,
  788. status: Optional[str] = None,
  789. ) -> BizMaterialStockBatch:
  790. from app.crud import material as mat_svc
  791. batch = get_batch(db, batch_id, tenant_no)
  792. if not batch:
  793. raise ValueError("入库批次不存在")
  794. material = _get_material_for_update(db, batch.material_id, tenant_no)
  795. mat_data: Dict[str, Any] = {}
  796. if material_code is not None:
  797. mat_data["material_code"] = material_code
  798. if material_name is not None:
  799. mat_data["material_name"] = material_name
  800. if category is not None:
  801. mat_data["category"] = (category or "").strip() or None
  802. if spec_model is not None:
  803. mat_data["spec_model"] = spec_model
  804. if unit is not None:
  805. mat_data["unit"] = unit
  806. if warning_qty is not None:
  807. mat_data["warning_qty"] = Decimal(str(warning_qty))
  808. if status is not None:
  809. mat_data["status"] = _norm_status(status)
  810. if mat_data:
  811. material = mat_svc.update_material_fields(
  812. db,
  813. batch.material_id,
  814. tenant_no=tenant_no,
  815. user_id=user_id,
  816. **mat_data,
  817. )
  818. if stock_in_no is not None:
  819. s = str(stock_in_no).strip()
  820. if s:
  821. if stock_in_no_exists(db, s, tenant_no, exclude_id=batch_id):
  822. raise ValueError(f"入库编号「{s}」已存在")
  823. batch.stock_in_no = s
  824. if stock_in_date is not None:
  825. s = str(stock_in_date).strip()
  826. if s:
  827. batch.stock_in_date = _parse_date(stock_in_date)
  828. if expire_date is not None:
  829. s = str(expire_date).strip()
  830. if s:
  831. batch.expire_date = _parse_expire_date(expire_date)
  832. else:
  833. batch.expire_date = _SENTINEL_EXPIRE_DATE
  834. if catalog_no is not None:
  835. batch.catalog_no = _opt_str(catalog_no)
  836. if batch_no is not None:
  837. batch.batch_no = _opt_str(batch_no)
  838. if manufacturer is not None:
  839. batch.manufacturer = _opt_str(manufacturer)
  840. if unit_price is not None:
  841. batch.unit_price = _opt_decimal(unit_price)
  842. if amount is not None:
  843. batch.amount = _opt_decimal(amount)
  844. if remark is not None:
  845. batch.remark = (remark or "").strip() or None
  846. batch.material_code = material.material_code
  847. batch.material_name = material.material_name
  848. batch.spec_model = material.spec_model
  849. batch.unit = material.unit
  850. batch.update_by = user_id
  851. batch.update_time = datetime.now()
  852. db.add(batch)
  853. in_usage = (
  854. db.query(BizMaterialUsage)
  855. .filter(
  856. BizMaterialUsage.batch_id == batch_id,
  857. BizMaterialUsage.tenant_no == tenant_no,
  858. BizMaterialUsage.del_flag == 0,
  859. BizMaterialUsage.change_type == "in",
  860. BizMaterialUsage.source_type == "manual_in",
  861. )
  862. .first()
  863. )
  864. if in_usage:
  865. in_usage.material_code = material.material_code
  866. in_usage.material_name = material.material_name
  867. in_usage.stock_in_no = batch.stock_in_no
  868. in_usage.catalog_no = batch.catalog_no
  869. in_usage.batch_no = batch.batch_no
  870. in_usage.manufacturer = batch.manufacturer
  871. in_usage.unit_price = batch.unit_price
  872. in_usage.amount = batch.amount
  873. in_usage.remark = batch.remark
  874. in_usage.update_by = user_id
  875. in_usage.update_time = datetime.now()
  876. db.add(in_usage)
  877. db.commit()
  878. db.refresh(batch)
  879. return batch
  880. def delete_batch(
  881. db: Session,
  882. batch_id: str,
  883. *,
  884. tenant_no: str,
  885. user_id: Optional[str] = None,
  886. ) -> None:
  887. batch = get_batch(db, batch_id, tenant_no)
  888. if not batch:
  889. raise ValueError("入库批次不存在")
  890. used = (
  891. db.query(BizMaterialUsage)
  892. .filter(
  893. BizMaterialUsage.batch_id == batch_id,
  894. BizMaterialUsage.tenant_no == tenant_no,
  895. BizMaterialUsage.del_flag == 0,
  896. BizMaterialUsage.change_type == "use",
  897. )
  898. .count()
  899. )
  900. if used > 0:
  901. raise ValueError("该批次已有领用或出库记录,无法删除")
  902. material = _get_material_for_update(db, batch.material_id, tenant_no)
  903. rem = batch.remaining_qty or Decimal("0")
  904. material.stock_qty = (material.stock_qty or Decimal("0")) - rem
  905. material.update_by = user_id
  906. material.update_time = datetime.now()
  907. db.add(material)
  908. now = datetime.now()
  909. batch.del_flag = 1
  910. batch.update_by = user_id
  911. batch.update_time = now
  912. db.add(batch)
  913. usages = (
  914. db.query(BizMaterialUsage)
  915. .filter(
  916. BizMaterialUsage.batch_id == batch_id,
  917. BizMaterialUsage.tenant_no == tenant_no,
  918. BizMaterialUsage.del_flag == 0,
  919. )
  920. .all()
  921. )
  922. for u in usages:
  923. u.del_flag = 1
  924. u.update_by = user_id
  925. u.update_time = now
  926. db.add(u)
  927. db.commit()