material.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  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 sqlalchemy import func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.models.material import BizMaterial, BizMaterialStockBatch, BizMaterialUsage
  10. def _dec(v: Any) -> float:
  11. if v is None:
  12. return 0.0
  13. return float(v)
  14. def _fmt_dt(t: Optional[datetime]) -> str:
  15. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  16. def _norm_status(v: Any) -> str:
  17. if v is None:
  18. return "1"
  19. if isinstance(v, bool):
  20. return "1" if v else "0"
  21. if isinstance(v, (int, float)):
  22. if int(v) == 0:
  23. return "0"
  24. if int(v) == 1:
  25. return "1"
  26. s = str(v).strip()
  27. if s in ("0", "1"):
  28. return s
  29. return "1"
  30. def _is_enabled_status(v: Any) -> bool:
  31. return _norm_status(v) == "1"
  32. def _row_to_dict(r: BizMaterial) -> Dict[str, Any]:
  33. return {
  34. "id": r.id,
  35. "materialCode": r.material_code,
  36. "materialName": r.material_name,
  37. "category": r.category or "",
  38. "specModel": r.spec_model or "",
  39. "unit": r.unit or "",
  40. "stockQty": _dec(r.stock_qty),
  41. "warningQty": _dec(r.warning_qty),
  42. "status": _norm_status(r.status),
  43. "imageUrls": r.image_urls or "",
  44. "orderNum": r.order_num if r.order_num is not None else 0,
  45. "remark": r.remark or "",
  46. "createTime": _fmt_dt(r.create_time),
  47. "updateTime": _fmt_dt(r.update_time),
  48. }
  49. def _code_exists(
  50. db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
  51. ) -> bool:
  52. c = (code or "").strip()
  53. if not c:
  54. return False
  55. q = db.query(BizMaterial).filter(
  56. BizMaterial.material_code == c,
  57. BizMaterial.tenant_no == tenant_no,
  58. BizMaterial.del_flag == 0,
  59. )
  60. if exclude_id:
  61. q = q.filter(BizMaterial.id != exclude_id)
  62. return q.first() is not None
  63. def list_materials(
  64. db: Session,
  65. tenant_no: str,
  66. *,
  67. page: int = 1,
  68. page_size: int = 10,
  69. keyword: Optional[str] = None,
  70. category: Optional[str] = None,
  71. status: Optional[str] = None,
  72. user_info=None,
  73. ) -> Tuple[List[Dict[str, Any]], int]:
  74. def _filtered():
  75. q0 = db.query(BizMaterial).filter(
  76. BizMaterial.tenant_no == tenant_no,
  77. BizMaterial.del_flag == 0,
  78. )
  79. kw = (keyword or "").strip()
  80. if kw:
  81. like = f"%{kw}%"
  82. q0 = q0.filter(
  83. or_(
  84. BizMaterial.material_code.ilike(like),
  85. BizMaterial.material_name.ilike(like),
  86. )
  87. )
  88. cat = (category or "").strip()
  89. if cat:
  90. q0 = q0.filter(BizMaterial.category == cat)
  91. st = (status or "").strip()
  92. if st in ("0", "1"):
  93. q0 = q0.filter(BizMaterial.status == st)
  94. if user_info:
  95. from app.core.data_scope import apply_data_scope
  96. q0 = apply_data_scope(q0, BizMaterial, user_info, db, tenant_no)
  97. return q0
  98. total = _filtered().order_by(None).with_entities(func.count(BizMaterial.id)).scalar() or 0
  99. page = max(1, page)
  100. page_size = min(max(1, page_size), 100)
  101. rows = (
  102. _filtered()
  103. .order_by(BizMaterial.create_time.desc())
  104. .offset((page - 1) * page_size)
  105. .limit(page_size)
  106. .all()
  107. )
  108. return [_row_to_dict(x) for x in rows], int(total)
  109. def get_material(
  110. db: Session, material_id: str, tenant_no: str
  111. ) -> Optional[Dict[str, Any]]:
  112. obj = (
  113. db.query(BizMaterial)
  114. .filter(
  115. BizMaterial.id == material_id,
  116. BizMaterial.tenant_no == tenant_no,
  117. BizMaterial.del_flag == 0,
  118. )
  119. .first()
  120. )
  121. if not obj:
  122. return None
  123. return _row_to_dict(obj)
  124. def create_material(
  125. db: Session,
  126. data: Dict[str, Any],
  127. *,
  128. tenant_no: str,
  129. user_id: Optional[str] = None,
  130. dept_id: str = "",
  131. ) -> Dict[str, Any]:
  132. code = (data.get("materialCode") or "").strip()
  133. if not code:
  134. raise ValueError("物料编码不能为空")
  135. if _code_exists(db, code, tenant_no):
  136. raise ValueError("物料编码已存在")
  137. name = (data.get("materialName") or "").strip()
  138. if not name:
  139. raise ValueError("物料名称不能为空")
  140. status = (data.get("status") or "1").strip()
  141. if status not in ("0", "1"):
  142. raise ValueError("状态无效")
  143. obj = BizMaterial(
  144. id=generate_uuid(),
  145. material_code=code,
  146. material_name=name,
  147. category=(data.get("category") or "").strip() or None,
  148. spec_model=(data.get("specModel") or "").strip() or None,
  149. unit=(data.get("unit") or "").strip() or None,
  150. stock_qty=0,
  151. warning_qty=Decimal(str(data.get("warningQty") or 0)),
  152. status=status,
  153. image_urls=(data.get("imageUrls") or "").strip() or None,
  154. tenant_no=tenant_no,
  155. order_num=data.get("orderNum") if data.get("orderNum") is not None else 0,
  156. remark=(data.get("remark") or "").strip() or None,
  157. create_by=user_id,
  158. create_dept_id=dept_id or "",
  159. create_time=datetime.now(),
  160. update_by=user_id,
  161. update_time=datetime.now(),
  162. del_flag=0,
  163. )
  164. db.add(obj)
  165. db.commit()
  166. db.refresh(obj)
  167. return _row_to_dict(obj)
  168. def update_material(
  169. db: Session,
  170. material_id: str,
  171. data: Dict[str, Any],
  172. *,
  173. tenant_no: str,
  174. user_id: Optional[str] = None,
  175. ) -> Dict[str, Any]:
  176. obj = (
  177. db.query(BizMaterial)
  178. .filter(
  179. BizMaterial.id == material_id,
  180. BizMaterial.tenant_no == tenant_no,
  181. BizMaterial.del_flag == 0,
  182. )
  183. .first()
  184. )
  185. if not obj:
  186. raise ValueError("物料不存在")
  187. if "materialCode" in data and data["materialCode"] is not None:
  188. code = data["materialCode"].strip()
  189. if not code:
  190. raise ValueError("物料编码不能为空")
  191. if code != obj.material_code and _code_exists(db, code, tenant_no, exclude_id=material_id):
  192. raise ValueError("物料编码已存在")
  193. obj.material_code = code
  194. if "materialName" in data and data["materialName"] is not None:
  195. name = data["materialName"].strip()
  196. if not name:
  197. raise ValueError("物料名称不能为空")
  198. obj.material_name = name
  199. if "category" in data:
  200. obj.category = (data["category"] or "").strip() or None
  201. if "specModel" in data:
  202. obj.spec_model = (data["specModel"] or "").strip() or None
  203. if "unit" in data:
  204. obj.unit = (data["unit"] or "").strip() or None
  205. if "warningQty" in data and data["warningQty"] is not None:
  206. obj.warning_qty = Decimal(str(data["warningQty"]))
  207. if "status" in data and data["status"] is not None:
  208. st = data["status"].strip()
  209. if st not in ("0", "1"):
  210. raise ValueError("状态无效")
  211. obj.status = st
  212. if "imageUrls" in data:
  213. obj.image_urls = (data["imageUrls"] or "").strip() or None
  214. if "orderNum" in data:
  215. obj.order_num = data["orderNum"]
  216. if "remark" in data:
  217. obj.remark = (data["remark"] or "").strip() or None
  218. obj.update_by = user_id
  219. obj.update_time = datetime.now()
  220. db.add(obj)
  221. db.commit()
  222. db.refresh(obj)
  223. return _row_to_dict(obj)
  224. def soft_delete_material(
  225. db: Session,
  226. material_id: str,
  227. tenant_no: str,
  228. user_id: Optional[str] = None,
  229. ) -> None:
  230. obj = (
  231. db.query(BizMaterial)
  232. .filter(
  233. BizMaterial.id == material_id,
  234. BizMaterial.tenant_no == tenant_no,
  235. BizMaterial.del_flag == 0,
  236. )
  237. .first()
  238. )
  239. if not obj:
  240. raise ValueError("物料不存在")
  241. if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
  242. raise ValueError("该物料仍有库存,无法删除")
  243. obj.del_flag = 1
  244. obj.update_by = user_id
  245. obj.update_time = datetime.now()
  246. db.add(obj)
  247. db.commit()
  248. def get_material_entity(
  249. db: Session, material_id: str, tenant_no: str
  250. ) -> Optional[BizMaterial]:
  251. return (
  252. db.query(BizMaterial)
  253. .filter(
  254. BizMaterial.id == material_id,
  255. BizMaterial.tenant_no == tenant_no,
  256. BizMaterial.del_flag == 0,
  257. )
  258. .first()
  259. )
  260. def update_material_fields(
  261. db: Session,
  262. material_id: str,
  263. *,
  264. tenant_no: str,
  265. user_id: Optional[str] = None,
  266. material_code: Optional[str] = None,
  267. material_name: Optional[str] = None,
  268. category: Optional[str] = None,
  269. spec_model: Optional[str] = None,
  270. unit: Optional[str] = None,
  271. warning_qty: Optional[Decimal] = None,
  272. status: Optional[str] = None,
  273. order_num: Optional[int] = None,
  274. remark: Optional[str] = None,
  275. ) -> BizMaterial:
  276. obj = get_material_entity(db, material_id, tenant_no)
  277. if not obj:
  278. raise ValueError("物料不存在")
  279. if material_code is not None:
  280. code = material_code.strip()
  281. if not code:
  282. raise ValueError("物料编码不能为空")
  283. if code != obj.material_code and _code_exists(db, code, tenant_no, exclude_id=material_id):
  284. raise ValueError("物料编码已存在")
  285. obj.material_code = code
  286. if material_name is not None:
  287. name = (material_name or "").strip()
  288. if not name:
  289. raise ValueError("物料名称不能为空")
  290. obj.material_name = name
  291. if category is not None:
  292. obj.category = (category or "").strip() or None
  293. if spec_model is not None:
  294. obj.spec_model = (spec_model or "").strip() or None
  295. if unit is not None:
  296. obj.unit = (unit or "").strip() or None
  297. if warning_qty is not None:
  298. obj.warning_qty = warning_qty
  299. if status is not None:
  300. obj.status = _norm_status(status)
  301. if order_num is not None:
  302. obj.order_num = order_num
  303. if remark is not None:
  304. obj.remark = (remark or "").strip() or None
  305. obj.update_by = user_id
  306. obj.update_time = datetime.now()
  307. db.add(obj)
  308. db.commit()
  309. db.refresh(obj)
  310. return obj
  311. def options_list(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
  312. rows = (
  313. db.query(BizMaterial)
  314. .filter(
  315. BizMaterial.tenant_no == tenant_no,
  316. BizMaterial.del_flag == 0,
  317. BizMaterial.status == "1",
  318. )
  319. .order_by(BizMaterial.create_time.desc())
  320. .all()
  321. )
  322. return [
  323. {
  324. "id": r.id,
  325. "label": r.material_name,
  326. "materialCode": r.material_code,
  327. "materialName": r.material_name,
  328. "specModel": r.spec_model or "",
  329. "unit": r.unit or "",
  330. "stockQty": _dec(r.stock_qty),
  331. }
  332. for r in rows
  333. ]
  334. def _my_usage_row_to_dict(
  335. r: BizMaterialUsage,
  336. *,
  337. create_by_name: str = "",
  338. batch: Optional[BizMaterialStockBatch] = None,
  339. receiver_name: str = "",
  340. ) -> Dict[str, Any]:
  341. """将 BizMaterialUsage 行转换为字典(用于我的领用记录)。"""
  342. create_by = r.create_by or ""
  343. receiver = (getattr(r, "receiver", None) or "").strip()
  344. purpose = (getattr(r, "purpose", None) or "").strip()
  345. create_time = r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else ""
  346. stock_after = float(r.stock_after) if r.stock_after is not None else 0
  347. change_qty = float(r.change_qty) if r.change_qty is not None else 0
  348. stock_before = (
  349. float(r.stock_before)
  350. if r.stock_before is not None
  351. else stock_after - change_qty
  352. )
  353. unit = (getattr(r, "usage_unit", None) or "").strip()
  354. if not unit and batch:
  355. unit = (batch.unit or "").strip()
  356. return {
  357. "id": r.id,
  358. "materialId": r.material_id,
  359. "materialCode": r.material_code or "",
  360. "materialName": r.material_name or "",
  361. "batchId": r.batch_id or "",
  362. "stockInNo": r.stock_in_no or "",
  363. "catalogNo": r.catalog_no or "",
  364. "batchNo": r.batch_no or "",
  365. "manufacturer": r.manufacturer or "",
  366. "unitPrice": float(r.unit_price) if r.unit_price is not None else None,
  367. "amount": float(r.amount) if r.amount is not None else None,
  368. "changeType": r.change_type,
  369. "changeQty": change_qty,
  370. "usageUnit": unit,
  371. "stockBefore": stock_before,
  372. "stockAfter": stock_after,
  373. "receiver": receiver,
  374. "receiverName": receiver_name or receiver,
  375. "purpose": purpose,
  376. "sourceType": r.source_type or "",
  377. "requisitionStatus": (getattr(r, "requisition_status", None) or "open").strip() or "open",
  378. "useTime": create_time,
  379. "createBy": create_by,
  380. "createByName": create_by_name or create_by,
  381. "createTime": create_time,
  382. "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
  383. }
  384. def _my_usage_batch_map(db: Session, batch_ids: List[str]) -> Dict[str, BizMaterialStockBatch]:
  385. ids = [x for x in batch_ids if x]
  386. if not ids:
  387. return {}
  388. rows = (
  389. db.query(BizMaterialStockBatch)
  390. .filter(BizMaterialStockBatch.id.in_(ids), BizMaterialStockBatch.del_flag == 0)
  391. .all()
  392. )
  393. return {r.id: r for r in rows}
  394. def _my_usage_user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  395. ids = list({x for x in user_ids if x})
  396. if not ids:
  397. return {}
  398. from app.models.models import SysUser
  399. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  400. return {u.id: (u.nick_name or "").strip() for u in rows}
  401. def _mine_usage_receiver_filter(user_id: str, nick_name: str):
  402. """当前登录人的手动出库领用记录:领用人字段存昵称或用户 ID(与生产/质检一致)。"""
  403. uid = (user_id or "").strip()
  404. nn = (nick_name or "").strip()
  405. receiver_match: List[Any] = []
  406. if nn:
  407. receiver_match.append(BizMaterialUsage.receiver == nn)
  408. receiver_match.append(BizMaterialUsage.remark.like(f"%领用人:{nn}%"))
  409. if uid:
  410. receiver_match.append(BizMaterialUsage.receiver == uid)
  411. if receiver_match:
  412. return or_(*receiver_match)
  413. return BizMaterialUsage.id == ""
  414. def get_my_usages(
  415. db: Session,
  416. current_user_id: str,
  417. tenant_no: str,
  418. *,
  419. current_nick_name: str = "",
  420. page: int = 1,
  421. page_size: int = 10,
  422. keyword: Optional[str] = None,
  423. material_id: Optional[str] = None,
  424. material_type: Optional[str] = None,
  425. change_type: Optional[str] = None,
  426. start_time: Optional[str] = None,
  427. end_time: Optional[str] = None,
  428. ) -> Tuple[List[Dict[str, Any]], int]:
  429. """
  430. 获取当前用户的物料领用记录(手动出库记录)
  431. 筛选条件:
  432. - tenant_no = 当前用户租户
  433. - source_type = 'manual_out'(手动出库)
  434. - receiver 匹配当前用户昵称或用户 ID
  435. - del_flag = 0
  436. 按 create_time DESC 排序
  437. """
  438. q0 = db.query(BizMaterialUsage).filter(
  439. BizMaterialUsage.tenant_no == tenant_no,
  440. BizMaterialUsage.del_flag == 0,
  441. BizMaterialUsage.source_type == "manual_out",
  442. _mine_usage_receiver_filter(current_user_id, current_nick_name),
  443. )
  444. if material_id:
  445. q0 = q0.filter(BizMaterialUsage.material_id == material_id)
  446. kw = (keyword or "").strip()
  447. if kw:
  448. like = f"%{kw}%"
  449. q0 = q0.filter(
  450. or_(
  451. BizMaterialUsage.material_code.like(like),
  452. BizMaterialUsage.material_name.like(like),
  453. BizMaterialUsage.stock_in_no.like(like),
  454. BizMaterialUsage.batch_no.like(like),
  455. BizMaterialUsage.catalog_no.like(like),
  456. BizMaterialUsage.purpose.like(like),
  457. )
  458. )
  459. if start_time:
  460. q0 = q0.filter(BizMaterialUsage.create_time >= start_time)
  461. if end_time:
  462. q0 = q0.filter(BizMaterialUsage.create_time <= end_time + " 23:59:59")
  463. total = q0.order_by(None).with_entities(func.count(BizMaterialUsage.id)).scalar() or 0
  464. page = max(1, page)
  465. page_size = min(max(1, page_size), 100)
  466. rows = (
  467. q0.order_by(BizMaterialUsage.create_time.desc())
  468. .offset((page - 1) * page_size)
  469. .limit(page_size)
  470. .all()
  471. )
  472. bmap = _my_usage_batch_map(db, [x.batch_id for x in rows if x.batch_id])
  473. name_map = _my_usage_user_nick_name_map(db, [x.create_by for x in rows if x.create_by])
  474. return [
  475. _my_usage_row_to_dict(
  476. x,
  477. create_by_name=name_map.get(x.create_by or "", ""),
  478. batch=bmap.get(x.batch_id or ""),
  479. )
  480. for x in rows
  481. ], int(total)