material_usage.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. """市场物料变动记录 CRUD"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from typing import Any, Dict, List, Optional, Tuple
  5. from sqlalchemy import func, or_
  6. from sqlalchemy.orm import Session
  7. from app.models.material import BizMaterialStockBatch, BizMaterialUsage
  8. from app.models.models import SysUser
  9. def _parse_receiver_purpose_from_remark(remark: Optional[str]) -> Tuple[str, str]:
  10. receiver, purpose = "", ""
  11. for part in (remark or "").split(";"):
  12. seg = part.strip()
  13. if seg.startswith("领用人:"):
  14. receiver = seg[len("领用人:") :].strip()
  15. elif seg.startswith("用途:"):
  16. purpose = seg[len("用途:") :].strip()
  17. return receiver, purpose
  18. def _resolve_receiver_purpose(r: BizMaterialUsage) -> Tuple[str, str]:
  19. receiver = (getattr(r, "receiver", None) or "").strip()
  20. purpose = (getattr(r, "purpose", None) or "").strip()
  21. if receiver or purpose:
  22. return receiver, purpose
  23. if (r.source_type or "") == "manual_out":
  24. return _parse_receiver_purpose_from_remark(r.remark)
  25. return "", ""
  26. def _display_remark(r: BizMaterialUsage) -> str:
  27. remark = (r.remark or "").strip()
  28. if (r.source_type or "") != "manual_out" or not remark:
  29. return remark
  30. parts: List[str] = []
  31. for part in remark.split(";"):
  32. seg = part.strip()
  33. if not seg or seg.startswith("领用人:") or seg.startswith("用途:"):
  34. continue
  35. if seg.startswith("备注"):
  36. seg = seg[2:].strip() or seg
  37. parts.append(seg)
  38. return ";".join(parts) if parts else ""
  39. def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  40. ids = list({x for x in user_ids if x})
  41. if not ids:
  42. return {}
  43. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  44. return {u.id: (u.nick_name or "").strip() for u in rows}
  45. def _receiver_display_map(db: Session, receivers: List[str]) -> Dict[str, str]:
  46. keys = list({(x or "").strip() for x in receivers if (x or "").strip()})
  47. if not keys:
  48. return {}
  49. by_id = _user_nick_name_map(db, keys)
  50. out: Dict[str, str] = {}
  51. for k in keys:
  52. out[k] = by_id.get(k) or k
  53. return out
  54. def _usage_unit_label(r: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> str:
  55. u = (getattr(r, "usage_unit", None) or "").strip()
  56. if u:
  57. return u
  58. if batch and (batch.unit or "").strip():
  59. return (batch.unit or "").strip()
  60. return ""
  61. def _stock_in_date_for_row(r: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> str:
  62. if batch and batch.stock_in_date:
  63. return batch.stock_in_date.strftime("%Y-%m-%d")
  64. return ""
  65. def _unit_price_for_row(r: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> Optional[float]:
  66. if r.unit_price is not None:
  67. return float(r.unit_price)
  68. if batch and batch.unit_price is not None:
  69. return float(batch.unit_price)
  70. return None
  71. def _amount_for_row(r: BizMaterialUsage, batch: Optional[BizMaterialStockBatch]) -> Optional[float]:
  72. is_in = (r.change_type or "") == "in"
  73. if is_in:
  74. if r.amount is not None:
  75. return float(r.amount)
  76. if batch and batch.amount is not None:
  77. return float(batch.amount)
  78. return None
  79. unit_price = _unit_price_for_row(r, batch)
  80. change_qty = float(r.change_qty) if r.change_qty is not None else 0
  81. qty = abs(change_qty)
  82. if unit_price is None or qty <= 0:
  83. return None
  84. return round(unit_price * qty, 2)
  85. def _row_to_dict(
  86. r: BizMaterialUsage,
  87. *,
  88. create_by_name: str = "",
  89. batch: Optional[BizMaterialStockBatch] = None,
  90. receiver_name: str = "",
  91. ) -> Dict[str, Any]:
  92. create_by = r.create_by or ""
  93. receiver, purpose = _resolve_receiver_purpose(r)
  94. create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  95. is_manual_out = (r.source_type or "") == "manual_out"
  96. stock_after = float(r.stock_after) if r.stock_after is not None else 0
  97. change_qty = float(r.change_qty) if r.change_qty is not None else 0
  98. stock_before = (
  99. float(r.stock_before)
  100. if r.stock_before is not None
  101. else stock_after - change_qty
  102. )
  103. usage_unit = _usage_unit_label(r, batch)
  104. return {
  105. "id": r.id,
  106. "materialId": r.material_id,
  107. "materialCode": r.material_code or "",
  108. "materialName": r.material_name or "",
  109. "batchId": r.batch_id or "",
  110. "stockInNo": r.stock_in_no or "",
  111. "stockInDate": _stock_in_date_for_row(r, batch),
  112. "catalogNo": r.catalog_no or "",
  113. "batchNo": r.batch_no or "",
  114. "manufacturer": r.manufacturer or "",
  115. "unitPrice": _unit_price_for_row(r, batch),
  116. "amount": _amount_for_row(r, batch),
  117. "changeType": r.change_type,
  118. "changeQty": change_qty,
  119. "usageUnit": usage_unit,
  120. "stockBefore": stock_before,
  121. "stockAfter": stock_after,
  122. "receiver": receiver,
  123. "receiverName": receiver_name or receiver,
  124. "operatorName": (getattr(r, "operator_name", None) or "").strip(),
  125. "purpose": purpose,
  126. "sourceType": r.source_type or "",
  127. "requisitionStatus": (getattr(r, "requisition_status", None) or "open").strip() or "open",
  128. "useTime": create_time if is_manual_out else "",
  129. "remark": _display_remark(r),
  130. "createBy": create_by,
  131. "createByName": create_by_name or create_by,
  132. "createTime": create_time,
  133. "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
  134. }
  135. def _batch_map(db: Session, batch_ids: List[str]) -> Dict[str, BizMaterialStockBatch]:
  136. ids = [x for x in batch_ids if x]
  137. if not ids:
  138. return {}
  139. rows = (
  140. db.query(BizMaterialStockBatch)
  141. .filter(BizMaterialStockBatch.id.in_(ids), BizMaterialStockBatch.del_flag == 0)
  142. .all()
  143. )
  144. return {r.id: r for r in rows}
  145. def _list_usages_query(
  146. db: Session,
  147. tenant_no: str,
  148. *,
  149. material_id: Optional[str] = None,
  150. change_type: Optional[str] = None,
  151. start_time: Optional[str] = None,
  152. end_time: Optional[str] = None,
  153. keyword: Optional[str] = None,
  154. stock_in_no: Optional[str] = None,
  155. user_info=None,
  156. ):
  157. q0 = db.query(BizMaterialUsage).filter(
  158. BizMaterialUsage.tenant_no == tenant_no,
  159. BizMaterialUsage.del_flag == 0,
  160. )
  161. if material_id:
  162. q0 = q0.filter(BizMaterialUsage.material_id == material_id)
  163. ct = (change_type or "").strip()
  164. if ct == "out":
  165. q0 = q0.filter(
  166. BizMaterialUsage.change_type == "use",
  167. BizMaterialUsage.source_type == "manual_out",
  168. )
  169. elif ct == "in":
  170. q0 = q0.filter(BizMaterialUsage.change_type == "in")
  171. elif ct == "discard":
  172. q0 = q0.filter(BizMaterialUsage.change_type == "discard")
  173. sno = (stock_in_no or "").strip()
  174. if sno:
  175. q0 = q0.filter(BizMaterialUsage.stock_in_no.like(f"%{sno}%"))
  176. kw = (keyword or "").strip()
  177. if kw:
  178. like = f"%{kw}%"
  179. q0 = q0.filter(
  180. or_(
  181. BizMaterialUsage.material_code.like(like),
  182. BizMaterialUsage.material_name.like(like),
  183. BizMaterialUsage.stock_in_no.like(like),
  184. BizMaterialUsage.batch_no.like(like),
  185. BizMaterialUsage.catalog_no.like(like),
  186. )
  187. )
  188. if start_time:
  189. q0 = q0.filter(BizMaterialUsage.create_time >= start_time)
  190. if end_time:
  191. q0 = q0.filter(BizMaterialUsage.create_time <= end_time + " 23:59:59")
  192. if user_info:
  193. from app.core.data_scope import apply_data_scope
  194. q0 = apply_data_scope(q0, BizMaterialUsage, user_info, db, tenant_no)
  195. return q0
  196. def _paginate_usages(db: Session, q0, *, page: int, page_size: int) -> Tuple[List[Dict[str, Any]], int]:
  197. total = q0.order_by(None).with_entities(func.count(BizMaterialUsage.id)).scalar() or 0
  198. page = max(1, page)
  199. page_size = min(max(1, page_size), 100)
  200. rows = (
  201. q0.order_by(BizMaterialUsage.create_time.desc())
  202. .offset((page - 1) * page_size)
  203. .limit(page_size)
  204. .all()
  205. )
  206. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  207. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  208. receivers = []
  209. for x in rows:
  210. rcv, _ = _resolve_receiver_purpose(x)
  211. if rcv:
  212. receivers.append(rcv)
  213. receiver_map = _receiver_display_map(db, receivers)
  214. return [
  215. _row_to_dict(
  216. x,
  217. create_by_name=name_map.get(x.create_by or "", ""),
  218. batch=bmap.get(x.batch_id or ""),
  219. receiver_name=receiver_map.get(
  220. (_resolve_receiver_purpose(x)[0] or ""), _resolve_receiver_purpose(x)[0] or ""
  221. ),
  222. )
  223. for x in rows
  224. ], int(total)
  225. def list_usages(
  226. db: Session,
  227. tenant_no: str,
  228. *,
  229. page: int = 1,
  230. page_size: int = 10,
  231. material_id: Optional[str] = None,
  232. change_type: Optional[str] = None,
  233. start_time: Optional[str] = None,
  234. end_time: Optional[str] = None,
  235. keyword: Optional[str] = None,
  236. stock_in_no: Optional[str] = None,
  237. user_info=None,
  238. ) -> Tuple[List[Dict[str, Any]], int]:
  239. q0 = _list_usages_query(
  240. db,
  241. tenant_no,
  242. material_id=material_id,
  243. change_type=change_type,
  244. start_time=start_time,
  245. end_time=end_time,
  246. keyword=keyword,
  247. stock_in_no=stock_in_no,
  248. user_info=user_info,
  249. )
  250. return _paginate_usages(db, q0, page=page, page_size=page_size)
  251. def _fmt_date_val(v) -> str:
  252. if v is None:
  253. return ""
  254. if hasattr(v, "strftime"):
  255. return v.strftime("%Y-%m-%d")
  256. return str(v)[:10]
  257. def list_stock_check_requisitions(
  258. db: Session,
  259. tenant_no: str,
  260. *,
  261. page: int = 1,
  262. page_size: int = 50,
  263. keyword: Optional[str] = None,
  264. user_info=None,
  265. ) -> Tuple[List[Dict[str, Any]], int]:
  266. """盘点登记弹窗:待处理领用记录(未标记已使用完)。"""
  267. q0 = db.query(BizMaterialUsage).filter(
  268. BizMaterialUsage.tenant_no == tenant_no,
  269. BizMaterialUsage.del_flag == 0,
  270. BizMaterialUsage.change_type == "use",
  271. BizMaterialUsage.source_type == "manual_out",
  272. BizMaterialUsage.requisition_status == "open",
  273. )
  274. if user_info:
  275. from app.core.data_scope import apply_data_scope
  276. q0 = apply_data_scope(q0, BizMaterialUsage, user_info, db, tenant_no)
  277. kw = (keyword or "").strip()
  278. if kw:
  279. like = f"%{kw}%"
  280. q0 = q0.filter(
  281. or_(
  282. BizMaterialUsage.material_code.like(like),
  283. BizMaterialUsage.material_name.like(like),
  284. BizMaterialUsage.stock_in_no.like(like),
  285. BizMaterialUsage.batch_no.like(like),
  286. BizMaterialUsage.catalog_no.like(like),
  287. BizMaterialUsage.receiver.like(like),
  288. )
  289. )
  290. total = q0.order_by(None).with_entities(func.count(BizMaterialUsage.id)).scalar() or 0
  291. page = max(1, page)
  292. page_size = min(max(1, page_size), 200)
  293. rows = (
  294. q0.order_by(BizMaterialUsage.create_time.desc())
  295. .offset((page - 1) * page_size)
  296. .limit(page_size)
  297. .all()
  298. )
  299. bmap = _batch_map(db, [x.batch_id for x in rows if x.batch_id])
  300. name_map = _user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  301. receivers = []
  302. for r in rows:
  303. rcv, _ = _resolve_receiver_purpose(r)
  304. if rcv:
  305. receivers.append(rcv)
  306. receiver_map = _receiver_display_map(db, receivers)
  307. out: List[Dict[str, Any]] = []
  308. for r in rows:
  309. batch = bmap.get(r.batch_id or "")
  310. rcv, purpose = _resolve_receiver_purpose(r)
  311. rcv_name = receiver_map.get(rcv, rcv) if rcv else ""
  312. qty_abs = abs(float(r.change_qty or 0))
  313. unit = _usage_unit_label(r, batch)
  314. use_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  315. out.append(
  316. {
  317. "id": r.id,
  318. "materialId": r.material_id,
  319. "materialCode": r.material_code or "",
  320. "materialName": r.material_name or "",
  321. "specModel": (batch.spec_model if batch else "") or "",
  322. "batchId": r.batch_id or "",
  323. "stockInNo": r.stock_in_no or "",
  324. "stockInDate": _stock_in_date_for_row(r, batch),
  325. "expireDate": _fmt_date_val(batch.expire_date if batch else None),
  326. "catalogNo": r.catalog_no or "",
  327. "batchNo": r.batch_no or "",
  328. "manufacturer": r.manufacturer or "",
  329. "unitPrice": _unit_price_for_row(r, batch),
  330. "unit": unit,
  331. "requisitionQty": qty_abs,
  332. "taskConsumedQty": 0,
  333. "receiver": rcv,
  334. "receiverName": rcv_name,
  335. "operatorName": (getattr(r, "operator_name", None) or "").strip(),
  336. "purpose": purpose,
  337. "useTime": use_time,
  338. "requisitionStatus": (r.requisition_status or "open").strip() or "open",
  339. "createByName": name_map.get(r.create_by or "", r.create_by or ""),
  340. }
  341. )
  342. return out, int(total)
  343. def mark_requisition_fully_used(
  344. db: Session,
  345. usage_id: str,
  346. *,
  347. tenant_no: str,
  348. user_id: Optional[str] = None,
  349. user_info=None,
  350. ) -> None:
  351. row = (
  352. db.query(BizMaterialUsage)
  353. .filter(
  354. BizMaterialUsage.id == usage_id,
  355. BizMaterialUsage.tenant_no == tenant_no,
  356. BizMaterialUsage.del_flag == 0,
  357. BizMaterialUsage.change_type == "use",
  358. BizMaterialUsage.source_type == "manual_out",
  359. )
  360. .first()
  361. )
  362. if not row:
  363. raise ValueError("领用记录不存在或不可标记")
  364. if user_info:
  365. from app.core.data_scope import apply_data_scope
  366. scoped = apply_data_scope(
  367. db.query(BizMaterialUsage).filter(BizMaterialUsage.id == usage_id),
  368. BizMaterialUsage,
  369. user_info,
  370. db,
  371. tenant_no,
  372. )
  373. if not scoped.first():
  374. raise PermissionError("无权操作该领用记录")
  375. if (row.requisition_status or "open") == "closed":
  376. return
  377. now = datetime.now()
  378. row.requisition_status = "closed"
  379. row.update_by = user_id
  380. row.update_time = now
  381. db.add(row)
  382. db.commit()