production_material.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. """物料库 CRUD"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from sqlalchemy import and_, func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.models.production_material import BizProductionMaterial, BizProductionMaterialUsage
  10. def _dec(v: Any) -> float:
  11. if v is None:
  12. return 0.0
  13. return float(v)
  14. def _fmt_dt(t: Optional[datetime]) -> str:
  15. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  16. def _norm_status(v: Any) -> str:
  17. """统一状态为 '1'(启用)或 '0'(停用),避免 0 / '0' / or 判断歧义。"""
  18. if v is None:
  19. return "1"
  20. if isinstance(v, bool):
  21. return "1" if v else "0"
  22. if isinstance(v, (int, float)):
  23. if int(v) == 0:
  24. return "0"
  25. if int(v) == 1:
  26. return "1"
  27. s = str(v).strip()
  28. if s in ("0", "1"):
  29. return s
  30. return "1"
  31. def _is_enabled_status(v: Any) -> bool:
  32. return _norm_status(v) == "1"
  33. MATERIAL_TYPES = ("reagent", "consumable", "configured_reagent")
  34. def _norm_material_type(v: Any) -> str:
  35. s = (str(v or "").strip()).lower()
  36. if s in MATERIAL_TYPES:
  37. return s
  38. raise ValueError("物料类型无效,应为 reagent、consumable 或 configured_reagent")
  39. def _filter_material_type(mt: Optional[str]) -> Optional[str]:
  40. s = (mt or "").strip().lower()
  41. return s if s in MATERIAL_TYPES else None
  42. def _row_to_dict(r: BizProductionMaterial) -> Dict[str, Any]:
  43. return {
  44. "id": r.id,
  45. "materialType": r.material_type or "",
  46. "materialCode": r.material_code,
  47. "materialName": r.material_name,
  48. "specModel": r.spec_model or "",
  49. "unit": r.unit or "",
  50. "stockQty": _dec(r.stock_qty),
  51. "warningQty": _dec(r.warning_qty),
  52. "status": _norm_status(r.status),
  53. "orderNum": r.order_num if r.order_num is not None else 0,
  54. "remark": r.remark or "",
  55. "createTime": _fmt_dt(r.create_time),
  56. "updateTime": _fmt_dt(r.update_time),
  57. }
  58. def code_exists(
  59. db: Session,
  60. code: str,
  61. material_type: str,
  62. tenant_no: str,
  63. exclude_id: Optional[str] = None,
  64. ) -> bool:
  65. c = (code or "").strip()
  66. if not c:
  67. return False
  68. mt = _norm_material_type(material_type)
  69. q = db.query(BizProductionMaterial).filter(
  70. BizProductionMaterial.material_code == c,
  71. BizProductionMaterial.material_type == mt,
  72. BizProductionMaterial.tenant_no == tenant_no,
  73. BizProductionMaterial.del_flag == 0,
  74. )
  75. if exclude_id:
  76. q = q.filter(BizProductionMaterial.id != exclude_id)
  77. return q.first() is not None
  78. def total_pages(db: Session, tenant_no: str, page_size: int = 10) -> int:
  79. cnt = (
  80. db.query(func.count(BizProductionMaterial.id))
  81. .filter(
  82. BizProductionMaterial.tenant_no == tenant_no,
  83. BizProductionMaterial.del_flag == 0,
  84. )
  85. .scalar()
  86. or 0
  87. )
  88. ps = max(1, page_size)
  89. return (cnt + ps - 1) // ps
  90. def list_materials(
  91. db: Session,
  92. *,
  93. page: int = 1,
  94. page_size: int = 10,
  95. keyword: Optional[str] = None,
  96. material_type: Optional[str] = None,
  97. status: Optional[str] = None,
  98. tenant_no: Optional[str] = None,
  99. user_info=None,
  100. ) -> Tuple[List[Dict[str, Any]], int]:
  101. def _filtered():
  102. q0 = db.query(BizProductionMaterial).filter(BizProductionMaterial.del_flag == 0)
  103. if tenant_no:
  104. q0 = q0.filter(BizProductionMaterial.tenant_no == tenant_no)
  105. mt = _filter_material_type(material_type)
  106. if mt:
  107. q0 = q0.filter(BizProductionMaterial.material_type == mt)
  108. kw = (keyword or "").strip()
  109. if kw:
  110. like = f"%{kw}%"
  111. q0 = q0.filter(
  112. or_(
  113. BizProductionMaterial.material_code.like(like),
  114. BizProductionMaterial.material_name.like(like),
  115. )
  116. )
  117. st = (status or "").strip()
  118. if st in ("0", "1"):
  119. q0 = q0.filter(BizProductionMaterial.status == st)
  120. if user_info:
  121. from app.core.data_scope import apply_data_scope
  122. q0 = apply_data_scope(q0, BizProductionMaterial, user_info, db, tenant_no)
  123. return q0
  124. total = _filtered().order_by(None).with_entities(func.count(BizProductionMaterial.id)).scalar() or 0
  125. page = max(1, page)
  126. page_size = min(max(1, page_size), 100)
  127. rows = (
  128. _filtered()
  129. .order_by(BizProductionMaterial.create_time.desc())
  130. .offset((page - 1) * page_size)
  131. .limit(page_size)
  132. .all()
  133. )
  134. return [_row_to_dict(x) for x in rows], int(total)
  135. def get_material(
  136. db: Session, row_id: str, tenant_no: Optional[str] = None
  137. ) -> Optional[BizProductionMaterial]:
  138. q = db.query(BizProductionMaterial).filter(
  139. BizProductionMaterial.id == row_id, BizProductionMaterial.del_flag == 0
  140. )
  141. if tenant_no:
  142. q = q.filter(BizProductionMaterial.tenant_no == tenant_no)
  143. return q.first()
  144. def create_material(
  145. db: Session,
  146. *,
  147. material_type: str,
  148. material_code: str,
  149. material_name: str,
  150. spec_model: Optional[str] = None,
  151. unit: Optional[str] = None,
  152. stock_qty: Optional[Decimal] = None,
  153. warning_qty: Optional[Decimal] = None,
  154. status: str = "1",
  155. tenant_no: str,
  156. order_num: Optional[int] = None,
  157. remark: Optional[str] = None,
  158. user_id: Optional[str] = None,
  159. create_dept_id: str = "",
  160. ) -> BizProductionMaterial:
  161. code = (material_code or "").strip()
  162. if not code:
  163. raise ValueError("物料编码不能为空")
  164. mt = _norm_material_type(material_type)
  165. if code_exists(db, code, mt, tenant_no):
  166. raise ValueError("物料编码已存在")
  167. name = (material_name or "").strip()
  168. if not name:
  169. raise ValueError("物料名称不能为空")
  170. st = _norm_status(status)
  171. obj = BizProductionMaterial(
  172. id=generate_uuid(),
  173. material_type=mt,
  174. material_code=code,
  175. material_name=name,
  176. spec_model=(spec_model or "").strip() or None,
  177. unit=(unit or "").strip() or None,
  178. stock_qty=stock_qty if stock_qty is not None else 0,
  179. warning_qty=warning_qty if warning_qty is not None else 0,
  180. status=st,
  181. tenant_no=tenant_no,
  182. order_num=order_num if order_num is not None else 0,
  183. remark=(remark or "").strip() or None,
  184. create_by=user_id,
  185. create_dept_id=create_dept_id or "",
  186. create_time=datetime.now(),
  187. update_by=user_id,
  188. update_time=datetime.now(),
  189. del_flag=0,
  190. )
  191. db.add(obj)
  192. db.commit()
  193. db.refresh(obj)
  194. return obj
  195. def update_material(
  196. db: Session,
  197. row_id: str,
  198. *,
  199. material_type: Optional[str] = None,
  200. material_code: Optional[str] = None,
  201. material_name: Optional[str] = None,
  202. spec_model: Optional[str] = None,
  203. unit: Optional[str] = None,
  204. warning_qty: Optional[Decimal] = None,
  205. status: Optional[str] = None,
  206. order_num: Optional[int] = None,
  207. remark: Optional[str] = None,
  208. user_id: Optional[str] = None,
  209. tenant_no: Optional[str] = None,
  210. ) -> BizProductionMaterial:
  211. obj = get_material(db, row_id, tenant_no=tenant_no)
  212. if not obj:
  213. raise ValueError("物料不存在")
  214. mt = _norm_material_type(material_type) if material_type is not None else obj.material_type
  215. if material_type is not None:
  216. obj.material_type = mt
  217. if material_code is not None:
  218. code = material_code.strip()
  219. if not code:
  220. raise ValueError("物料编码不能为空")
  221. if code != obj.material_code and code_exists(
  222. db, code, mt, obj.tenant_no, exclude_id=row_id
  223. ):
  224. raise ValueError("物料编码已存在")
  225. obj.material_code = code
  226. if material_name is not None:
  227. name = (material_name or "").strip()
  228. if not name:
  229. raise ValueError("物料名称不能为空")
  230. obj.material_name = name
  231. if spec_model is not None:
  232. obj.spec_model = (spec_model or "").strip() or None
  233. if unit is not None:
  234. obj.unit = (unit or "").strip() or None
  235. if warning_qty is not None:
  236. obj.warning_qty = warning_qty
  237. if status is not None:
  238. obj.status = _norm_status(status)
  239. if order_num is not None:
  240. obj.order_num = order_num
  241. if remark is not None:
  242. obj.remark = (remark or "").strip() or None
  243. obj.update_by = user_id
  244. obj.update_time = datetime.now()
  245. db.add(obj)
  246. db.commit()
  247. db.refresh(obj)
  248. return obj
  249. def soft_delete_material(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
  250. obj = get_material(db, row_id, tenant_no=tenant_no)
  251. if not obj:
  252. raise ValueError("物料不存在")
  253. if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
  254. raise ValueError("该物料仍有库存,无法删除")
  255. obj.del_flag = 1
  256. obj.update_time = datetime.now()
  257. db.add(obj)
  258. db.commit()
  259. def _build_manual_out_remark(
  260. *,
  261. receiver: Optional[str] = None,
  262. purpose: Optional[str] = None,
  263. remark: Optional[str] = None,
  264. line_remark: Optional[str] = None,
  265. ) -> Optional[str]:
  266. parts: List[str] = []
  267. r = (receiver or "").strip()
  268. if r:
  269. parts.append(f"领用人:{r}")
  270. p = (purpose or "").strip()
  271. if p:
  272. parts.append(f"用途:{p}")
  273. extra = (line_remark or remark or "").strip()
  274. if extra:
  275. parts.append(extra)
  276. return ";".join(parts) if parts else None
  277. def stock_in(
  278. db: Session,
  279. row_id: str,
  280. *,
  281. qty: Decimal,
  282. remark: Optional[str] = None,
  283. user_id: Optional[str] = None,
  284. tenant_no: Optional[str] = None,
  285. operate_time: Optional[datetime] = None,
  286. ) -> BizProductionMaterial:
  287. if qty is None or float(qty) <= 0:
  288. raise ValueError("入库数量必须大于0")
  289. # 行锁锁定物料行
  290. obj = (
  291. db.query(BizProductionMaterial)
  292. .filter(BizProductionMaterial.id == row_id, BizProductionMaterial.del_flag == 0)
  293. .with_for_update()
  294. .first()
  295. )
  296. if not obj:
  297. raise ValueError("物料不存在")
  298. if tenant_no and obj.tenant_no != tenant_no:
  299. raise ValueError("物料不存在")
  300. if not _is_enabled_status(obj.status):
  301. raise ValueError(f"物料「{obj.material_name}」已停用,无法入库")
  302. op_time = operate_time or datetime.now()
  303. old_stock = obj.stock_qty or 0
  304. new_stock = old_stock + qty
  305. obj.stock_qty = new_stock
  306. obj.update_by = user_id
  307. obj.update_time = datetime.now()
  308. db.add(obj)
  309. usage = BizProductionMaterialUsage(
  310. id=generate_uuid(),
  311. material_id=obj.id,
  312. material_type=obj.material_type,
  313. material_code=obj.material_code,
  314. material_name=obj.material_name,
  315. change_type="in",
  316. change_qty=qty,
  317. stock_after=new_stock,
  318. source_type="manual_in",
  319. tenant_no=obj.tenant_no,
  320. remark=(remark or "").strip() or None,
  321. create_by=user_id,
  322. create_dept_id=obj.create_dept_id or "",
  323. create_time=op_time,
  324. update_by=user_id,
  325. update_time=op_time,
  326. del_flag=0,
  327. )
  328. db.add(usage)
  329. db.commit()
  330. db.refresh(obj)
  331. return obj
  332. def batch_stock_in(
  333. db: Session,
  334. *,
  335. items: List[Dict[str, Any]],
  336. remark: Optional[str] = None,
  337. tenant_no: str,
  338. user_id: Optional[str] = None,
  339. operate_time: Optional[datetime] = None,
  340. ) -> int:
  341. """批量入库,同一事务;任一条失败则全部回滚。"""
  342. if not items:
  343. raise ValueError("请至少选择一条物料")
  344. op_time = operate_time or datetime.now()
  345. shared_remark = (remark or "").strip() or None
  346. try:
  347. for it in items:
  348. material_id = (it.get("material_id") or it.get("materialId") or "").strip()
  349. qty = it.get("qty")
  350. if not material_id:
  351. raise ValueError("物料ID不能为空")
  352. if qty is None or float(qty) <= 0:
  353. raise ValueError("入库数量必须大于0")
  354. qty_dec = Decimal(str(qty))
  355. line_remark = (it.get("remark") or "").strip()
  356. log_remark = line_remark or shared_remark or None
  357. obj = (
  358. db.query(BizProductionMaterial)
  359. .filter(
  360. BizProductionMaterial.id == material_id,
  361. BizProductionMaterial.tenant_no == tenant_no,
  362. BizProductionMaterial.del_flag == 0,
  363. )
  364. .with_for_update()
  365. .first()
  366. )
  367. if not obj:
  368. raise ValueError("物料不存在")
  369. if not _is_enabled_status(obj.status):
  370. raise ValueError(f"物料「{obj.material_name}」已停用,无法入库")
  371. old_stock = obj.stock_qty or 0
  372. new_stock = old_stock + qty_dec
  373. obj.stock_qty = new_stock
  374. obj.update_by = user_id
  375. obj.update_time = datetime.now()
  376. db.add(obj)
  377. usage = BizProductionMaterialUsage(
  378. id=generate_uuid(),
  379. material_id=obj.id,
  380. material_type=obj.material_type,
  381. material_code=obj.material_code,
  382. material_name=obj.material_name,
  383. change_type="in",
  384. change_qty=qty_dec,
  385. stock_after=new_stock,
  386. source_type="manual_in",
  387. tenant_no=obj.tenant_no,
  388. remark=log_remark,
  389. create_by=user_id,
  390. create_dept_id=obj.create_dept_id or "",
  391. create_time=op_time,
  392. update_by=user_id,
  393. update_time=op_time,
  394. del_flag=0,
  395. )
  396. db.add(usage)
  397. db.commit()
  398. return len(items)
  399. except Exception:
  400. db.rollback()
  401. raise
  402. def stock_out(
  403. db: Session,
  404. row_id: str,
  405. *,
  406. qty: Decimal,
  407. receiver: Optional[str] = None,
  408. purpose: Optional[str] = None,
  409. remark: Optional[str] = None,
  410. user_id: Optional[str] = None,
  411. tenant_no: Optional[str] = None,
  412. operate_time: Optional[datetime] = None,
  413. ) -> BizProductionMaterial:
  414. if qty is None or float(qty) <= 0:
  415. raise ValueError("出库数量必须大于0")
  416. qty_dec = Decimal(str(qty))
  417. if not (receiver or "").strip():
  418. raise ValueError("请选择领用人")
  419. obj = (
  420. db.query(BizProductionMaterial)
  421. .filter(BizProductionMaterial.id == row_id, BizProductionMaterial.del_flag == 0)
  422. .with_for_update()
  423. .first()
  424. )
  425. if not obj:
  426. raise ValueError("物料不存在")
  427. if tenant_no and obj.tenant_no != tenant_no:
  428. raise ValueError("物料不存在")
  429. if not _is_enabled_status(obj.status):
  430. raise ValueError(f"物料「{obj.material_name}」已停用,无法出库")
  431. stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
  432. if stock_before < qty_dec:
  433. raise ValueError(
  434. f"物料「{obj.material_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
  435. )
  436. op_time = operate_time or datetime.now()
  437. new_stock = stock_before - qty_dec
  438. obj.stock_qty = new_stock
  439. obj.update_by = user_id
  440. obj.update_time = datetime.now()
  441. db.add(obj)
  442. usage = BizProductionMaterialUsage(
  443. id=generate_uuid(),
  444. material_id=obj.id,
  445. material_type=obj.material_type,
  446. material_code=obj.material_code,
  447. material_name=obj.material_name,
  448. change_type="use",
  449. change_qty=-qty_dec,
  450. stock_after=new_stock,
  451. source_type="manual_out",
  452. tenant_no=obj.tenant_no,
  453. receiver=(receiver or "").strip() or None,
  454. purpose=(purpose or "").strip() or None,
  455. remark=(remark or "").strip() or None,
  456. create_by=user_id,
  457. create_dept_id=obj.create_dept_id or "",
  458. create_time=op_time,
  459. update_by=user_id,
  460. update_time=op_time,
  461. del_flag=0,
  462. )
  463. db.add(usage)
  464. db.commit()
  465. db.refresh(obj)
  466. return obj
  467. def batch_stock_out(
  468. db: Session,
  469. *,
  470. items: List[Dict[str, Any]],
  471. receiver: Optional[str] = None,
  472. purpose: Optional[str] = None,
  473. remark: Optional[str] = None,
  474. tenant_no: str,
  475. user_id: Optional[str] = None,
  476. operate_time: Optional[datetime] = None,
  477. ) -> int:
  478. """批量出库,同一事务;任一条失败则全部回滚。"""
  479. if not items:
  480. raise ValueError("请至少选择一条物料")
  481. if not (receiver or "").strip():
  482. raise ValueError("请选择领用人")
  483. op_time = operate_time or datetime.now()
  484. recv = (receiver or "").strip() or None
  485. purp = (purpose or "").strip() or None
  486. shared_remark = (remark or "").strip() or None
  487. try:
  488. for it in items:
  489. material_id = (it.get("material_id") or it.get("materialId") or "").strip()
  490. qty = it.get("qty")
  491. if not material_id:
  492. raise ValueError("物料ID不能为空")
  493. if qty is None or float(qty) <= 0:
  494. raise ValueError("出库数量必须大于0")
  495. qty_dec = Decimal(str(qty))
  496. line_remark = (it.get("remark") or "").strip() or shared_remark
  497. obj = (
  498. db.query(BizProductionMaterial)
  499. .filter(
  500. BizProductionMaterial.id == material_id,
  501. BizProductionMaterial.tenant_no == tenant_no,
  502. BizProductionMaterial.del_flag == 0,
  503. )
  504. .with_for_update()
  505. .first()
  506. )
  507. if not obj:
  508. raise ValueError("物料不存在")
  509. if not _is_enabled_status(obj.status):
  510. raise ValueError(f"物料「{obj.material_name}」已停用,无法出库")
  511. stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
  512. if stock_before < qty_dec:
  513. raise ValueError(
  514. f"物料「{obj.material_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
  515. )
  516. new_stock = stock_before - qty_dec
  517. obj.stock_qty = new_stock
  518. obj.update_by = user_id
  519. obj.update_time = datetime.now()
  520. db.add(obj)
  521. usage = BizProductionMaterialUsage(
  522. id=generate_uuid(),
  523. material_id=obj.id,
  524. material_type=obj.material_type,
  525. material_code=obj.material_code,
  526. material_name=obj.material_name,
  527. change_type="use",
  528. change_qty=-qty_dec,
  529. stock_after=new_stock,
  530. source_type="manual_out",
  531. tenant_no=obj.tenant_no,
  532. receiver=recv,
  533. purpose=purp,
  534. remark=line_remark,
  535. create_by=user_id,
  536. create_dept_id=obj.create_dept_id or "",
  537. create_time=op_time,
  538. update_by=user_id,
  539. update_time=op_time,
  540. del_flag=0,
  541. )
  542. db.add(usage)
  543. db.commit()
  544. return len(items)
  545. except Exception:
  546. db.rollback()
  547. raise
  548. def options_list(
  549. db: Session,
  550. tenant_no: str,
  551. *,
  552. material_type: Optional[str] = None,
  553. ) -> List[Dict[str, Any]]:
  554. q = db.query(BizProductionMaterial).filter(
  555. BizProductionMaterial.tenant_no == tenant_no,
  556. BizProductionMaterial.del_flag == 0,
  557. BizProductionMaterial.status == "1",
  558. )
  559. mt = _filter_material_type(material_type)
  560. if mt:
  561. q = q.filter(BizProductionMaterial.material_type == mt)
  562. rows = q.order_by(BizProductionMaterial.create_time.desc()).all()
  563. return [
  564. {
  565. "id": r.id,
  566. "label": r.material_name,
  567. "materialType": r.material_type or "",
  568. "materialCode": r.material_code,
  569. "materialName": r.material_name,
  570. "specModel": r.spec_model or "",
  571. "unit": r.unit or "",
  572. "stockQty": _dec(r.stock_qty),
  573. }
  574. for r in rows
  575. ]