qc_material_stock_batch.py 36 KB

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