inventory.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. from __future__ import annotations
  2. import re
  3. from datetime import datetime
  4. from fastapi import APIRouter, Depends, HTTPException, Query, status
  5. from sqlalchemy import func
  6. from sqlmodel import select
  7. from sqlmodel.ext.asyncio.session import AsyncSession
  8. from app.db.session import get_async_session
  9. from app.models.inventory import InboundOutbound
  10. from app.models.master_data import Vehicle
  11. from app.models.weighing import WeighingSlip
  12. from app.models.plans import PurchasePlan
  13. from app.schemas.inventory import (
  14. InboundOutboundCreate,
  15. InboundOutboundRead,
  16. InboundOutboundUpdate,
  17. )
  18. from app.schemas.plans import PurchasePlanRead
  19. from app.schemas.weighing import WeighingSlipRead
  20. from pydantic import BaseModel, Field
  21. router = APIRouter(prefix="/inventory", tags=["inventory"])
  22. def _base_query():
  23. return select(InboundOutbound).where(InboundOutbound.is_deleted == 0)
  24. async def _generate_batch_no(session: AsyncSession) -> str:
  25. """
  26. 生成出入库单号,格式为 CRKDH+0000001 这样的递增序号
  27. """
  28. # 查询所有以 CRKDH 开头的 batch_no
  29. result = await session.exec(
  30. select(InboundOutbound.batch_no)
  31. .where(
  32. InboundOutbound.batch_no.like("CRKDH%"),
  33. InboundOutbound.is_deleted == 0
  34. )
  35. )
  36. batch_nos = result.all()
  37. # 提取所有序号
  38. max_seq = 0
  39. pattern = re.compile(r"^CRKDH(\d+)$")
  40. for batch_no in batch_nos:
  41. if batch_no:
  42. match = pattern.match(batch_no)
  43. if match:
  44. seq = int(match.group(1))
  45. if seq > max_seq:
  46. max_seq = seq
  47. # 生成新的序号(递增1)
  48. new_seq = max_seq + 1
  49. return f"CRKDH{new_seq:07d}"
  50. @router.get("/", response_model=list[InboundOutboundRead])
  51. async def list_inventory(
  52. q: str | None = Query(default=None, description="模糊搜索往来单位或产品"),
  53. # 前端使用的是 dateFrom / dateTo,这里通过 alias 做映射
  54. date_from: str | None = Query(default=None, alias="dateFrom", description="开始日期"),
  55. date_to: str | None = Query(default=None, alias="dateTo", description="结束日期"),
  56. status_filter: str | None = Query(default=None, alias="status", description="状态筛选"),
  57. business_type: str | None = Query(default=None, alias="businessType", description="业务类型"),
  58. session: AsyncSession = Depends(get_async_session),
  59. ) -> list[InboundOutbound]:
  60. query = _base_query().order_by(InboundOutbound.date.desc(), InboundOutbound.id.desc())
  61. if q:
  62. query = query.where(
  63. (func.lower(InboundOutbound.partner_company).contains(q.lower()))
  64. | (func.lower(InboundOutbound.product).contains(q.lower()))
  65. )
  66. if date_from:
  67. query = query.where(InboundOutbound.date >= date_from)
  68. if date_to:
  69. query = query.where(InboundOutbound.date <= date_to)
  70. if status_filter:
  71. query = query.where(InboundOutbound.status == status_filter)
  72. if business_type:
  73. query = query.where(InboundOutbound.business_type == business_type)
  74. result = await session.exec(query)
  75. inventories = result.all()
  76. # 批量查询所有出入库单关联的地磅单(避免 N+1)
  77. inventory_ids = [inv.id for inv in inventories if inv.id is not None]
  78. weighing_map: dict[int, WeighingSlip] = {}
  79. if inventory_ids:
  80. weighing_result = await session.exec(
  81. select(WeighingSlip).where(
  82. WeighingSlip.inbound_outbound_id.in_(inventory_ids),
  83. WeighingSlip.is_voided == 0,
  84. ).order_by(WeighingSlip.id.asc())
  85. )
  86. for w in weighing_result.all():
  87. if w.inbound_outbound_id is not None and w.inbound_outbound_id not in weighing_map:
  88. weighing_map[w.inbound_outbound_id] = w
  89. result_list = []
  90. for inventory in inventories:
  91. weighing = weighing_map.get(inventory.id) if inventory.id else None
  92. inventory_dict = inventory.model_dump()
  93. if weighing:
  94. inventory_dict['gross_weight'] = weighing.gross_weight if weighing.gross_weight is not None else None
  95. inventory_dict['tare_weight'] = weighing.tare_weight if weighing.tare_weight is not None else None
  96. inventory_dict['deduction'] = weighing.deduction if weighing.deduction is not None else None
  97. inventory_dict['net_weight'] = weighing.net_weight if weighing.net_weight is not None else None
  98. inventory_dict['gross_weight_time'] = weighing.gross_weight_time if weighing.gross_weight_time and weighing.gross_weight_time.strip() else None
  99. inventory_dict['tare_weight_time'] = weighing.tare_weight_time if weighing.tare_weight_time and weighing.tare_weight_time.strip() else None
  100. inventory_dict['has_weighing_slip'] = True
  101. else:
  102. inventory_dict['gross_weight'] = None
  103. inventory_dict['tare_weight'] = None
  104. inventory_dict['deduction'] = None
  105. inventory_dict['net_weight'] = None
  106. inventory_dict['gross_weight_time'] = None
  107. inventory_dict['tare_weight_time'] = None
  108. inventory_dict['has_weighing_slip'] = False
  109. try:
  110. result_list.append(InboundOutboundRead.model_validate(inventory_dict))
  111. except Exception:
  112. # 单条数据校验失败不影响整表返回,跳过该条
  113. continue
  114. return result_list
  115. @router.post("/", response_model=InboundOutboundRead, status_code=status.HTTP_201_CREATED)
  116. async def create_inventory(
  117. payload: InboundOutboundCreate,
  118. session: AsyncSession = Depends(get_async_session),
  119. ) -> InboundOutbound:
  120. # 如果没有提供 batch_no,自动生成
  121. batch_no = payload.batch_no.strip() if payload.batch_no else None
  122. if not batch_no:
  123. batch_no = await _generate_batch_no(session)
  124. # 确保 status 有合理的默认值:如果前端未传入,则根据业务类型设置为待入库或待出库
  125. status_value = (
  126. payload.status.strip()
  127. if getattr(payload, "status", None) and str(payload.status).strip()
  128. else ("待入库" if payload.business_type == "入库" else "待出库")
  129. )
  130. inventory = InboundOutbound(
  131. date=payload.date,
  132. partner_company=payload.partner_company.strip(),
  133. business_type=payload.business_type,
  134. product=payload.product.strip(),
  135. quantity=payload.quantity,
  136. plan_no=payload.plan_no.strip() if payload.plan_no else None,
  137. batch_no=batch_no,
  138. vehicle_no=payload.vehicle_no.strip(),
  139. status=status_value,
  140. remarks=payload.remarks,
  141. purchase_plan_id=payload.purchase_plan_id,
  142. is_deleted=0,
  143. created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  144. )
  145. session.add(inventory)
  146. await session.commit()
  147. await session.refresh(inventory)
  148. return inventory
  149. class CreateLinkForPlanPayload(BaseModel):
  150. purchase_plan_id: int = Field(alias="purchasePlanId")
  151. remarks: str | None = None
  152. model_config = {"populate_by_name": True}
  153. def _status_third_party_fill() -> str:
  154. """三方填写待完善状态的 status 值"""
  155. return "待三方填写"
  156. # 必须放在 /{id} 之前,否则 create-link-for-plan 会被匹配成 id 导致 405
  157. @router.post("/create-link-for-plan", response_model=InboundOutboundRead, status_code=status.HTTP_201_CREATED)
  158. async def create_link_for_plan(
  159. payload: CreateLinkForPlanPayload,
  160. session: AsyncSession = Depends(get_async_session),
  161. ) -> InboundOutbound:
  162. """根据计划预生成出入库单(日期、数量、车牌号为空),供三方通过链接填写。同一计划可多次点击,每次生成一条新出入库单。"""
  163. plan_result = await session.exec(
  164. select(PurchasePlan).where(
  165. PurchasePlan.id == payload.purchase_plan_id,
  166. PurchasePlan.is_deleted == 0,
  167. )
  168. )
  169. plan = plan_result.one_or_none()
  170. if not plan:
  171. raise HTTPException(
  172. status_code=status.HTTP_404_NOT_FOUND,
  173. detail="Plan not found",
  174. )
  175. batch_no = await _generate_batch_no(session)
  176. inventory = InboundOutbound(
  177. date="",
  178. partner_company=(plan.customer_name or "").strip() or "待填写",
  179. business_type=plan.business_type,
  180. product=(plan.product or "").strip(),
  181. quantity=0.0,
  182. plan_no=(plan.plan_no or "").strip() or None,
  183. batch_no=batch_no,
  184. vehicle_no="",
  185. status=_status_third_party_fill(),
  186. remarks=payload.remarks,
  187. purchase_plan_id=plan.id,
  188. is_deleted=0,
  189. created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  190. )
  191. session.add(inventory)
  192. await session.commit()
  193. await session.refresh(inventory)
  194. return inventory
  195. @router.get("/{id}", response_model=InboundOutboundRead)
  196. async def get_inventory(
  197. id: int,
  198. session: AsyncSession = Depends(get_async_session),
  199. ) -> InboundOutbound:
  200. return await _get_inventory(id, session)
  201. async def _ensure_vehicle_exists(vehicle_no: str, session: AsyncSession) -> None:
  202. if not (vehicle_no and vehicle_no.strip()):
  203. return
  204. from sqlalchemy import func
  205. v = vehicle_no.strip()
  206. result = await session.exec(
  207. select(Vehicle).where(
  208. Vehicle.is_deleted == 0,
  209. func.lower(Vehicle.vehicle_no) == v.lower(),
  210. ).limit(1)
  211. )
  212. if result.first():
  213. return
  214. from datetime import datetime as dt
  215. vehicle = Vehicle(
  216. vehicle_no=v,
  217. remarks=None,
  218. is_deleted=0,
  219. created_at=dt.now().isoformat(),
  220. )
  221. session.add(vehicle)
  222. @router.patch("/{id}", response_model=InboundOutboundRead)
  223. async def update_inventory(
  224. id: int,
  225. payload: InboundOutboundUpdate,
  226. session: AsyncSession = Depends(get_async_session),
  227. ) -> InboundOutbound:
  228. inventory = await _get_inventory(id, session)
  229. payload_dict = payload.model_dump(exclude_unset=True)
  230. vehicle_no_updated = payload_dict.get("vehicle_no")
  231. if vehicle_no_updated is not None and str(vehicle_no_updated).strip():
  232. await _ensure_vehicle_exists(str(vehicle_no_updated).strip(), session)
  233. for key, value in payload_dict.items():
  234. if isinstance(value, str):
  235. value = value.strip()
  236. setattr(inventory, key, value)
  237. # 三方填写链接保存后:若当前为「待三方填写」且日期、数量、车牌号均已填写,则改为待入库/待出库
  238. if inventory.status == _status_third_party_fill():
  239. has_date = bool(inventory.date and inventory.date.strip())
  240. has_qty = inventory.quantity is not None and inventory.quantity != 0
  241. has_vehicle = bool(inventory.vehicle_no and inventory.vehicle_no.strip())
  242. if has_date and has_qty and has_vehicle:
  243. inventory.status = "待入库" if inventory.business_type == "入库" else "待出库"
  244. await session.commit()
  245. await session.refresh(inventory)
  246. return inventory
  247. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  248. async def delete_inventory(
  249. id: int,
  250. session: AsyncSession = Depends(get_async_session),
  251. ) -> None:
  252. inventory = await _get_inventory(id, session)
  253. inventory.is_deleted = 1
  254. await session.commit()
  255. async def _get_inventory(id: int, session: AsyncSession) -> InboundOutbound:
  256. result = await session.exec(_base_query().where(InboundOutbound.id == id))
  257. inventory = result.one_or_none()
  258. if inventory is None:
  259. raise HTTPException(
  260. status_code=status.HTTP_404_NOT_FOUND,
  261. detail=f"Inventory {id} not found",
  262. )
  263. return inventory
  264. class InventoryRelatedData(BaseModel):
  265. plan: PurchasePlanRead | None
  266. weighing: WeighingSlipRead | None
  267. @router.get("/{id}/related", response_model=InventoryRelatedData)
  268. async def get_inventory_related(
  269. id: int,
  270. session: AsyncSession = Depends(get_async_session),
  271. ) -> InventoryRelatedData:
  272. """获取出入库单关联的计划和地磅单"""
  273. inventory = await _get_inventory(id, session)
  274. # 获取关联的计划(通过 purchase_plan_id)
  275. plan = None
  276. if inventory.purchase_plan_id:
  277. plan_result = await session.exec(
  278. select(PurchasePlan).where(
  279. PurchasePlan.id == inventory.purchase_plan_id,
  280. PurchasePlan.is_deleted == 0
  281. )
  282. )
  283. plan = plan_result.one_or_none()
  284. # 获取关联的地磅单(通过 inbound_outbound_id)
  285. weighing_result = await session.exec(
  286. select(WeighingSlip).where(
  287. WeighingSlip.inbound_outbound_id == inventory.id,
  288. WeighingSlip.is_voided == 0
  289. ).limit(1)
  290. )
  291. weighing = weighing_result.first()
  292. return InventoryRelatedData(
  293. plan=PurchasePlanRead.model_validate(plan.model_dump()) if plan else None,
  294. weighing=WeighingSlipRead.model_validate(weighing.model_dump()) if weighing else None
  295. )