qc_material_stock_batch.py 36 KB

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