qc_material.py 19 KB

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