qc_material.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  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 create_material(
  125. db: Session,
  126. *,
  127. material_code: str,
  128. material_name: str,
  129. spec_model: Optional[str] = None,
  130. unit: Optional[str] = None,
  131. stock_qty: Optional[Decimal] = None,
  132. warning_qty: Optional[Decimal] = None,
  133. status: str = "1",
  134. tenant_no: str,
  135. order_num: Optional[int] = None,
  136. remark: Optional[str] = None,
  137. user_id: Optional[str] = None,
  138. create_dept_id: str = "",
  139. ) -> BizQcMaterial:
  140. code = (material_code or "").strip()
  141. if not code:
  142. raise ValueError("物料编码不能为空")
  143. if code_exists(db, code, tenant_no):
  144. raise ValueError("物料编码已存在")
  145. name = (material_name or "").strip()
  146. if not name:
  147. raise ValueError("物料名称不能为空")
  148. st = _norm_status(status)
  149. obj = BizQcMaterial(
  150. id=generate_uuid(),
  151. material_code=code,
  152. material_name=name,
  153. spec_model=(spec_model or "").strip() or None,
  154. unit=(unit or "").strip() or None,
  155. stock_qty=stock_qty if stock_qty is not None else 0,
  156. warning_qty=warning_qty if warning_qty is not None else 0,
  157. status=st,
  158. tenant_no=tenant_no,
  159. order_num=order_num if order_num is not None else 0,
  160. remark=(remark or "").strip() or None,
  161. create_by=user_id,
  162. create_dept_id=create_dept_id or "",
  163. create_time=datetime.now(),
  164. update_by=user_id,
  165. update_time=datetime.now(),
  166. del_flag=0,
  167. )
  168. db.add(obj)
  169. db.commit()
  170. db.refresh(obj)
  171. return obj
  172. def update_material(
  173. db: Session,
  174. row_id: str,
  175. *,
  176. material_code: Optional[str] = None,
  177. material_name: Optional[str] = None,
  178. spec_model: Optional[str] = None,
  179. unit: Optional[str] = None,
  180. warning_qty: Optional[Decimal] = None,
  181. status: Optional[str] = None,
  182. order_num: Optional[int] = None,
  183. remark: Optional[str] = None,
  184. user_id: Optional[str] = None,
  185. tenant_no: Optional[str] = None,
  186. ) -> BizQcMaterial:
  187. obj = get_material(db, row_id, tenant_no=tenant_no)
  188. if not obj:
  189. raise ValueError("物料不存在")
  190. if material_code is not None:
  191. code = material_code.strip()
  192. if not code:
  193. raise ValueError("物料编码不能为空")
  194. if code != obj.material_code and code_exists(db, code, obj.tenant_no, exclude_id=row_id):
  195. raise ValueError("物料编码已存在")
  196. obj.material_code = code
  197. if material_name is not None:
  198. name = (material_name or "").strip()
  199. if not name:
  200. raise ValueError("物料名称不能为空")
  201. obj.material_name = name
  202. if spec_model is not None:
  203. obj.spec_model = (spec_model or "").strip() or None
  204. if unit is not None:
  205. obj.unit = (unit or "").strip() or None
  206. if warning_qty is not None:
  207. obj.warning_qty = warning_qty
  208. if status is not None:
  209. obj.status = _norm_status(status)
  210. if order_num is not None:
  211. obj.order_num = order_num
  212. if remark is not None:
  213. obj.remark = (remark or "").strip() or None
  214. obj.update_by = user_id
  215. obj.update_time = datetime.now()
  216. db.add(obj)
  217. db.commit()
  218. db.refresh(obj)
  219. return obj
  220. def soft_delete_material(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
  221. obj = get_material(db, row_id, tenant_no=tenant_no)
  222. if not obj:
  223. raise ValueError("物料不存在")
  224. if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
  225. raise ValueError("该物料仍有库存,无法删除")
  226. obj.del_flag = 1
  227. obj.update_time = datetime.now()
  228. db.add(obj)
  229. db.commit()
  230. def _build_manual_out_remark(
  231. *,
  232. receiver: Optional[str] = None,
  233. purpose: Optional[str] = None,
  234. remark: Optional[str] = None,
  235. line_remark: Optional[str] = None,
  236. ) -> Optional[str]:
  237. parts: List[str] = []
  238. r = (receiver or "").strip()
  239. if r:
  240. parts.append(f"领用人:{r}")
  241. p = (purpose or "").strip()
  242. if p:
  243. parts.append(f"用途:{p}")
  244. extra = (line_remark or remark or "").strip()
  245. if extra:
  246. parts.append(extra)
  247. return ";".join(parts) if parts else None
  248. def stock_in(
  249. db: Session,
  250. row_id: str,
  251. *,
  252. qty: Decimal,
  253. remark: Optional[str] = None,
  254. user_id: Optional[str] = None,
  255. tenant_no: Optional[str] = None,
  256. operate_time: Optional[datetime] = None,
  257. ) -> BizQcMaterial:
  258. if qty is None or float(qty) <= 0:
  259. raise ValueError("入库数量必须大于0")
  260. # 行锁锁定物料行
  261. obj = (
  262. db.query(BizQcMaterial)
  263. .filter(BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0)
  264. .with_for_update()
  265. .first()
  266. )
  267. if not obj:
  268. raise ValueError("物料不存在")
  269. if tenant_no and obj.tenant_no != tenant_no:
  270. raise ValueError("物料不存在")
  271. if not _is_enabled_status(obj.status):
  272. raise ValueError(f"物料「{obj.material_name}」已停用,无法入库")
  273. op_time = operate_time or datetime.now()
  274. old_stock = obj.stock_qty or 0
  275. new_stock = old_stock + qty
  276. obj.stock_qty = new_stock
  277. obj.update_by = user_id
  278. obj.update_time = datetime.now()
  279. db.add(obj)
  280. usage = BizQcMaterialUsage(
  281. id=generate_uuid(),
  282. material_id=obj.id,
  283. material_code=obj.material_code,
  284. material_name=obj.material_name,
  285. change_type="in",
  286. change_qty=qty,
  287. stock_after=new_stock,
  288. source_type="manual_in",
  289. tenant_no=obj.tenant_no,
  290. remark=(remark or "").strip() or None,
  291. create_by=user_id,
  292. create_dept_id=obj.create_dept_id or "",
  293. create_time=op_time,
  294. update_by=user_id,
  295. update_time=op_time,
  296. del_flag=0,
  297. )
  298. db.add(usage)
  299. db.commit()
  300. db.refresh(obj)
  301. return obj
  302. def batch_stock_in(
  303. db: Session,
  304. *,
  305. items: List[Dict[str, Any]],
  306. remark: Optional[str] = None,
  307. tenant_no: str,
  308. user_id: Optional[str] = None,
  309. operate_time: Optional[datetime] = None,
  310. ) -> int:
  311. """批量入库,同一事务;任一条失败则全部回滚。"""
  312. if not items:
  313. raise ValueError("请至少选择一条物料")
  314. op_time = operate_time or datetime.now()
  315. shared_remark = (remark or "").strip() or None
  316. try:
  317. for it in items:
  318. material_id = (it.get("material_id") or it.get("materialId") or "").strip()
  319. qty = it.get("qty")
  320. if not material_id:
  321. raise ValueError("物料ID不能为空")
  322. if qty is None or float(qty) <= 0:
  323. raise ValueError("入库数量必须大于0")
  324. qty_dec = Decimal(str(qty))
  325. line_remark = (it.get("remark") or "").strip()
  326. log_remark = line_remark or shared_remark or None
  327. obj = (
  328. db.query(BizQcMaterial)
  329. .filter(
  330. BizQcMaterial.id == material_id,
  331. BizQcMaterial.tenant_no == tenant_no,
  332. BizQcMaterial.del_flag == 0,
  333. )
  334. .with_for_update()
  335. .first()
  336. )
  337. if not obj:
  338. raise ValueError("物料不存在")
  339. if not _is_enabled_status(obj.status):
  340. raise ValueError(f"物料「{obj.material_name}」已停用,无法入库")
  341. old_stock = obj.stock_qty or 0
  342. new_stock = old_stock + qty_dec
  343. obj.stock_qty = new_stock
  344. obj.update_by = user_id
  345. obj.update_time = datetime.now()
  346. db.add(obj)
  347. usage = BizQcMaterialUsage(
  348. id=generate_uuid(),
  349. material_id=obj.id,
  350. material_code=obj.material_code,
  351. material_name=obj.material_name,
  352. change_type="in",
  353. change_qty=qty_dec,
  354. stock_after=new_stock,
  355. source_type="manual_in",
  356. tenant_no=obj.tenant_no,
  357. remark=log_remark,
  358. create_by=user_id,
  359. create_dept_id=obj.create_dept_id or "",
  360. create_time=op_time,
  361. update_by=user_id,
  362. update_time=op_time,
  363. del_flag=0,
  364. )
  365. db.add(usage)
  366. db.commit()
  367. return len(items)
  368. except Exception:
  369. db.rollback()
  370. raise
  371. def stock_out(
  372. db: Session,
  373. row_id: str,
  374. *,
  375. qty: Decimal,
  376. receiver: Optional[str] = None,
  377. purpose: Optional[str] = None,
  378. remark: Optional[str] = None,
  379. user_id: Optional[str] = None,
  380. tenant_no: Optional[str] = None,
  381. operate_time: Optional[datetime] = None,
  382. ) -> BizQcMaterial:
  383. if qty is None or float(qty) <= 0:
  384. raise ValueError("出库数量必须大于0")
  385. qty_dec = Decimal(str(qty))
  386. if not (receiver or "").strip():
  387. raise ValueError("请选择领用人")
  388. obj = (
  389. db.query(BizQcMaterial)
  390. .filter(BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0)
  391. .with_for_update()
  392. .first()
  393. )
  394. if not obj:
  395. raise ValueError("物料不存在")
  396. if tenant_no and obj.tenant_no != tenant_no:
  397. raise ValueError("物料不存在")
  398. if not _is_enabled_status(obj.status):
  399. raise ValueError(f"物料「{obj.material_name}」已停用,无法出库")
  400. stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
  401. if stock_before < qty_dec:
  402. raise ValueError(
  403. f"物料「{obj.material_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
  404. )
  405. op_time = operate_time or datetime.now()
  406. new_stock = stock_before - qty_dec
  407. obj.stock_qty = new_stock
  408. obj.update_by = user_id
  409. obj.update_time = datetime.now()
  410. db.add(obj)
  411. usage = BizQcMaterialUsage(
  412. id=generate_uuid(),
  413. material_id=obj.id,
  414. material_code=obj.material_code,
  415. material_name=obj.material_name,
  416. change_type="use",
  417. change_qty=-qty_dec,
  418. stock_after=new_stock,
  419. source_type="manual_out",
  420. tenant_no=obj.tenant_no,
  421. receiver=(receiver or "").strip() or None,
  422. purpose=(purpose or "").strip() or None,
  423. remark=(remark or "").strip() or None,
  424. create_by=user_id,
  425. create_dept_id=obj.create_dept_id or "",
  426. create_time=op_time,
  427. update_by=user_id,
  428. update_time=op_time,
  429. del_flag=0,
  430. )
  431. db.add(usage)
  432. db.commit()
  433. db.refresh(obj)
  434. return obj
  435. def batch_stock_out(
  436. db: Session,
  437. *,
  438. items: List[Dict[str, Any]],
  439. receiver: Optional[str] = None,
  440. purpose: Optional[str] = None,
  441. remark: Optional[str] = None,
  442. tenant_no: str,
  443. user_id: Optional[str] = None,
  444. operate_time: Optional[datetime] = None,
  445. ) -> int:
  446. """批量出库,同一事务;任一条失败则全部回滚。"""
  447. if not items:
  448. raise ValueError("请至少选择一条物料")
  449. if not (receiver or "").strip():
  450. raise ValueError("请选择领用人")
  451. op_time = operate_time or datetime.now()
  452. recv = (receiver or "").strip() or None
  453. purp = (purpose or "").strip() or None
  454. shared_remark = (remark or "").strip() or None
  455. try:
  456. for it in items:
  457. material_id = (it.get("material_id") or it.get("materialId") or "").strip()
  458. qty = it.get("qty")
  459. if not material_id:
  460. raise ValueError("物料ID不能为空")
  461. if qty is None or float(qty) <= 0:
  462. raise ValueError("出库数量必须大于0")
  463. qty_dec = Decimal(str(qty))
  464. line_remark = (it.get("remark") or "").strip() or shared_remark
  465. obj = (
  466. db.query(BizQcMaterial)
  467. .filter(
  468. BizQcMaterial.id == material_id,
  469. BizQcMaterial.tenant_no == tenant_no,
  470. BizQcMaterial.del_flag == 0,
  471. )
  472. .with_for_update()
  473. .first()
  474. )
  475. if not obj:
  476. raise ValueError("物料不存在")
  477. if not _is_enabled_status(obj.status):
  478. raise ValueError(f"物料「{obj.material_name}」已停用,无法出库")
  479. stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
  480. if stock_before < qty_dec:
  481. raise ValueError(
  482. f"物料「{obj.material_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
  483. )
  484. new_stock = stock_before - qty_dec
  485. obj.stock_qty = new_stock
  486. obj.update_by = user_id
  487. obj.update_time = datetime.now()
  488. db.add(obj)
  489. usage = BizQcMaterialUsage(
  490. id=generate_uuid(),
  491. material_id=obj.id,
  492. material_code=obj.material_code,
  493. material_name=obj.material_name,
  494. change_type="use",
  495. change_qty=-qty_dec,
  496. stock_after=new_stock,
  497. source_type="manual_out",
  498. tenant_no=obj.tenant_no,
  499. receiver=recv,
  500. purpose=purp,
  501. remark=line_remark,
  502. create_by=user_id,
  503. create_dept_id=obj.create_dept_id or "",
  504. create_time=op_time,
  505. update_by=user_id,
  506. update_time=op_time,
  507. del_flag=0,
  508. )
  509. db.add(usage)
  510. db.commit()
  511. return len(items)
  512. except Exception:
  513. db.rollback()
  514. raise
  515. def options_list(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
  516. rows = (
  517. db.query(BizQcMaterial)
  518. .filter(
  519. BizQcMaterial.tenant_no == tenant_no,
  520. BizQcMaterial.del_flag == 0,
  521. BizQcMaterial.status == "1",
  522. )
  523. .order_by(BizQcMaterial.create_time.desc())
  524. .all()
  525. )
  526. return [
  527. {
  528. "id": r.id,
  529. "label": r.material_name,
  530. "materialCode": r.material_code,
  531. "materialName": r.material_name,
  532. "specModel": r.spec_model or "",
  533. "unit": r.unit or "",
  534. "stockQty": _dec(r.stock_qty),
  535. }
  536. for r in rows
  537. ]