2026-10-09 06:46:36 -04:00
|
|
|
|
"""命令行管理工具。
|
|
|
|
|
|
|
|
|
|
|
|
用法(在 backend/ 目录下执行)::
|
|
|
|
|
|
|
|
|
|
|
|
uv run python -m app.cli create-admin --username admin
|
|
|
|
|
|
|
|
|
|
|
|
密码优先**交互式输入**而非命令行参数:命令行参数会进入 shell 历史,
|
|
|
|
|
|
在多用户机器上还能被 `ps` 看到。`--password` 仅为自动化脚本保留。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import getpass
|
2026-11-01 20:42:53 -05:00
|
|
|
|
import json
|
|
|
|
|
|
import signal
|
2026-10-09 06:46:36 -04:00
|
|
|
|
import sys
|
2026-11-01 20:42:53 -05:00
|
|
|
|
import threading
|
2026-10-27 23:42:15 -04:00
|
|
|
|
from datetime import datetime
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from uuid import uuid4
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
2026-10-27 23:42:15 -04:00
|
|
|
|
from app.core.config import get_settings
|
2026-10-09 06:46:36 -04:00
|
|
|
|
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
|
|
|
|
|
from app.core.db import dispose_engine, get_session_factory
|
|
|
|
|
|
from app.core.security import hash_password
|
2026-10-27 23:42:15 -04:00
|
|
|
|
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
|
2026-10-09 06:46:36 -04:00
|
|
|
|
from app.models import Role, User, UserRole
|
2026-10-27 23:42:15 -04:00
|
|
|
|
from app.repositories.tplus_repository import get_batch
|
|
|
|
|
|
from app.services.tplus.sync_service import TPlusSyncService
|
|
|
|
|
|
from app.services.tplus.worker import run_once
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _prompt_password() -> str:
|
|
|
|
|
|
"""交互式读取并二次确认密码,循环直到符合策略。"""
|
|
|
|
|
|
while True:
|
|
|
|
|
|
password = getpass.getpass(
|
|
|
|
|
|
f"请输入密码({PASSWORD_MIN_BYTES}-{PASSWORD_MAX_BYTES} 字节): "
|
|
|
|
|
|
)
|
|
|
|
|
|
length = len(password.encode("utf-8"))
|
|
|
|
|
|
if length < PASSWORD_MIN_BYTES:
|
|
|
|
|
|
print(f" 密码不足 {PASSWORD_MIN_BYTES} 字节,请重新输入。")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if length > PASSWORD_MAX_BYTES:
|
|
|
|
|
|
print(f" 密码超过 {PASSWORD_MAX_BYTES} 字节上限(中文一字占 3 字节),请重新输入。")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if password != getpass.getpass("请再次输入以确认: "):
|
|
|
|
|
|
print(" 两次输入不一致,请重新输入。")
|
|
|
|
|
|
continue
|
|
|
|
|
|
return password
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_admin(args: argparse.Namespace) -> int:
|
|
|
|
|
|
"""幂等创建管理员账号。
|
|
|
|
|
|
|
|
|
|
|
|
已存在时**不覆盖密码** —— 意外重跑脚本不应把已有账号的密码改掉。
|
|
|
|
|
|
"""
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
existing = session.execute(
|
|
|
|
|
|
select(User).where(User.username == args.username)
|
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
|
if existing is not None:
|
|
|
|
|
|
print(f"用户 {args.username!r} 已存在(id={existing.id}),未做任何修改。")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
role = session.execute(
|
|
|
|
|
|
select(Role).where(Role.role_code == ROLE_ADMIN)
|
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
|
if role is None:
|
|
|
|
|
|
print(
|
|
|
|
|
|
"错误:未找到 admin 角色,请先执行数据库迁移(uv run alembic upgrade head)。",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
password = args.password or _prompt_password()
|
|
|
|
|
|
user = User(
|
|
|
|
|
|
username=args.username,
|
|
|
|
|
|
password=hash_password(password),
|
|
|
|
|
|
real_name=args.real_name,
|
|
|
|
|
|
status=1,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(user)
|
|
|
|
|
|
session.flush() # 取得自增 id 后才能建关联
|
|
|
|
|
|
session.add(UserRole(user_id=user.id, role_id=role.id))
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
print(f"已创建管理员 {args.username!r}(id={user.id}),角色:{role.role_name}。")
|
|
|
|
|
|
print("首次登录后请立即修改密码。")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-27 23:42:15 -04:00
|
|
|
|
def _stage_tplus(args: argparse.Namespace) -> int:
|
|
|
|
|
|
"""读取 T+ 源库并写入 MySQL staging。"""
|
|
|
|
|
|
target_factory = get_session_factory()
|
|
|
|
|
|
source_factory = get_tplus_session_factory()
|
|
|
|
|
|
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
|
|
|
|
|
|
with source_factory() as source_session, target_factory() as target_session:
|
|
|
|
|
|
batch = TPlusSyncService(source_session, target_session).run(
|
|
|
|
|
|
entity_types=args.entity,
|
|
|
|
|
|
watermark=watermark,
|
|
|
|
|
|
batch_size=args.batch_size,
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
|
|
|
|
|
|
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
|
|
|
|
|
|
f"失败={batch.failed_record_count}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return 0 if batch.status == "SUCCEEDED" else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tplus_worker(args: argparse.Namespace) -> int:
|
|
|
|
|
|
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
|
|
|
|
|
|
if args.connection_file:
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
|
|
|
|
|
if not 2 <= args.poll_seconds <= 60:
|
|
|
|
|
|
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
|
|
|
|
|
|
return 1
|
|
|
|
|
|
factory = get_session_factory()
|
2026-11-01 20:42:53 -05:00
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file
|
|
|
|
|
|
stopped = threading.Event()
|
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
|
previous_handlers = {}
|
|
|
|
|
|
if threading.current_thread() is threading.main_thread():
|
|
|
|
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
|
|
|
|
previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
|
2026-10-27 23:42:15 -04:00
|
|
|
|
try:
|
2026-11-01 20:42:53 -05:00
|
|
|
|
while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
|
2026-10-27 23:42:15 -04:00
|
|
|
|
with factory() as session:
|
2026-11-01 20:42:53 -05:00
|
|
|
|
batch_no = run_once(session, worker_owner=owner)
|
2026-10-27 23:42:15 -04:00
|
|
|
|
batch = get_batch(session, batch_no) if batch_no else None
|
2026-11-01 20:42:53 -05:00
|
|
|
|
if batch_no and batch is None:
|
|
|
|
|
|
from app.models import TPlusWorkRun
|
|
|
|
|
|
batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no))
|
2026-10-27 23:42:15 -04:00
|
|
|
|
status = batch.status if batch else None
|
|
|
|
|
|
if batch_no:
|
|
|
|
|
|
print(f"任务完成:{batch_no} 状态={status}", flush=True)
|
|
|
|
|
|
if not args.watch:
|
|
|
|
|
|
return 0 if status in (None, "SUCCEEDED") else 1
|
|
|
|
|
|
if batch_no is None or status != "SUCCEEDED":
|
2026-11-01 20:42:53 -05:00
|
|
|
|
stopped.wait(args.poll_seconds)
|
|
|
|
|
|
print("T+读取服务已停止领取新任务", flush=True)
|
|
|
|
|
|
return 0
|
2026-10-27 23:42:15 -04:00
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
print("T+ 读取服务已停止")
|
|
|
|
|
|
return 130
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
|
|
|
|
|
|
return 1
|
2026-11-01 20:42:53 -05:00
|
|
|
|
finally:
|
|
|
|
|
|
for sig, handler in previous_handlers.items():
|
|
|
|
|
|
signal.signal(sig, handler)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.tplus.automation import heartbeat
|
|
|
|
|
|
|
|
|
|
|
|
heartbeat(factory, settings, owner, "STOPPED")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _maintenance(args):
|
|
|
|
|
|
from app.services.tplus.automation import tick
|
|
|
|
|
|
|
|
|
|
|
|
if args.connection_file:
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
|
|
|
|
|
if args.stop_file:
|
|
|
|
|
|
get_settings().t_plus_stop_file = args.stop_file
|
|
|
|
|
|
with get_session_factory()() as session:
|
|
|
|
|
|
result = tick(session)
|
|
|
|
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _materials(args):
|
|
|
|
|
|
from app.services.tplus.materials import collect_materials, render_markdown
|
|
|
|
|
|
|
|
|
|
|
|
if args.connection_file:
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
|
|
|
|
|
output = Path(args.output_dir).resolve()
|
|
|
|
|
|
targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")]
|
|
|
|
|
|
if any(p.exists() for p in targets):
|
|
|
|
|
|
raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据")
|
|
|
|
|
|
with get_tplus_session_factory()() as source:
|
|
|
|
|
|
report = collect_materials(source, args.customer_id, args.month)
|
|
|
|
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
targets[1].write_text(render_markdown(report), encoding="utf-8")
|
|
|
|
|
|
print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}")
|
|
|
|
|
|
return 0
|
2026-10-27 23:42:15 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 06:46:36 -04:00
|
|
|
|
def main() -> int:
|
|
|
|
|
|
"""命令行入口。"""
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
prog="app.cli", description="衍水集团营销人员工资管理系统 - 命令行工具"
|
|
|
|
|
|
)
|
|
|
|
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
|
|
|
|
|
|
|
|
p_admin = sub.add_parser("create-admin", help="创建初始管理员账号(幂等)")
|
|
|
|
|
|
p_admin.add_argument("--username", default="admin", help="登录用户名,默认 admin")
|
|
|
|
|
|
p_admin.add_argument("--real-name", default="系统管理员", help="真实姓名")
|
|
|
|
|
|
p_admin.add_argument(
|
|
|
|
|
|
"--password",
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-27 23:42:15 -04:00
|
|
|
|
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
|
|
|
|
|
|
p_tplus.add_argument(
|
|
|
|
|
|
"--entity",
|
|
|
|
|
|
action="append",
|
|
|
|
|
|
choices=[
|
|
|
|
|
|
"personnel",
|
|
|
|
|
|
"products",
|
|
|
|
|
|
"customers",
|
|
|
|
|
|
"businesses",
|
|
|
|
|
|
"shipments",
|
|
|
|
|
|
"receipts",
|
|
|
|
|
|
"business_items",
|
|
|
|
|
|
"shipment_items",
|
|
|
|
|
|
"receipt_items",
|
|
|
|
|
|
],
|
|
|
|
|
|
required=True,
|
|
|
|
|
|
help="实体类型,可重复指定",
|
|
|
|
|
|
)
|
|
|
|
|
|
p_tplus.add_argument(
|
|
|
|
|
|
"--watermark",
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
|
|
|
|
|
|
)
|
|
|
|
|
|
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
|
|
|
|
|
|
|
|
|
|
|
|
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
|
|
|
|
|
|
modes = p_worker.add_mutually_exclusive_group()
|
|
|
|
|
|
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
|
|
|
|
|
|
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
|
|
|
|
|
|
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
|
|
|
|
|
|
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
|
2026-11-01 20:42:53 -05:00
|
|
|
|
p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务")
|
|
|
|
|
|
|
|
|
|
|
|
p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资")
|
|
|
|
|
|
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
|
|
|
|
|
|
p_maintenance.add_argument("--connection-file", default=None)
|
|
|
|
|
|
p_maintenance.add_argument("--stop-file", default=None)
|
|
|
|
|
|
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
|
|
|
|
|
|
p_materials.add_argument("--customer-id", type=int, required=True)
|
|
|
|
|
|
p_materials.add_argument("--month", required=True)
|
|
|
|
|
|
p_materials.add_argument("--connection-file", default=None)
|
|
|
|
|
|
p_materials.add_argument("--output-dir", required=True)
|
2026-10-27 23:42:15 -04:00
|
|
|
|
|
2026-10-09 06:46:36 -04:00
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-10-27 23:42:15 -04:00
|
|
|
|
if args.command == "create-admin":
|
|
|
|
|
|
return _create_admin(args)
|
|
|
|
|
|
if args.command == "tplus-worker":
|
|
|
|
|
|
return _tplus_worker(args)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
if args.command == "tplus-maintain":
|
|
|
|
|
|
return _maintenance(args)
|
|
|
|
|
|
if args.command == "tplus-materials":
|
|
|
|
|
|
return _materials(args)
|
2026-10-27 23:42:15 -04:00
|
|
|
|
return _stage_tplus(args)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
|
|
|
|
|
|
return 1
|
2026-10-09 06:46:36 -04:00
|
|
|
|
finally:
|
|
|
|
|
|
dispose_engine()
|
2026-10-27 23:42:15 -04:00
|
|
|
|
dispose_tplus_engine()
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
raise SystemExit(main())
|