inventory.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. from typing import Optional
  2. import re
  3. from datetime import datetime
  4. from fastapi import APIRouter, Depends, HTTPException, Query, status
  5. from pydantic import BaseModel
  6. from sqlalchemy import func, or_
  7. from sqlalchemy.orm import Session
  8. from app.core.database import get_db
  9. from app.core.db_auth import DbUserContext, get_db_current_user, get_db_current_user_optional
  10. from app.core.tenant_context import get_tenant_no
  11. from app.services.db.inventory_id import next_inbound_outbound_id
  12. from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
  13. from app.schemas.db.inventory import (
  14. CreateLinkForPlanPayload,
  15. InboundOutboundCreate,
  16. InboundOutboundRead,
  17. InboundOutboundUpdate,
  18. )
  19. from app.schemas.db.public_inventory import InventorySiteUrlRead
  20. from app.schemas.db.plans import PurchasePlanRead
  21. from app.schemas.db.weighing import WeighingSlipRead
  22. from app.services.db.mappers import (
  23. EMPTY_INVENTORY_DATE,
  24. inventory_to_read,
  25. is_empty_inventory_date,
  26. parse_date_str,
  27. plan_to_read,
  28. weighing_to_read,
  29. )
  30. from app.services.db.product_helpers import (
  31. apply_product_snapshot_to_inventory,
  32. resolve_crm_product,
  33. )
  34. from app.services.db_inventory_site_token import build_site_inventory_url, ensure_site_access_token
  35. router = APIRouter(prefix="/inventory", tags=["inventory"])
  36. STATUS_THIRD_PARTY_FILL = "待三方填写"
  37. _INVENTORY_REMARK_FIELD = "remarks"
  38. def _base_query(db: Session, tenant_no: str):
  39. return db.query(DbInboundOutbound).filter(
  40. DbInboundOutbound.tenant_no == tenant_no,
  41. DbInboundOutbound.del_flag == 0,
  42. )
  43. def _generate_batch_no(db: Session, tenant_no: str) -> str:
  44. rows = (
  45. db.query(DbInboundOutbound.batch_no)
  46. .filter(
  47. DbInboundOutbound.tenant_no == tenant_no,
  48. DbInboundOutbound.del_flag == 0,
  49. DbInboundOutbound.batch_no.like("CRKDH%"),
  50. )
  51. .all()
  52. )
  53. max_seq = 0
  54. pattern = re.compile(r"^CRKDH(\d+)$")
  55. for (batch_no,) in rows:
  56. if batch_no:
  57. match = pattern.match(batch_no)
  58. if match:
  59. max_seq = max(max_seq, int(match.group(1)))
  60. return f"CRKDH{max_seq + 1:07d}"
  61. def _resolve_tenant_no(
  62. db: Session,
  63. user: Optional[DbUserContext],
  64. purchase_plan_id: Optional[str] = None,
  65. ) -> str:
  66. if user:
  67. return get_tenant_no(db, user.to_tenant_user_dict())
  68. if purchase_plan_id:
  69. plan = (
  70. db.query(DbPurchasePlan)
  71. .filter(DbPurchasePlan.id == purchase_plan_id, DbPurchasePlan.del_flag == 0)
  72. .one_or_none()
  73. )
  74. if plan:
  75. return plan.tenant_no
  76. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无法确定租户")
  77. def _get_inventory_by_id(db: Session, inventory_id: str) -> DbInboundOutbound:
  78. row = (
  79. db.query(DbInboundOutbound)
  80. .filter(DbInboundOutbound.id == inventory_id, DbInboundOutbound.del_flag == 0)
  81. .one_or_none()
  82. )
  83. if row is None:
  84. raise HTTPException(
  85. status_code=status.HTTP_404_NOT_FOUND,
  86. detail=f"Inventory {inventory_id} not found",
  87. )
  88. return row
  89. def _get_inventory(db: Session, tenant_no: str, inventory_id: str) -> DbInboundOutbound:
  90. row = (
  91. _base_query(db, tenant_no)
  92. .filter(DbInboundOutbound.id == inventory_id)
  93. .one_or_none()
  94. )
  95. if row is None:
  96. raise HTTPException(
  97. status_code=status.HTTP_404_NOT_FOUND,
  98. detail=f"Inventory {inventory_id} not found",
  99. )
  100. return row
  101. def _apply_inventory_product_update(
  102. db: Session, tenant_no: str, row: DbInboundOutbound, payload_dict: dict
  103. ) -> None:
  104. if "product" in payload_dict and "product_id" not in payload_dict:
  105. raise HTTPException(
  106. status_code=status.HTTP_400_BAD_REQUEST,
  107. detail="请从 CRM 重新选择产品",
  108. )
  109. product_id = payload_dict.pop("product_id", None)
  110. payload_dict.pop("product", None)
  111. payload_dict.pop("specification", None)
  112. if product_id is not None:
  113. snapshot = resolve_crm_product(db, tenant_no, product_id)
  114. apply_product_snapshot_to_inventory(row, snapshot)
  115. def _apply_inventory_update(row: DbInboundOutbound, payload_dict: dict) -> None:
  116. for key, value in payload_dict.items():
  117. if key == _INVENTORY_REMARK_FIELD:
  118. if isinstance(value, str):
  119. value = value.strip()
  120. row.remark = value
  121. continue
  122. if key == "date":
  123. if value is None or (isinstance(value, str) and not value.strip()):
  124. row.date = EMPTY_INVENTORY_DATE
  125. else:
  126. row.date = parse_date_str(str(value)) or EMPTY_INVENTORY_DATE
  127. continue
  128. if isinstance(value, str):
  129. value = value.strip()
  130. setattr(row, key, value)
  131. def _maybe_promote_third_party_status(row: DbInboundOutbound) -> None:
  132. if row.status != STATUS_THIRD_PARTY_FILL:
  133. return
  134. has_date = not is_empty_inventory_date(row.date)
  135. has_qty = row.quantity is not None and row.quantity != 0
  136. has_vehicle = bool(row.vehicle_no and row.vehicle_no.strip())
  137. if has_date and has_qty and has_vehicle:
  138. row.status = "待入库" if row.business_type == "入库" else "待出库"
  139. @router.get("/", response_model=list[InboundOutboundRead])
  140. def list_inventory(
  141. q: Optional[str] = Query(default=None, description="模糊搜索关键词"),
  142. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划号"),
  143. batch_no: Optional[str] = Query(default=None, alias="batchNo", description="出入库单号"),
  144. partner_company: Optional[str] = Query(
  145. default=None, alias="partnerCompany", description="往来单位"
  146. ),
  147. product: Optional[str] = Query(default=None, description="产品"),
  148. vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo", description="车牌号"),
  149. date_from: Optional[str] = Query(default=None, alias="dateFrom", description="开始日期"),
  150. date_to: Optional[str] = Query(default=None, alias="dateTo", description="结束日期"),
  151. status_filter: Optional[str] = Query(default=None, alias="status", description="状态筛选"),
  152. business_type: Optional[str] = Query(default=None, alias="businessType", description="业务类型"),
  153. db: Session = Depends(get_db),
  154. user: DbUserContext = Depends(get_db_current_user),
  155. ) -> list[InboundOutboundRead]:
  156. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  157. query = _base_query(db, tenant_no).order_by(
  158. DbInboundOutbound.date.desc(),
  159. DbInboundOutbound.id.desc(),
  160. )
  161. if q:
  162. keyword = q.lower()
  163. query = query.filter(
  164. or_(
  165. func.lower(DbInboundOutbound.partner_company).contains(keyword),
  166. func.lower(DbInboundOutbound.product).contains(keyword),
  167. func.lower(DbInboundOutbound.plan_no).contains(keyword),
  168. func.lower(DbInboundOutbound.batch_no).contains(keyword),
  169. func.lower(DbInboundOutbound.vehicle_no).contains(keyword),
  170. )
  171. )
  172. if plan_no:
  173. query = query.filter(DbInboundOutbound.plan_no.contains(plan_no))
  174. if batch_no:
  175. query = query.filter(DbInboundOutbound.batch_no.contains(batch_no))
  176. if partner_company:
  177. query = query.filter(
  178. func.lower(DbInboundOutbound.partner_company).contains(partner_company.lower())
  179. )
  180. if product:
  181. query = query.filter(func.lower(DbInboundOutbound.product).contains(product.lower()))
  182. if vehicle_no:
  183. query = query.filter(DbInboundOutbound.vehicle_no.contains(vehicle_no))
  184. if date_from:
  185. start = parse_date_str(date_from)
  186. if start:
  187. query = query.filter(DbInboundOutbound.date >= start)
  188. if date_to:
  189. end = parse_date_str(date_to)
  190. if end:
  191. query = query.filter(DbInboundOutbound.date <= end)
  192. if status_filter:
  193. query = query.filter(DbInboundOutbound.status == status_filter)
  194. if business_type:
  195. query = query.filter(DbInboundOutbound.business_type == business_type)
  196. inventories = query.all()
  197. inventory_ids = [inv.id for inv in inventories]
  198. weighing_map: dict[str, DbWeighingSlip] = {}
  199. if inventory_ids:
  200. weighings = (
  201. db.query(DbWeighingSlip)
  202. .filter(
  203. DbWeighingSlip.tenant_no == tenant_no,
  204. DbWeighingSlip.del_flag == 0,
  205. DbWeighingSlip.inbound_outbound_id.in_(inventory_ids),
  206. DbWeighingSlip.is_voided == 0,
  207. )
  208. .order_by(DbWeighingSlip.id.asc())
  209. .all()
  210. )
  211. for weighing in weighings:
  212. if (
  213. weighing.inbound_outbound_id
  214. and weighing.inbound_outbound_id not in weighing_map
  215. ):
  216. weighing_map[weighing.inbound_outbound_id] = weighing
  217. result_list: list[InboundOutboundRead] = []
  218. for inventory in inventories:
  219. try:
  220. result_list.append(
  221. inventory_to_read(
  222. inventory,
  223. weighing=weighing_map.get(inventory.id),
  224. )
  225. )
  226. except Exception:
  227. continue
  228. return result_list
  229. @router.post("/", response_model=InboundOutboundRead, status_code=status.HTTP_201_CREATED)
  230. def create_inventory(
  231. payload: InboundOutboundCreate,
  232. db: Session = Depends(get_db),
  233. user: Optional[DbUserContext] = Depends(get_db_current_user_optional),
  234. ) -> InboundOutboundRead:
  235. tenant_no = _resolve_tenant_no(db, user, payload.purchase_plan_id)
  236. batch_no = payload.batch_no.strip() if payload.batch_no else None
  237. if not batch_no:
  238. batch_no = _generate_batch_no(db, tenant_no)
  239. status_value = (
  240. payload.status.strip()
  241. if payload.status and str(payload.status).strip()
  242. else ("待入库" if payload.business_type == "入库" else "待出库")
  243. )
  244. snapshot = resolve_crm_product(db, tenant_no, payload.product_id)
  245. row = DbInboundOutbound(
  246. id=next_inbound_outbound_id(db, tenant_no),
  247. tenant_no=tenant_no,
  248. date=parse_date_str(payload.date) or datetime.now().date(),
  249. partner_company=payload.partner_company.strip(),
  250. business_type=payload.business_type,
  251. quantity=payload.quantity,
  252. plan_no=payload.plan_no.strip() if payload.plan_no else None,
  253. batch_no=batch_no,
  254. vehicle_no=payload.vehicle_no.strip(),
  255. status=status_value,
  256. remark=payload.remarks,
  257. purchase_plan_id=payload.purchase_plan_id,
  258. create_by=user.id if user else None,
  259. create_dept_id=user.dept_id if user else "",
  260. )
  261. apply_product_snapshot_to_inventory(row, snapshot)
  262. ensure_site_access_token(row)
  263. db.add(row)
  264. db.commit()
  265. db.refresh(row)
  266. return inventory_to_read(row)
  267. @router.post(
  268. "/create-link-for-plan",
  269. response_model=InboundOutboundRead,
  270. status_code=status.HTTP_201_CREATED,
  271. )
  272. def create_link_for_plan(
  273. payload: CreateLinkForPlanPayload,
  274. db: Session = Depends(get_db),
  275. user: DbUserContext = Depends(get_db_current_user),
  276. ) -> InboundOutboundRead:
  277. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  278. plan = (
  279. db.query(DbPurchasePlan)
  280. .filter(
  281. DbPurchasePlan.id == payload.purchase_plan_id,
  282. DbPurchasePlan.tenant_no == tenant_no,
  283. DbPurchasePlan.del_flag == 0,
  284. )
  285. .one_or_none()
  286. )
  287. if not plan:
  288. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
  289. if not plan.product_id:
  290. raise HTTPException(
  291. status_code=status.HTTP_400_BAD_REQUEST,
  292. detail="计划未关联 CRM 产品,请先编辑计划并选择产品",
  293. )
  294. row = DbInboundOutbound(
  295. id=next_inbound_outbound_id(db, tenant_no),
  296. tenant_no=tenant_no,
  297. date=EMPTY_INVENTORY_DATE,
  298. partner_company=(plan.customer_name or "").strip() or "待填写",
  299. business_type=plan.business_type,
  300. product_id=plan.product_id,
  301. product=(plan.product or "").strip(),
  302. specification=plan.specification,
  303. quantity=0.0,
  304. plan_no=(plan.plan_no or "").strip() or None,
  305. batch_no=_generate_batch_no(db, tenant_no),
  306. vehicle_no="",
  307. status=STATUS_THIRD_PARTY_FILL,
  308. remark=payload.remarks,
  309. purchase_plan_id=plan.id,
  310. create_by=user.id,
  311. create_dept_id=user.dept_id,
  312. )
  313. ensure_site_access_token(row)
  314. db.add(row)
  315. db.commit()
  316. db.refresh(row)
  317. return inventory_to_read(row)
  318. @router.get("/{id}/site-url", response_model=InventorySiteUrlRead)
  319. def get_inventory_site_url(
  320. id: str,
  321. db: Session = Depends(get_db),
  322. user: DbUserContext = Depends(get_db_current_user),
  323. ) -> InventorySiteUrlRead:
  324. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  325. row = _get_inventory(db, tenant_no, id)
  326. token = ensure_site_access_token(row)
  327. db.commit()
  328. return InventorySiteUrlRead(site_url=build_site_inventory_url(token))
  329. @router.get("/{id}", response_model=InboundOutboundRead)
  330. def get_inventory(
  331. id: str,
  332. db: Session = Depends(get_db),
  333. ) -> InboundOutboundRead:
  334. """单条查询(三方 H5 填写页无需门户登录)。"""
  335. row = _get_inventory_by_id(db, id)
  336. weighing = (
  337. db.query(DbWeighingSlip)
  338. .filter(
  339. DbWeighingSlip.inbound_outbound_id == row.id,
  340. DbWeighingSlip.is_voided == 0,
  341. DbWeighingSlip.del_flag == 0,
  342. )
  343. .order_by(DbWeighingSlip.id.asc())
  344. .first()
  345. )
  346. return inventory_to_read(row, weighing=weighing)
  347. @router.patch("/{id}", response_model=InboundOutboundRead)
  348. def update_inventory(
  349. id: str,
  350. payload: InboundOutboundUpdate,
  351. db: Session = Depends(get_db),
  352. ) -> InboundOutboundRead:
  353. """更新(三方 H5 填写页无需门户登录)。"""
  354. row = _get_inventory_by_id(db, id)
  355. payload_dict = payload.model_dump(exclude_unset=True)
  356. _apply_inventory_product_update(db, row.tenant_no, row, payload_dict)
  357. _apply_inventory_update(row, payload_dict)
  358. _maybe_promote_third_party_status(row)
  359. db.commit()
  360. db.refresh(row)
  361. return inventory_to_read(row)
  362. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  363. def delete_inventory(
  364. id: str,
  365. db: Session = Depends(get_db),
  366. user: DbUserContext = Depends(get_db_current_user),
  367. ) -> None:
  368. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  369. row = _get_inventory(db, tenant_no, id)
  370. row.del_flag = 1
  371. row.update_by = user.id
  372. db.commit()
  373. class InventoryRelatedData(BaseModel):
  374. plan: Optional[PurchasePlanRead]
  375. weighing: Optional[WeighingSlipRead]
  376. @router.get("/{id}/related", response_model=InventoryRelatedData)
  377. def get_inventory_related(
  378. id: str,
  379. db: Session = Depends(get_db),
  380. ) -> InventoryRelatedData:
  381. row = _get_inventory_by_id(db, id)
  382. plan = None
  383. if row.purchase_plan_id:
  384. plan = (
  385. db.query(DbPurchasePlan)
  386. .filter(
  387. DbPurchasePlan.id == row.purchase_plan_id,
  388. DbPurchasePlan.del_flag == 0,
  389. )
  390. .one_or_none()
  391. )
  392. weighing = (
  393. db.query(DbWeighingSlip)
  394. .filter(
  395. DbWeighingSlip.inbound_outbound_id == row.id,
  396. DbWeighingSlip.is_voided == 0,
  397. DbWeighingSlip.del_flag == 0,
  398. )
  399. .limit(1)
  400. .first()
  401. )
  402. return InventoryRelatedData(
  403. plan=plan_to_read(plan) if plan else None,
  404. weighing=weighing_to_read(weighing) if weighing else None,
  405. )