plans.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. from __future__ import annotations
  2. from datetime import datetime, date
  3. from fastapi import APIRouter, Depends, HTTPException, Query, status
  4. from sqlalchemy import func
  5. from sqlmodel import select
  6. from sqlmodel.ext.asyncio.session import AsyncSession
  7. from app.db.session import get_async_session
  8. from app.models.plans import PurchasePlan
  9. from app.models.inventory import InboundOutbound
  10. from app.models.weighing import WeighingSlip
  11. from app.schemas.plans import PurchasePlanCreate, PurchasePlanRead, PurchasePlanUpdate, PurchasePlanWithFillLink
  12. from app.schemas.inventory import InboundOutboundRead
  13. from app.schemas.weighing import WeighingSlipRead
  14. from pydantic import BaseModel
  15. router = APIRouter(prefix="/plans", tags=["plans"])
  16. def _base_query():
  17. return select(PurchasePlan).where(PurchasePlan.is_deleted == 0)
  18. STATUS_THIRD_PARTY_FILL = "待三方填写"
  19. @router.get("/", response_model=list[PurchasePlanWithFillLink])
  20. async def list_plans(
  21. q: str | None = Query(default=None, description="模糊搜索往来单位或产品"),
  22. business_type: str | None = Query(default=None, alias="businessType", description="业务类型"),
  23. execution_status: str | None = Query(default=None, alias="executionStatus", description="执行状态"),
  24. created_from: str | None = Query(default=None, alias="createdFrom", description="创建开始日期"),
  25. created_to: str | None = Query(default=None, alias="createdTo", description="创建结束日期"),
  26. start_date_from: str | None = Query(default=None, alias="startDateFrom", description="计划起始日期开始"),
  27. start_date_to: str | None = Query(default=None, alias="startDateTo", description="计划起始日期结束"),
  28. session: AsyncSession = Depends(get_async_session),
  29. ) -> list[PurchasePlanWithFillLink]:
  30. # 默认按创建时间倒序
  31. query = _base_query().order_by(PurchasePlan.created_at.desc())
  32. if q:
  33. query = query.where(
  34. (func.lower(PurchasePlan.customer_name).contains(q.lower()))
  35. | (func.lower(PurchasePlan.product).contains(q.lower()))
  36. | (func.lower(PurchasePlan.plan_no).contains(q.lower()))
  37. )
  38. if business_type:
  39. query = query.where(PurchasePlan.business_type == business_type)
  40. if execution_status:
  41. query = query.where(PurchasePlan.execution_status == execution_status)
  42. # 按创建时间过滤(日期粒度)
  43. if created_from:
  44. query = query.where(PurchasePlan.created_at >= created_from)
  45. if created_to:
  46. query = query.where(PurchasePlan.created_at <= created_to + " 23:59:59")
  47. # 按计划起始日期过滤
  48. if start_date_from:
  49. query = query.where(PurchasePlan.start_date >= start_date_from)
  50. if start_date_to:
  51. query = query.where(PurchasePlan.start_date <= start_date_to)
  52. result = await session.exec(query)
  53. plans = result.all()
  54. if not plans:
  55. return []
  56. # 1)查出「生成链接给三方填写」对应的出入库单 id
  57. plan_ids = [p.id for p in plans]
  58. fill_link_result = await session.exec(
  59. select(InboundOutbound).where(
  60. InboundOutbound.purchase_plan_id.in_(plan_ids),
  61. InboundOutbound.is_deleted == 0,
  62. InboundOutbound.status == STATUS_THIRD_PARTY_FILL,
  63. )
  64. )
  65. fill_links = fill_link_result.all()
  66. plan_id_to_inv_id = {
  67. inv.purchase_plan_id: inv.id for inv in fill_links if inv.purchase_plan_id
  68. }
  69. # 2)不依赖 purchase_plans.executed_quantity 字段,直接从地磅表实时汇总已执行量(kg)
  70. plan_nos = [p.plan_no for p in plans if p.plan_no]
  71. executed_sum_map: dict[tuple[int | None, str | None], float] = {}
  72. if plan_ids or plan_nos:
  73. weighing_query = (
  74. select(
  75. WeighingSlip.purchase_plan_id,
  76. WeighingSlip.plan_no,
  77. func.sum(WeighingSlip.net_weight).label("total_executed"),
  78. )
  79. .where(
  80. WeighingSlip.is_voided == 0,
  81. (
  82. (WeighingSlip.purchase_plan_id.in_(plan_ids))
  83. | (WeighingSlip.plan_no.in_(plan_nos))
  84. ),
  85. )
  86. .group_by(WeighingSlip.purchase_plan_id, WeighingSlip.plan_no)
  87. )
  88. weighing_result = await session.exec(weighing_query)
  89. for row in weighing_result.all():
  90. key = (row.purchase_plan_id, row.plan_no)
  91. executed_sum_map[key] = float(row.total_executed or 0)
  92. def calc_executed_for_plan(plan: PurchasePlan) -> float:
  93. """
  94. 根据业务逻辑,对每个计划实时计算已执行量(kg):
  95. - 关联条件与 _process_weighing_business_logic 中保持一致:
  96. WeighingSlip.plan_no == plan.plan_no OR WeighingSlip.purchase_plan_id == plan.id
  97. - 这里先按 (purchase_plan_id, plan_no) 汇总,再在内存中做一次匹配累加。
  98. """
  99. if not executed_sum_map:
  100. return 0.0
  101. total = 0.0
  102. for (pid, pno), executed in executed_sum_map.items():
  103. if pid is not None and pid == plan.id:
  104. total += executed
  105. elif (
  106. pno
  107. and plan.plan_no
  108. and pno == plan.plan_no
  109. ):
  110. total += executed
  111. return total
  112. # 3)构造返回数据:使用实时计算的 executed_quantity 覆盖模型里的字段
  113. result_list: list[PurchasePlanWithFillLink] = []
  114. for p in plans:
  115. data = p.model_dump()
  116. data["executed_quantity"] = calc_executed_for_plan(p)
  117. data["fill_link_inventory_id"] = plan_id_to_inv_id.get(p.id)
  118. result_list.append(PurchasePlanWithFillLink(**data))
  119. return result_list
  120. def _generate_plan_no() -> str:
  121. """生成计划号:SX-YYYYMMDDHHMMSS001"""
  122. now = datetime.now()
  123. return f"SX-{now.strftime('%Y%m%d%H%M%S')}001"
  124. @router.post("/", response_model=PurchasePlanRead, status_code=status.HTTP_201_CREATED)
  125. async def create_plan(
  126. payload: PurchasePlanCreate,
  127. session: AsyncSession = Depends(get_async_session),
  128. ) -> PurchasePlan:
  129. # 如果没有提供计划号,自动生成
  130. plan_no = payload.plan_no if payload.plan_no else _generate_plan_no()
  131. # 设置默认执行状态
  132. execution_status = payload.execution_status
  133. if not execution_status:
  134. execution_status = "待入库" if payload.business_type == "入库" else "待出库"
  135. plan = PurchasePlan(
  136. plan_no=plan_no,
  137. plan_date=payload.plan_date,
  138. start_date=payload.start_date,
  139. end_date=payload.end_date,
  140. business_type=payload.business_type,
  141. product=payload.product.strip(),
  142. specification=payload.specification,
  143. planned_quantity=payload.planned_quantity,
  144. vehicle_no=payload.vehicle_no or "",
  145. customer_name=payload.customer_name.strip() if payload.customer_name else None,
  146. remarks=payload.remarks,
  147. is_deleted=0,
  148. execution_status=execution_status,
  149. executed_at=None,
  150. last_weighing_slip_id=None,
  151. executed_quantity=0,
  152. created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  153. auto_generate_enabled=payload.auto_generate_enabled or 0,
  154. auto_generate_end_month=payload.auto_generate_end_month,
  155. auto_generate_last_month=None,
  156. )
  157. session.add(plan)
  158. await session.commit()
  159. await session.refresh(plan)
  160. return plan
  161. @router.get("/{id}", response_model=PurchasePlanRead)
  162. async def get_plan(
  163. id: int,
  164. session: AsyncSession = Depends(get_async_session),
  165. ) -> PurchasePlan:
  166. return await _get_plan(id, session)
  167. @router.patch("/{id}", response_model=PurchasePlanRead)
  168. async def update_plan(
  169. id: int,
  170. payload: PurchasePlanUpdate,
  171. session: AsyncSession = Depends(get_async_session),
  172. ) -> PurchasePlan:
  173. plan = await _get_plan(id, session)
  174. payload_dict = payload.model_dump(exclude_unset=True)
  175. for key, value in payload_dict.items():
  176. if isinstance(value, str):
  177. value = value.strip()
  178. setattr(plan, key, value)
  179. await session.commit()
  180. await session.refresh(plan)
  181. return plan
  182. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  183. async def delete_plan(
  184. id: int,
  185. session: AsyncSession = Depends(get_async_session),
  186. ) -> None:
  187. plan = await _get_plan(id, session)
  188. plan.is_deleted = 1
  189. await session.commit()
  190. async def _get_plan(id: int, session: AsyncSession) -> PurchasePlan:
  191. result = await session.exec(_base_query().where(PurchasePlan.id == id))
  192. plan = result.one_or_none()
  193. if plan is None:
  194. raise HTTPException(
  195. status_code=status.HTTP_404_NOT_FOUND,
  196. detail=f"Plan {id} not found",
  197. )
  198. return plan
  199. class PlanRelatedData(BaseModel):
  200. inventories: list[InboundOutboundRead]
  201. weighings: list[WeighingSlipRead]
  202. @router.get("/{id}/related", response_model=PlanRelatedData)
  203. async def get_plan_related(
  204. id: int,
  205. session: AsyncSession = Depends(get_async_session),
  206. ) -> PlanRelatedData:
  207. """获取计划关联的出入库单和地磅单"""
  208. plan = await _get_plan(id, session)
  209. # 获取关联的出入库单(通过 purchase_plan_id)
  210. inventory_result = await session.exec(
  211. select(InboundOutbound).where(
  212. InboundOutbound.purchase_plan_id == plan.id,
  213. InboundOutbound.is_deleted == 0
  214. ).order_by(InboundOutbound.date.desc(), InboundOutbound.id.desc())
  215. )
  216. inventories = inventory_result.all()
  217. # 获取关联的地磅单(通过 plan_no 或 purchase_plan_id)
  218. weighing_query = select(WeighingSlip).where(
  219. WeighingSlip.is_voided == 0
  220. )
  221. if plan.plan_no:
  222. weighing_query = weighing_query.where(
  223. (WeighingSlip.plan_no == plan.plan_no) |
  224. (WeighingSlip.purchase_plan_id == plan.id)
  225. )
  226. else:
  227. weighing_query = weighing_query.where(
  228. WeighingSlip.purchase_plan_id == plan.id
  229. )
  230. weighing_query = weighing_query.order_by(WeighingSlip.created_at.desc())
  231. weighing_result = await session.exec(weighing_query)
  232. weighings = weighing_result.all()
  233. return PlanRelatedData(
  234. inventories=[InboundOutboundRead.model_validate(inv.model_dump()) for inv in inventories],
  235. weighings=[WeighingSlipRead.model_validate(w.model_dump()) for w in weighings]
  236. )
  237. class ThirdPartyPlanPageData(BaseModel):
  238. """三方填写 H5 页面数据:计划信息 + 当天出入库单列表 + 本月历史出入库单列表"""
  239. plan: PurchasePlanRead
  240. today_inventories: list[InboundOutboundRead]
  241. month_inventories: list[InboundOutboundRead]
  242. def _parse_date_str(d: str | None) -> date | None:
  243. if not d:
  244. return None
  245. try:
  246. return datetime.strptime(d, "%Y-%m-%d").date()
  247. except Exception:
  248. return None
  249. @router.get("/{id}/third-party-page", response_model=ThirdPartyPlanPageData)
  250. async def get_third_party_plan_page(
  251. id: int,
  252. session: AsyncSession = Depends(get_async_session),
  253. ) -> ThirdPartyPlanPageData:
  254. """
  255. 提供给三方使用的 H5 页面初始化数据:
  256. - 校验当前日期是否在计划有效期内(start_date/end_date)
  257. - 返回计划基本信息
  258. - 返回该计划“当天”的出入库单列表(按日期+id 倒序)
  259. """
  260. plan = await _get_plan(id, session)
  261. today = date.today()
  262. start = _parse_date_str(plan.start_date)
  263. end = _parse_date_str(plan.end_date)
  264. # 仅在计划有效期内允许填写:如果配置了开始/结束日期,则进行约束
  265. if start and today < start:
  266. raise HTTPException(
  267. status_code=status.HTTP_400_BAD_REQUEST,
  268. detail="计划尚未生效,当前日期不允许填写出入库单",
  269. )
  270. if end and today > end:
  271. raise HTTPException(
  272. status_code=status.HTTP_400_BAD_REQUEST,
  273. detail="计划已过期,当前日期不允许填写出入库单",
  274. )
  275. # 查询该计划当天的出入库单(基于 date 字段)
  276. today_str = today.strftime("%Y-%m-%d")
  277. inventory_result = await session.exec(
  278. select(InboundOutbound)
  279. .where(
  280. InboundOutbound.purchase_plan_id == plan.id,
  281. InboundOutbound.is_deleted == 0,
  282. InboundOutbound.date == today_str,
  283. )
  284. .order_by(InboundOutbound.date.desc(), InboundOutbound.id.desc())
  285. )
  286. inventories = inventory_result.all()
  287. # 查询该计划本月(不含今天)的历史出入库单
  288. month_start_str = today.replace(day=1).strftime("%Y-%m-%d")
  289. month_history_result = await session.exec(
  290. select(InboundOutbound)
  291. .where(
  292. InboundOutbound.purchase_plan_id == plan.id,
  293. InboundOutbound.is_deleted == 0,
  294. InboundOutbound.date >= month_start_str,
  295. InboundOutbound.date < today_str,
  296. )
  297. .order_by(InboundOutbound.date.desc(), InboundOutbound.id.desc())
  298. )
  299. month_history_inventories = month_history_result.all()
  300. return ThirdPartyPlanPageData(
  301. plan=PurchasePlanRead.model_validate(plan.model_dump()),
  302. today_inventories=[
  303. InboundOutboundRead.model_validate(inv.model_dump()) for inv in inventories
  304. ],
  305. month_inventories=[
  306. InboundOutboundRead.model_validate(inv.model_dump())
  307. for inv in month_history_inventories
  308. ],
  309. )