plans.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. from typing import Optional
  2. from datetime import date, datetime
  3. from fastapi import APIRouter, Depends, HTTPException, Query, status
  4. from pydantic import BaseModel
  5. from sqlalchemy import func, or_
  6. from sqlalchemy.orm import Session
  7. from app.core.database import get_db
  8. from app.core.db_auth import DbUserContext, get_db_current_user
  9. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  10. from app.core.tenant_context import get_tenant_no
  11. from app.core.utils import generate_uuid
  12. from app.services.db_data_scope import apply_db_scope, assert_db_row_in_scope
  13. from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
  14. from app.schemas.db.inventory import InboundOutboundRead
  15. from app.schemas.db.plans import (
  16. PurchasePlanCreate,
  17. PurchasePlanRead,
  18. PurchasePlanUpdate,
  19. PurchasePlanWithFillLink,
  20. )
  21. from app.schemas.db.weighing import WeighingSlipRead
  22. from app.services.db.mappers import (
  23. inventory_to_read,
  24. parse_date_str,
  25. parse_datetime_str,
  26. plan_to_read,
  27. plan_to_read_with_fill_link,
  28. weighing_to_read,
  29. )
  30. from app.services.db.product_helpers import (
  31. apply_product_snapshot_to_plan,
  32. resolve_crm_product,
  33. )
  34. router = APIRouter(prefix="/plans", tags=["plans"])
  35. STATUS_THIRD_PARTY_FILL = "待三方填写"
  36. _PLAN_DATE_FIELDS = {"plan_date", "start_date", "end_date"}
  37. _PLAN_REMARK_FIELD = "remarks"
  38. def _base_query(db: Session, tenant_no: str):
  39. return db.query(DbPurchasePlan).filter(
  40. DbPurchasePlan.tenant_no == tenant_no,
  41. DbPurchasePlan.del_flag == 0,
  42. )
  43. @router.get("/", response_model=list[PurchasePlanWithFillLink])
  44. def list_plans(
  45. q: Optional[str] = Query(default=None, description="模糊搜索往来单位或产品"),
  46. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划号"),
  47. customer_name: Optional[str] = Query(
  48. default=None, alias="customerName", description="往来单位"
  49. ),
  50. product: Optional[str] = Query(default=None, description="产品"),
  51. business_type: Optional[str] = Query(default=None, alias="businessType", description="业务类型"),
  52. execution_status: Optional[str] = Query(
  53. default=None, alias="executionStatus", description="执行状态"
  54. ),
  55. created_from: Optional[str] = Query(default=None, alias="createdFrom", description="创建开始日期"),
  56. created_to: Optional[str] = Query(default=None, alias="createdTo", description="创建结束日期"),
  57. start_date_from: Optional[str] = Query(
  58. default=None, alias="startDateFrom", description="计划起始日期开始"
  59. ),
  60. start_date_to: Optional[str] = Query(
  61. default=None, alias="startDateTo", description="计划起始日期结束"
  62. ),
  63. db: Session = Depends(get_db),
  64. user: DbUserContext = Depends(get_db_current_user),
  65. ) -> list[PurchasePlanWithFillLink]:
  66. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  67. query = _base_query(db, tenant_no).order_by(DbPurchasePlan.create_time.desc())
  68. if q:
  69. keyword = q.lower()
  70. query = query.filter(
  71. or_(
  72. func.lower(DbPurchasePlan.customer_name).contains(keyword),
  73. func.lower(DbPurchasePlan.product).contains(keyword),
  74. func.lower(DbPurchasePlan.plan_no).contains(keyword),
  75. )
  76. )
  77. if plan_no:
  78. query = query.filter(DbPurchasePlan.plan_no.contains(plan_no))
  79. if customer_name:
  80. query = query.filter(
  81. func.lower(DbPurchasePlan.customer_name).contains(customer_name.lower())
  82. )
  83. if product:
  84. query = query.filter(func.lower(DbPurchasePlan.product).contains(product.lower()))
  85. if business_type:
  86. query = query.filter(DbPurchasePlan.business_type == business_type)
  87. if execution_status:
  88. query = query.filter(DbPurchasePlan.execution_status == execution_status)
  89. if created_from:
  90. query = query.filter(DbPurchasePlan.create_time >= created_from)
  91. if created_to:
  92. query = query.filter(DbPurchasePlan.create_time <= f"{created_to} 23:59:59")
  93. if start_date_from:
  94. start_from = parse_date_str(start_date_from)
  95. if start_from:
  96. query = query.filter(DbPurchasePlan.start_date >= start_from)
  97. if start_date_to:
  98. start_to = parse_date_str(start_date_to)
  99. if start_to:
  100. query = query.filter(DbPurchasePlan.start_date <= start_to)
  101. query = apply_db_scope(query, DbPurchasePlan, user, db, tenant_no)
  102. plans = query.all()
  103. if not plans:
  104. return []
  105. plan_ids = [p.id for p in plans]
  106. fill_links = (
  107. db.query(DbInboundOutbound)
  108. .filter(
  109. DbInboundOutbound.tenant_no == tenant_no,
  110. DbInboundOutbound.del_flag == 0,
  111. DbInboundOutbound.purchase_plan_id.in_(plan_ids),
  112. DbInboundOutbound.status == STATUS_THIRD_PARTY_FILL,
  113. )
  114. .all()
  115. )
  116. plan_id_to_inv_id = {
  117. inv.purchase_plan_id: inv.id for inv in fill_links if inv.purchase_plan_id
  118. }
  119. plan_nos = [p.plan_no for p in plans if p.plan_no]
  120. executed_sum_map: dict[tuple[Optional[str], Optional[str]], float] = {}
  121. if plan_ids or plan_nos:
  122. weighing_filters = [DbWeighingSlip.is_voided == 0, DbWeighingSlip.del_flag == 0]
  123. weighing_filters.append(DbWeighingSlip.tenant_no == tenant_no)
  124. id_filters = []
  125. if plan_ids:
  126. id_filters.append(DbWeighingSlip.purchase_plan_id.in_(plan_ids))
  127. if plan_nos:
  128. id_filters.append(DbWeighingSlip.plan_no.in_(plan_nos))
  129. weighing_filters.append(or_(*id_filters))
  130. weighing_rows = (
  131. db.query(
  132. DbWeighingSlip.purchase_plan_id,
  133. DbWeighingSlip.plan_no,
  134. func.sum(DbWeighingSlip.net_weight).label("total_executed"),
  135. )
  136. .filter(*weighing_filters)
  137. .group_by(DbWeighingSlip.purchase_plan_id, DbWeighingSlip.plan_no)
  138. .all()
  139. )
  140. for row in weighing_rows:
  141. key = (row.purchase_plan_id, row.plan_no)
  142. executed_sum_map[key] = float(row.total_executed or 0)
  143. def calc_executed_for_plan(plan: DbPurchasePlan) -> float:
  144. if not executed_sum_map:
  145. return 0.0
  146. total = 0.0
  147. for (pid, pno), executed in executed_sum_map.items():
  148. if pid is not None and pid == plan.id:
  149. total += executed
  150. elif pno and plan.plan_no and pno == plan.plan_no:
  151. total += executed
  152. return total
  153. result_list: list[PurchasePlanWithFillLink] = []
  154. for plan in plans:
  155. result_list.append(
  156. plan_to_read_with_fill_link(
  157. plan,
  158. executed_quantity=calc_executed_for_plan(plan),
  159. fill_link_inventory_id=plan_id_to_inv_id.get(plan.id),
  160. )
  161. )
  162. return result_list
  163. def _generate_plan_no() -> str:
  164. from app.services.db.plan_helpers import generate_plan_no
  165. return generate_plan_no()
  166. @router.post("/", response_model=PurchasePlanRead, status_code=status.HTTP_201_CREATED)
  167. def create_plan(
  168. payload: PurchasePlanCreate,
  169. db: Session = Depends(get_db),
  170. user: DbUserContext = Depends(get_db_current_user),
  171. ) -> PurchasePlanRead:
  172. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  173. plan_no = payload.plan_no if payload.plan_no else _generate_plan_no()
  174. execution_status = payload.execution_status
  175. if not execution_status:
  176. execution_status = "待入库" if payload.business_type == "入库" else "待出库"
  177. snapshot = resolve_crm_product(db, tenant_no, payload.product_id)
  178. plan = DbPurchasePlan(
  179. id=generate_uuid(),
  180. tenant_no=tenant_no,
  181. plan_no=plan_no,
  182. plan_date=parse_date_str(payload.plan_date) or date.today(),
  183. start_date=parse_date_str(payload.start_date),
  184. end_date=parse_date_str(payload.end_date),
  185. business_type=payload.business_type,
  186. planned_quantity=payload.planned_quantity,
  187. vehicle_no=payload.vehicle_no or "",
  188. customer_name=payload.customer_name.strip() if payload.customer_name else None,
  189. remark=payload.remarks,
  190. execution_status=execution_status,
  191. executed_at=None,
  192. last_weighing_slip_id=None,
  193. executed_quantity=0,
  194. auto_generate_enabled=payload.auto_generate_enabled or 0,
  195. auto_generate_end_month=payload.auto_generate_end_month,
  196. auto_generate_last_month=None,
  197. create_by=user.id,
  198. create_dept_id=user.dept_id,
  199. )
  200. apply_product_snapshot_to_plan(plan, snapshot)
  201. db.add(plan)
  202. db.commit()
  203. db.refresh(plan)
  204. return plan_to_read(plan)
  205. class AutoGenerateMonthlyResult(BaseModel):
  206. created_count: int
  207. skipped_reason: Optional[str] = None
  208. current_month: str
  209. @router.post("/run-auto-generate", response_model=AutoGenerateMonthlyResult)
  210. def run_auto_generate_monthly(
  211. force: bool = Query(default=False, description="true 时跳过「仅每月1日」限制,供验收手动触发"),
  212. db: Session = Depends(get_db),
  213. user: DbUserContext = Depends(get_db_current_user),
  214. ) -> AutoGenerateMonthlyResult:
  215. """手动触发计划按月自动生成(定时任务同款逻辑)。"""
  216. get_tenant_no(db, user.to_tenant_user_dict())
  217. from app.services.db.auto_generate_plans import run_auto_generate_monthly_plans
  218. result = run_auto_generate_monthly_plans(db, force=force)
  219. return AutoGenerateMonthlyResult(**result)
  220. @router.get("/{id}", response_model=PurchasePlanRead)
  221. def get_plan(
  222. id: str,
  223. db: Session = Depends(get_db),
  224. user: DbUserContext = Depends(get_db_current_user),
  225. ) -> PurchasePlanRead:
  226. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  227. plan = _get_plan(db, tenant_no, id, user)
  228. return plan_to_read(plan)
  229. @router.patch("/{id}", response_model=PurchasePlanRead)
  230. def update_plan(
  231. id: str,
  232. payload: PurchasePlanUpdate,
  233. db: Session = Depends(get_db),
  234. user: DbUserContext = Depends(get_db_current_user),
  235. ) -> PurchasePlanRead:
  236. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  237. plan = _get_plan(db, tenant_no, id, user)
  238. payload_dict = payload.model_dump(exclude_unset=True)
  239. _apply_plan_product_update(db, tenant_no, plan, payload_dict)
  240. _apply_plan_update(plan, payload_dict)
  241. plan.update_by = user.id
  242. db.commit()
  243. db.refresh(plan)
  244. return plan_to_read(plan)
  245. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  246. def delete_plan(
  247. id: str,
  248. db: Session = Depends(get_db),
  249. user: DbUserContext = Depends(get_db_current_user),
  250. ) -> None:
  251. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  252. plan = _get_plan(db, tenant_no, id, user)
  253. if plan.execution_status in ("已入库", "已出库"):
  254. raise HTTPException(
  255. status_code=status.HTTP_400_BAD_REQUEST,
  256. detail="已完成的计划不能删除",
  257. )
  258. assert_row_deletable_by_creator(plan, user_info_from_context(user))
  259. plan.del_flag = 1
  260. plan.update_by = user.id
  261. db.commit()
  262. def _get_plan(
  263. db: Session, tenant_no: str, plan_id: str, user: DbUserContext
  264. ) -> DbPurchasePlan:
  265. plan = (
  266. _base_query(db, tenant_no)
  267. .filter(DbPurchasePlan.id == plan_id)
  268. .one_or_none()
  269. )
  270. if plan is None:
  271. raise HTTPException(
  272. status_code=status.HTTP_404_NOT_FOUND,
  273. detail=f"Plan {plan_id} not found",
  274. )
  275. assert_db_row_in_scope(db, plan, DbPurchasePlan, user, tenant_no)
  276. return plan
  277. def _get_plan_by_id_only(db: Session, plan_id: str) -> DbPurchasePlan:
  278. plan = (
  279. db.query(DbPurchasePlan)
  280. .filter(DbPurchasePlan.id == plan_id, DbPurchasePlan.del_flag == 0)
  281. .one_or_none()
  282. )
  283. if plan is None:
  284. raise HTTPException(
  285. status_code=status.HTTP_404_NOT_FOUND,
  286. detail=f"Plan {plan_id} not found",
  287. )
  288. return plan
  289. def _apply_plan_product_update(
  290. db: Session, tenant_no: str, plan: DbPurchasePlan, payload_dict: dict
  291. ) -> None:
  292. if "product" in payload_dict and "product_id" not in payload_dict:
  293. raise HTTPException(
  294. status_code=status.HTTP_400_BAD_REQUEST,
  295. detail="请从 CRM 重新选择产品",
  296. )
  297. product_id = payload_dict.pop("product_id", None)
  298. payload_dict.pop("product", None)
  299. payload_dict.pop("specification", None)
  300. if product_id is not None:
  301. snapshot = resolve_crm_product(db, tenant_no, product_id)
  302. apply_product_snapshot_to_plan(plan, snapshot)
  303. def _apply_plan_update(plan: DbPurchasePlan, payload_dict: dict) -> None:
  304. for key, value in payload_dict.items():
  305. if key == _PLAN_REMARK_FIELD:
  306. if isinstance(value, str):
  307. value = value.strip()
  308. plan.remark = value
  309. continue
  310. if key in _PLAN_DATE_FIELDS:
  311. if value is None:
  312. setattr(plan, key, None)
  313. else:
  314. setattr(plan, key, parse_date_str(str(value)))
  315. continue
  316. if key == "executed_at":
  317. plan.executed_at = parse_datetime_str(str(value)) if value else None
  318. continue
  319. if isinstance(value, str):
  320. value = value.strip()
  321. setattr(plan, key, value)
  322. class PlanRelatedData(BaseModel):
  323. inventories: list[InboundOutboundRead]
  324. weighings: list[WeighingSlipRead]
  325. @router.get("/{id}/related", response_model=PlanRelatedData)
  326. def get_plan_related(
  327. id: str,
  328. db: Session = Depends(get_db),
  329. user: DbUserContext = Depends(get_db_current_user),
  330. ) -> PlanRelatedData:
  331. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  332. plan = _get_plan(db, tenant_no, id, user)
  333. inventories = (
  334. db.query(DbInboundOutbound)
  335. .filter(
  336. DbInboundOutbound.tenant_no == tenant_no,
  337. DbInboundOutbound.del_flag == 0,
  338. DbInboundOutbound.purchase_plan_id == plan.id,
  339. )
  340. .order_by(DbInboundOutbound.date.desc(), DbInboundOutbound.id.desc())
  341. .all()
  342. )
  343. weighing_query = db.query(DbWeighingSlip).filter(
  344. DbWeighingSlip.tenant_no == tenant_no,
  345. DbWeighingSlip.del_flag == 0,
  346. DbWeighingSlip.is_voided == 0,
  347. )
  348. if plan.plan_no:
  349. weighing_query = weighing_query.filter(
  350. or_(
  351. DbWeighingSlip.plan_no == plan.plan_no,
  352. DbWeighingSlip.purchase_plan_id == plan.id,
  353. )
  354. )
  355. else:
  356. weighing_query = weighing_query.filter(DbWeighingSlip.purchase_plan_id == plan.id)
  357. weighings = weighing_query.order_by(DbWeighingSlip.create_time.desc()).all()
  358. return PlanRelatedData(
  359. inventories=[inventory_to_read(inv) for inv in inventories],
  360. weighings=[weighing_to_read(w) for w in weighings],
  361. )
  362. class ThirdPartyPlanPageData(BaseModel):
  363. plan: PurchasePlanRead
  364. today_inventories: list[InboundOutboundRead]
  365. month_inventories: list[InboundOutboundRead]
  366. @router.get("/{id}/third-party-page", response_model=ThirdPartyPlanPageData)
  367. def get_third_party_plan_page(
  368. id: str,
  369. db: Session = Depends(get_db),
  370. ) -> ThirdPartyPlanPageData:
  371. """三方 H5 填写页初始化数据(无需门户登录,与原系统一致)。"""
  372. plan = _get_plan_by_id_only(db, id)
  373. tenant_no = plan.tenant_no
  374. today = date.today()
  375. start = plan.start_date
  376. end = plan.end_date
  377. if start and today < start:
  378. raise HTTPException(
  379. status_code=status.HTTP_400_BAD_REQUEST,
  380. detail="计划尚未生效,当前日期不允许填写出入库单",
  381. )
  382. if end and today > end:
  383. raise HTTPException(
  384. status_code=status.HTTP_400_BAD_REQUEST,
  385. detail="计划已过期,当前日期不允许填写出入库单",
  386. )
  387. today_str = today.strftime("%Y-%m-%d")
  388. today_date = parse_date_str(today_str)
  389. inventories = (
  390. db.query(DbInboundOutbound)
  391. .filter(
  392. DbInboundOutbound.tenant_no == tenant_no,
  393. DbInboundOutbound.del_flag == 0,
  394. DbInboundOutbound.purchase_plan_id == plan.id,
  395. DbInboundOutbound.date == today_date,
  396. )
  397. .order_by(DbInboundOutbound.date.desc(), DbInboundOutbound.id.desc())
  398. .all()
  399. )
  400. month_start = today.replace(day=1)
  401. month_history = (
  402. db.query(DbInboundOutbound)
  403. .filter(
  404. DbInboundOutbound.tenant_no == tenant_no,
  405. DbInboundOutbound.del_flag == 0,
  406. DbInboundOutbound.purchase_plan_id == plan.id,
  407. DbInboundOutbound.date >= month_start,
  408. DbInboundOutbound.date < today,
  409. )
  410. .order_by(DbInboundOutbound.date.desc(), DbInboundOutbound.id.desc())
  411. .all()
  412. )
  413. return ThirdPartyPlanPageData(
  414. plan=plan_to_read(plan),
  415. today_inventories=[inventory_to_read(inv) for inv in inventories],
  416. month_inventories=[inventory_to_read(inv) for inv in month_history],
  417. )