plans.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. from typing import Any, List, Optional, Tuple
  2. from datetime import date, datetime
  3. from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
  4. from fastapi.responses import JSONResponse
  5. from pydantic import BaseModel, ConfigDict, Field
  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
  10. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  11. from app.core.tenant_context import get_tenant_no
  12. from app.core.utils import generate_uuid
  13. from app.services.db_data_scope import apply_db_scope, assert_db_row_in_scope
  14. from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
  15. from app.schemas.db.inventory import InboundOutboundRead
  16. from app.schemas.db.plans import (
  17. PurchasePlanCreate,
  18. PurchasePlanListResponse,
  19. PurchasePlanRead,
  20. PurchasePlanUpdate,
  21. PurchasePlanWithFillLink,
  22. )
  23. from app.schemas.db.weighing import WeighingSlipRead
  24. from app.services.db.mappers import (
  25. inventory_to_read,
  26. load_user_display_name_map,
  27. parse_date_str,
  28. parse_datetime_str,
  29. plan_to_read,
  30. plan_to_read_with_fill_link,
  31. weighing_to_read,
  32. )
  33. from app.services.db.plan_list_filters import build_plan_column_filters
  34. from app.services.db.product_helpers import (
  35. apply_product_snapshot_to_plan,
  36. resolve_crm_product,
  37. )
  38. from app.utils.excel_export import (
  39. EXPORT_MAX_ROWS,
  40. ExportColumn,
  41. ExportValidationError,
  42. export_rows_to_xlsx_response,
  43. filter_export_columns,
  44. )
  45. router = APIRouter(prefix="/plans", tags=["plans"])
  46. STATUS_THIRD_PARTY_FILL = "待三方填写"
  47. _PLAN_DATE_FIELDS = {"plan_date", "start_date", "end_date"}
  48. _PLAN_REMARK_FIELD = "remarks"
  49. def _base_query(db: Session, tenant_no: str):
  50. return db.query(DbPurchasePlan).filter(
  51. DbPurchasePlan.tenant_no == tenant_no,
  52. DbPurchasePlan.del_flag == 0,
  53. )
  54. def _merge_query(request: Request, **known) -> dict:
  55. out = {k: v for k, v in known.items() if v is not None and v != ""}
  56. for k, v in request.query_params.multi_items():
  57. if k in ("page", "pageSize"):
  58. continue
  59. if k not in out and v not in (None, ""):
  60. out[k] = v
  61. return out
  62. def _param_get(params: dict, *keys: str) -> Any:
  63. for key in keys:
  64. if key in params and params[key] not in (None, ""):
  65. return params[key]
  66. return None
  67. def _filters_from_params(params: dict) -> list:
  68. return build_plan_column_filters(
  69. plan_no=_param_get(params, "planNo", "plan_no"),
  70. plan_no_in=_param_get(params, "planNoIn", "plan_no_in"),
  71. customer_name=_param_get(params, "customerName", "customer_name"),
  72. customer_name_in=_param_get(params, "customerNameIn", "customer_name_in"),
  73. product=_param_get(params, "product"),
  74. product_in=_param_get(params, "productIn", "product_in"),
  75. specification=_param_get(params, "specification"),
  76. specification_in=_param_get(params, "specificationIn", "specification_in"),
  77. business_type=_param_get(params, "businessType", "business_type"),
  78. business_type_in=_param_get(params, "businessTypeIn", "business_type_in"),
  79. execution_status=_param_get(params, "executionStatus", "execution_status"),
  80. execution_status_in=_param_get(params, "executionStatusIn", "execution_status_in"),
  81. planned_quantity_op=_param_get(params, "plannedQuantityOp", "planned_quantity_op"),
  82. planned_quantity=_param_get(params, "plannedQuantity", "planned_quantity"),
  83. planned_quantity_min=_param_get(params, "plannedQuantityMin", "planned_quantity_min"),
  84. planned_quantity_max=_param_get(params, "plannedQuantityMax", "planned_quantity_max"),
  85. executed_quantity_op=_param_get(params, "executedQuantityOp", "executed_quantity_op"),
  86. executed_quantity=_param_get(params, "executedQuantity", "executed_quantity"),
  87. executed_quantity_min=_param_get(params, "executedQuantityMin", "executed_quantity_min"),
  88. executed_quantity_max=_param_get(params, "executedQuantityMax", "executed_quantity_max"),
  89. created_from=_param_get(params, "createdFrom", "created_from"),
  90. created_to=_param_get(params, "createdTo", "created_to"),
  91. start_date_from=_param_get(params, "startDateFrom", "start_date_from"),
  92. start_date_to=_param_get(params, "startDateTo", "start_date_to"),
  93. )
  94. def _body_to_params(body: "PlanExportBody") -> dict:
  95. data = body.model_dump(by_alias=True, exclude_none=True)
  96. data.pop("ids", None)
  97. data.pop("columns", None)
  98. extra = getattr(body, "model_extra", None) or {}
  99. for key, value in extra.items():
  100. if key not in data and value not in (None, ""):
  101. data[key] = value
  102. return data
  103. def _plans_query(db: Session, user: DbUserContext, params: dict):
  104. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  105. query = _base_query(db, tenant_no).order_by(DbPurchasePlan.create_time.desc())
  106. q = _param_get(params, "q")
  107. if q:
  108. keyword = str(q).lower()
  109. query = query.filter(
  110. or_(
  111. func.lower(DbPurchasePlan.customer_name).contains(keyword),
  112. func.lower(DbPurchasePlan.product).contains(keyword),
  113. func.lower(DbPurchasePlan.plan_no).contains(keyword),
  114. )
  115. )
  116. column_filters = _filters_from_params(params)
  117. if column_filters:
  118. query = query.filter(*column_filters)
  119. return apply_db_scope(query, DbPurchasePlan, user, db, tenant_no), tenant_no
  120. def _map_plans(
  121. db: Session, tenant_no: str, plans: list
  122. ) -> list[PurchasePlanWithFillLink]:
  123. if not plans:
  124. return []
  125. plan_ids = [p.id for p in plans]
  126. fill_links = (
  127. db.query(DbInboundOutbound)
  128. .filter(
  129. DbInboundOutbound.tenant_no == tenant_no,
  130. DbInboundOutbound.del_flag == 0,
  131. DbInboundOutbound.purchase_plan_id.in_(plan_ids),
  132. DbInboundOutbound.status == STATUS_THIRD_PARTY_FILL,
  133. )
  134. .all()
  135. )
  136. plan_id_to_inv_id = {
  137. inv.purchase_plan_id: inv.id for inv in fill_links if inv.purchase_plan_id
  138. }
  139. plan_nos = [p.plan_no for p in plans if p.plan_no]
  140. executed_sum_map: dict[tuple[Optional[str], Optional[str]], float] = {}
  141. if plan_ids or plan_nos:
  142. weighing_filters = [DbWeighingSlip.is_voided == 0, DbWeighingSlip.del_flag == 0]
  143. weighing_filters.append(DbWeighingSlip.tenant_no == tenant_no)
  144. id_filters = []
  145. if plan_ids:
  146. id_filters.append(DbWeighingSlip.purchase_plan_id.in_(plan_ids))
  147. if plan_nos:
  148. id_filters.append(DbWeighingSlip.plan_no.in_(plan_nos))
  149. weighing_filters.append(or_(*id_filters))
  150. weighing_rows = (
  151. db.query(
  152. DbWeighingSlip.purchase_plan_id,
  153. DbWeighingSlip.plan_no,
  154. func.sum(DbWeighingSlip.net_weight).label("total_executed"),
  155. )
  156. .filter(*weighing_filters)
  157. .group_by(DbWeighingSlip.purchase_plan_id, DbWeighingSlip.plan_no)
  158. .all()
  159. )
  160. for row in weighing_rows:
  161. key = (row.purchase_plan_id, row.plan_no)
  162. executed_sum_map[key] = float(row.total_executed or 0)
  163. def calc_executed_for_plan(plan: DbPurchasePlan) -> float:
  164. if not executed_sum_map:
  165. return 0.0
  166. total = 0.0
  167. for (pid, pno), executed in executed_sum_map.items():
  168. if pid is not None and pid == plan.id:
  169. total += executed
  170. elif pno and plan.plan_no and pno == plan.plan_no:
  171. total += executed
  172. return total
  173. user_name_map = load_user_display_name_map(db, [p.create_by for p in plans])
  174. result_list: list[PurchasePlanWithFillLink] = []
  175. for plan in plans:
  176. result_list.append(
  177. plan_to_read_with_fill_link(
  178. plan,
  179. executed_quantity=calc_executed_for_plan(plan),
  180. fill_link_inventory_id=plan_id_to_inv_id.get(plan.id),
  181. user_name_map=user_name_map,
  182. )
  183. )
  184. return result_list
  185. def _fetch_plans(
  186. db: Session,
  187. user: DbUserContext,
  188. params: dict,
  189. *,
  190. page: Optional[int] = None,
  191. page_size: Optional[int] = None,
  192. ) -> Tuple[list[PurchasePlanWithFillLink], int]:
  193. query, tenant_no = _plans_query(db, user, params)
  194. if page is not None and page_size is not None:
  195. total = query.order_by(None).count()
  196. plans = query.offset((page - 1) * page_size).limit(page_size).all()
  197. return _map_plans(db, tenant_no, plans), total
  198. plans = query.all()
  199. items = _map_plans(db, tenant_no, plans)
  200. return items, len(items)
  201. @router.get("/")
  202. def list_plans(
  203. request: Request,
  204. q: Optional[str] = Query(default=None, description="模糊搜索往来单位或产品"),
  205. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划号"),
  206. customer_name: Optional[str] = Query(
  207. default=None, alias="customerName", description="往来单位"
  208. ),
  209. product: Optional[str] = Query(default=None, description="产品"),
  210. business_type: Optional[str] = Query(default=None, alias="businessType", description="业务类型"),
  211. execution_status: Optional[str] = Query(
  212. default=None, alias="executionStatus", description="执行状态"
  213. ),
  214. created_from: Optional[str] = Query(default=None, alias="createdFrom", description="创建开始日期"),
  215. created_to: Optional[str] = Query(default=None, alias="createdTo", description="创建结束日期"),
  216. start_date_from: Optional[str] = Query(
  217. default=None,
  218. alias="startDateFrom",
  219. description="筛选区间开始:与计划有效期(起始~截止)有交集",
  220. ),
  221. start_date_to: Optional[str] = Query(
  222. default=None,
  223. alias="startDateTo",
  224. description="筛选区间结束:与计划有效期(起始~截止)有交集",
  225. ),
  226. page: Optional[int] = Query(default=None, ge=1, description="页码;传入则返回分页结构"),
  227. page_size: Optional[int] = Query(
  228. default=None, ge=1, le=500, alias="pageSize", description="每页条数"
  229. ),
  230. db: Session = Depends(get_db),
  231. user: DbUserContext = Depends(get_db_current_user),
  232. ):
  233. params = _merge_query(
  234. request,
  235. q=q,
  236. planNo=plan_no,
  237. customerName=customer_name,
  238. product=product,
  239. businessType=business_type,
  240. executionStatus=execution_status,
  241. createdFrom=created_from,
  242. createdTo=created_to,
  243. startDateFrom=start_date_from,
  244. startDateTo=start_date_to,
  245. )
  246. paged = page is not None
  247. size = page_size if page_size is not None else 20
  248. page_num = page if page is not None else 1
  249. items, total = _fetch_plans(
  250. db,
  251. user,
  252. params,
  253. page=page_num if paged else None,
  254. page_size=size if paged else None,
  255. )
  256. if paged:
  257. body = PurchasePlanListResponse(
  258. items=items, total=total, page=page_num, pageSize=size
  259. )
  260. return JSONResponse(content=body.model_dump(by_alias=True))
  261. return JSONResponse(content=[item.model_dump(by_alias=True) for item in items])
  262. PLAN_EXPORT_COLUMNS = [
  263. ExportColumn(title="计划号", field="planNo"),
  264. ExportColumn(title="业务类型", field="businessType"),
  265. ExportColumn(title="往来单位", field="customerName"),
  266. ExportColumn(title="计划日期", field="planDate"),
  267. ExportColumn(title="起始日期", field="startDate"),
  268. ExportColumn(title="截止日期", field="endDate"),
  269. ExportColumn(title="产品", field="product"),
  270. ExportColumn(title="规格", field="specification"),
  271. ExportColumn(title="车牌号", field="vehicleNo"),
  272. ExportColumn(title="计划量(吨)", field="plannedQuantity"),
  273. ExportColumn(title="已执行量(吨)", field="executedQuantity"),
  274. ExportColumn(title="执行状态", field="executionStatus"),
  275. ExportColumn(title="备注", field="remarks"),
  276. ExportColumn(title="自动生成", field="autoGenerate"),
  277. ExportColumn(title="创建人", field="createByName"),
  278. ]
  279. class PlanColumnFacetBody(BaseModel):
  280. """地磅计划列头可选值(去重;可附带列表上下文)"""
  281. model_config = ConfigDict(populate_by_name=True, extra="allow")
  282. field: str = Field(..., description="planNo | customerName | product | specification")
  283. keyword: Optional[str] = Field(default=None, description="可选值关键字")
  284. limit: Optional[int] = Field(default=500, ge=1, le=500, description="返回条数上限")
  285. _PLAN_FACET_COLUMNS = {
  286. "planNo": DbPurchasePlan.plan_no,
  287. "customerName": DbPurchasePlan.customer_name,
  288. "product": DbPurchasePlan.product,
  289. "specification": DbPurchasePlan.specification,
  290. }
  291. @router.post("/column-facet-values")
  292. def plan_column_facet_values(
  293. body: PlanColumnFacetBody,
  294. db: Session = Depends(get_db),
  295. user: DbUserContext = Depends(get_db_current_user),
  296. ):
  297. """地磅计划列头可选值:在列表上下文(排除本列)下去重。"""
  298. from app.services.column_facet import list_column_facet_values
  299. from app.services.pl.facet_context import strip_simple_facet_self_params
  300. column = _PLAN_FACET_COLUMNS.get(body.field)
  301. if column is None:
  302. raise HTTPException(status_code=400, detail="不支持的列")
  303. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  304. params = strip_simple_facet_self_params(_body_to_params(body), body.field)
  305. extra_filters = list(_filters_from_params(params))
  306. qv = _param_get(params, "q")
  307. if qv:
  308. keyword = str(qv).lower()
  309. extra_filters.append(
  310. or_(
  311. func.lower(DbPurchasePlan.customer_name).contains(keyword),
  312. func.lower(DbPurchasePlan.product).contains(keyword),
  313. func.lower(DbPurchasePlan.plan_no).contains(keyword),
  314. )
  315. )
  316. rows = list_column_facet_values(
  317. db,
  318. DbPurchasePlan,
  319. column,
  320. tenant_no=tenant_no,
  321. keyword=body.keyword,
  322. limit=body.limit if body.limit is not None else 500,
  323. apply_scope=lambda q: apply_db_scope(q, DbPurchasePlan, user, db, tenant_no),
  324. extra_filters=extra_filters or None,
  325. )
  326. return {"list": rows}
  327. class PlanExportBody(BaseModel):
  328. q: Optional[str] = None
  329. plan_no: Optional[str] = Field(default=None, alias="planNo")
  330. customer_name: Optional[str] = Field(default=None, alias="customerName")
  331. product: Optional[str] = None
  332. business_type: Optional[str] = Field(default=None, alias="businessType")
  333. execution_status: Optional[str] = Field(default=None, alias="executionStatus")
  334. created_from: Optional[str] = Field(default=None, alias="createdFrom")
  335. created_to: Optional[str] = Field(default=None, alias="createdTo")
  336. start_date_from: Optional[str] = Field(default=None, alias="startDateFrom")
  337. start_date_to: Optional[str] = Field(default=None, alias="startDateTo")
  338. ids: Optional[List[str]] = None
  339. columns: Optional[List[str]] = None
  340. model_config = {"populate_by_name": True, "extra": "allow"}
  341. @router.post("/export")
  342. def export_plans(
  343. body: PlanExportBody,
  344. db: Session = Depends(get_db),
  345. user: DbUserContext = Depends(get_db_current_user),
  346. ):
  347. """导出计划列表(当前筛选全量,或 ids 已选)。"""
  348. try:
  349. items, _total = _fetch_plans(db, user, _body_to_params(body))
  350. if body.ids:
  351. id_set = set(body.ids)
  352. items = [x for x in items if x.id in id_set]
  353. if len(items) > EXPORT_MAX_ROWS:
  354. raise ExportValidationError(f"导出超过 {EXPORT_MAX_ROWS} 条,请缩小筛选范围")
  355. rows = []
  356. for item in items:
  357. data = item.model_dump(by_alias=True)
  358. data["autoGenerate"] = "是" if data.get("autoGenerateEnabled") else "否"
  359. eq = data.get("executedQuantity") or 0
  360. try:
  361. data["executedQuantity"] = round(float(eq) / 1000, 2)
  362. except (TypeError, ValueError):
  363. data["executedQuantity"] = 0
  364. if not data.get("createByName"):
  365. data["createByName"] = data.get("createBy") or ""
  366. rows.append(data)
  367. cols = filter_export_columns(PLAN_EXPORT_COLUMNS, body.columns)
  368. date_str = datetime.now().strftime("%Y%m%d")
  369. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  370. return export_rows_to_xlsx_response(
  371. db,
  372. tenant_no=tenant_no,
  373. sheet_title="计划列表",
  374. columns=cols,
  375. rows=rows,
  376. filename_stem=f"计划列表_{date_str}",
  377. )
  378. except ExportValidationError as e:
  379. raise HTTPException(status_code=400, detail=str(e)) from e
  380. def _generate_plan_no() -> str:
  381. from app.services.db.plan_helpers import generate_plan_no
  382. return generate_plan_no()
  383. @router.post("/", response_model=PurchasePlanRead, status_code=status.HTTP_201_CREATED)
  384. def create_plan(
  385. payload: PurchasePlanCreate,
  386. db: Session = Depends(get_db),
  387. user: DbUserContext = Depends(get_db_current_user),
  388. ) -> PurchasePlanRead:
  389. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  390. plan_no = payload.plan_no if payload.plan_no else _generate_plan_no()
  391. execution_status = payload.execution_status
  392. if not execution_status:
  393. execution_status = "待入库" if payload.business_type == "入库" else "待出库"
  394. snapshot = resolve_crm_product(db, tenant_no, payload.product_id)
  395. plan = DbPurchasePlan(
  396. id=generate_uuid(),
  397. tenant_no=tenant_no,
  398. plan_no=plan_no,
  399. plan_date=parse_date_str(payload.plan_date) or date.today(),
  400. start_date=parse_date_str(payload.start_date),
  401. end_date=parse_date_str(payload.end_date),
  402. business_type=payload.business_type,
  403. planned_quantity=payload.planned_quantity,
  404. vehicle_no=payload.vehicle_no or "",
  405. customer_name=payload.customer_name.strip() if payload.customer_name else None,
  406. remark=payload.remarks,
  407. execution_status=execution_status,
  408. executed_at=None,
  409. last_weighing_slip_id=None,
  410. executed_quantity=0,
  411. auto_generate_enabled=payload.auto_generate_enabled or 0,
  412. auto_generate_end_month=payload.auto_generate_end_month,
  413. auto_generate_last_month=None,
  414. create_by=user.id,
  415. create_dept_id=user.dept_id,
  416. )
  417. apply_product_snapshot_to_plan(plan, snapshot)
  418. db.add(plan)
  419. db.commit()
  420. db.refresh(plan)
  421. return plan_to_read(plan, db=db)
  422. class AutoGenerateMonthlyResult(BaseModel):
  423. created_count: int
  424. skipped_reason: Optional[str] = None
  425. current_month: str
  426. @router.post("/run-auto-generate", response_model=AutoGenerateMonthlyResult)
  427. def run_auto_generate_monthly(
  428. force: bool = Query(default=False, description="true 时跳过「仅每月1日」限制,供验收手动触发"),
  429. db: Session = Depends(get_db),
  430. user: DbUserContext = Depends(get_db_current_user),
  431. ) -> AutoGenerateMonthlyResult:
  432. """手动触发计划按月自动生成(定时任务同款逻辑)。"""
  433. get_tenant_no(db, user.to_tenant_user_dict())
  434. from app.services.db.auto_generate_plans import run_auto_generate_monthly_plans
  435. result = run_auto_generate_monthly_plans(db, force=force)
  436. return AutoGenerateMonthlyResult(**result)
  437. @router.get("/{id}", response_model=PurchasePlanRead)
  438. def get_plan(
  439. id: str,
  440. db: Session = Depends(get_db),
  441. user: DbUserContext = Depends(get_db_current_user),
  442. ) -> PurchasePlanRead:
  443. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  444. plan = _get_plan(db, tenant_no, id, user)
  445. return plan_to_read(plan, db=db)
  446. @router.patch("/{id}", response_model=PurchasePlanRead)
  447. def update_plan(
  448. id: str,
  449. payload: PurchasePlanUpdate,
  450. db: Session = Depends(get_db),
  451. user: DbUserContext = Depends(get_db_current_user),
  452. ) -> PurchasePlanRead:
  453. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  454. plan = _get_plan(db, tenant_no, id, user)
  455. payload_dict = payload.model_dump(exclude_unset=True)
  456. _apply_plan_product_update(db, tenant_no, plan, payload_dict)
  457. _apply_plan_update(plan, payload_dict)
  458. plan.update_by = user.id
  459. db.commit()
  460. db.refresh(plan)
  461. return plan_to_read(plan, db=db)
  462. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  463. def delete_plan(
  464. id: str,
  465. db: Session = Depends(get_db),
  466. user: DbUserContext = Depends(get_db_current_user),
  467. ) -> None:
  468. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  469. plan = _get_plan(db, tenant_no, id, user)
  470. if plan.execution_status in ("已入库", "已出库"):
  471. raise HTTPException(
  472. status_code=status.HTTP_400_BAD_REQUEST,
  473. detail="已完成的计划不能删除",
  474. )
  475. assert_row_deletable_by_creator(plan, user_info_from_context(user))
  476. plan.del_flag = 1
  477. plan.update_by = user.id
  478. db.commit()
  479. def _get_plan(
  480. db: Session, tenant_no: str, plan_id: str, user: DbUserContext
  481. ) -> DbPurchasePlan:
  482. plan = (
  483. _base_query(db, tenant_no)
  484. .filter(DbPurchasePlan.id == plan_id)
  485. .one_or_none()
  486. )
  487. if plan is None:
  488. raise HTTPException(
  489. status_code=status.HTTP_404_NOT_FOUND,
  490. detail=f"Plan {plan_id} not found",
  491. )
  492. assert_db_row_in_scope(db, plan, DbPurchasePlan, user, tenant_no)
  493. return plan
  494. def _get_plan_by_id_only(db: Session, plan_id: str) -> DbPurchasePlan:
  495. plan = (
  496. db.query(DbPurchasePlan)
  497. .filter(DbPurchasePlan.id == plan_id, DbPurchasePlan.del_flag == 0)
  498. .one_or_none()
  499. )
  500. if plan is None:
  501. raise HTTPException(
  502. status_code=status.HTTP_404_NOT_FOUND,
  503. detail=f"Plan {plan_id} not found",
  504. )
  505. return plan
  506. def _apply_plan_product_update(
  507. db: Session, tenant_no: str, plan: DbPurchasePlan, payload_dict: dict
  508. ) -> None:
  509. if "product" in payload_dict and "product_id" not in payload_dict:
  510. raise HTTPException(
  511. status_code=status.HTTP_400_BAD_REQUEST,
  512. detail="请从 CRM 重新选择产品",
  513. )
  514. product_id = payload_dict.pop("product_id", None)
  515. payload_dict.pop("product", None)
  516. payload_dict.pop("specification", None)
  517. if product_id is not None:
  518. snapshot = resolve_crm_product(db, tenant_no, product_id)
  519. apply_product_snapshot_to_plan(plan, snapshot)
  520. def _apply_plan_update(plan: DbPurchasePlan, payload_dict: dict) -> None:
  521. for key, value in payload_dict.items():
  522. if key == _PLAN_REMARK_FIELD:
  523. if isinstance(value, str):
  524. value = value.strip()
  525. plan.remark = value
  526. continue
  527. if key in _PLAN_DATE_FIELDS:
  528. if value is None:
  529. setattr(plan, key, None)
  530. else:
  531. setattr(plan, key, parse_date_str(str(value)))
  532. continue
  533. if key == "executed_at":
  534. plan.executed_at = parse_datetime_str(str(value)) if value else None
  535. continue
  536. if isinstance(value, str):
  537. value = value.strip()
  538. setattr(plan, key, value)
  539. class PlanRelatedData(BaseModel):
  540. inventories: list[InboundOutboundRead]
  541. weighings: list[WeighingSlipRead]
  542. @router.get("/{id}/related", response_model=PlanRelatedData)
  543. def get_plan_related(
  544. id: str,
  545. db: Session = Depends(get_db),
  546. user: DbUserContext = Depends(get_db_current_user),
  547. ) -> PlanRelatedData:
  548. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  549. plan = _get_plan(db, tenant_no, id, user)
  550. inventories = (
  551. db.query(DbInboundOutbound)
  552. .filter(
  553. DbInboundOutbound.tenant_no == tenant_no,
  554. DbInboundOutbound.del_flag == 0,
  555. DbInboundOutbound.purchase_plan_id == plan.id,
  556. )
  557. .order_by(DbInboundOutbound.date.desc(), DbInboundOutbound.id.desc())
  558. .all()
  559. )
  560. weighing_query = db.query(DbWeighingSlip).filter(
  561. DbWeighingSlip.tenant_no == tenant_no,
  562. DbWeighingSlip.del_flag == 0,
  563. DbWeighingSlip.is_voided == 0,
  564. )
  565. if plan.plan_no:
  566. weighing_query = weighing_query.filter(
  567. or_(
  568. DbWeighingSlip.plan_no == plan.plan_no,
  569. DbWeighingSlip.purchase_plan_id == plan.id,
  570. )
  571. )
  572. else:
  573. weighing_query = weighing_query.filter(DbWeighingSlip.purchase_plan_id == plan.id)
  574. weighings = weighing_query.order_by(DbWeighingSlip.create_time.desc()).all()
  575. user_name_map = load_user_display_name_map(
  576. db,
  577. [inv.create_by for inv in inventories] + [w.create_by for w in weighings],
  578. )
  579. return PlanRelatedData(
  580. inventories=[inventory_to_read(inv, user_name_map=user_name_map) for inv in inventories],
  581. weighings=[weighing_to_read(w, user_name_map=user_name_map) for w in weighings],
  582. )
  583. class ThirdPartyPlanPageData(BaseModel):
  584. plan: PurchasePlanRead
  585. today_inventories: list[InboundOutboundRead]
  586. month_inventories: list[InboundOutboundRead]
  587. @router.get("/{id}/third-party-page", response_model=ThirdPartyPlanPageData)
  588. def get_third_party_plan_page(
  589. id: str,
  590. db: Session = Depends(get_db),
  591. ) -> ThirdPartyPlanPageData:
  592. """三方 H5 填写页初始化数据(无需门户登录,与原系统一致)。"""
  593. plan = _get_plan_by_id_only(db, id)
  594. tenant_no = plan.tenant_no
  595. today = date.today()
  596. start = plan.start_date
  597. end = plan.end_date
  598. if start and today < start:
  599. raise HTTPException(
  600. status_code=status.HTTP_400_BAD_REQUEST,
  601. detail="计划尚未生效,当前日期不允许填写出入库单",
  602. )
  603. if end and today > end:
  604. raise HTTPException(
  605. status_code=status.HTTP_400_BAD_REQUEST,
  606. detail="计划已过期,当前日期不允许填写出入库单",
  607. )
  608. today_str = today.strftime("%Y-%m-%d")
  609. today_date = parse_date_str(today_str)
  610. inventories = (
  611. db.query(DbInboundOutbound)
  612. .filter(
  613. DbInboundOutbound.tenant_no == tenant_no,
  614. DbInboundOutbound.del_flag == 0,
  615. DbInboundOutbound.purchase_plan_id == plan.id,
  616. DbInboundOutbound.date == today_date,
  617. )
  618. .order_by(DbInboundOutbound.date.desc(), DbInboundOutbound.id.desc())
  619. .all()
  620. )
  621. month_start = today.replace(day=1)
  622. month_history = (
  623. db.query(DbInboundOutbound)
  624. .filter(
  625. DbInboundOutbound.tenant_no == tenant_no,
  626. DbInboundOutbound.del_flag == 0,
  627. DbInboundOutbound.purchase_plan_id == plan.id,
  628. DbInboundOutbound.date >= month_start,
  629. DbInboundOutbound.date < today,
  630. )
  631. .order_by(DbInboundOutbound.date.desc(), DbInboundOutbound.id.desc())
  632. .all()
  633. )
  634. user_name_map = load_user_display_name_map(
  635. db,
  636. [plan.create_by]
  637. + [inv.create_by for inv in inventories]
  638. + [inv.create_by for inv in month_history],
  639. )
  640. return ThirdPartyPlanPageData(
  641. plan=plan_to_read(plan, user_name_map=user_name_map),
  642. today_inventories=[
  643. inventory_to_read(inv, user_name_map=user_name_map) for inv in inventories
  644. ],
  645. month_inventories=[
  646. inventory_to_read(inv, user_name_map=user_name_map) for inv in month_history
  647. ],
  648. )