inventory.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. """运输计划管理 - 盘存统计 API"""
  2. from datetime import date, datetime
  3. from decimal import Decimal
  4. from typing import Any, List, Optional
  5. from fastapi import APIRouter, Depends, HTTPException, Query, Request
  6. from pydantic import BaseModel, Field
  7. from sqlalchemy import func, or_
  8. from sqlalchemy.orm import Session
  9. from app.core.database import get_db
  10. from app.core.db_auth import DbUserContext, get_db_current_user
  11. from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
  12. from app.core.tenant_context import get_tenant_no
  13. from app.utils.excel_export import (
  14. EXPORT_MAX_ROWS,
  15. ExportColumn,
  16. ExportValidationError,
  17. export_rows_to_xlsx_response,
  18. filter_export_columns,
  19. )
  20. from app.core.utils import generate_uuid
  21. from app.models.pl_models import (
  22. PlDocumentReminder,
  23. PlInventory,
  24. PlInventoryTransaction,
  25. PlInventoryWarning,
  26. )
  27. from app.schemas.pl.inventory import (
  28. DocumentReminderRead,
  29. DocumentReminderStatsRead,
  30. DocumentReminderUpdate,
  31. InventoryLedgerQuery,
  32. InventoryLedgerResponse,
  33. InventoryRead,
  34. InventorySummaryItem,
  35. InventorySummaryResponse,
  36. InventoryTransactionQuery,
  37. InventoryTransactionRead,
  38. InventoryTransactionResponse,
  39. InventoryWarningRead,
  40. InventoryWarningThreshold,
  41. InventoryWarningUpdate,
  42. TransportStatsItem,
  43. TransportStatsResponse,
  44. )
  45. from app.services.pl_cert_service import scan_cert_expiry
  46. router = APIRouter(prefix="/inventory", tags=["运输计划-盘存统计"])
  47. # =============================================================================
  48. # 库存台账
  49. # =============================================================================
  50. @router.get("/ledger", response_model=InventoryLedgerResponse)
  51. def get_inventory_ledger(
  52. product_name: Optional[str] = Query(default=None, alias="productName", description="产品名称"),
  53. page: int = Query(default=1, ge=1),
  54. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  55. db: Session = Depends(get_db),
  56. user: DbUserContext = Depends(get_db_current_user),
  57. ) -> InventoryLedgerResponse:
  58. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  59. query = db.query(PlInventory).filter(
  60. PlInventory.tenant_no == tenant_no,
  61. PlInventory.del_flag == 0,
  62. )
  63. if product_name:
  64. query = query.filter(PlInventory.product_name.contains(product_name))
  65. query = apply_pl_scope(query, PlInventory, user, db, tenant_no)
  66. total = query.count()
  67. offset = (page - 1) * page_size
  68. items = query.order_by(PlInventory.product_name).offset(offset).limit(page_size).all()
  69. return InventoryLedgerResponse(total=total, list=items)
  70. # =============================================================================
  71. # 库存预警阈值设置
  72. # =============================================================================
  73. LEDGER_EXPORT_COLUMNS = [
  74. ExportColumn(title="产品名称", field="productName"),
  75. ExportColumn(title="品牌", field="brandName"),
  76. ExportColumn(title="产品编号", field="productNo"),
  77. ExportColumn(title="规格型号", field="model"),
  78. ExportColumn(title="单位", field="unit"),
  79. ExportColumn(title="库存数量", field="stockQty"),
  80. ExportColumn(title="预警阈值", field="warnThreshold"),
  81. ExportColumn(title="状态", field="status"),
  82. ]
  83. class InventoryLedgerExportBody(BaseModel):
  84. product_name: Optional[str] = Field(default=None, alias="productName")
  85. ids: Optional[List[str]] = None
  86. columns: Optional[List[str]] = None
  87. model_config = {"populate_by_name": True}
  88. @router.post("/ledger/export")
  89. def export_inventory_ledger(
  90. body: InventoryLedgerExportBody,
  91. db: Session = Depends(get_db),
  92. user: DbUserContext = Depends(get_db_current_user),
  93. ):
  94. try:
  95. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  96. query = db.query(PlInventory).filter(
  97. PlInventory.tenant_no == tenant_no,
  98. PlInventory.del_flag == 0,
  99. )
  100. if body.product_name:
  101. query = query.filter(PlInventory.product_name.contains(body.product_name))
  102. if body.ids:
  103. query = query.filter(PlInventory.id.in_(body.ids))
  104. query = apply_pl_scope(query, PlInventory, user, db, tenant_no)
  105. items = query.order_by(PlInventory.product_name).limit(EXPORT_MAX_ROWS + 1).all()
  106. rows = []
  107. for x in items:
  108. data = InventoryRead.model_validate(x).model_dump(by_alias=True)
  109. low = data.get("lowStockWarning")
  110. high = data.get("highStockWarning")
  111. data["warnThreshold"] = f"{low or '-'} ~ {high or '-'}"
  112. stock = data.get("stockQty") or 0
  113. try:
  114. stock_f = float(stock)
  115. low_f = float(low or 0)
  116. except (TypeError, ValueError):
  117. stock_f, low_f = 0.0, 0.0
  118. data["status"] = "库存不足" if stock_f < low_f else "正常"
  119. rows.append(data)
  120. cols = filter_export_columns(LEDGER_EXPORT_COLUMNS, body.columns)
  121. date_str = datetime.now().strftime("%Y%m%d")
  122. return export_rows_to_xlsx_response(
  123. db, tenant_no=tenant_no, sheet_title="库存台账", columns=cols, rows=rows,
  124. filename_stem=f"库存台账_{date_str}",
  125. )
  126. except ExportValidationError as e:
  127. raise HTTPException(status_code=400, detail=str(e)) from e
  128. @router.post("/warning-threshold")
  129. def set_warning_threshold(
  130. payload: InventoryWarningThreshold,
  131. db: Session = Depends(get_db),
  132. user: DbUserContext = Depends(get_db_current_user),
  133. ) -> dict:
  134. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  135. brand_id = payload.brand_id or ""
  136. inventory = (
  137. db.query(PlInventory)
  138. .filter(
  139. PlInventory.product_id == payload.product_id,
  140. PlInventory.brand_id == brand_id,
  141. PlInventory.tenant_no == tenant_no,
  142. PlInventory.del_flag == 0,
  143. )
  144. .first()
  145. )
  146. if inventory:
  147. assert_pl_row_in_scope(db, inventory, PlInventory, user, tenant_no)
  148. inventory.low_stock_warning = payload.low_stock_warning
  149. inventory.high_stock_warning = payload.high_stock_warning
  150. if payload.brand_name is not None:
  151. inventory.brand_name = payload.brand_name
  152. else:
  153. # 创建库存记录
  154. inventory = PlInventory(
  155. id=generate_uuid(),
  156. tenant_no=tenant_no,
  157. product_id=payload.product_id,
  158. brand_id=brand_id,
  159. brand_name=payload.brand_name,
  160. product_no="",
  161. product_name="",
  162. unit="吨",
  163. stock_qty=0,
  164. stock_amount=0,
  165. low_stock_warning=payload.low_stock_warning,
  166. high_stock_warning=payload.high_stock_warning,
  167. create_by=user.id,
  168. create_dept_id=user.dept_id,
  169. )
  170. db.add(inventory)
  171. db.commit()
  172. return {"success": True}
  173. # =============================================================================
  174. # 库存预警列表
  175. # =============================================================================
  176. @router.get("/warnings", response_model=list[InventoryWarningRead])
  177. def list_warnings(
  178. status: Optional[str] = Query(default=None, description="状态"),
  179. db: Session = Depends(get_db),
  180. user: DbUserContext = Depends(get_db_current_user),
  181. ) -> list[InventoryWarningRead]:
  182. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  183. query = db.query(PlInventoryWarning).filter(
  184. PlInventoryWarning.tenant_no == tenant_no,
  185. PlInventoryWarning.del_flag == 0,
  186. )
  187. if status:
  188. query = query.filter(PlInventoryWarning.status == status)
  189. query = apply_pl_scope(query, PlInventoryWarning, user, db, tenant_no)
  190. return query.order_by(PlInventoryWarning.create_time.desc()).all()
  191. @router.put("/warnings/{id}", response_model=InventoryWarningRead)
  192. def update_warning(
  193. id: str,
  194. payload: InventoryWarningUpdate,
  195. db: Session = Depends(get_db),
  196. user: DbUserContext = Depends(get_db_current_user),
  197. ) -> InventoryWarningRead:
  198. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  199. warning = (
  200. db.query(PlInventoryWarning)
  201. .filter(
  202. PlInventoryWarning.id == id,
  203. PlInventoryWarning.tenant_no == tenant_no,
  204. PlInventoryWarning.del_flag == 0,
  205. )
  206. .first()
  207. )
  208. if not warning:
  209. raise HTTPException(status_code=404, detail="预警不存在")
  210. assert_pl_row_in_scope(db, warning, PlInventoryWarning, user, tenant_no)
  211. if payload.status == "resolved":
  212. warning.status = "resolved"
  213. from datetime import datetime
  214. warning.resolve_time = datetime.now()
  215. warning.resolve_remark = payload.resolve_remark
  216. db.commit()
  217. db.refresh(warning)
  218. return warning
  219. # =============================================================================
  220. # 出入库明细
  221. # =============================================================================
  222. @router.get("/transactions", response_model=InventoryTransactionResponse)
  223. def list_transactions(
  224. product_id: Optional[str] = Query(default=None, alias="productId", description="产品ID"),
  225. transaction_type: Optional[str] = Query(default=None, alias="transactionType", description="事务类型"),
  226. date_from: Optional[date] = Query(default=None, alias="dateFrom", description="开始日期"),
  227. date_to: Optional[date] = Query(default=None, alias="dateTo", description="结束日期"),
  228. page: int = Query(default=1, ge=1),
  229. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  230. db: Session = Depends(get_db),
  231. user: DbUserContext = Depends(get_db_current_user),
  232. ) -> InventoryTransactionResponse:
  233. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  234. query = db.query(PlInventoryTransaction).filter(
  235. PlInventoryTransaction.tenant_no == tenant_no,
  236. PlInventoryTransaction.del_flag == 0,
  237. )
  238. if product_id:
  239. query = query.filter(PlInventoryTransaction.product_id == product_id)
  240. if transaction_type:
  241. query = query.filter(PlInventoryTransaction.transaction_type == transaction_type)
  242. if date_from:
  243. query = query.filter(PlInventoryTransaction.create_time >= date_from)
  244. if date_to:
  245. query = query.filter(PlInventoryTransaction.create_time <= date_to)
  246. query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)
  247. total = query.count()
  248. offset = (page - 1) * page_size
  249. items = query.order_by(PlInventoryTransaction.create_time.desc()).offset(offset).limit(page_size).all()
  250. return InventoryTransactionResponse(total=total, list=items)
  251. # =============================================================================
  252. # 出入库汇总
  253. # =============================================================================
  254. @router.get("/summary", response_model=InventorySummaryResponse)
  255. def get_summary(
  256. group_by: str = Query(default="month", description="汇总维度 month/week"),
  257. date_from: date = Query(..., alias="dateFrom", description="开始日期"),
  258. date_to: date = Query(..., alias="dateTo", description="结束日期"),
  259. product_id: Optional[str] = Query(default=None, alias="productId", description="产品ID"),
  260. db: Session = Depends(get_db),
  261. user: DbUserContext = Depends(get_db_current_user),
  262. ) -> InventorySummaryResponse:
  263. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  264. query = db.query(PlInventoryTransaction).filter(
  265. PlInventoryTransaction.tenant_no == tenant_no,
  266. PlInventoryTransaction.del_flag == 0,
  267. PlInventoryTransaction.create_time >= date_from,
  268. PlInventoryTransaction.create_time <= date_to,
  269. )
  270. query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)
  271. if product_id:
  272. query = query.filter(PlInventoryTransaction.product_id == product_id)
  273. transactions = query.all()
  274. # 按产品分组汇总
  275. summary_map = {}
  276. for t in transactions:
  277. key = t.product_id
  278. if key not in summary_map:
  279. summary_map[key] = {
  280. "product_id": t.product_id,
  281. "product_name": t.product_name,
  282. "inbound_qty": Decimal("0"),
  283. "outbound_qty": Decimal("0"),
  284. }
  285. if t.transaction_type == "inbound":
  286. summary_map[key]["inbound_qty"] += t.qty
  287. else:
  288. summary_map[key]["outbound_qty"] += abs(t.qty)
  289. items = []
  290. for key, data in summary_map.items():
  291. net = data["inbound_qty"] - data["outbound_qty"]
  292. items.append(InventorySummaryItem(
  293. period="汇总",
  294. product_id=data["product_id"],
  295. product_name=data["product_name"],
  296. inbound_qty=data["inbound_qty"],
  297. outbound_qty=data["outbound_qty"],
  298. net_change=net,
  299. ))
  300. return InventorySummaryResponse(list=items)
  301. # =============================================================================
  302. # 证件到期提醒
  303. # =============================================================================
  304. def _merge_query(request: Request, **known) -> dict:
  305. out = {k: v for k, v in known.items() if v is not None and v != ""}
  306. for k, v in request.query_params.multi_items():
  307. if k not in out and v not in (None, ""):
  308. out[k] = v
  309. return out
  310. def _param_get(params: dict, *keys: str) -> Any:
  311. for key in keys:
  312. if key in params and params[key] not in (None, ""):
  313. return params[key]
  314. return None
  315. def _body_to_params(body: BaseModel) -> dict:
  316. data = body.model_dump(by_alias=True, exclude_none=True)
  317. for key in ("ids", "columns"):
  318. data.pop(key, None)
  319. extra = getattr(body, "model_extra", None) or {}
  320. for key, value in extra.items():
  321. if key not in data and value not in (None, ""):
  322. data[key] = value
  323. return data
  324. def _document_reminder_list_kwargs(params: dict) -> dict:
  325. return dict(
  326. q=_param_get(params, "q"),
  327. owner_label=_param_get(params, "ownerLabel", "owner_label"),
  328. owner_type=_param_get(params, "ownerType", "owner_type"),
  329. owner_type_in=_param_get(params, "ownerTypeIn", "owner_type_in"),
  330. document_type=_param_get(params, "documentType", "document_type"),
  331. document_no=_param_get(params, "documentNo", "document_no"),
  332. status=_param_get(params, "status"),
  333. status_in=_param_get(params, "statusIn", "status_in"),
  334. expire_date_from=_param_get(params, "expireDateFrom", "expire_date_from"),
  335. expire_date_to=_param_get(params, "expireDateTo", "expire_date_to"),
  336. sort_field=_param_get(params, "sortField", "sort_field"),
  337. sort_order=_param_get(params, "sortOrder", "sort_order"),
  338. )
  339. def _query_document_reminders(
  340. db: Session,
  341. *,
  342. tenant_no: str,
  343. user: DbUserContext,
  344. q: Optional[str] = None,
  345. owner_label: Optional[str] = None,
  346. owner_type: Optional[str] = None,
  347. owner_type_in: Optional[str] = None,
  348. document_type: Optional[str] = None,
  349. document_no: Optional[str] = None,
  350. status: Optional[str] = None,
  351. status_in: Optional[str] = None,
  352. expire_date_from=None,
  353. expire_date_to=None,
  354. ids: Optional[List[str]] = None,
  355. sort_field: Optional[str] = None,
  356. sort_order: Optional[str] = None,
  357. ):
  358. from app.services.pl.document_reminder_list_filters import (
  359. build_document_reminder_column_filters,
  360. )
  361. from app.services.pl.list_sort import order_reminder
  362. query = db.query(PlDocumentReminder).filter(
  363. PlDocumentReminder.tenant_no == tenant_no,
  364. PlDocumentReminder.del_flag == 0,
  365. )
  366. if q:
  367. keyword = str(q).strip()
  368. if keyword:
  369. query = query.filter(
  370. or_(
  371. PlDocumentReminder.owner_label.contains(keyword),
  372. PlDocumentReminder.driver_name.contains(keyword),
  373. PlDocumentReminder.document_no.contains(keyword),
  374. PlDocumentReminder.document_type.contains(keyword),
  375. )
  376. )
  377. column_filters = build_document_reminder_column_filters(
  378. owner_label=owner_label,
  379. owner_type=owner_type,
  380. owner_type_in=owner_type_in,
  381. document_type=document_type,
  382. document_no=document_no,
  383. status=status,
  384. status_in=status_in,
  385. expire_date_from=expire_date_from,
  386. expire_date_to=expire_date_to,
  387. )
  388. if column_filters:
  389. query = query.filter(*column_filters)
  390. if ids:
  391. query = query.filter(PlDocumentReminder.id.in_(ids))
  392. query = apply_pl_scope(query, PlDocumentReminder, user, db, tenant_no)
  393. return query.order_by(order_reminder(sort_field, sort_order))
  394. @router.get("/document-reminders/stats", response_model=DocumentReminderStatsRead)
  395. def document_reminder_stats(
  396. db: Session = Depends(get_db),
  397. user: DbUserContext = Depends(get_db_current_user),
  398. ) -> DocumentReminderStatsRead:
  399. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  400. scan_cert_expiry(db, tenant_no)
  401. db.commit()
  402. query = db.query(PlDocumentReminder).filter(
  403. PlDocumentReminder.tenant_no == tenant_no,
  404. PlDocumentReminder.del_flag == 0,
  405. PlDocumentReminder.status == "pending",
  406. PlDocumentReminder.days_remaining <= 0,
  407. )
  408. query = apply_pl_scope(query, PlDocumentReminder, user, db, tenant_no)
  409. expired_pending_count = query.count()
  410. return DocumentReminderStatsRead(expired_pending_count=expired_pending_count)
  411. @router.get("/document-reminders", response_model=list[DocumentReminderRead])
  412. def list_document_reminders(
  413. request: Request,
  414. status: Optional[str] = Query(default=None, description="状态"),
  415. q: Optional[str] = Query(default=None),
  416. db: Session = Depends(get_db),
  417. user: DbUserContext = Depends(get_db_current_user),
  418. ) -> list[DocumentReminderRead]:
  419. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  420. scan_cert_expiry(db, tenant_no)
  421. db.commit()
  422. params = _merge_query(request, status=status, q=q)
  423. query = _query_document_reminders(
  424. db,
  425. tenant_no=tenant_no,
  426. user=user,
  427. **_document_reminder_list_kwargs(params),
  428. )
  429. return query.all()
  430. DOCUMENT_REMINDER_EXPORT_COLUMNS = [
  431. ExportColumn(title="主体名称", field="ownerLabel"),
  432. ExportColumn(title="主体类型", field="ownerType"),
  433. ExportColumn(title="证件类型", field="documentType"),
  434. ExportColumn(title="证件号", field="documentNo"),
  435. ExportColumn(title="到期日期", field="expireDate"),
  436. ExportColumn(title="剩余天数", field="daysRemaining"),
  437. ExportColumn(title="状态", field="status"),
  438. ExportColumn(title="处理备注", field="remark"),
  439. ]
  440. class DocumentReminderExportBody(BaseModel):
  441. q: Optional[str] = None
  442. status: Optional[str] = None
  443. ids: Optional[List[str]] = None
  444. columns: Optional[List[str]] = None
  445. model_config = {"populate_by_name": True, "extra": "allow"}
  446. @router.post("/document-reminders/export")
  447. def export_document_reminders(
  448. body: DocumentReminderExportBody,
  449. db: Session = Depends(get_db),
  450. user: DbUserContext = Depends(get_db_current_user),
  451. ):
  452. try:
  453. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  454. scan_cert_expiry(db, tenant_no)
  455. db.commit()
  456. params = _body_to_params(body)
  457. query = _query_document_reminders(
  458. db,
  459. tenant_no=tenant_no,
  460. user=user,
  461. **_document_reminder_list_kwargs(params),
  462. ids=body.ids,
  463. )
  464. items = query.limit(EXPORT_MAX_ROWS + 1).all()
  465. status_labels = {"pending": "待处理", "resolved": "已处理"}
  466. owner_labels = {"vehicle": "车辆", "personnel": "人员", "driver": "司机"}
  467. rows = []
  468. for x in items:
  469. data = DocumentReminderRead.model_validate(x).model_dump(by_alias=True)
  470. data["remark"] = getattr(x, "remark", None) or ""
  471. data["status"] = status_labels.get(str(data.get("status") or ""), data.get("status"))
  472. data["ownerType"] = owner_labels.get(str(data.get("ownerType") or ""), data.get("ownerType"))
  473. for k, v in list(data.items()):
  474. if hasattr(v, "isoformat"):
  475. data[k] = str(v)[:19]
  476. rows.append(data)
  477. cols = filter_export_columns(DOCUMENT_REMINDER_EXPORT_COLUMNS, body.columns)
  478. date_str = datetime.now().strftime("%Y%m%d")
  479. return export_rows_to_xlsx_response(
  480. db, tenant_no=tenant_no, sheet_title="证件提醒", columns=cols, rows=rows,
  481. filename_stem=f"证件到期提醒_{date_str}",
  482. )
  483. except ExportValidationError as e:
  484. raise HTTPException(status_code=400, detail=str(e)) from e
  485. @router.put("/document-reminders/{id}", response_model=DocumentReminderRead)
  486. def update_document_reminder(
  487. id: str,
  488. payload: DocumentReminderUpdate,
  489. db: Session = Depends(get_db),
  490. user: DbUserContext = Depends(get_db_current_user),
  491. ) -> DocumentReminderRead:
  492. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  493. reminder = (
  494. db.query(PlDocumentReminder)
  495. .filter(
  496. PlDocumentReminder.id == id,
  497. PlDocumentReminder.tenant_no == tenant_no,
  498. PlDocumentReminder.del_flag == 0,
  499. )
  500. .first()
  501. )
  502. if not reminder:
  503. raise HTTPException(status_code=404, detail="提醒不存在")
  504. assert_pl_row_in_scope(db, reminder, PlDocumentReminder, user, tenant_no)
  505. reminder.status = payload.status
  506. if payload.remark:
  507. reminder.remark = payload.remark
  508. db.commit()
  509. db.refresh(reminder)
  510. return reminder
  511. # =============================================================================
  512. # 生成证件到期提醒(定时任务调用)
  513. # =============================================================================
  514. @router.post("/check-document-expiry")
  515. def check_document_expiry(db: Session = Depends(get_db)) -> dict:
  516. """检查证件到期,生成提醒(供定时任务调用)"""
  517. created_count = scan_cert_expiry(db)
  518. db.commit()
  519. return {"created_count": created_count}