material_stock_check.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  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.crud.material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
  10. from app.models.models import SysUser
  11. from app.models.material import (
  12. BizMaterialStockBatch,
  13. BizMaterialStockCheck,
  14. BizMaterialStockCheckBatchLine,
  15. BizMaterialStockCheckUnusedLine,
  16. )
  17. def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  18. ids = list({x for x in user_ids if x})
  19. if not ids:
  20. return {}
  21. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  22. return {u.id: (u.nick_name or "").strip() for u in rows}
  23. def _status_label(status: str) -> str:
  24. if status == "completed":
  25. return "已完成"
  26. if status == "cancelled":
  27. return "已作废"
  28. return "进行中"
  29. def _batch_line_to_dict(row: BizMaterialStockCheckBatchLine) -> Dict[str, Any]:
  30. return {
  31. "id": row.id,
  32. "checkId": row.check_id,
  33. "batchId": row.batch_id,
  34. "materialId": row.material_id,
  35. "materialCode": row.material_code or "",
  36. "materialName": row.material_name or "",
  37. "specModel": row.spec_model or "",
  38. "unit": row.unit or "",
  39. "stockInNo": row.stock_in_no or "",
  40. "stockInDate": _fmt_date(row.stock_in_date),
  41. "expireDate": _fmt_date(row.expire_date),
  42. "catalogNo": row.catalog_no or "",
  43. "batchNo": row.batch_no or "",
  44. "manufacturer": row.manufacturer or "",
  45. "unitPrice": float(row.unit_price) if row.unit_price is not None else 0,
  46. "amount": float(row.amount) if row.amount is not None else 0,
  47. "remainingQty": _dec(row.remaining_qty),
  48. "remainingAmount": _unused_amount(row.unit_price, row.remaining_qty),
  49. "remark": row.remark or "",
  50. }
  51. def _unused_amount(unit_price: Any, qty: Any) -> float:
  52. if unit_price is None:
  53. return 0
  54. return float((Decimal(str(unit_price)) * Decimal(str(qty))).quantize(Decimal("0.01")))
  55. def _unused_line_to_dict(row: BizMaterialStockCheckUnusedLine) -> Dict[str, Any]:
  56. unit_price = float(row.unit_price) if row.unit_price is not None else None
  57. unused_qty = _dec(row.unused_qty)
  58. return {
  59. "id": row.id,
  60. "checkId": row.check_id,
  61. "usageId": row.usage_id or "",
  62. "batchId": row.batch_id or "",
  63. "materialCode": row.material_code or "",
  64. "materialName": row.material_name or "",
  65. "specModel": row.spec_model or "",
  66. "unit": row.unit or "",
  67. "stockInNo": row.stock_in_no or "",
  68. "stockInDate": _fmt_date(row.stock_in_date),
  69. "expireDate": _fmt_date(row.expire_date),
  70. "catalogNo": row.catalog_no or "",
  71. "batchNo": row.batch_no or "",
  72. "manufacturer": row.manufacturer or "",
  73. "unitPrice": unit_price,
  74. "remainingAmount": _unused_amount(row.unit_price, row.unused_qty) if unit_price is not None else None,
  75. "holder": row.holder or "",
  76. "unusedQty": unused_qty,
  77. "remark": row.remark or "",
  78. }
  79. def _check_header_to_dict(
  80. row: BizMaterialStockCheck,
  81. *,
  82. create_by_name: str = "",
  83. batch_line_count: int = 0,
  84. unused_line_count: int = 0,
  85. ) -> Dict[str, Any]:
  86. return {
  87. "id": row.id,
  88. "title": row.title or "",
  89. "checkTime": _fmt_dt(row.check_time),
  90. "status": row.status or "draft",
  91. "statusLabel": _status_label(row.status or "draft"),
  92. "completeTime": _fmt_dt(row.complete_time) if row.complete_time else "",
  93. "remark": row.remark or "",
  94. "batchLineCount": batch_line_count,
  95. "unusedLineCount": unused_line_count,
  96. "createBy": row.create_by or "",
  97. "createByName": create_by_name or row.create_by or "",
  98. "createTime": _fmt_dt(row.create_time),
  99. "updateTime": _fmt_dt(row.update_time),
  100. }
  101. def _get_check(db: Session, check_id: str, tenant_no: str) -> Optional[BizMaterialStockCheck]:
  102. return (
  103. db.query(BizMaterialStockCheck)
  104. .filter(
  105. BizMaterialStockCheck.id == check_id,
  106. BizMaterialStockCheck.tenant_no == tenant_no,
  107. BizMaterialStockCheck.del_flag == 0,
  108. )
  109. .first()
  110. )
  111. def _line_counts(db: Session, check_ids: List[str]) -> Dict[str, Tuple[int, int]]:
  112. if not check_ids:
  113. return {}
  114. batch_rows = (
  115. db.query(
  116. BizMaterialStockCheckBatchLine.check_id,
  117. func.count(BizMaterialStockCheckBatchLine.id),
  118. )
  119. .filter(
  120. BizMaterialStockCheckBatchLine.check_id.in_(check_ids),
  121. BizMaterialStockCheckBatchLine.del_flag == 0,
  122. )
  123. .group_by(BizMaterialStockCheckBatchLine.check_id)
  124. .all()
  125. )
  126. unused_rows = (
  127. db.query(
  128. BizMaterialStockCheckUnusedLine.check_id,
  129. func.count(BizMaterialStockCheckUnusedLine.id),
  130. )
  131. .filter(
  132. BizMaterialStockCheckUnusedLine.check_id.in_(check_ids),
  133. BizMaterialStockCheckUnusedLine.del_flag == 0,
  134. )
  135. .group_by(BizMaterialStockCheckUnusedLine.check_id)
  136. .all()
  137. )
  138. batch_map = {cid: int(cnt) for cid, cnt in batch_rows}
  139. unused_map = {cid: int(cnt) for cid, cnt in unused_rows}
  140. out: Dict[str, Tuple[int, int]] = {}
  141. for cid in check_ids:
  142. out[cid] = (batch_map.get(cid, 0), unused_map.get(cid, 0))
  143. return out
  144. def list_checks(
  145. db: Session,
  146. tenant_no: str,
  147. *,
  148. page: int = 1,
  149. page_size: int = 10,
  150. keyword: Optional[str] = None,
  151. status: Optional[str] = None,
  152. start_time: Optional[str] = None,
  153. end_time: Optional[str] = None,
  154. user_info=None,
  155. ) -> Tuple[List[Dict[str, Any]], int]:
  156. def _filtered():
  157. q0 = db.query(BizMaterialStockCheck).filter(
  158. BizMaterialStockCheck.tenant_no == tenant_no,
  159. BizMaterialStockCheck.del_flag == 0,
  160. )
  161. kw = (keyword or "").strip()
  162. if kw:
  163. q0 = q0.filter(BizMaterialStockCheck.title.like(f"%{kw}%"))
  164. st = (status or "").strip()
  165. if st:
  166. q0 = q0.filter(BizMaterialStockCheck.status == st)
  167. if start_time:
  168. q0 = q0.filter(BizMaterialStockCheck.check_time >= start_time)
  169. if end_time:
  170. q0 = q0.filter(BizMaterialStockCheck.check_time <= end_time + " 23:59:59")
  171. if user_info:
  172. from app.core.data_scope import apply_data_scope
  173. q0 = apply_data_scope(q0, BizMaterialStockCheck, user_info, db, tenant_no)
  174. return q0
  175. total = _filtered().order_by(None).with_entities(func.count(BizMaterialStockCheck.id)).scalar() or 0
  176. page = max(1, page)
  177. page_size = min(max(1, page_size), 100)
  178. rows = (
  179. _filtered()
  180. .order_by(BizMaterialStockCheck.check_time.desc(), BizMaterialStockCheck.create_time.desc())
  181. .offset((page - 1) * page_size)
  182. .limit(page_size)
  183. .all()
  184. )
  185. ids = [r.id for r in rows]
  186. counts = _line_counts(db, ids)
  187. name_map = _user_nick_name_map(db, [r.create_by for r in rows if r.create_by])
  188. items = []
  189. for r in rows:
  190. bc, uc = counts.get(r.id, (0, 0))
  191. items.append(
  192. _check_header_to_dict(
  193. r,
  194. create_by_name=name_map.get(r.create_by or "", ""),
  195. batch_line_count=bc,
  196. unused_line_count=uc,
  197. )
  198. )
  199. return items, int(total)
  200. def get_check_detail(db: Session, check_id: str, tenant_no: str) -> Dict[str, Any]:
  201. row = _get_check(db, check_id, tenant_no)
  202. if not row:
  203. raise ValueError("盘点单不存在")
  204. bc, uc = _line_counts(db, [check_id]).get(check_id, (0, 0))
  205. name_map = _user_nick_name_map(db, [row.create_by] if row.create_by else [])
  206. batch_lines = (
  207. db.query(BizMaterialStockCheckBatchLine)
  208. .filter(
  209. BizMaterialStockCheckBatchLine.check_id == check_id,
  210. BizMaterialStockCheckBatchLine.del_flag == 0,
  211. )
  212. .order_by(BizMaterialStockCheckBatchLine.stock_in_no.asc())
  213. .all()
  214. )
  215. unused_lines = (
  216. db.query(BizMaterialStockCheckUnusedLine)
  217. .filter(
  218. BizMaterialStockCheckUnusedLine.check_id == check_id,
  219. BizMaterialStockCheckUnusedLine.del_flag == 0,
  220. )
  221. .order_by(BizMaterialStockCheckUnusedLine.create_time.asc())
  222. .all()
  223. )
  224. data = _check_header_to_dict(
  225. row,
  226. create_by_name=name_map.get(row.create_by or "", ""),
  227. batch_line_count=bc,
  228. unused_line_count=uc,
  229. )
  230. data["batchLines"] = [_batch_line_to_dict(x) for x in batch_lines]
  231. data["unusedLines"] = [_unused_line_to_dict(x) for x in unused_lines]
  232. return data
  233. def _parse_check_time(v: Any) -> datetime:
  234. if isinstance(v, datetime):
  235. return v
  236. s = str(v or "").strip()
  237. if not s:
  238. return datetime.now()
  239. for fmt, length in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
  240. try:
  241. return datetime.strptime(s[:length], fmt)
  242. except ValueError:
  243. continue
  244. raise ValueError("盘点时间格式无效")
  245. def create_check(
  246. db: Session,
  247. *,
  248. title: str,
  249. tenant_no: str,
  250. user_id: Optional[str] = None,
  251. dept_id: str = "",
  252. check_time: Optional[Any] = None,
  253. remark: Optional[str] = None,
  254. ) -> Dict[str, Any]:
  255. t = _require_str(title, "盘点标题")
  256. op_time = datetime.now()
  257. ck_time = _parse_check_time(check_time) if check_time else op_time
  258. check = BizMaterialStockCheck(
  259. id=generate_uuid(),
  260. title=t,
  261. check_time=ck_time,
  262. status="draft",
  263. tenant_no=tenant_no,
  264. remark=(remark or "").strip() or None,
  265. create_by=user_id,
  266. create_dept_id=dept_id or "",
  267. create_time=op_time,
  268. update_by=user_id,
  269. update_time=op_time,
  270. del_flag=0,
  271. )
  272. db.add(check)
  273. batches = (
  274. db.query(BizMaterialStockBatch)
  275. .filter(
  276. BizMaterialStockBatch.tenant_no == tenant_no,
  277. BizMaterialStockBatch.del_flag == 0,
  278. BizMaterialStockBatch.remaining_qty > 0,
  279. )
  280. .order_by(BizMaterialStockBatch.stock_in_date.desc(), BizMaterialStockBatch.create_time.desc())
  281. .all()
  282. )
  283. for b in batches:
  284. line = BizMaterialStockCheckBatchLine(
  285. id=generate_uuid(),
  286. check_id=check.id,
  287. batch_id=b.id,
  288. material_id=b.material_id,
  289. material_code=b.material_code,
  290. material_name=b.material_name,
  291. spec_model=b.spec_model,
  292. unit=b.unit,
  293. stock_in_no=b.stock_in_no,
  294. stock_in_date=b.stock_in_date,
  295. expire_date=b.expire_date,
  296. catalog_no=b.catalog_no,
  297. batch_no=b.batch_no,
  298. manufacturer=b.manufacturer,
  299. unit_price=b.unit_price,
  300. amount=b.amount,
  301. remaining_qty=b.remaining_qty,
  302. tenant_no=tenant_no,
  303. create_by=user_id,
  304. create_dept_id=dept_id or "",
  305. create_time=op_time,
  306. update_by=user_id,
  307. update_time=op_time,
  308. del_flag=0,
  309. )
  310. db.add(line)
  311. db.commit()
  312. db.refresh(check)
  313. return {
  314. "id": check.id,
  315. "batchLineCount": len(batches),
  316. }
  317. def _parse_optional_date(v: Any) -> Optional[Any]:
  318. if v is None or str(v).strip() == "":
  319. return None
  320. return _parse_date(v, label="日期")
  321. def _resolve_unused_item_fields(
  322. db: Session,
  323. it: Dict[str, Any],
  324. tenant_no: str,
  325. ) -> Dict[str, Any]:
  326. """根据 usage_id / batch_id 补全登记行快照字段。"""
  327. usage_id = (it.get("usage_id") or it.get("usageId") or "").strip()
  328. batch_id = (it.get("batch_id") or it.get("batchId") or "").strip()
  329. out = dict(it)
  330. if usage_id:
  331. from app.models.material import BizMaterialUsage, BizMaterialStockBatch
  332. usage = (
  333. db.query(BizMaterialUsage)
  334. .filter(
  335. BizMaterialUsage.id == usage_id,
  336. BizMaterialUsage.tenant_no == tenant_no,
  337. BizMaterialUsage.del_flag == 0,
  338. BizMaterialUsage.source_type == "manual_out",
  339. )
  340. .first()
  341. )
  342. if not usage:
  343. raise ValueError("关联领用记录不存在")
  344. batch = None
  345. if usage.batch_id:
  346. batch = (
  347. db.query(BizMaterialStockBatch)
  348. .filter(
  349. BizMaterialStockBatch.id == usage.batch_id,
  350. BizMaterialStockBatch.tenant_no == tenant_no,
  351. BizMaterialStockBatch.del_flag == 0,
  352. )
  353. .first()
  354. )
  355. out.setdefault("material_code", usage.material_code or "")
  356. out.setdefault("material_name", usage.material_name or "")
  357. out.setdefault("spec_model", (batch.spec_model if batch else "") or "")
  358. out.setdefault("unit", (batch.unit if batch else "") or usage.usage_unit or "")
  359. out.setdefault("stock_in_no", usage.stock_in_no or "")
  360. out.setdefault("stock_in_date", _fmt_date(batch.stock_in_date) if batch and batch.stock_in_date else "")
  361. out.setdefault("expire_date", _fmt_date(batch.expire_date) if batch and batch.expire_date else "")
  362. out.setdefault("catalog_no", usage.catalog_no or "")
  363. out.setdefault("batch_no", usage.batch_no or "")
  364. out.setdefault("manufacturer", usage.manufacturer or "")
  365. if out.get("unit_price") is None and out.get("unitPrice") is None:
  366. up = float(usage.unit_price) if usage.unit_price is not None else None
  367. if up is not None:
  368. out["unit_price"] = up
  369. rcv = (usage.receiver or "").strip()
  370. if rcv and not (out.get("holder") or "").strip():
  371. out["holder"] = rcv
  372. if batch and not batch_id:
  373. out["batch_id"] = batch.id
  374. return out
  375. if batch_id:
  376. from app.models.material import BizMaterialStockBatch
  377. batch = (
  378. db.query(BizMaterialStockBatch)
  379. .filter(
  380. BizMaterialStockBatch.id == batch_id,
  381. BizMaterialStockBatch.tenant_no == tenant_no,
  382. BizMaterialStockBatch.del_flag == 0,
  383. )
  384. .first()
  385. )
  386. if not batch:
  387. raise ValueError("关联入库批次不存在")
  388. out.setdefault("material_code", batch.material_code or "")
  389. out.setdefault("material_name", batch.material_name or "")
  390. out.setdefault("spec_model", batch.spec_model or "")
  391. out.setdefault("unit", batch.unit or "")
  392. out.setdefault("stock_in_no", batch.stock_in_no or "")
  393. out.setdefault("stock_in_date", _fmt_date(batch.stock_in_date) if batch.stock_in_date else "")
  394. out.setdefault("expire_date", _fmt_date(batch.expire_date) if batch.expire_date else "")
  395. out.setdefault("catalog_no", batch.catalog_no or "")
  396. out.setdefault("batch_no", batch.batch_no or "")
  397. out.setdefault("manufacturer", batch.manufacturer or "")
  398. if out.get("unit_price") is None and out.get("unitPrice") is None:
  399. out["unit_price"] = float(batch.unit_price) if batch.unit_price is not None else None
  400. return out
  401. return out
  402. def save_unused_lines(
  403. db: Session,
  404. check_id: str,
  405. *,
  406. items: List[Dict[str, Any]],
  407. tenant_no: str,
  408. user_id: Optional[str] = None,
  409. dept_id: str = "",
  410. ) -> int:
  411. check = _get_check(db, check_id, tenant_no)
  412. if not check:
  413. raise ValueError("盘点单不存在")
  414. if (check.status or "") != "draft":
  415. raise ValueError("仅进行中的盘点单可登记未用完物料")
  416. now = datetime.now()
  417. old_rows = (
  418. db.query(BizMaterialStockCheckUnusedLine)
  419. .filter(
  420. BizMaterialStockCheckUnusedLine.check_id == check_id,
  421. BizMaterialStockCheckUnusedLine.del_flag == 0,
  422. )
  423. .all()
  424. )
  425. for r in old_rows:
  426. r.del_flag = 1
  427. r.update_by = user_id
  428. r.update_time = now
  429. db.add(r)
  430. count = 0
  431. for it in items or []:
  432. it = _resolve_unused_item_fields(db, it, tenant_no)
  433. name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
  434. qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
  435. if qty is None or Decimal(str(qty)) <= 0:
  436. raise ValueError("未用完数量必须大于0")
  437. qty_dec = Decimal(str(qty))
  438. stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
  439. expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
  440. usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
  441. batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
  442. row = BizMaterialStockCheckUnusedLine(
  443. id=generate_uuid(),
  444. check_id=check_id,
  445. usage_id=usage_id,
  446. batch_id=batch_id,
  447. material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
  448. material_name=name,
  449. spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
  450. unit=(it.get("unit") or "").strip() or None,
  451. stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
  452. stock_in_date=stock_in_date,
  453. expire_date=expire_date,
  454. catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
  455. batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
  456. manufacturer=(it.get("manufacturer") or "").strip() or None,
  457. unit_price=(
  458. Decimal(str(it.get("unit_price") or it.get("unitPrice")))
  459. if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
  460. else None
  461. ),
  462. holder=(it.get("holder") or "").strip() or None,
  463. unused_qty=qty_dec,
  464. tenant_no=tenant_no,
  465. remark=(it.get("remark") or "").strip() or None,
  466. create_by=user_id,
  467. create_dept_id=dept_id or "",
  468. create_time=now,
  469. update_by=user_id,
  470. update_time=now,
  471. del_flag=0,
  472. )
  473. db.add(row)
  474. count += 1
  475. check.update_by = user_id
  476. check.update_time = now
  477. db.add(check)
  478. db.commit()
  479. return count
  480. def complete_check(
  481. db: Session,
  482. check_id: str,
  483. *,
  484. tenant_no: str,
  485. user_id: Optional[str] = None,
  486. ) -> None:
  487. check = _get_check(db, check_id, tenant_no)
  488. if not check:
  489. raise ValueError("盘点单不存在")
  490. if (check.status or "") != "draft":
  491. raise ValueError("仅进行中的盘点单可完成")
  492. now = datetime.now()
  493. check.status = "completed"
  494. check.complete_time = now
  495. check.update_by = user_id
  496. check.update_time = now
  497. db.add(check)
  498. db.commit()
  499. def cancel_check(
  500. db: Session,
  501. check_id: str,
  502. *,
  503. tenant_no: str,
  504. user_id: Optional[str] = None,
  505. ) -> None:
  506. check = _get_check(db, check_id, tenant_no)
  507. if not check:
  508. raise ValueError("盘点单不存在")
  509. if (check.status or "") != "draft":
  510. raise ValueError("仅进行中的盘点单可作废")
  511. now = datetime.now()
  512. check.status = "cancelled"
  513. check.update_by = user_id
  514. check.update_time = now
  515. db.add(check)
  516. db.commit()