qc_material_usage.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080
  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.models.qc_material import BizQcMaterial, BizQcMaterialStockBatch, BizQcMaterialUsage
  11. from app.models.models import SysUser
  12. from app.crud.qc_material_stock_batch import _usage_batch_snapshots
  13. def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
  14. """兼容旧数据:领用人/用途曾拼接在 remark 中。"""
  15. receiver, purpose = "", ""
  16. for part in (remark or "").split(";"):
  17. seg = part.strip()
  18. if seg.startswith("领用人:"):
  19. receiver = seg[len("领用人:") :].strip()
  20. elif seg.startswith("用途:"):
  21. purpose = seg[len("用途:") :].strip()
  22. return receiver, purpose
  23. def _resolve_receiver_purpose(r: BizQcMaterialUsage) -> Tuple[str, str]:
  24. receiver = (getattr(r, "receiver", None) or "").strip()
  25. purpose = (getattr(r, "purpose", None) or "").strip()
  26. if receiver or purpose:
  27. return receiver, purpose
  28. if (r.source_type or "") == "manual_out":
  29. return _parse_receiver_purpose_from_remark(r.remark)
  30. return "", ""
  31. def _display_remark(r: BizQcMaterialUsage) -> str:
  32. """手动出库时备注仅展示用户填写内容,不含领用人/用途拼接段。"""
  33. remark = (r.remark or "").strip()
  34. if (r.source_type or "") != "manual_out" or not remark:
  35. return remark
  36. parts: List[str] = []
  37. for part in remark.split(";"):
  38. seg = part.strip()
  39. if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
  40. continue
  41. if seg.startswith("备注"):
  42. seg = seg[2:].strip() or seg
  43. parts.append(seg)
  44. return ";".join(parts) if parts else ""
  45. def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  46. ids = list({x for x in user_ids if x})
  47. if not ids:
  48. return {}
  49. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  50. return {u.id: (u.nick_name or "").strip() for u in rows}
  51. def _receiver_display_map(db: Session, receivers: List[str]) -> Dict[str, str]:
  52. """领用人字段可能是用户 ID 或昵称。"""
  53. keys = list({(x or "").strip() for x in receivers if (x or "").strip()})
  54. if not keys:
  55. return {}
  56. by_id = _user_nick_name_map(db, keys)
  57. out: Dict[str, str] = {}
  58. for k in keys:
  59. out[k] = by_id.get(k) or k
  60. return out
  61. def _usage_unit_label(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> str:
  62. u = (getattr(r, "usage_unit", None) or "").strip()
  63. if u:
  64. return u
  65. if batch and (batch.unit or "").strip():
  66. return (batch.unit or "").strip()
  67. return ""
  68. def _stock_in_date_for_row(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> str:
  69. if batch and batch.stock_in_date:
  70. return batch.stock_in_date.strftime("%Y-%m-%d")
  71. return ""
  72. def _unit_price_for_row(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> Optional[float]:
  73. if r.unit_price is not None:
  74. return float(r.unit_price)
  75. if batch and batch.unit_price is not None:
  76. return float(batch.unit_price)
  77. return None
  78. def _amount_for_row(r: BizQcMaterialUsage, batch: Optional[BizQcMaterialStockBatch]) -> Optional[float]:
  79. is_in = (r.change_type or "") == "in"
  80. if is_in:
  81. if r.amount is not None:
  82. return float(r.amount)
  83. if batch and batch.amount is not None:
  84. return float(batch.amount)
  85. return None
  86. unit_price = _unit_price_for_row(r, batch)
  87. change_qty = float(r.change_qty) if r.change_qty is not None else 0
  88. qty = abs(change_qty)
  89. if unit_price is None or qty <= 0:
  90. return None
  91. return round(unit_price * qty, 2)
  92. def _row_to_dict(
  93. r: BizQcMaterialUsage,
  94. *,
  95. create_by_name: str = "",
  96. batch: Optional[BizQcMaterialStockBatch] = None,
  97. receiver_name: str = "",
  98. requisition_receiver: str = "",
  99. requisition_receiver_name: str = "",
  100. ) -> Dict[str, Any]:
  101. create_by = r.create_by or ""
  102. receiver, purpose = _resolve_receiver_purpose(r)
  103. create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  104. is_manual_out = (r.source_type or "") == "manual_out"
  105. stock_after = float(r.stock_after) if r.stock_after is not None else 0
  106. change_qty = float(r.change_qty) if r.change_qty is not None else 0
  107. stock_before = (
  108. float(r.stock_before)
  109. if r.stock_before is not None
  110. else stock_after - change_qty
  111. )
  112. usage_unit = _usage_unit_label(r, batch)
  113. req_receiver = requisition_receiver or receiver
  114. req_receiver_name = requisition_receiver_name or receiver_name or req_receiver
  115. return {
  116. "id": r.id,
  117. "materialId": r.material_id,
  118. "materialCode": r.material_code or "",
  119. "materialName": r.material_name or "",
  120. "batchId": r.batch_id or "",
  121. "stockInNo": r.stock_in_no or "",
  122. "stockInDate": _stock_in_date_for_row(r, batch),
  123. "catalogNo": r.catalog_no or "",
  124. "batchNo": r.batch_no or "",
  125. "manufacturer": r.manufacturer or "",
  126. "unitPrice": _unit_price_for_row(r, batch),
  127. "amount": _amount_for_row(r, batch),
  128. "changeType": r.change_type,
  129. "changeQty": change_qty,
  130. "usageUnit": usage_unit,
  131. "stockBefore": stock_before,
  132. "stockAfter": stock_after,
  133. "qcTaskId": r.qc_task_id or "",
  134. "qcTaskNo": r.qc_task_no or "",
  135. "parentUsageId": (r.parent_usage_id or "").strip(),
  136. "requisitionUsageId": (r.parent_usage_id or "").strip(),
  137. "receiver": receiver,
  138. "receiverName": receiver_name or receiver,
  139. "requisitionReceiver": req_receiver,
  140. "requisitionReceiverName": req_receiver_name,
  141. "purpose": purpose,
  142. "sourceType": r.source_type or "",
  143. "requisitionStatus": (getattr(r, "requisition_status", None) or "open").strip() or "open",
  144. "useTime": create_time if is_manual_out else "",
  145. "remark": _display_remark(r),
  146. "createBy": create_by,
  147. "createByName": create_by_name or create_by,
  148. "createTime": create_time,
  149. "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
  150. }
  151. def _linked_qc_task_no_map(
  152. db: Session,
  153. tenant_no: str,
  154. parent_usage_ids: List[str],
  155. ) -> Dict[str, str]:
  156. """领用记录(manual_out)关联的质检任务号,聚合为 parent_usage_id → 任务号列表。"""
  157. ids = [x for x in parent_usage_ids if x]
  158. if not ids:
  159. return {}
  160. rows = (
  161. db.query(BizQcMaterialUsage.parent_usage_id, BizQcMaterialUsage.qc_task_no)
  162. .filter(
  163. BizQcMaterialUsage.tenant_no == tenant_no,
  164. BizQcMaterialUsage.del_flag == 0,
  165. BizQcMaterialUsage.parent_usage_id.in_(ids),
  166. BizQcMaterialUsage.change_type == "use",
  167. BizQcMaterialUsage.source_type == "task_complete",
  168. )
  169. .order_by(BizQcMaterialUsage.create_time.asc())
  170. .all()
  171. )
  172. grouped: Dict[str, List[str]] = {}
  173. for pid, tno in rows:
  174. pid = (pid or "").strip()
  175. tno = (tno or "").strip()
  176. if not pid or not tno:
  177. continue
  178. grouped.setdefault(pid, [])
  179. if tno not in grouped[pid]:
  180. grouped[pid].append(tno)
  181. return {pid: "、".join(nos) for pid, nos in grouped.items()}
  182. def _batch_map(db: Session, batch_ids: List[str]) -> Dict[str, BizQcMaterialStockBatch]:
  183. ids = [x for x in batch_ids if x]
  184. if not ids:
  185. return {}
  186. rows = (
  187. db.query(BizQcMaterialStockBatch)
  188. .filter(BizQcMaterialStockBatch.id.in_(ids), BizQcMaterialStockBatch.del_flag == 0)
  189. .all()
  190. )
  191. return {r.id: r for r in rows}
  192. def _mine_usage_filter(user_id: str, nick_name: str):
  193. """当前登录人的领用记录:手动出库(领用人)或任务使用(填报人)。"""
  194. uid = (user_id or "").strip()
  195. nn = (nick_name or "").strip()
  196. manual_conds = [BizQcMaterialUsage.source_type == "manual_out"]
  197. receiver_match: List[Any] = []
  198. if nn:
  199. receiver_match.append(BizQcMaterialUsage.receiver == nn)
  200. receiver_match.append(BizQcMaterialUsage.remark.like(f"%领用人:{nn}%"))
  201. if uid:
  202. receiver_match.append(BizQcMaterialUsage.receiver == uid)
  203. if receiver_match:
  204. manual_conds.append(or_(*receiver_match))
  205. else:
  206. manual_conds.append(BizQcMaterialUsage.id == "")
  207. task_conds = [
  208. BizQcMaterialUsage.source_type == "task_complete",
  209. BizQcMaterialUsage.create_by == uid,
  210. ]
  211. return and_(
  212. BizQcMaterialUsage.change_type == "use",
  213. or_(and_(*manual_conds), and_(*task_conds)),
  214. )
  215. def _list_usages_query(
  216. db: Session,
  217. tenant_no: str,
  218. *,
  219. material_id: Optional[str] = None,
  220. change_type: Optional[str] = None,
  221. start_time: Optional[str] = None,
  222. end_time: Optional[str] = None,
  223. keyword: Optional[str] = None,
  224. stock_in_no: Optional[str] = None,
  225. user_info=None,
  226. mine_user_id: Optional[str] = None,
  227. mine_nick_name: Optional[str] = None,
  228. ):
  229. q0 = db.query(BizQcMaterialUsage).filter(
  230. BizQcMaterialUsage.tenant_no == tenant_no,
  231. BizQcMaterialUsage.del_flag == 0,
  232. )
  233. if mine_user_id is not None:
  234. q0 = q0.filter(_mine_usage_filter(mine_user_id, mine_nick_name or ""))
  235. if material_id:
  236. q0 = q0.filter(BizQcMaterialUsage.material_id == material_id)
  237. ct = (change_type or "").strip()
  238. if ct == "out":
  239. q0 = q0.filter(
  240. BizQcMaterialUsage.change_type == "use",
  241. BizQcMaterialUsage.source_type == "manual_out",
  242. )
  243. elif ct == "use":
  244. q0 = q0.filter(
  245. BizQcMaterialUsage.change_type == "use",
  246. BizQcMaterialUsage.source_type == "task_complete",
  247. )
  248. elif ct == "in":
  249. q0 = q0.filter(BizQcMaterialUsage.change_type == "in")
  250. elif ct == "discard":
  251. q0 = q0.filter(BizQcMaterialUsage.change_type == "discard")
  252. elif ct == "adjust":
  253. q0 = q0.filter(BizQcMaterialUsage.change_type == "adjust")
  254. # 任务消耗子流水(parent_usage_id 有值)仅在「任务使用」筛选下展示,避免与领用行重复
  255. if ct != "use":
  256. q0 = q0.filter(
  257. or_(
  258. BizQcMaterialUsage.source_type != "task_complete",
  259. BizQcMaterialUsage.parent_usage_id.is_(None),
  260. BizQcMaterialUsage.parent_usage_id == "",
  261. )
  262. )
  263. sno = (stock_in_no or "").strip()
  264. if sno:
  265. q0 = q0.filter(BizQcMaterialUsage.stock_in_no.like(f"%{sno}%"))
  266. kw = (keyword or "").strip()
  267. if kw:
  268. like = f"%{kw}%"
  269. q0 = q0.filter(
  270. or_(
  271. BizQcMaterialUsage.material_code.like(like),
  272. BizQcMaterialUsage.material_name.like(like),
  273. BizQcMaterialUsage.stock_in_no.like(like),
  274. BizQcMaterialUsage.batch_no.like(like),
  275. BizQcMaterialUsage.catalog_no.like(like),
  276. )
  277. )
  278. if start_time:
  279. q0 = q0.filter(BizQcMaterialUsage.create_time >= start_time)
  280. if end_time:
  281. q0 = q0.filter(BizQcMaterialUsage.create_time <= end_time + " 23:59:59")
  282. if user_info and mine_user_id is None:
  283. from app.core.data_scope import apply_data_scope
  284. q0 = apply_data_scope(q0, BizQcMaterialUsage, user_info, db, tenant_no)
  285. return q0
  286. def _paginate_usages(
  287. db: Session,
  288. q0,
  289. *,
  290. tenant_no: str,
  291. page: int,
  292. page_size: int,
  293. ) -> Tuple[List[Dict[str, Any]], int]:
  294. total = q0.order_by(None).with_entities(func.count(BizQcMaterialUsage.id)).scalar() or 0
  295. page = max(1, page)
  296. page_size = min(max(1, page_size), 100)
  297. rows = (
  298. q0.order_by(BizQcMaterialUsage.create_time.desc())
  299. .offset((page - 1) * page_size)
  300. .limit(page_size)
  301. .all()
  302. )
  303. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  304. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  305. receivers = []
  306. for x in rows:
  307. rcv, _ = _resolve_receiver_purpose(x)
  308. if rcv:
  309. receivers.append(rcv)
  310. receiver_map = _receiver_display_map(db, receivers)
  311. manual_out_ids = [x.id for x in rows if (x.source_type or "") == "manual_out"]
  312. linked_task_no_map = _linked_qc_task_no_map(db, tenant_no, manual_out_ids)
  313. out: List[Dict[str, Any]] = []
  314. for x in rows:
  315. row_dict = _row_to_dict(
  316. x,
  317. create_by_name=name_map.get(x.create_by or "", ""),
  318. batch=bmap.get(x.batch_id or ""),
  319. receiver_name=receiver_map.get(
  320. (_resolve_receiver_purpose(x)[0] or ""), _resolve_receiver_purpose(x)[0] or ""
  321. ),
  322. )
  323. if (x.source_type or "") == "manual_out":
  324. linked = linked_task_no_map.get(x.id, "")
  325. if linked:
  326. row_dict["qcTaskNo"] = linked
  327. out.append(row_dict)
  328. return out, int(total)
  329. def list_usages(
  330. db: Session,
  331. tenant_no: str,
  332. *,
  333. page: int = 1,
  334. page_size: int = 10,
  335. material_id: Optional[str] = None,
  336. change_type: Optional[str] = None,
  337. start_time: Optional[str] = None,
  338. end_time: Optional[str] = None,
  339. keyword: Optional[str] = None,
  340. stock_in_no: Optional[str] = None,
  341. user_info=None,
  342. ) -> Tuple[List[Dict[str, Any]], int]:
  343. q0 = _list_usages_query(
  344. db,
  345. tenant_no,
  346. material_id=material_id,
  347. change_type=change_type,
  348. start_time=start_time,
  349. end_time=end_time,
  350. keyword=keyword,
  351. stock_in_no=stock_in_no,
  352. user_info=user_info,
  353. )
  354. return _paginate_usages(db, q0, tenant_no=tenant_no, page=page, page_size=page_size)
  355. def list_my_usages(
  356. db: Session,
  357. tenant_no: str,
  358. *,
  359. user_id: str,
  360. nick_name: str = "",
  361. page: int = 1,
  362. page_size: int = 10,
  363. material_id: Optional[str] = None,
  364. change_type: Optional[str] = None,
  365. start_time: Optional[str] = None,
  366. end_time: Optional[str] = None,
  367. keyword: Optional[str] = None,
  368. stock_in_no: Optional[str] = None,
  369. ) -> Tuple[List[Dict[str, Any]], int]:
  370. """当前登录人的物料领用记录(手动出库 + 任务使用)。"""
  371. q0 = _list_usages_query(
  372. db,
  373. tenant_no,
  374. material_id=material_id,
  375. change_type=change_type,
  376. start_time=start_time,
  377. end_time=end_time,
  378. keyword=keyword,
  379. stock_in_no=stock_in_no,
  380. mine_user_id=user_id,
  381. mine_nick_name=nick_name,
  382. )
  383. return _paginate_usages(db, q0, tenant_no=tenant_no, page=page, page_size=page_size)
  384. def list_usages_by_task(
  385. db: Session,
  386. task_id: str,
  387. tenant_no: str,
  388. *,
  389. user_info=None,
  390. ) -> List[Dict[str, Any]]:
  391. """查询质检任务关联的物料使用记录(task_complete)。"""
  392. q = db.query(BizQcMaterialUsage).filter(
  393. BizQcMaterialUsage.tenant_no == tenant_no,
  394. BizQcMaterialUsage.del_flag == 0,
  395. BizQcMaterialUsage.qc_task_id == task_id,
  396. BizQcMaterialUsage.change_type == "use",
  397. BizQcMaterialUsage.source_type == "task_complete",
  398. )
  399. if user_info:
  400. from app.core.data_scope import apply_data_scope
  401. q = apply_data_scope(q, BizQcMaterialUsage, user_info, db, tenant_no)
  402. rows = q.order_by(BizQcMaterialUsage.create_time.asc()).all()
  403. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  404. parent_ids = [x.parent_usage_id for x in rows if (x.parent_usage_id or "").strip()]
  405. parent_map: Dict[str, BizQcMaterialUsage] = {}
  406. if parent_ids:
  407. parents = (
  408. db.query(BizQcMaterialUsage)
  409. .filter(
  410. BizQcMaterialUsage.id.in_(parent_ids),
  411. BizQcMaterialUsage.del_flag == 0,
  412. )
  413. .all()
  414. )
  415. parent_map = {p.id: p for p in parents}
  416. parent_receivers = []
  417. for p in parent_map.values():
  418. rcv, _ = _resolve_receiver_purpose(p)
  419. if rcv:
  420. parent_receivers.append(rcv)
  421. parent_receiver_map = _receiver_display_map(db, parent_receivers)
  422. out: List[Dict[str, Any]] = []
  423. for x in rows:
  424. parent = parent_map.get((x.parent_usage_id or "").strip())
  425. req_rcv, _ = _resolve_receiver_purpose(parent) if parent else ("", "")
  426. req_rcv_name = parent_receiver_map.get(req_rcv, req_rcv) if req_rcv else ""
  427. rcv, _ = _resolve_receiver_purpose(x)
  428. out.append(
  429. _row_to_dict(
  430. x,
  431. batch=bmap.get(x.batch_id or ""),
  432. receiver_name=parent_receiver_map.get(rcv, rcv) if not parent else req_rcv_name,
  433. requisition_receiver=req_rcv,
  434. requisition_receiver_name=req_rcv_name,
  435. )
  436. )
  437. return out
  438. def list_linked_task_usages(
  439. db: Session,
  440. parent_usage_id: str,
  441. tenant_no: str,
  442. *,
  443. user_info=None,
  444. ) -> List[Dict[str, Any]]:
  445. """查询领用记录关联的质检任务物料消耗(parent_usage_id → task_complete)。"""
  446. pid = (parent_usage_id or "").strip()
  447. if not pid:
  448. return []
  449. parent = (
  450. db.query(BizQcMaterialUsage)
  451. .filter(
  452. BizQcMaterialUsage.id == pid,
  453. BizQcMaterialUsage.tenant_no == tenant_no,
  454. BizQcMaterialUsage.del_flag == 0,
  455. )
  456. .first()
  457. )
  458. if not parent:
  459. raise HTTPException(status_code=404, detail="领用记录不存在")
  460. if (parent.source_type or "") != "manual_out":
  461. return []
  462. q = db.query(BizQcMaterialUsage).filter(
  463. BizQcMaterialUsage.tenant_no == tenant_no,
  464. BizQcMaterialUsage.del_flag == 0,
  465. BizQcMaterialUsage.parent_usage_id == pid,
  466. BizQcMaterialUsage.change_type == "use",
  467. BizQcMaterialUsage.source_type == "task_complete",
  468. )
  469. if user_info:
  470. from app.core.data_scope import apply_data_scope
  471. q = apply_data_scope(q, BizQcMaterialUsage, user_info, db, tenant_no)
  472. rows = q.order_by(BizQcMaterialUsage.create_time.desc()).all()
  473. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  474. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  475. return [
  476. _row_to_dict(
  477. x,
  478. create_by_name=name_map.get(x.create_by or "", ""),
  479. batch=bmap.get(x.batch_id or ""),
  480. requisition_receiver_name="",
  481. )
  482. for x in rows
  483. ]
  484. def revert_task_material_usages(
  485. db: Session,
  486. task_id: str,
  487. tenant_no: str,
  488. user_id: Optional[str] = None,
  489. ) -> None:
  490. """撤销任务此前 task_complete 扣减,便于重新填报物料使用。"""
  491. rows = (
  492. db.query(BizQcMaterialUsage)
  493. .filter(
  494. BizQcMaterialUsage.qc_task_id == task_id,
  495. BizQcMaterialUsage.tenant_no == tenant_no,
  496. BizQcMaterialUsage.del_flag == 0,
  497. BizQcMaterialUsage.source_type == "task_complete",
  498. BizQcMaterialUsage.change_type == "use",
  499. )
  500. .all()
  501. )
  502. if not rows:
  503. return
  504. now = datetime.now()
  505. for r in rows:
  506. if (r.parent_usage_id or "").strip():
  507. r.del_flag = 1
  508. r.update_by = user_id
  509. r.update_time = now
  510. continue
  511. restore_qty = abs(r.change_qty) if r.change_qty is not None else Decimal("0")
  512. if restore_qty <= 0:
  513. r.del_flag = 1
  514. r.update_by = user_id
  515. r.update_time = now
  516. continue
  517. batch_id = (r.batch_id or "").strip()
  518. if batch_id:
  519. batch = (
  520. db.query(BizQcMaterialStockBatch)
  521. .with_for_update()
  522. .filter(
  523. BizQcMaterialStockBatch.id == batch_id,
  524. BizQcMaterialStockBatch.tenant_no == tenant_no,
  525. BizQcMaterialStockBatch.del_flag == 0,
  526. )
  527. .first()
  528. )
  529. if batch:
  530. batch.remaining_qty = (batch.remaining_qty or Decimal("0")) + restore_qty
  531. batch.update_by = user_id
  532. batch.update_time = now
  533. material = (
  534. db.query(BizQcMaterial)
  535. .with_for_update()
  536. .filter(
  537. BizQcMaterial.id == r.material_id,
  538. BizQcMaterial.tenant_no == tenant_no,
  539. BizQcMaterial.del_flag == 0,
  540. )
  541. .first()
  542. )
  543. if material:
  544. material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
  545. material.update_by = user_id
  546. material.update_time = now
  547. r.del_flag = 1
  548. r.update_by = user_id
  549. r.update_time = now
  550. def list_requisition_options(
  551. db: Session,
  552. tenant_no: str,
  553. *,
  554. keyword: Optional[str] = None,
  555. limit: int = 50,
  556. user_info=None,
  557. ) -> List[Dict[str, Any]]:
  558. """领用记录选项(manual_out),供质检任务关联物料使用。"""
  559. q0 = db.query(BizQcMaterialUsage).filter(
  560. BizQcMaterialUsage.tenant_no == tenant_no,
  561. BizQcMaterialUsage.del_flag == 0,
  562. BizQcMaterialUsage.change_type == "use",
  563. BizQcMaterialUsage.source_type == "manual_out",
  564. BizQcMaterialUsage.requisition_status == "open",
  565. )
  566. if user_info:
  567. from app.core.data_scope import apply_data_scope
  568. q0 = apply_data_scope(q0, BizQcMaterialUsage, user_info, db, tenant_no)
  569. kw = (keyword or "").strip()
  570. if kw:
  571. like = f"%{kw}%"
  572. q0 = q0.filter(
  573. or_(
  574. BizQcMaterialUsage.material_code.like(like),
  575. BizQcMaterialUsage.material_name.like(like),
  576. BizQcMaterialUsage.stock_in_no.like(like),
  577. BizQcMaterialUsage.batch_no.like(like),
  578. BizQcMaterialUsage.catalog_no.like(like),
  579. BizQcMaterialUsage.receiver.like(like),
  580. )
  581. )
  582. limit = min(max(1, limit), 100)
  583. rows = q0.order_by(BizQcMaterialUsage.create_time.desc()).limit(limit).all()
  584. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  585. receivers = []
  586. for r in rows:
  587. rcv, _ = _resolve_receiver_purpose(r)
  588. if rcv:
  589. receivers.append(rcv)
  590. receiver_map = _receiver_display_map(db, receivers)
  591. options: List[Dict[str, Any]] = []
  592. for r in rows:
  593. batch = bmap.get(r.batch_id or "")
  594. rcv, _ = _resolve_receiver_purpose(r)
  595. rcv_name = receiver_map.get(rcv, rcv) if rcv else ""
  596. qty_abs = abs(float(r.change_qty or 0))
  597. unit = _usage_unit_label(r, batch)
  598. use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  599. unit_suffix = unit or ""
  600. label = (
  601. f"{use_time} {r.material_name or ''} / {r.stock_in_no or ''} "
  602. f"/ 批号:{r.batch_no or '—'} / 领用:{qty_abs}{unit_suffix} / 领用人:{rcv_name or '—'}"
  603. ).strip()
  604. options.append(
  605. {
  606. "id": r.id,
  607. "label": label,
  608. "materialId": r.material_id,
  609. "materialName": r.material_name or "",
  610. "stockInNo": r.stock_in_no or "",
  611. "batchNo": r.batch_no or "",
  612. "batchId": r.batch_id or "",
  613. "receiver": rcv,
  614. "receiverName": rcv_name,
  615. "requisitionQty": qty_abs,
  616. "usageUnit": unit,
  617. "useTime": use_time,
  618. }
  619. )
  620. return options
  621. def _record_task_usage_from_requisition(
  622. db: Session,
  623. task_id: str,
  624. task_no: str,
  625. item: Dict[str, Any],
  626. tenant_no: str,
  627. user_id: Optional[str] = None,
  628. dept_id: str = "",
  629. ) -> None:
  630. req_id = (
  631. item.get("requisitionUsageId")
  632. or item.get("requisition_usage_id")
  633. or item.get("parentUsageId")
  634. or item.get("parent_usage_id")
  635. or ""
  636. ).strip()
  637. if not req_id:
  638. raise HTTPException(status_code=400, detail="请选择领用记录")
  639. qty = item.get("qty")
  640. if qty is None or Decimal(str(qty)) <= 0:
  641. raise HTTPException(status_code=400, detail="物料使用数量必须大于0")
  642. qty_dec = Decimal(str(qty))
  643. req = (
  644. db.query(BizQcMaterialUsage)
  645. .filter(
  646. BizQcMaterialUsage.id == req_id,
  647. BizQcMaterialUsage.tenant_no == tenant_no,
  648. BizQcMaterialUsage.del_flag == 0,
  649. BizQcMaterialUsage.source_type == "manual_out",
  650. BizQcMaterialUsage.change_type == "use",
  651. )
  652. .first()
  653. )
  654. if not req:
  655. raise HTTPException(status_code=400, detail="领用记录不存在或已失效")
  656. material = (
  657. db.query(BizQcMaterial)
  658. .filter(
  659. BizQcMaterial.id == req.material_id,
  660. BizQcMaterial.tenant_no == tenant_no,
  661. BizQcMaterial.del_flag == 0,
  662. )
  663. .first()
  664. )
  665. if not material:
  666. raise HTTPException(status_code=400, detail="关联物料不存在")
  667. usage_unit = (
  668. str(item.get("usageUnit") or item.get("usage_unit") or "").strip()
  669. or _usage_unit_label(req, None)
  670. or ""
  671. )[:32]
  672. stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
  673. usage_remark = str(item.get("remark") or "").strip()[:512]
  674. now = datetime.now()
  675. usage = BizQcMaterialUsage(
  676. id=generate_uuid(),
  677. material_id=req.material_id,
  678. material_code=req.material_code or material.material_code,
  679. material_name=req.material_name or material.material_name,
  680. batch_id=req.batch_id,
  681. stock_in_no=req.stock_in_no,
  682. catalog_no=req.catalog_no,
  683. batch_no=req.batch_no,
  684. manufacturer=req.manufacturer,
  685. unit_price=req.unit_price,
  686. amount=req.amount,
  687. change_type="use",
  688. change_qty=-qty_dec,
  689. usage_unit=usage_unit or None,
  690. stock_before=stock_qty,
  691. stock_after=stock_qty,
  692. qc_task_id=task_id,
  693. qc_task_no=task_no,
  694. parent_usage_id=req_id,
  695. source_type="task_complete",
  696. tenant_no=tenant_no,
  697. remark=usage_remark,
  698. create_by=user_id,
  699. create_dept_id=dept_id,
  700. create_time=now,
  701. update_by=user_id,
  702. update_time=now,
  703. del_flag=0,
  704. )
  705. db.add(usage)
  706. def _deduct_task_usage_legacy_batch(
  707. db: Session,
  708. task_id: str,
  709. task_no: str,
  710. item: Dict[str, Any],
  711. tenant_no: str,
  712. user_id: Optional[str] = None,
  713. dept_id: str = "",
  714. ) -> None:
  715. """旧版:按入库批次扣减库存(兼容历史数据)。"""
  716. batch_id = (item.get("batchId") or item.get("batch_id") or "").strip()
  717. qty = item.get("qty")
  718. if not batch_id:
  719. raise HTTPException(status_code=400, detail="入库批次不能为空")
  720. if qty is None or Decimal(str(qty)) <= 0:
  721. raise HTTPException(status_code=400, detail="物料使用数量必须大于0")
  722. qty_dec = Decimal(str(qty))
  723. batch = (
  724. db.query(BizQcMaterialStockBatch)
  725. .with_for_update()
  726. .filter(
  727. BizQcMaterialStockBatch.id == batch_id,
  728. BizQcMaterialStockBatch.tenant_no == tenant_no,
  729. BizQcMaterialStockBatch.del_flag == 0,
  730. )
  731. .first()
  732. )
  733. if not batch:
  734. raise HTTPException(status_code=400, detail=f"入库批次不存在(ID: {batch_id})")
  735. rem = batch.remaining_qty or Decimal("0")
  736. if rem < qty_dec:
  737. raise HTTPException(
  738. status_code=400,
  739. detail=f"批次「{batch.stock_in_no}」库存不足,当前剩余 {rem},需使用 {qty_dec}",
  740. )
  741. material = (
  742. db.query(BizQcMaterial)
  743. .with_for_update()
  744. .filter(
  745. BizQcMaterial.id == batch.material_id,
  746. BizQcMaterial.tenant_no == tenant_no,
  747. BizQcMaterial.del_flag == 0,
  748. BizQcMaterial.status == "1",
  749. )
  750. .first()
  751. )
  752. if not material:
  753. raise HTTPException(status_code=400, detail="物料不存在或已停用")
  754. batch.remaining_qty = rem - qty_dec
  755. batch.update_by = user_id
  756. batch.update_time = datetime.now()
  757. db.add(batch)
  758. stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
  759. if stock_qty < qty_dec:
  760. raise HTTPException(
  761. status_code=400,
  762. detail=f"物料「{material.material_name}」汇总库存不足",
  763. )
  764. new_stock = stock_qty - qty_dec
  765. material.stock_qty = new_stock
  766. material.update_by = user_id
  767. material.update_time = datetime.now()
  768. db.add(material)
  769. usage_remark = str(item.get("remark") or "").strip()[:512]
  770. usage_unit = str(item.get("usageUnit") or item.get("usage_unit") or "").strip()[:32] or None
  771. snaps = _usage_batch_snapshots(batch, out_qty=qty_dec)
  772. usage = BizQcMaterialUsage(
  773. id=generate_uuid(),
  774. material_id=material.id,
  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. qc_task_id=task_id,
  783. qc_task_no=task_no,
  784. source_type="task_complete",
  785. tenant_no=tenant_no,
  786. remark=usage_remark,
  787. create_by=user_id,
  788. create_dept_id=dept_id,
  789. create_time=datetime.now(),
  790. update_by=user_id,
  791. update_time=datetime.now(),
  792. del_flag=0,
  793. **snaps,
  794. )
  795. db.add(usage)
  796. def deduct_for_task(
  797. db: Session,
  798. task_id: str,
  799. task_no: str,
  800. material_usages: List[Dict[str, Any]],
  801. tenant_no: str,
  802. user_id: Optional[str] = None,
  803. dept_id: str = "",
  804. ) -> None:
  805. """登记任务物料使用;关联领用记录时不扣减库存。调用方控制事务,本函数不 commit。"""
  806. revert_task_material_usages(db, task_id, tenant_no, user_id=user_id)
  807. seen_requisition: set = set()
  808. seen_batch: set = set()
  809. for item in material_usages:
  810. req_id = (
  811. item.get("requisitionUsageId")
  812. or item.get("requisition_usage_id")
  813. or item.get("parentUsageId")
  814. or ""
  815. ).strip()
  816. if req_id:
  817. if req_id in seen_requisition:
  818. raise HTTPException(status_code=400, detail="物料列表中存在重复领用记录,请检查")
  819. seen_requisition.add(req_id)
  820. _record_task_usage_from_requisition(
  821. db,
  822. task_id,
  823. task_no,
  824. item,
  825. tenant_no,
  826. user_id=user_id,
  827. dept_id=dept_id,
  828. )
  829. continue
  830. batch_id = (item.get("batchId") or item.get("batch_id") or "").strip()
  831. if not batch_id:
  832. raise HTTPException(status_code=400, detail="请选择领用记录")
  833. if batch_id in seen_batch:
  834. raise HTTPException(status_code=400, detail="物料列表中存在重复入库批次,请检查")
  835. seen_batch.add(batch_id)
  836. _deduct_task_usage_legacy_batch(
  837. db,
  838. task_id,
  839. task_no,
  840. item,
  841. tenant_no,
  842. user_id=user_id,
  843. dept_id=dept_id,
  844. )
  845. def _task_consumed_qty_map(
  846. db: Session,
  847. tenant_no: str,
  848. parent_usage_ids: List[str],
  849. ) -> Dict[str, float]:
  850. ids = [x for x in parent_usage_ids if x]
  851. if not ids:
  852. return {}
  853. rows = (
  854. db.query(
  855. BizQcMaterialUsage.parent_usage_id,
  856. func.coalesce(func.sum(func.abs(BizQcMaterialUsage.change_qty)), 0),
  857. )
  858. .filter(
  859. BizQcMaterialUsage.tenant_no == tenant_no,
  860. BizQcMaterialUsage.del_flag == 0,
  861. BizQcMaterialUsage.parent_usage_id.in_(ids),
  862. BizQcMaterialUsage.change_type == "use",
  863. BizQcMaterialUsage.source_type == "task_complete",
  864. )
  865. .group_by(BizQcMaterialUsage.parent_usage_id)
  866. .all()
  867. )
  868. return {pid: float(qty or 0) for pid, qty in rows if pid}
  869. def _fmt_date_val(v) -> str:
  870. if v is None:
  871. return ""
  872. if hasattr(v, "strftime"):
  873. return v.strftime("%Y-%m-%d")
  874. return str(v)[:10]
  875. def list_stock_check_requisitions(
  876. db: Session,
  877. tenant_no: str,
  878. *,
  879. page: int = 1,
  880. page_size: int = 50,
  881. keyword: Optional[str] = None,
  882. user_info=None,
  883. ) -> Tuple[List[Dict[str, Any]], int]:
  884. """盘点登记弹窗:待处理领用记录(未标记已使用完)。"""
  885. q0 = db.query(BizQcMaterialUsage).filter(
  886. BizQcMaterialUsage.tenant_no == tenant_no,
  887. BizQcMaterialUsage.del_flag == 0,
  888. BizQcMaterialUsage.change_type == "use",
  889. BizQcMaterialUsage.source_type == "manual_out",
  890. BizQcMaterialUsage.requisition_status == "open",
  891. )
  892. if user_info:
  893. from app.core.data_scope import apply_data_scope
  894. q0 = apply_data_scope(q0, BizQcMaterialUsage, user_info, db, tenant_no)
  895. kw = (keyword or "").strip()
  896. if kw:
  897. like = f"%{kw}%"
  898. q0 = q0.filter(
  899. or_(
  900. BizQcMaterialUsage.material_code.like(like),
  901. BizQcMaterialUsage.material_name.like(like),
  902. BizQcMaterialUsage.stock_in_no.like(like),
  903. BizQcMaterialUsage.batch_no.like(like),
  904. BizQcMaterialUsage.catalog_no.like(like),
  905. BizQcMaterialUsage.receiver.like(like),
  906. )
  907. )
  908. total = q0.order_by(None).with_entities(func.count(BizQcMaterialUsage.id)).scalar() or 0
  909. page = max(1, page)
  910. page_size = min(max(1, page_size), 200)
  911. rows = (
  912. q0.order_by(BizQcMaterialUsage.create_time.desc())
  913. .offset((page - 1) * page_size)
  914. .limit(page_size)
  915. .all()
  916. )
  917. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  918. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  919. receivers = []
  920. for r in rows:
  921. rcv, _ = _resolve_receiver_purpose(r)
  922. if rcv:
  923. receivers.append(rcv)
  924. receiver_map = _receiver_display_map(db, receivers)
  925. consumed_map = _task_consumed_qty_map(db, tenant_no, [x.id for x in rows])
  926. out: List[Dict[str, Any]] = []
  927. for r in rows:
  928. batch = bmap.get(r.batch_id or "")
  929. rcv, purpose = _resolve_receiver_purpose(r)
  930. rcv_name = receiver_map.get(rcv, rcv) if rcv else ""
  931. qty_abs = abs(float(r.change_qty or 0))
  932. unit = _usage_unit_label(r, batch)
  933. use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  934. task_consumed = consumed_map.get(r.id, 0)
  935. out.append(
  936. {
  937. "id": r.id,
  938. "materialId": r.material_id,
  939. "materialCode": r.material_code or "",
  940. "materialName": r.material_name or "",
  941. "specModel": (batch.spec_model if batch else "") or "",
  942. "batchId": r.batch_id or "",
  943. "stockInNo": r.stock_in_no or "",
  944. "stockInDate": _stock_in_date_for_row(r, batch),
  945. "expireDate": _fmt_date_val(batch.expire_date if batch else None),
  946. "catalogNo": r.catalog_no or "",
  947. "batchNo": r.batch_no or "",
  948. "manufacturer": r.manufacturer or "",
  949. "unitPrice": _unit_price_for_row(r, batch),
  950. "unit": unit,
  951. "requisitionQty": qty_abs,
  952. "taskConsumedQty": task_consumed,
  953. "receiver": rcv,
  954. "receiverName": rcv_name,
  955. "purpose": purpose,
  956. "useTime": use_time,
  957. "requisitionStatus": (r.requisition_status or "open").strip() or "open",
  958. "createByName": name_map.get(r.create_by or "", r.create_by or ""),
  959. }
  960. )
  961. return out, int(total)
  962. def mark_requisition_fully_used(
  963. db: Session,
  964. usage_id: str,
  965. *,
  966. tenant_no: str,
  967. user_id: Optional[str] = None,
  968. user_info=None,
  969. ) -> None:
  970. row = (
  971. db.query(BizQcMaterialUsage)
  972. .filter(
  973. BizQcMaterialUsage.id == usage_id,
  974. BizQcMaterialUsage.tenant_no == tenant_no,
  975. BizQcMaterialUsage.del_flag == 0,
  976. BizQcMaterialUsage.change_type == "use",
  977. BizQcMaterialUsage.source_type == "manual_out",
  978. )
  979. .first()
  980. )
  981. if not row:
  982. raise ValueError("领用记录不存在或不可标记")
  983. if user_info:
  984. from app.core.data_scope import apply_data_scope
  985. scoped = apply_data_scope(
  986. db.query(BizQcMaterialUsage).filter(BizQcMaterialUsage.id == usage_id),
  987. BizQcMaterialUsage,
  988. user_info,
  989. db,
  990. tenant_no,
  991. )
  992. if not scoped.first():
  993. raise PermissionError("无权操作该领用记录")
  994. if (row.requisition_status or "open") == "closed":
  995. return
  996. now = datetime.now()
  997. row.requisition_status = "closed"
  998. row.update_by = user_id
  999. row.update_time = now
  1000. db.add(row)
  1001. db.commit()