| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- from __future__ import annotations
- """
- 按月自动生成采购计划单。
- 运行方式(示例):
- cd auto-create-plan
- python auto_create_monthly_plan.py
- 建议在服务器上用守护进程(如 systemd、supervisor)长期运行本脚本,
- 不依赖 crontab,由脚本内部每小时自行判断是否需要执行当月生成逻辑。
- """
- import configparser
- import logging
- import sys
- import time
- from dataclasses import dataclass
- from datetime import datetime, timedelta, timezone
- from pathlib import Path
- from typing import Optional
- from sqlalchemy import and_, or_, select
- from sqlalchemy.orm import Session
- from sqlmodel import Field, SQLModel, create_engine
- ROOT_DIR = Path(__file__).resolve().parents[1]
- class PurchasePlan(SQLModel, table=True):
- """计划表 - 对应 purchase_plans。"""
- __tablename__ = "purchase_plans"
- id: int | None = Field(default=None, primary_key=True)
- plan_no: str | None = Field(default=None, max_length=255, index=True)
- plan_date: str = Field(max_length=255, index=True)
- start_date: str | None = Field(default=None, max_length=255, index=True)
- end_date: str | None = Field(default=None, max_length=255, index=True)
- business_type: str = Field(max_length=255, index=True)
- product: str = Field(max_length=255, index=True)
- specification: str | None = Field(default=None, max_length=255)
- planned_quantity: float = Field()
- vehicle_no: str | None = Field(default="", max_length=255)
- customer_name: str | None = Field(default=None, max_length=255)
- remarks: str | None = Field(default=None)
- is_deleted: int = Field(default=0, index=True)
- execution_status: str | None = Field(default=None, max_length=50, index=True)
- executed_at: str | None = Field(default=None, max_length=255)
- last_weighing_slip_id: int | None = Field(default=None)
- executed_quantity: float | None = Field(default=0)
- created_at: str = Field(max_length=255)
- auto_generate_enabled: int = Field(default=0, index=True)
- auto_generate_end_month: str | None = Field(default=None, max_length=7, index=True)
- auto_generate_last_month: str | None = Field(default=None, max_length=7)
- @dataclass
- class JobConfig:
- db_url: str
- loop_interval_seconds: int
- tz: timezone
- log_file: Optional[Path]
- def load_config(conf_path: Path) -> JobConfig:
- cfg = configparser.ConfigParser()
- if not conf_path.exists():
- raise FileNotFoundError(f"配置文件不存在: {conf_path}")
- cfg.read(conf_path, encoding="utf-8")
- db_url = cfg.get("database", "url", fallback="").strip()
- if not db_url:
- raise ValueError("配置 [database] url 不能为空")
- loop_interval_seconds = cfg.getint("job", "loop_interval_seconds", fallback=3600)
- tz_name = cfg.get("job", "timezone", fallback="Asia/Shanghai").strip()
- # 简单处理:目前只考虑 Asia/Shanghai,其他时区可以按需扩展
- if tz_name == "Asia/Shanghai":
- tz = timezone(timedelta(hours=8))
- else:
- # 回退为 UTC
- tz = timezone.utc
- log_file_raw = cfg.get("job", "log_file", fallback="").strip()
- log_file = ROOT_DIR / "auto-create-plan" / log_file_raw if log_file_raw else None
- return JobConfig(
- db_url=db_url,
- loop_interval_seconds=loop_interval_seconds,
- tz=tz,
- log_file=log_file,
- )
- def setup_logging(log_file: Optional[Path]) -> None:
- handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
- if log_file:
- log_file.parent.mkdir(parents=True, exist_ok=True)
- handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(message)s",
- handlers=handlers,
- )
- def get_now(cfg: JobConfig) -> datetime:
- return datetime.now(tz=cfg.tz)
- def month_range(dt: datetime) -> tuple[str, str]:
- """返回 dt 所在月份的首日、末日(字符串 YYYY-MM-DD)。"""
- first = dt.replace(day=1)
- # 下月一号减一天
- if first.month == 12:
- next_month = first.replace(year=first.year + 1, month=1, day=1)
- else:
- next_month = first.replace(month=first.month + 1, day=1)
- last = next_month - timedelta(days=1)
- return first.strftime("%Y-%m-%d"), last.strftime("%Y-%m-%d")
- def get_last_month_range(now: datetime) -> tuple[str, str]:
- """返回上个月的首日、末日(字符串 YYYY-MM-DD)。"""
- first_this, _ = month_range(now)
- first_this_dt = datetime.strptime(first_this, "%Y-%m-%d").replace(tzinfo=now.tzinfo)
- last_prev = first_this_dt - timedelta(days=1)
- return month_range(last_prev)
- def generate_plan_no() -> str:
- """生成计划号:格式与后端 _generate_plan_no 一致:SX-YYYYMMDDHHMMSS001。"""
- now = datetime.now()
- return f"SX-{now.strftime('%Y%m%d%H%M%S')}001"
- def default_execution_status(business_type: str) -> str:
- return "待入库" if business_type == "入库" else "待出库"
- def run_job_once(session: Session, cfg: JobConfig) -> None:
- now = get_now(cfg)
- # 仅在每月 1 号执行
- if now.day != 1:
- logging.info("当前不是每月 1 日,跳过本次执行")
- return
- current_month = now.strftime("%Y-%m")
- this_month_first, this_month_last = month_range(now)
- last_month_first, last_month_last = get_last_month_range(now)
- logging.info(
- "开始执行自动生成计划任务,current_month=%s, last_month=[%s, %s], this_month=[%s, %s]",
- current_month,
- last_month_first,
- last_month_last,
- this_month_first,
- this_month_last,
- )
- # 选出“上个月的计划”,且开启了自动生成,且本月尚未生成过,且未超过结束月份
- stmt = (
- select(PurchasePlan)
- .where(
- and_(
- PurchasePlan.is_deleted == 0,
- PurchasePlan.auto_generate_enabled == 1,
- or_(
- PurchasePlan.auto_generate_last_month.is_(None),
- PurchasePlan.auto_generate_last_month != current_month,
- ),
- or_(
- PurchasePlan.auto_generate_end_month.is_(None),
- PurchasePlan.auto_generate_end_month >= current_month,
- ),
- PurchasePlan.start_date == last_month_first,
- PurchasePlan.end_date == last_month_last,
- )
- )
- )
- plans = list(session.execute(stmt).scalars())
- if not plans:
- logging.info("未找到需要自动生成下月计划的记录")
- return
- logging.info("找到 %d 条可自动生成的上月计划", len(plans))
- created_count = 0
- for p in plans:
- new_plan = PurchasePlan(
- plan_no=generate_plan_no(),
- plan_date=this_month_first,
- start_date=this_month_first,
- end_date=this_month_last,
- business_type=p.business_type,
- product=p.product,
- specification=p.specification,
- planned_quantity=p.planned_quantity,
- vehicle_no=p.vehicle_no or "",
- customer_name=p.customer_name,
- remarks=p.remarks,
- is_deleted=0,
- execution_status=default_execution_status(p.business_type),
- executed_at=None,
- last_weighing_slip_id=None,
- executed_quantity=0,
- created_at=now.strftime("%Y-%m-%d %H:%M:%S"),
- auto_generate_enabled=p.auto_generate_enabled,
- auto_generate_end_month=p.auto_generate_end_month,
- auto_generate_last_month=None,
- )
- session.add(new_plan)
- # 标记老计划这个月已经处理过
- p.auto_generate_last_month = current_month
- created_count += 1
- session.commit()
- logging.info("本次自动生成完成,共生成 %d 条新计划", created_count)
- def main() -> None:
- conf_path = ROOT_DIR / "auto-create-plan" / "setting.conf"
- cfg = load_config(conf_path)
- setup_logging(cfg.log_file)
- logging.info("启动自动生成月度计划任务,使用数据库:%s", cfg.db_url)
- engine = create_engine(cfg.db_url, echo=False, future=True)
- # 确保元数据已加载(只针对 PurchasePlan 即可)
- SQLModel.metadata.create_all(engine, tables=[PurchasePlan.__table__])
- while True:
- try:
- with Session(engine) as session:
- run_job_once(session, cfg)
- except Exception:
- logging.exception("执行自动生成计划任务时发生异常")
- time.sleep(cfg.loop_interval_seconds)
- if __name__ == "__main__":
- main()
|