production_material_usage.py 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394
  1. """物料变动记录 CRUD"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from fastapi import HTTPException
  7. from sqlalchemy import and_, func, or_
  8. from sqlalchemy.orm import Session
  9. from app.core.utils import generate_uuid
  10. from app.crud.production_material import _filter_material_type
  11. from app.models.production_material import BizProductionMaterial, BizProductionMaterialPrep, BizProductionMaterialStockBatch, BizProductionMaterialUsage
  12. from app.models.models import SysUser
  13. from app.crud.production_material_stock_batch import _usage_batch_snapshots
  14. def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
  15. """兼容旧数据:领用人/用途曾拼接在 remark 中。"""
  16. receiver, purpose = "", ""
  17. for part in (remark or "").split(";"):
  18. seg = part.strip()
  19. if seg.startswith("领用人:"):
  20. receiver = seg[len("领用人:") :].strip()
  21. elif seg.startswith("用途:"):
  22. purpose = seg[len("用途:") :].strip()
  23. return receiver, purpose
  24. def _resolve_receiver_purpose(r: BizProductionMaterialUsage) -> Tuple[str, str]:
  25. receiver = (getattr(r, "receiver", None) or "").strip()
  26. purpose = (getattr(r, "purpose", None) or "").strip()
  27. if receiver or purpose:
  28. return receiver, purpose
  29. if (r.source_type or "") == "manual_out":
  30. return _parse_receiver_purpose_from_remark(r.remark)
  31. return "", ""
  32. def _display_remark(r: BizProductionMaterialUsage) -> str:
  33. """手动出库时备注仅展示用户填写内容,不含领用人/用途拼接段。"""
  34. remark = (r.remark or "").strip()
  35. if (r.source_type or "") != "manual_out" or not remark:
  36. return remark
  37. parts: List[str] = []
  38. for part in remark.split(";"):
  39. seg = part.strip()
  40. if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
  41. continue
  42. if seg.startswith("备注"):
  43. seg = seg[2:].strip() or seg
  44. parts.append(seg)
  45. return ";".join(parts) if parts else ""
  46. def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  47. ids = list({x for x in user_ids if x})
  48. if not ids:
  49. return {}
  50. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  51. return {u.id: (u.nick_name or "").strip() for u in rows}
  52. def _receiver_display_map(db: Session, receivers: List[str]) -> Dict[str, str]:
  53. """领用人字段可能是用户 ID 或昵称。"""
  54. keys = list({(x or "").strip() for x in receivers if (x or "").strip()})
  55. if not keys:
  56. return {}
  57. by_id = _user_nick_name_map(db, keys)
  58. out: Dict[str, str] = {}
  59. for k in keys:
  60. out[k] = by_id.get(k) or k
  61. return out
  62. def _usage_unit_label(r: BizProductionMaterialUsage, batch: Optional[BizProductionMaterialStockBatch]) -> str:
  63. u = (getattr(r, "usage_unit", None) or "").strip()
  64. if u:
  65. return u
  66. if batch and (batch.unit or "").strip():
  67. return (batch.unit or "").strip()
  68. return ""
  69. def _stock_in_date_for_row(r: BizProductionMaterialUsage, batch: Optional[BizProductionMaterialStockBatch]) -> str:
  70. if batch and batch.stock_in_date:
  71. return batch.stock_in_date.strftime("%Y-%m-%d")
  72. return ""
  73. def _unit_price_for_row(r: BizProductionMaterialUsage, batch: Optional[BizProductionMaterialStockBatch]) -> Optional[float]:
  74. if r.unit_price is not None:
  75. return float(r.unit_price)
  76. if batch and batch.unit_price is not None:
  77. return float(batch.unit_price)
  78. return None
  79. def _amount_for_row(r: BizProductionMaterialUsage, batch: Optional[BizProductionMaterialStockBatch]) -> Optional[float]:
  80. is_in = (r.change_type or "") == "in"
  81. if is_in:
  82. if r.amount is not None:
  83. return float(r.amount)
  84. if batch and batch.amount is not None:
  85. return float(batch.amount)
  86. return None
  87. unit_price = _unit_price_for_row(r, batch)
  88. change_qty = float(r.change_qty) if r.change_qty is not None else 0
  89. qty = abs(change_qty)
  90. if unit_price is None or qty <= 0:
  91. return None
  92. return round(unit_price * qty, 2)
  93. def _row_to_dict(
  94. r: BizProductionMaterialUsage,
  95. *,
  96. create_by_name: str = "",
  97. batch: Optional[BizProductionMaterialStockBatch] = None,
  98. receiver_name: str = "",
  99. requisition_receiver: str = "",
  100. requisition_receiver_name: str = "",
  101. ) -> Dict[str, Any]:
  102. create_by = r.create_by or ""
  103. receiver, purpose = _resolve_receiver_purpose(r)
  104. create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  105. is_manual_out = (r.source_type or "") == "manual_out"
  106. stock_after = float(r.stock_after) if r.stock_after is not None else 0
  107. change_qty = float(r.change_qty) if r.change_qty is not None else 0
  108. stock_before = (
  109. float(r.stock_before)
  110. if r.stock_before is not None
  111. else stock_after - change_qty
  112. )
  113. usage_unit = _usage_unit_label(r, batch)
  114. req_receiver = requisition_receiver or receiver
  115. req_receiver_name = requisition_receiver_name or receiver_name or req_receiver
  116. return {
  117. "id": r.id,
  118. "materialId": r.material_id,
  119. "materialType": r.material_type or "",
  120. "materialCode": r.material_code or "",
  121. "materialName": r.material_name or "",
  122. "batchId": r.batch_id or "",
  123. "stockInNo": r.stock_in_no or "",
  124. "stockInDate": _stock_in_date_for_row(r, batch),
  125. "catalogNo": r.catalog_no or "",
  126. "batchNo": r.batch_no or "",
  127. "manufacturer": r.manufacturer or "",
  128. "storageLocation": (r.storage_location or "").strip() or (batch.storage_location if batch else "") or "",
  129. "unitPrice": _unit_price_for_row(r, batch),
  130. "amount": _amount_for_row(r, batch),
  131. "changeType": r.change_type,
  132. "changeQty": change_qty,
  133. "usageUnit": usage_unit,
  134. "stockBefore": stock_before,
  135. "stockAfter": stock_after,
  136. "stepId": r.step_id or "",
  137. "stepName": r.step_name or "",
  138. "taskId": r.task_id or "",
  139. "taskNo": r.task_no or "",
  140. "parentUsageId": (r.parent_usage_id or "").strip(),
  141. "requisitionUsageId": (r.parent_usage_id or "").strip(),
  142. "prepId": (r.prep_id or "").strip(),
  143. "receiver": receiver,
  144. "receiverName": receiver_name or receiver,
  145. "requisitionReceiver": req_receiver,
  146. "requisitionReceiverName": req_receiver_name,
  147. "purpose": purpose,
  148. "sourceType": r.source_type or "",
  149. "requisitionStatus": (getattr(r, "requisition_status", None) or "open").strip() or "open",
  150. "useTime": create_time if is_manual_out else "",
  151. "remark": _display_remark(r),
  152. "createBy": create_by,
  153. "createByName": create_by_name or create_by,
  154. "createTime": create_time,
  155. "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
  156. }
  157. def _batch_map(db: Session, batch_ids: List[str]) -> Dict[str, BizProductionMaterialStockBatch]:
  158. ids = [x for x in batch_ids if x]
  159. if not ids:
  160. return {}
  161. rows = (
  162. db.query(BizProductionMaterialStockBatch)
  163. .filter(BizProductionMaterialStockBatch.id.in_(ids), BizProductionMaterialStockBatch.del_flag == 0)
  164. .all()
  165. )
  166. return {r.id: r for r in rows}
  167. def _mine_usage_filter(user_id: str, nick_name: str):
  168. """当前登录人的领用记录:手动出库(领用人)或任务使用(填报人)。"""
  169. uid = (user_id or "").strip()
  170. nn = (nick_name or "").strip()
  171. manual_conds = [BizProductionMaterialUsage.source_type == "manual_out"]
  172. receiver_match: List[Any] = []
  173. if nn:
  174. receiver_match.append(BizProductionMaterialUsage.receiver == nn)
  175. receiver_match.append(BizProductionMaterialUsage.remark.like(f"%领用人:{nn}%"))
  176. if uid:
  177. receiver_match.append(BizProductionMaterialUsage.receiver == uid)
  178. if receiver_match:
  179. manual_conds.append(or_(*receiver_match))
  180. else:
  181. manual_conds.append(BizProductionMaterialUsage.id == "")
  182. task_conds = [
  183. BizProductionMaterialUsage.source_type == "step_save",
  184. BizProductionMaterialUsage.create_by == uid,
  185. ]
  186. return and_(
  187. BizProductionMaterialUsage.change_type == "use",
  188. or_(and_(*manual_conds), and_(*task_conds)),
  189. )
  190. def _list_usages_query(
  191. db: Session,
  192. tenant_no: str,
  193. *,
  194. material_id: Optional[str] = None,
  195. material_type: Optional[str] = None,
  196. change_type: Optional[str] = None,
  197. start_time: Optional[str] = None,
  198. end_time: Optional[str] = None,
  199. keyword: Optional[str] = None,
  200. stock_in_no: Optional[str] = None,
  201. user_info=None,
  202. mine_user_id: Optional[str] = None,
  203. mine_nick_name: Optional[str] = None,
  204. ):
  205. q0 = db.query(BizProductionMaterialUsage).filter(
  206. BizProductionMaterialUsage.tenant_no == tenant_no,
  207. BizProductionMaterialUsage.del_flag == 0,
  208. or_(
  209. BizProductionMaterialUsage.source_type.is_(None),
  210. BizProductionMaterialUsage.source_type != "prep_consume",
  211. ),
  212. )
  213. if mine_user_id is not None:
  214. q0 = q0.filter(_mine_usage_filter(mine_user_id, mine_nick_name or ""))
  215. if material_id:
  216. q0 = q0.filter(BizProductionMaterialUsage.material_id == material_id)
  217. mt = _filter_material_type(material_type)
  218. if mt:
  219. q0 = q0.filter(BizProductionMaterialUsage.material_type == mt)
  220. ct = (change_type or "").strip()
  221. if ct == "out":
  222. q0 = q0.filter(
  223. BizProductionMaterialUsage.change_type == "use",
  224. BizProductionMaterialUsage.source_type == "manual_out",
  225. )
  226. elif ct == "use":
  227. q0 = q0.filter(
  228. BizProductionMaterialUsage.change_type == "use",
  229. BizProductionMaterialUsage.source_type == "step_save",
  230. )
  231. elif ct == "in":
  232. q0 = q0.filter(BizProductionMaterialUsage.change_type == "in")
  233. elif ct == "discard":
  234. q0 = q0.filter(BizProductionMaterialUsage.change_type == "discard")
  235. sno = (stock_in_no or "").strip()
  236. if sno:
  237. q0 = q0.filter(BizProductionMaterialUsage.stock_in_no.like(f"%{sno}%"))
  238. kw = (keyword or "").strip()
  239. if kw:
  240. like = f"%{kw}%"
  241. q0 = q0.filter(
  242. or_(
  243. BizProductionMaterialUsage.material_code.like(like),
  244. BizProductionMaterialUsage.material_name.like(like),
  245. BizProductionMaterialUsage.stock_in_no.like(like),
  246. BizProductionMaterialUsage.batch_no.like(like),
  247. BizProductionMaterialUsage.catalog_no.like(like),
  248. )
  249. )
  250. if start_time:
  251. q0 = q0.filter(BizProductionMaterialUsage.create_time >= start_time)
  252. if end_time:
  253. q0 = q0.filter(BizProductionMaterialUsage.create_time <= end_time + " 23:59:59")
  254. if user_info and mine_user_id is None:
  255. from app.core.data_scope import apply_data_scope
  256. q0 = apply_data_scope(q0, BizProductionMaterialUsage, user_info, db, tenant_no)
  257. return q0
  258. def _paginate_usages(db: Session, q0, *, page: int, page_size: int) -> Tuple[List[Dict[str, Any]], int]:
  259. total = q0.order_by(None).with_entities(func.count(BizProductionMaterialUsage.id)).scalar() or 0
  260. page = max(1, page)
  261. page_size = min(max(1, page_size), 100)
  262. rows = (
  263. q0.order_by(BizProductionMaterialUsage.create_time.desc())
  264. .offset((page - 1) * page_size)
  265. .limit(page_size)
  266. .all()
  267. )
  268. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  269. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  270. receivers = []
  271. for x in rows:
  272. rcv, _ = _resolve_receiver_purpose(x)
  273. if rcv:
  274. receivers.append(rcv)
  275. receiver_map = _receiver_display_map(db, receivers)
  276. return [
  277. _row_to_dict(
  278. x,
  279. create_by_name=name_map.get(x.create_by or "", ""),
  280. batch=bmap.get(x.batch_id or ""),
  281. receiver_name=receiver_map.get(
  282. (_resolve_receiver_purpose(x)[0] or ""), _resolve_receiver_purpose(x)[0] or ""
  283. ),
  284. )
  285. for x in rows
  286. ], int(total)
  287. def list_usages(
  288. db: Session,
  289. tenant_no: str,
  290. *,
  291. page: int = 1,
  292. page_size: int = 10,
  293. material_id: Optional[str] = None,
  294. material_type: Optional[str] = None,
  295. change_type: Optional[str] = None,
  296. start_time: Optional[str] = None,
  297. end_time: Optional[str] = None,
  298. keyword: Optional[str] = None,
  299. stock_in_no: Optional[str] = None,
  300. user_info=None,
  301. ) -> Tuple[List[Dict[str, Any]], int]:
  302. q0 = _list_usages_query(
  303. db,
  304. tenant_no,
  305. material_id=material_id,
  306. material_type=material_type,
  307. change_type=change_type,
  308. start_time=start_time,
  309. end_time=end_time,
  310. keyword=keyword,
  311. stock_in_no=stock_in_no,
  312. user_info=user_info,
  313. )
  314. return _paginate_usages(db, q0, page=page, page_size=page_size)
  315. def list_my_usages(
  316. db: Session,
  317. tenant_no: str,
  318. *,
  319. user_id: str,
  320. nick_name: str = "",
  321. page: int = 1,
  322. page_size: int = 10,
  323. material_id: Optional[str] = None,
  324. material_type: Optional[str] = None,
  325. change_type: Optional[str] = None,
  326. start_time: Optional[str] = None,
  327. end_time: Optional[str] = None,
  328. keyword: Optional[str] = None,
  329. stock_in_no: Optional[str] = None,
  330. ) -> Tuple[List[Dict[str, Any]], int]:
  331. """当前登录人的物料领用记录(手动出库 + 任务使用)。"""
  332. q0 = _list_usages_query(
  333. db,
  334. tenant_no,
  335. material_id=material_id,
  336. material_type=material_type,
  337. change_type=change_type,
  338. start_time=start_time,
  339. end_time=end_time,
  340. keyword=keyword,
  341. stock_in_no=stock_in_no,
  342. mine_user_id=user_id,
  343. mine_nick_name=nick_name,
  344. )
  345. return _paginate_usages(db, q0, page=page, page_size=page_size)
  346. def list_usages_by_step(
  347. db: Session,
  348. step_id: str,
  349. tenant_no: str,
  350. *,
  351. user_info=None,
  352. ) -> List[Dict[str, Any]]:
  353. """查询生产步骤关联的物料使用记录(step_save)。"""
  354. q = db.query(BizProductionMaterialUsage).filter(
  355. BizProductionMaterialUsage.tenant_no == tenant_no,
  356. BizProductionMaterialUsage.del_flag == 0,
  357. BizProductionMaterialUsage.step_id == step_id,
  358. BizProductionMaterialUsage.change_type == "use",
  359. BizProductionMaterialUsage.source_type == "step_save",
  360. )
  361. if user_info:
  362. from app.core.data_scope import apply_data_scope
  363. q = apply_data_scope(q, BizProductionMaterialUsage, user_info, db, tenant_no)
  364. rows = q.order_by(BizProductionMaterialUsage.create_time.asc()).all()
  365. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  366. parent_ids = [x.parent_usage_id for x in rows if (x.parent_usage_id or "").strip()]
  367. parent_map: Dict[str, BizProductionMaterialUsage] = {}
  368. if parent_ids:
  369. parents = (
  370. db.query(BizProductionMaterialUsage)
  371. .filter(
  372. BizProductionMaterialUsage.id.in_(parent_ids),
  373. BizProductionMaterialUsage.del_flag == 0,
  374. )
  375. .all()
  376. )
  377. parent_map = {p.id: p for p in parents}
  378. parent_receivers = []
  379. for p in parent_map.values():
  380. rcv, _ = _resolve_receiver_purpose(p)
  381. if rcv:
  382. parent_receivers.append(rcv)
  383. parent_receiver_map = _receiver_display_map(db, parent_receivers)
  384. out: List[Dict[str, Any]] = []
  385. for x in rows:
  386. parent = parent_map.get((x.parent_usage_id or "").strip())
  387. req_rcv, _ = _resolve_receiver_purpose(parent) if parent else ("", "")
  388. req_rcv_name = parent_receiver_map.get(req_rcv, req_rcv) if req_rcv else ""
  389. rcv, _ = _resolve_receiver_purpose(x)
  390. out.append(
  391. _row_to_dict(
  392. x,
  393. batch=bmap.get(x.batch_id or ""),
  394. receiver_name=parent_receiver_map.get(rcv, rcv) if not parent else req_rcv_name,
  395. requisition_receiver=req_rcv,
  396. requisition_receiver_name=req_rcv_name,
  397. )
  398. )
  399. return out
  400. def list_linked_step_usages(
  401. db: Session,
  402. parent_usage_id: str,
  403. tenant_no: str,
  404. *,
  405. user_info=None,
  406. ) -> List[Dict[str, Any]]:
  407. """查询领用记录关联的消耗明细(parent_usage_id → step_save / prep_consume)。"""
  408. pid = (parent_usage_id or "").strip()
  409. if not pid:
  410. return []
  411. parent = (
  412. db.query(BizProductionMaterialUsage)
  413. .filter(
  414. BizProductionMaterialUsage.id == pid,
  415. BizProductionMaterialUsage.tenant_no == tenant_no,
  416. BizProductionMaterialUsage.del_flag == 0,
  417. )
  418. .first()
  419. )
  420. if not parent:
  421. raise HTTPException(status_code=404, detail="领用记录不存在")
  422. if (parent.source_type or "") != "manual_out":
  423. return []
  424. q = db.query(BizProductionMaterialUsage).filter(
  425. BizProductionMaterialUsage.tenant_no == tenant_no,
  426. BizProductionMaterialUsage.del_flag == 0,
  427. BizProductionMaterialUsage.parent_usage_id == pid,
  428. BizProductionMaterialUsage.change_type == "use",
  429. BizProductionMaterialUsage.source_type.in_(["step_save", "prep_consume"]),
  430. )
  431. if user_info:
  432. from app.core.data_scope import apply_data_scope
  433. q = apply_data_scope(q, BizProductionMaterialUsage, user_info, db, tenant_no)
  434. rows = q.order_by(BizProductionMaterialUsage.create_time.desc()).all()
  435. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  436. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  437. prep_ids = list({(x.prep_id or "").strip() for x in rows if (x.prep_id or "").strip()})
  438. prep_map: Dict[str, Dict[str, str]] = {}
  439. if prep_ids:
  440. preps = (
  441. db.query(BizProductionMaterialPrep)
  442. .filter(
  443. BizProductionMaterialPrep.id.in_(prep_ids),
  444. BizProductionMaterialPrep.tenant_no == tenant_no,
  445. BizProductionMaterialPrep.del_flag == 0,
  446. )
  447. .all()
  448. )
  449. prep_map = {
  450. p.id: {
  451. "prepNo": p.prep_no or "",
  452. "configuredMaterialName": p.configured_material_name or "",
  453. }
  454. for p in preps
  455. }
  456. out: List[Dict[str, Any]] = []
  457. for x in rows:
  458. item = _row_to_dict(
  459. x,
  460. create_by_name=name_map.get(x.create_by or "", ""),
  461. batch=bmap.get(x.batch_id or ""),
  462. requisition_receiver_name="",
  463. )
  464. item["productionTaskId"] = item.get("taskId") or ""
  465. item["productionTaskNo"] = item.get("taskNo") or ""
  466. prep_info = prep_map.get((x.prep_id or "").strip(), {})
  467. item["prepNo"] = prep_info.get("prepNo", "")
  468. item["configuredMaterialName"] = prep_info.get("configuredMaterialName", "")
  469. out.append(item)
  470. return out
  471. def revert_step_material_usages(
  472. db: Session,
  473. step_id: str,
  474. tenant_no: str,
  475. user_id: Optional[str] = None,
  476. ) -> None:
  477. """撤销步骤此前 step_save 扣减,便于重新填报物料使用。"""
  478. rows = (
  479. db.query(BizProductionMaterialUsage)
  480. .filter(
  481. BizProductionMaterialUsage.step_id == step_id,
  482. BizProductionMaterialUsage.tenant_no == tenant_no,
  483. BizProductionMaterialUsage.del_flag == 0,
  484. BizProductionMaterialUsage.source_type == "step_save",
  485. BizProductionMaterialUsage.change_type == "use",
  486. )
  487. .all()
  488. )
  489. if not rows:
  490. return
  491. now = datetime.now()
  492. for r in rows:
  493. if (r.parent_usage_id or "").strip():
  494. r.del_flag = 1
  495. r.update_by = user_id
  496. r.update_time = now
  497. continue
  498. restore_qty = abs(r.change_qty) if r.change_qty is not None else Decimal("0")
  499. if restore_qty <= 0:
  500. r.del_flag = 1
  501. r.update_by = user_id
  502. r.update_time = now
  503. continue
  504. batch_id = (r.batch_id or "").strip()
  505. if batch_id:
  506. batch = (
  507. db.query(BizProductionMaterialStockBatch)
  508. .with_for_update()
  509. .filter(
  510. BizProductionMaterialStockBatch.id == batch_id,
  511. BizProductionMaterialStockBatch.tenant_no == tenant_no,
  512. BizProductionMaterialStockBatch.del_flag == 0,
  513. )
  514. .first()
  515. )
  516. if batch:
  517. batch.remaining_qty = (batch.remaining_qty or Decimal("0")) + restore_qty
  518. batch.update_by = user_id
  519. batch.update_time = now
  520. material = (
  521. db.query(BizProductionMaterial)
  522. .with_for_update()
  523. .filter(
  524. BizProductionMaterial.id == r.material_id,
  525. BizProductionMaterial.tenant_no == tenant_no,
  526. BizProductionMaterial.del_flag == 0,
  527. )
  528. .first()
  529. )
  530. if material:
  531. material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
  532. material.update_by = user_id
  533. material.update_time = now
  534. r.del_flag = 1
  535. r.update_by = user_id
  536. r.update_time = now
  537. def list_requisition_options(
  538. db: Session,
  539. tenant_no: str,
  540. *,
  541. keyword: Optional[str] = None,
  542. material_type: Optional[str] = None,
  543. limit: int = 50,
  544. user_info=None,
  545. ) -> List[Dict[str, Any]]:
  546. """领用记录选项(manual_out),供生产步骤关联物料使用。"""
  547. q0 = db.query(BizProductionMaterialUsage).filter(
  548. BizProductionMaterialUsage.tenant_no == tenant_no,
  549. BizProductionMaterialUsage.del_flag == 0,
  550. BizProductionMaterialUsage.change_type == "use",
  551. BizProductionMaterialUsage.source_type == "manual_out",
  552. )
  553. mt = _filter_material_type(material_type)
  554. if mt:
  555. q0 = q0.filter(BizProductionMaterialUsage.material_type == mt)
  556. if user_info:
  557. from app.core.data_scope import apply_data_scope
  558. q0 = apply_data_scope(q0, BizProductionMaterialUsage, user_info, db, tenant_no)
  559. kw = (keyword or "").strip()
  560. if kw:
  561. like = f"%{kw}%"
  562. q0 = q0.filter(
  563. or_(
  564. BizProductionMaterialUsage.material_code.like(like),
  565. BizProductionMaterialUsage.material_name.like(like),
  566. BizProductionMaterialUsage.stock_in_no.like(like),
  567. BizProductionMaterialUsage.batch_no.like(like),
  568. BizProductionMaterialUsage.catalog_no.like(like),
  569. BizProductionMaterialUsage.receiver.like(like),
  570. )
  571. )
  572. limit = min(max(1, limit), 100)
  573. rows = q0.order_by(BizProductionMaterialUsage.create_time.desc()).limit(limit).all()
  574. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  575. receivers = []
  576. for r in rows:
  577. rcv, _ = _resolve_receiver_purpose(r)
  578. if rcv:
  579. receivers.append(rcv)
  580. receiver_map = _receiver_display_map(db, receivers)
  581. options: List[Dict[str, Any]] = []
  582. for r in rows:
  583. batch = bmap.get(r.batch_id or "")
  584. rcv, _ = _resolve_receiver_purpose(r)
  585. rcv_name = receiver_map.get(rcv, rcv) if rcv else ""
  586. qty_abs = abs(float(r.change_qty or 0))
  587. unit = _usage_unit_label(r, batch)
  588. use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  589. unit_suffix = unit or ""
  590. label = (
  591. f"{use_time} {r.material_name or ''} / {r.stock_in_no or ''} "
  592. f"/ 批号:{r.batch_no or '—'} / 领用:{qty_abs}{unit_suffix} / 领用人:{rcv_name or '—'}"
  593. ).strip()
  594. options.append(
  595. {
  596. "id": r.id,
  597. "label": label,
  598. "materialId": r.material_id,
  599. "materialType": r.material_type or "",
  600. "materialName": r.material_name or "",
  601. "stockInNo": r.stock_in_no or "",
  602. "batchNo": r.batch_no or "",
  603. "batchId": r.batch_id or "",
  604. "receiver": rcv,
  605. "receiverName": rcv_name,
  606. "requisitionQty": qty_abs,
  607. "usageUnit": unit,
  608. "useTime": use_time,
  609. }
  610. )
  611. return options
  612. def _record_step_usage_from_requisition(
  613. db: Session,
  614. step_id: str,
  615. step_name: str,
  616. task_id: str,
  617. task_no: str,
  618. item: Dict[str, Any],
  619. tenant_no: str,
  620. user_id: Optional[str] = None,
  621. dept_id: str = "",
  622. ) -> None:
  623. req_id = (
  624. item.get("requisitionUsageId")
  625. or item.get("requisition_usage_id")
  626. or item.get("parentUsageId")
  627. or item.get("parent_usage_id")
  628. or ""
  629. ).strip()
  630. if not req_id:
  631. raise HTTPException(status_code=400, detail="请选择领用记录")
  632. qty = item.get("qty")
  633. if qty is None or Decimal(str(qty)) <= 0:
  634. raise HTTPException(status_code=400, detail="物料使用数量必须大于0")
  635. qty_dec = Decimal(str(qty))
  636. req = (
  637. db.query(BizProductionMaterialUsage)
  638. .filter(
  639. BizProductionMaterialUsage.id == req_id,
  640. BizProductionMaterialUsage.tenant_no == tenant_no,
  641. BizProductionMaterialUsage.del_flag == 0,
  642. BizProductionMaterialUsage.source_type == "manual_out",
  643. BizProductionMaterialUsage.change_type == "use",
  644. )
  645. .first()
  646. )
  647. if not req:
  648. raise HTTPException(status_code=400, detail="领用记录不存在或已失效")
  649. material = (
  650. db.query(BizProductionMaterial)
  651. .filter(
  652. BizProductionMaterial.id == req.material_id,
  653. BizProductionMaterial.tenant_no == tenant_no,
  654. BizProductionMaterial.del_flag == 0,
  655. )
  656. .first()
  657. )
  658. if not material:
  659. raise HTTPException(status_code=400, detail="关联物料不存在")
  660. usage_unit = (
  661. str(item.get("usageUnit") or item.get("usage_unit") or "").strip()
  662. or _usage_unit_label(req, None)
  663. or ""
  664. )[:32]
  665. stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
  666. usage_remark = str(item.get("remark") or "").strip()[:512]
  667. now = datetime.now()
  668. usage = BizProductionMaterialUsage(
  669. id=generate_uuid(),
  670. material_id=req.material_id,
  671. material_type=req.material_type or material.material_type,
  672. material_code=req.material_code or material.material_code,
  673. material_name=req.material_name or material.material_name,
  674. batch_id=req.batch_id,
  675. stock_in_no=req.stock_in_no,
  676. catalog_no=req.catalog_no,
  677. batch_no=req.batch_no,
  678. manufacturer=req.manufacturer,
  679. storage_location=req.storage_location,
  680. unit_price=req.unit_price,
  681. amount=req.amount,
  682. change_type="use",
  683. change_qty=-qty_dec,
  684. usage_unit=usage_unit or None,
  685. stock_before=stock_qty,
  686. stock_after=stock_qty,
  687. step_id=step_id,
  688. step_name=step_name,
  689. task_id=task_id,
  690. task_no=task_no,
  691. parent_usage_id=req_id,
  692. source_type="step_save",
  693. tenant_no=tenant_no,
  694. remark=usage_remark,
  695. create_by=user_id,
  696. create_dept_id=dept_id,
  697. create_time=now,
  698. update_by=user_id,
  699. update_time=now,
  700. del_flag=0,
  701. )
  702. db.add(usage)
  703. def _deduct_step_usage_legacy_batch(
  704. db: Session,
  705. step_id: str,
  706. step_name: str,
  707. task_id: str,
  708. task_no: str,
  709. item: Dict[str, Any],
  710. tenant_no: str,
  711. user_id: Optional[str] = None,
  712. dept_id: str = "",
  713. ) -> None:
  714. """旧版:按入库批次扣减库存(兼容历史数据)。"""
  715. batch_id = (item.get("batchId") or item.get("batch_id") or "").strip()
  716. qty = item.get("qty")
  717. if not batch_id:
  718. raise HTTPException(status_code=400, detail="入库批次不能为空")
  719. if qty is None or Decimal(str(qty)) <= 0:
  720. raise HTTPException(status_code=400, detail="物料使用数量必须大于0")
  721. qty_dec = Decimal(str(qty))
  722. batch = (
  723. db.query(BizProductionMaterialStockBatch)
  724. .with_for_update()
  725. .filter(
  726. BizProductionMaterialStockBatch.id == batch_id,
  727. BizProductionMaterialStockBatch.tenant_no == tenant_no,
  728. BizProductionMaterialStockBatch.del_flag == 0,
  729. )
  730. .first()
  731. )
  732. if not batch:
  733. raise HTTPException(status_code=400, detail=f"入库批次不存在(ID: {batch_id})")
  734. rem = batch.remaining_qty or Decimal("0")
  735. if rem < qty_dec:
  736. raise HTTPException(
  737. status_code=400,
  738. detail=f"批次「{batch.stock_in_no}」库存不足,当前剩余 {rem},需使用 {qty_dec}",
  739. )
  740. material = (
  741. db.query(BizProductionMaterial)
  742. .with_for_update()
  743. .filter(
  744. BizProductionMaterial.id == batch.material_id,
  745. BizProductionMaterial.tenant_no == tenant_no,
  746. BizProductionMaterial.del_flag == 0,
  747. BizProductionMaterial.status == "1",
  748. )
  749. .first()
  750. )
  751. if not material:
  752. raise HTTPException(status_code=400, detail="物料不存在或已停用")
  753. batch.remaining_qty = rem - qty_dec
  754. batch.update_by = user_id
  755. batch.update_time = datetime.now()
  756. db.add(batch)
  757. stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
  758. if stock_qty < qty_dec:
  759. raise HTTPException(
  760. status_code=400,
  761. detail=f"物料「{material.material_name}」汇总库存不足",
  762. )
  763. new_stock = stock_qty - qty_dec
  764. material.stock_qty = new_stock
  765. material.update_by = user_id
  766. material.update_time = datetime.now()
  767. db.add(material)
  768. usage_remark = str(item.get("remark") or "").strip()[:512]
  769. usage_unit = str(item.get("usageUnit") or item.get("usage_unit") or "").strip()[:32] or None
  770. snaps = _usage_batch_snapshots(batch, out_qty=qty_dec)
  771. usage = BizProductionMaterialUsage(
  772. id=generate_uuid(),
  773. material_id=material.id,
  774. material_type=material.material_type,
  775. material_code=material.material_code,
  776. material_name=material.material_name,
  777. change_type="use",
  778. change_qty=-qty_dec,
  779. usage_unit=usage_unit,
  780. stock_before=stock_qty,
  781. stock_after=new_stock,
  782. step_id=step_id,
  783. step_name=step_name,
  784. task_id=task_id,
  785. task_no=task_no,
  786. source_type="step_save",
  787. tenant_no=tenant_no,
  788. remark=usage_remark,
  789. create_by=user_id,
  790. create_dept_id=dept_id,
  791. create_time=datetime.now(),
  792. update_by=user_id,
  793. update_time=datetime.now(),
  794. del_flag=0,
  795. **snaps,
  796. )
  797. db.add(usage)
  798. def register_step_usage_from_requisition(
  799. db: Session,
  800. step_id: str,
  801. step_name: str,
  802. task_id: str,
  803. task_no: str,
  804. material_usages: List[Dict[str, Any]],
  805. tenant_no: str,
  806. user_id: Optional[str] = None,
  807. dept_id: str = "",
  808. ) -> None:
  809. """登记步骤物料使用;关联领用记录时不扣减库存。调用方控制事务,本函数不 commit。"""
  810. revert_step_material_usages(db, step_id, tenant_no, user_id=user_id)
  811. seen_requisition: set = set()
  812. seen_batch: set = set()
  813. for item in material_usages:
  814. req_id = (
  815. item.get("requisitionUsageId")
  816. or item.get("requisition_usage_id")
  817. or item.get("parentUsageId")
  818. or ""
  819. ).strip()
  820. if req_id:
  821. if req_id in seen_requisition:
  822. raise HTTPException(status_code=400, detail="物料列表中存在重复领用记录,请检查")
  823. seen_requisition.add(req_id)
  824. _record_step_usage_from_requisition(
  825. db,
  826. step_id,
  827. step_name,
  828. task_id,
  829. task_no,
  830. item,
  831. tenant_no,
  832. user_id=user_id,
  833. dept_id=dept_id,
  834. )
  835. continue
  836. batch_id = (item.get("batchId") or item.get("batch_id") or "").strip()
  837. if not batch_id:
  838. raise HTTPException(status_code=400, detail="请选择领用记录")
  839. if batch_id in seen_batch:
  840. raise HTTPException(status_code=400, detail="物料列表中存在重复入库批次,请检查")
  841. seen_batch.add(batch_id)
  842. _deduct_step_usage_legacy_batch(
  843. db,
  844. step_id,
  845. step_name,
  846. task_id,
  847. task_no,
  848. item,
  849. tenant_no,
  850. user_id=user_id,
  851. dept_id=dept_id,
  852. )
  853. def list_available_requisitions_for_prep(
  854. db: Session,
  855. tenant_no: str,
  856. *,
  857. page: int = 1,
  858. page_size: int = 50,
  859. keyword: Optional[str] = None,
  860. material_type: Optional[str] = None,
  861. user_info=None,
  862. ) -> Tuple[List[Dict[str, Any]], int]:
  863. """配制子物料来源:全部未标记已用完的领用记录。"""
  864. q0 = db.query(BizProductionMaterialUsage).filter(
  865. BizProductionMaterialUsage.tenant_no == tenant_no,
  866. BizProductionMaterialUsage.del_flag == 0,
  867. BizProductionMaterialUsage.change_type == "use",
  868. BizProductionMaterialUsage.source_type == "manual_out",
  869. BizProductionMaterialUsage.material_type.in_(["reagent", "consumable"]),
  870. BizProductionMaterialUsage.requisition_status != "closed",
  871. )
  872. mt = _filter_material_type(material_type)
  873. if mt:
  874. q0 = q0.filter(BizProductionMaterialUsage.material_type == mt)
  875. kw = (keyword or "").strip()
  876. if kw:
  877. like = f"%{kw}%"
  878. q0 = q0.filter(
  879. or_(
  880. BizProductionMaterialUsage.material_code.like(like),
  881. BizProductionMaterialUsage.material_name.like(like),
  882. BizProductionMaterialUsage.stock_in_no.like(like),
  883. BizProductionMaterialUsage.batch_no.like(like),
  884. BizProductionMaterialUsage.catalog_no.like(like),
  885. BizProductionMaterialUsage.receiver.like(like),
  886. )
  887. )
  888. if user_info:
  889. from app.core.data_scope import apply_data_scope
  890. q0 = apply_data_scope(q0, BizProductionMaterialUsage, user_info, db, tenant_no)
  891. total = q0.order_by(None).with_entities(func.count(BizProductionMaterialUsage.id)).scalar() or 0
  892. page = max(1, page)
  893. page_size = min(max(1, page_size), 200)
  894. rows = (
  895. q0.order_by(BizProductionMaterialUsage.create_time.desc())
  896. .offset((page - 1) * page_size)
  897. .limit(page_size)
  898. .all()
  899. )
  900. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  901. receivers = []
  902. for r in rows:
  903. rcv, _ = _resolve_receiver_purpose(r)
  904. if rcv:
  905. receivers.append(rcv)
  906. receiver_map = _receiver_display_map(db, receivers)
  907. items: List[Dict[str, Any]] = []
  908. for r in rows:
  909. batch = bmap.get(r.batch_id or "")
  910. rcv, _ = _resolve_receiver_purpose(r)
  911. rcv_name = receiver_map.get(rcv, rcv) if rcv else ""
  912. req_qty = abs(float(r.change_qty or 0))
  913. unit = _usage_unit_label(r, batch)
  914. use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  915. items.append(
  916. {
  917. "requisitionUsageId": r.id,
  918. "batchId": r.batch_id or "",
  919. "materialId": r.material_id,
  920. "materialType": r.material_type or "",
  921. "materialCode": r.material_code or "",
  922. "materialName": r.material_name or "",
  923. "specModel": (batch.spec_model if batch else "") or "",
  924. "unit": unit,
  925. "stockInNo": r.stock_in_no or "",
  926. "stockInDate": _stock_in_date_for_row(r, batch),
  927. "expireDate": _fmt_date_val(batch.expire_date if batch else None),
  928. "catalogNo": r.catalog_no or "",
  929. "batchNo": r.batch_no or "",
  930. "manufacturer": r.manufacturer or "",
  931. "storageLocation": (r.storage_location or "").strip() or (batch.storage_location if batch else "") or "",
  932. "requisitionQty": req_qty,
  933. "unitPrice": _unit_price_for_row(r, batch),
  934. "receiver": rcv,
  935. "receiverName": rcv_name,
  936. "useTime": use_time,
  937. }
  938. )
  939. return items, int(total)
  940. def _step_consumed_qty_map(
  941. db: Session,
  942. tenant_no: str,
  943. parent_usage_ids: List[str],
  944. ) -> Dict[str, float]:
  945. """统计领用记录关联的生产步骤消耗量。"""
  946. ids = [x for x in parent_usage_ids if x]
  947. if not ids:
  948. return {}
  949. rows = (
  950. db.query(
  951. BizProductionMaterialUsage.parent_usage_id,
  952. func.coalesce(func.sum(func.abs(BizProductionMaterialUsage.change_qty)), 0),
  953. )
  954. .filter(
  955. BizProductionMaterialUsage.tenant_no == tenant_no,
  956. BizProductionMaterialUsage.del_flag == 0,
  957. BizProductionMaterialUsage.parent_usage_id.in_(ids),
  958. BizProductionMaterialUsage.change_type == "use",
  959. BizProductionMaterialUsage.source_type == "step_save",
  960. )
  961. .group_by(BizProductionMaterialUsage.parent_usage_id)
  962. .all()
  963. )
  964. return {pid: float(qty or 0) for pid, qty in rows if pid}
  965. def _fmt_date_val(v) -> str:
  966. if v is None:
  967. return ""
  968. if hasattr(v, "strftime"):
  969. return v.strftime("%Y-%m-%d")
  970. return str(v)[:10]
  971. def list_stock_check_requisitions(
  972. db: Session,
  973. tenant_no: str,
  974. *,
  975. page: int = 1,
  976. page_size: int = 50,
  977. keyword: Optional[str] = None,
  978. user_info=None,
  979. ) -> Tuple[List[Dict[str, Any]], int]:
  980. """盘点登记弹窗:待处理领用记录(未标记已使用完)。"""
  981. q0 = db.query(BizProductionMaterialUsage).filter(
  982. BizProductionMaterialUsage.tenant_no == tenant_no,
  983. BizProductionMaterialUsage.del_flag == 0,
  984. BizProductionMaterialUsage.change_type == "use",
  985. BizProductionMaterialUsage.source_type == "manual_out",
  986. BizProductionMaterialUsage.requisition_status == "open",
  987. )
  988. if user_info:
  989. from app.core.data_scope import apply_data_scope
  990. q0 = apply_data_scope(q0, BizProductionMaterialUsage, user_info, db, tenant_no)
  991. kw = (keyword or "").strip()
  992. if kw:
  993. like = f"%{kw}%"
  994. q0 = q0.filter(
  995. or_(
  996. BizProductionMaterialUsage.material_code.like(like),
  997. BizProductionMaterialUsage.material_name.like(like),
  998. BizProductionMaterialUsage.stock_in_no.like(like),
  999. BizProductionMaterialUsage.batch_no.like(like),
  1000. BizProductionMaterialUsage.catalog_no.like(like),
  1001. BizProductionMaterialUsage.receiver.like(like),
  1002. )
  1003. )
  1004. total = q0.order_by(None).with_entities(func.count(BizProductionMaterialUsage.id)).scalar() or 0
  1005. page = max(1, page)
  1006. page_size = min(max(1, page_size), 200)
  1007. rows = (
  1008. q0.order_by(BizProductionMaterialUsage.create_time.desc())
  1009. .offset((page - 1) * page_size)
  1010. .limit(page_size)
  1011. .all()
  1012. )
  1013. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  1014. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  1015. receivers = []
  1016. for r in rows:
  1017. rcv, _ = _resolve_receiver_purpose(r)
  1018. if rcv:
  1019. receivers.append(rcv)
  1020. receiver_map = _receiver_display_map(db, receivers)
  1021. consumed_map = _step_consumed_qty_map(db, tenant_no, [x.id for x in rows])
  1022. out: List[Dict[str, Any]] = []
  1023. for r in rows:
  1024. batch = bmap.get(r.batch_id or "")
  1025. rcv, purpose = _resolve_receiver_purpose(r)
  1026. rcv_name = receiver_map.get(rcv, rcv) if rcv else ""
  1027. qty_abs = abs(float(r.change_qty or 0))
  1028. unit = _usage_unit_label(r, batch)
  1029. use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  1030. task_consumed = consumed_map.get(r.id, 0)
  1031. out.append(
  1032. {
  1033. "id": r.id,
  1034. "materialId": r.material_id,
  1035. "materialType": r.material_type or "",
  1036. "materialCode": r.material_code or "",
  1037. "materialName": r.material_name or "",
  1038. "specModel": (batch.spec_model if batch else "") or "",
  1039. "batchId": r.batch_id or "",
  1040. "stockInNo": r.stock_in_no or "",
  1041. "stockInDate": _stock_in_date_for_row(r, batch),
  1042. "expireDate": _fmt_date_val(batch.expire_date if batch else None),
  1043. "catalogNo": r.catalog_no or "",
  1044. "batchNo": r.batch_no or "",
  1045. "manufacturer": r.manufacturer or "",
  1046. "storageLocation": (r.storage_location or "").strip() or (batch.storage_location if batch else "") or "",
  1047. "unitPrice": _unit_price_for_row(r, batch),
  1048. "unit": unit,
  1049. "requisitionQty": qty_abs,
  1050. "taskConsumedQty": task_consumed,
  1051. "receiver": rcv,
  1052. "receiverName": rcv_name,
  1053. "purpose": purpose,
  1054. "useTime": use_time,
  1055. "requisitionStatus": (r.requisition_status or "open").strip() or "open",
  1056. "createByName": name_map.get(r.create_by or "", r.create_by or ""),
  1057. }
  1058. )
  1059. return out, int(total)
  1060. def mark_requisition_fully_used(
  1061. db: Session,
  1062. usage_id: str,
  1063. *,
  1064. tenant_no: str,
  1065. user_id: Optional[str] = None,
  1066. user_info=None,
  1067. ) -> None:
  1068. row = (
  1069. db.query(BizProductionMaterialUsage)
  1070. .filter(
  1071. BizProductionMaterialUsage.id == usage_id,
  1072. BizProductionMaterialUsage.tenant_no == tenant_no,
  1073. BizProductionMaterialUsage.del_flag == 0,
  1074. BizProductionMaterialUsage.change_type == "use",
  1075. BizProductionMaterialUsage.source_type == "manual_out",
  1076. )
  1077. .first()
  1078. )
  1079. if not row:
  1080. raise ValueError("领用记录不存在或不可标记")
  1081. if user_info:
  1082. from app.core.data_scope import apply_data_scope
  1083. scoped = apply_data_scope(
  1084. db.query(BizProductionMaterialUsage).filter(BizProductionMaterialUsage.id == usage_id),
  1085. BizProductionMaterialUsage,
  1086. user_info,
  1087. db,
  1088. tenant_no,
  1089. )
  1090. if not scoped.first():
  1091. raise PermissionError("无权操作该领用记录")
  1092. if (row.requisition_status or "open") == "closed":
  1093. return
  1094. now = datetime.now()
  1095. row.requisition_status = "closed"
  1096. row.update_by = user_id
  1097. row.update_time = now
  1098. db.add(row)
  1099. db.commit()
  1100. def _prep_consumption_to_dict(
  1101. r: BizProductionMaterialUsage,
  1102. *,
  1103. prep_no: str = "",
  1104. configured_material_name: str = "",
  1105. create_by_name: str = "",
  1106. ) -> Dict[str, Any]:
  1107. """配制消耗明细行转字典。"""
  1108. return {
  1109. "id": r.id,
  1110. "materialId": r.material_id,
  1111. "materialType": r.material_type or "",
  1112. "materialCode": r.material_code or "",
  1113. "materialName": r.material_name or "",
  1114. "batchId": r.batch_id or "",
  1115. "stockInNo": r.stock_in_no or "",
  1116. "batchNo": r.batch_no or "",
  1117. "manufacturer": r.manufacturer or "",
  1118. "unitPrice": float(r.unit_price) if r.unit_price is not None else None,
  1119. "changeQty": float(r.change_qty) if r.change_qty is not None else 0,
  1120. "usageUnit": r.usage_unit or "",
  1121. "prepId": r.prep_id or "",
  1122. "prepNo": prep_no,
  1123. "configuredMaterialName": configured_material_name,
  1124. "sourceType": r.source_type or "",
  1125. "remark": r.remark or "",
  1126. "createBy": r.create_by or "",
  1127. "createByName": create_by_name or r.create_by or "",
  1128. "createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
  1129. }
  1130. def list_prep_consumptions(
  1131. db: Session,
  1132. tenant_no: str,
  1133. *,
  1134. page: int = 1,
  1135. page_size: int = 10,
  1136. keyword: Optional[str] = None,
  1137. material_type: Optional[str] = None,
  1138. start_time: Optional[str] = None,
  1139. end_time: Optional[str] = None,
  1140. ) -> Tuple[List[Dict[str, Any]], int]:
  1141. """查询配制消耗明细(sub_consume)。"""
  1142. q0 = db.query(BizProductionMaterialUsage).filter(
  1143. BizProductionMaterialUsage.tenant_no == tenant_no,
  1144. BizProductionMaterialUsage.del_flag == 0,
  1145. BizProductionMaterialUsage.change_type == "use",
  1146. BizProductionMaterialUsage.source_type == "prep_consume",
  1147. )
  1148. if material_type:
  1149. mt = material_type.strip().lower()
  1150. if mt in ("reagent", "consumable"):
  1151. q0 = q0.filter(BizProductionMaterialUsage.material_type == mt)
  1152. kw = (keyword or "").strip()
  1153. if kw:
  1154. like = f"%{kw}%"
  1155. q0 = q0.filter(
  1156. or_(
  1157. BizProductionMaterialUsage.material_name.like(like),
  1158. BizProductionMaterialUsage.material_code.like(like),
  1159. BizProductionMaterialUsage.batch_no.like(like),
  1160. )
  1161. )
  1162. if start_time:
  1163. q0 = q0.filter(BizProductionMaterialUsage.create_time >= start_time)
  1164. if end_time:
  1165. q0 = q0.filter(BizProductionMaterialUsage.create_time <= end_time + " 23:59:59")
  1166. total = q0.order_by(None).with_entities(func.count(BizProductionMaterialUsage.id)).scalar() or 0
  1167. rows = (
  1168. q0.order_by(BizProductionMaterialUsage.create_time.desc())
  1169. .offset((page - 1) * page_size)
  1170. .limit(page_size)
  1171. .all()
  1172. )
  1173. # 收集 prep_id 并查询配制单信息
  1174. prep_ids = list({(r.prep_id or "").strip() for r in rows if (r.prep_id or "").strip()})
  1175. prep_map: Dict[str, Dict[str, Any]] = {}
  1176. if prep_ids:
  1177. preps = (
  1178. db.query(BizProductionMaterialPrep)
  1179. .filter(
  1180. BizProductionMaterialPrep.id.in_(prep_ids),
  1181. BizProductionMaterialPrep.tenant_no == tenant_no,
  1182. BizProductionMaterialPrep.del_flag == 0,
  1183. )
  1184. .all()
  1185. )
  1186. prep_map = {p.id: {"prepNo": p.prep_no, "configuredMaterialName": p.configured_material_name or ""} for p in preps}
  1187. name_map = _user_nick_name_map(db, [r.create_by for r in rows if r.create_by])
  1188. items = [
  1189. _prep_consumption_to_dict(
  1190. r,
  1191. prep_no=prep_map.get((r.prep_id or "").strip(), {}).get("prepNo", ""),
  1192. configured_material_name=prep_map.get((r.prep_id or "").strip(), {}).get("configuredMaterialName", ""),
  1193. create_by_name=name_map.get(r.create_by or "", ""),
  1194. )
  1195. for r in rows
  1196. ]
  1197. return items, int(total)
  1198. def list_my_prep_consumptions(
  1199. db: Session,
  1200. tenant_no: str,
  1201. *,
  1202. user_id: str,
  1203. nick_name: str = "",
  1204. page: int = 1,
  1205. page_size: int = 10,
  1206. keyword: Optional[str] = None,
  1207. material_type: Optional[str] = None,
  1208. start_time: Optional[str] = None,
  1209. end_time: Optional[str] = None,
  1210. ) -> Tuple[List[Dict[str, Any]], int]:
  1211. """当前登录人领用批次的配制消耗明细(按领用人,非配制操作人)。"""
  1212. uid = (user_id or "").strip()
  1213. nn = (nick_name or "").strip()
  1214. parent_conds = [BizProductionMaterialUsage.source_type == "manual_out"]
  1215. receiver_match: List[Any] = []
  1216. if nn:
  1217. receiver_match.append(BizProductionMaterialUsage.receiver == nn)
  1218. receiver_match.append(BizProductionMaterialUsage.remark.like(f"%领用人:{nn}%"))
  1219. if uid:
  1220. receiver_match.append(BizProductionMaterialUsage.receiver == uid)
  1221. if not receiver_match:
  1222. return [], 0
  1223. parent_conds.append(or_(*receiver_match))
  1224. parent_id_q = (
  1225. db.query(BizProductionMaterialUsage.id)
  1226. .filter(
  1227. BizProductionMaterialUsage.tenant_no == tenant_no,
  1228. BizProductionMaterialUsage.del_flag == 0,
  1229. and_(*parent_conds),
  1230. )
  1231. )
  1232. q0 = db.query(BizProductionMaterialUsage).filter(
  1233. BizProductionMaterialUsage.tenant_no == tenant_no,
  1234. BizProductionMaterialUsage.del_flag == 0,
  1235. BizProductionMaterialUsage.change_type == "use",
  1236. BizProductionMaterialUsage.source_type == "prep_consume",
  1237. BizProductionMaterialUsage.parent_usage_id.in_(parent_id_q),
  1238. )
  1239. if material_type:
  1240. mt = material_type.strip().lower()
  1241. if mt in ("reagent", "consumable"):
  1242. q0 = q0.filter(BizProductionMaterialUsage.material_type == mt)
  1243. kw = (keyword or "").strip()
  1244. if kw:
  1245. like = f"%{kw}%"
  1246. q0 = q0.filter(
  1247. or_(
  1248. BizProductionMaterialUsage.material_name.like(like),
  1249. BizProductionMaterialUsage.material_code.like(like),
  1250. BizProductionMaterialUsage.batch_no.like(like),
  1251. )
  1252. )
  1253. if start_time:
  1254. q0 = q0.filter(BizProductionMaterialUsage.create_time >= start_time)
  1255. if end_time:
  1256. q0 = q0.filter(BizProductionMaterialUsage.create_time <= end_time + " 23:59:59")
  1257. total = q0.order_by(None).with_entities(func.count(BizProductionMaterialUsage.id)).scalar() or 0
  1258. rows = (
  1259. q0.order_by(BizProductionMaterialUsage.create_time.desc())
  1260. .offset((page - 1) * page_size)
  1261. .limit(page_size)
  1262. .all()
  1263. )
  1264. prep_ids = list({(r.prep_id or "").strip() for r in rows if (r.prep_id or "").strip()})
  1265. prep_map: Dict[str, Dict[str, Any]] = {}
  1266. if prep_ids:
  1267. preps = (
  1268. db.query(BizProductionMaterialPrep)
  1269. .filter(
  1270. BizProductionMaterialPrep.id.in_(prep_ids),
  1271. BizProductionMaterialPrep.tenant_no == tenant_no,
  1272. BizProductionMaterialPrep.del_flag == 0,
  1273. )
  1274. .all()
  1275. )
  1276. prep_map = {p.id: {"prepNo": p.prep_no, "configuredMaterialName": p.configured_material_name or ""} for p in preps}
  1277. items = [
  1278. _prep_consumption_to_dict(
  1279. r,
  1280. prep_no=prep_map.get((r.prep_id or "").strip(), {}).get("prepNo", ""),
  1281. configured_material_name=prep_map.get((r.prep_id or "").strip(), {}).get("configuredMaterialName", ""),
  1282. )
  1283. for r in rows
  1284. ]
  1285. return items, int(total)