consumable.py 17 KB

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