"""按租户和派车日期补派历史记录,并刷新已有自动现场记录。 这是运维脚本,使用显式租户范围和专用审计身份;不提供 HTTP 入口。 默认只读预览,--apply 才写入。历史记录仅在命中现有派发规则时启用后续同步。 在 backend 目录执行,例如: venv/bin/python scripts/refresh_dispatch_calendar_site.py --env-file .env \ --tenant shanxia2.0 --start-date 2026-10-28 --end-date 2026-10-30 增加 --apply 正式执行;生产配置使用 --env-file .env.production。 """ import argparse import json import sys from datetime import date from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from dotenv import dotenv_values from sqlalchemy import create_engine, or_ from sqlalchemy.orm import Session from app.core.db_auth import DbUserContext from app.models.pl_dispatch_calendar import PlDispatchCalendar as Record from app.services import pl_dispatch_calendar_service as svc SOURCE_KINDS = ('acid', 'alkali', 'other') COPY_FIELDS = ('dispatch_date', 'vehicle_id', 'license_plate', 'product_id', 'product_name', 'origin', 'destination', 'quantity', 'remark') def refresh(db: Session, *, tenant: str, start: date, end: date, operator: str, apply: bool = False) -> dict: """一次事务内补派;不改手工现场行,不按日期/车牌猜测关联。""" if not tenant.strip() or not operator.strip() or len(operator) > 64 or start > end: raise ValueError('须提供租户、有效审计身份及正确日期范围') if not date(1900, 1, 1) <= start <= end <= date(2100, 12, 31): raise ValueError('派车日期须在 1900-01-01 至 2100-12-31 范围内') user = DbUserContext(operator, '现场日历补派脚本', '', 'super_admin', tenant) result = {'tenant': tenant, 'startDate': start.isoformat(), 'endDate': end.isoformat(), 'apply': apply, 'counts': {key: 0 for key in ('created', 'updated', 'restored', 'removed', 'unchanged', 'ignored', 'enabled')}, 'records': []} try: query = db.query(Record).filter( Record.tenant_no == tenant, Record.calendar_kind.in_(SOURCE_KINDS), Record.dispatch_date >= start, Record.dispatch_date <= end, or_(Record.del_flag == 0, Record.site_sync_enabled == 1), ).order_by(Record.id) if apply: query = query.populate_existing().with_for_update() sources = query.all() for source in sources: target_query = db.query(Record).filter( Record.tenant_no == tenant, Record.source_record_id == source.id, ) if apply: target_query = target_query.populate_existing().with_for_update() target = target_query.one_or_none() if target is not None and target.calendar_kind != 'site': raise ValueError('来源关联到了非现场日历,停止刷新:' + source.id) document_type = None if source.del_flag == 1 else svc._site_document_type( source.origin, source.destination) enable = not source.site_sync_enabled and document_type is not None if not document_type: action = 'removed' if target is not None and target.del_flag == 0 else 'ignored' elif target is None: action = 'created' elif target.del_flag == 1: action = 'restored' elif target.document_type != document_type or any( getattr(target, field) != getattr(source, field) for field in COPY_FIELDS): action = 'updated' else: action = 'unchanged' changes_target = action in ('created', 'updated', 'restored', 'removed') # 预览与正式执行一致:异常关联须人工核对,不能静默越过同步开关。 if changes_target and not (source.site_sync_enabled or enable): raise ValueError('自动关联存在但来源未启用同步,停止刷新:' + source.id) result['counts'][action] += 1 result['counts']['enabled'] += int(enable) if apply: if enable: source.site_sync_enabled = 1 source.update_by = operator if changes_target: svc._sync_site_record(db, tenant, user, source) db.flush() result['records'].append({'sourceId': source.id, 'action': action, 'enabled': bool(enable)}) if apply: db.commit() else: db.rollback() return result except Exception: db.rollback() raise def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--env-file', required=True, help='明确指定 .env 或 .env.production') parser.add_argument('--tenant', required=True) parser.add_argument('--start-date', type=date.fromisoformat, required=True) parser.add_argument('--end-date', type=date.fromisoformat, required=True) parser.add_argument('--operator', default='calendar-site-refresh', help='写入 update_by 的运维审计身份') parser.add_argument('--apply', action='store_true', help='提交变更,默认只读预览') args = parser.parse_args() # 直接读取指定文件,避免进程环境 DATABASE_URL 覆盖所选数据库。 url = dotenv_values(args.env_file).get('DATABASE_URL') if not url: parser.error('指定配置文件没有 DATABASE_URL') engine = create_engine(url, pool_pre_ping=True, connect_args={ 'connect_timeout': 10, 'read_timeout': 60, 'write_timeout': 60}) try: with Session(engine, autoflush=False, expire_on_commit=False) as db: if engine.dialect.name == 'mysql': from sqlalchemy import text db.execute(text('SET SESSION innodb_lock_wait_timeout = 15')) result = refresh(db, tenant=args.tenant, start=args.start_date, end=args.end_date, operator=args.operator, apply=args.apply) print(json.dumps({'database': engine.url.database, 'host': engine.url.host, **result}, ensure_ascii=False, indent=2)) finally: engine.dispose() if __name__ == '__main__': main()