production_material_stock_batch.py 33 KB

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