auto_create_monthly_plan.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. from __future__ import annotations
  2. """
  3. 按月自动生成采购计划单。
  4. 运行方式(示例):
  5. cd auto-create-plan
  6. python auto_create_monthly_plan.py
  7. 建议在服务器上用守护进程(如 systemd、supervisor)长期运行本脚本,
  8. 不依赖 crontab,由脚本内部每小时自行判断是否需要执行当月生成逻辑。
  9. """
  10. import configparser
  11. import logging
  12. import sys
  13. import time
  14. from dataclasses import dataclass
  15. from datetime import datetime, timedelta, timezone
  16. from pathlib import Path
  17. from typing import Optional
  18. from sqlalchemy import and_, or_, select
  19. from sqlalchemy.orm import Session
  20. from sqlmodel import Field, SQLModel, create_engine
  21. ROOT_DIR = Path(__file__).resolve().parents[1]
  22. class PurchasePlan(SQLModel, table=True):
  23. """计划表 - 对应 purchase_plans。"""
  24. __tablename__ = "purchase_plans"
  25. id: int | None = Field(default=None, primary_key=True)
  26. plan_no: str | None = Field(default=None, max_length=255, index=True)
  27. plan_date: str = Field(max_length=255, index=True)
  28. start_date: str | None = Field(default=None, max_length=255, index=True)
  29. end_date: str | None = Field(default=None, max_length=255, index=True)
  30. business_type: str = Field(max_length=255, index=True)
  31. product: str = Field(max_length=255, index=True)
  32. specification: str | None = Field(default=None, max_length=255)
  33. planned_quantity: float = Field()
  34. vehicle_no: str | None = Field(default="", max_length=255)
  35. customer_name: str | None = Field(default=None, max_length=255)
  36. remarks: str | None = Field(default=None)
  37. is_deleted: int = Field(default=0, index=True)
  38. execution_status: str | None = Field(default=None, max_length=50, index=True)
  39. executed_at: str | None = Field(default=None, max_length=255)
  40. last_weighing_slip_id: int | None = Field(default=None)
  41. executed_quantity: float | None = Field(default=0)
  42. created_at: str = Field(max_length=255)
  43. auto_generate_enabled: int = Field(default=0, index=True)
  44. auto_generate_end_month: str | None = Field(default=None, max_length=7, index=True)
  45. auto_generate_last_month: str | None = Field(default=None, max_length=7)
  46. @dataclass
  47. class JobConfig:
  48. db_url: str
  49. loop_interval_seconds: int
  50. tz: timezone
  51. log_file: Optional[Path]
  52. def load_config(conf_path: Path) -> JobConfig:
  53. cfg = configparser.ConfigParser()
  54. if not conf_path.exists():
  55. raise FileNotFoundError(f"配置文件不存在: {conf_path}")
  56. cfg.read(conf_path, encoding="utf-8")
  57. db_url = cfg.get("database", "url", fallback="").strip()
  58. if not db_url:
  59. raise ValueError("配置 [database] url 不能为空")
  60. loop_interval_seconds = cfg.getint("job", "loop_interval_seconds", fallback=3600)
  61. tz_name = cfg.get("job", "timezone", fallback="Asia/Shanghai").strip()
  62. # 简单处理:目前只考虑 Asia/Shanghai,其他时区可以按需扩展
  63. if tz_name == "Asia/Shanghai":
  64. tz = timezone(timedelta(hours=8))
  65. else:
  66. # 回退为 UTC
  67. tz = timezone.utc
  68. log_file_raw = cfg.get("job", "log_file", fallback="").strip()
  69. log_file = ROOT_DIR / "auto-create-plan" / log_file_raw if log_file_raw else None
  70. return JobConfig(
  71. db_url=db_url,
  72. loop_interval_seconds=loop_interval_seconds,
  73. tz=tz,
  74. log_file=log_file,
  75. )
  76. def setup_logging(log_file: Optional[Path]) -> None:
  77. handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
  78. if log_file:
  79. log_file.parent.mkdir(parents=True, exist_ok=True)
  80. handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
  81. logging.basicConfig(
  82. level=logging.INFO,
  83. format="%(asctime)s [%(levelname)s] %(message)s",
  84. handlers=handlers,
  85. )
  86. def get_now(cfg: JobConfig) -> datetime:
  87. return datetime.now(tz=cfg.tz)
  88. def month_range(dt: datetime) -> tuple[str, str]:
  89. """返回 dt 所在月份的首日、末日(字符串 YYYY-MM-DD)。"""
  90. first = dt.replace(day=1)
  91. # 下月一号减一天
  92. if first.month == 12:
  93. next_month = first.replace(year=first.year + 1, month=1, day=1)
  94. else:
  95. next_month = first.replace(month=first.month + 1, day=1)
  96. last = next_month - timedelta(days=1)
  97. return first.strftime("%Y-%m-%d"), last.strftime("%Y-%m-%d")
  98. def get_last_month_range(now: datetime) -> tuple[str, str]:
  99. """返回上个月的首日、末日(字符串 YYYY-MM-DD)。"""
  100. first_this, _ = month_range(now)
  101. first_this_dt = datetime.strptime(first_this, "%Y-%m-%d").replace(tzinfo=now.tzinfo)
  102. last_prev = first_this_dt - timedelta(days=1)
  103. return month_range(last_prev)
  104. def generate_plan_no() -> str:
  105. """生成计划号:格式与后端 _generate_plan_no 一致:SX-YYYYMMDDHHMMSS001。"""
  106. now = datetime.now()
  107. return f"SX-{now.strftime('%Y%m%d%H%M%S')}001"
  108. def default_execution_status(business_type: str) -> str:
  109. return "待入库" if business_type == "入库" else "待出库"
  110. def run_job_once(session: Session, cfg: JobConfig) -> None:
  111. now = get_now(cfg)
  112. # 仅在每月 1 号执行
  113. if now.day != 1:
  114. logging.info("当前不是每月 1 日,跳过本次执行")
  115. return
  116. current_month = now.strftime("%Y-%m")
  117. this_month_first, this_month_last = month_range(now)
  118. last_month_first, last_month_last = get_last_month_range(now)
  119. logging.info(
  120. "开始执行自动生成计划任务,current_month=%s, last_month=[%s, %s], this_month=[%s, %s]",
  121. current_month,
  122. last_month_first,
  123. last_month_last,
  124. this_month_first,
  125. this_month_last,
  126. )
  127. # 选出“上个月的计划”,且开启了自动生成,且本月尚未生成过,且未超过结束月份
  128. stmt = (
  129. select(PurchasePlan)
  130. .where(
  131. and_(
  132. PurchasePlan.is_deleted == 0,
  133. PurchasePlan.auto_generate_enabled == 1,
  134. or_(
  135. PurchasePlan.auto_generate_last_month.is_(None),
  136. PurchasePlan.auto_generate_last_month != current_month,
  137. ),
  138. or_(
  139. PurchasePlan.auto_generate_end_month.is_(None),
  140. PurchasePlan.auto_generate_end_month >= current_month,
  141. ),
  142. PurchasePlan.start_date == last_month_first,
  143. PurchasePlan.end_date == last_month_last,
  144. )
  145. )
  146. )
  147. plans = list(session.execute(stmt).scalars())
  148. if not plans:
  149. logging.info("未找到需要自动生成下月计划的记录")
  150. return
  151. logging.info("找到 %d 条可自动生成的上月计划", len(plans))
  152. created_count = 0
  153. for p in plans:
  154. new_plan = PurchasePlan(
  155. plan_no=generate_plan_no(),
  156. plan_date=this_month_first,
  157. start_date=this_month_first,
  158. end_date=this_month_last,
  159. business_type=p.business_type,
  160. product=p.product,
  161. specification=p.specification,
  162. planned_quantity=p.planned_quantity,
  163. vehicle_no=p.vehicle_no or "",
  164. customer_name=p.customer_name,
  165. remarks=p.remarks,
  166. is_deleted=0,
  167. execution_status=default_execution_status(p.business_type),
  168. executed_at=None,
  169. last_weighing_slip_id=None,
  170. executed_quantity=0,
  171. created_at=now.strftime("%Y-%m-%d %H:%M:%S"),
  172. auto_generate_enabled=p.auto_generate_enabled,
  173. auto_generate_end_month=p.auto_generate_end_month,
  174. auto_generate_last_month=None,
  175. )
  176. session.add(new_plan)
  177. # 标记老计划这个月已经处理过
  178. p.auto_generate_last_month = current_month
  179. created_count += 1
  180. session.commit()
  181. logging.info("本次自动生成完成,共生成 %d 条新计划", created_count)
  182. def main() -> None:
  183. conf_path = ROOT_DIR / "auto-create-plan" / "setting.conf"
  184. cfg = load_config(conf_path)
  185. setup_logging(cfg.log_file)
  186. logging.info("启动自动生成月度计划任务,使用数据库:%s", cfg.db_url)
  187. engine = create_engine(cfg.db_url, echo=False, future=True)
  188. # 确保元数据已加载(只针对 PurchasePlan 即可)
  189. SQLModel.metadata.create_all(engine, tables=[PurchasePlan.__table__])
  190. while True:
  191. try:
  192. with Session(engine) as session:
  193. run_job_once(session, cfg)
  194. except Exception:
  195. logging.exception("执行自动生成计划任务时发生异常")
  196. time.sleep(cfg.loop_interval_seconds)
  197. if __name__ == "__main__":
  198. main()