import_vehicle_fuel_register.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. #!/usr/bin/env python3
  2. """导入客户「加油汇总表」到 pl_vehicle_fuel_register。
  3. 用法(在 backend 目录):
  4. # 测试环境 dry-run
  5. python scripts/import_vehicle_fuel_register.py --env-file .env --dry-run
  6. # 测试环境正式导入
  7. python scripts/import_vehicle_fuel_register.py --env-file .env
  8. # 生产环境
  9. python scripts/import_vehicle_fuel_register.py --env-file .env.production
  10. """
  11. from __future__ import annotations
  12. import argparse
  13. import os
  14. import re
  15. import sys
  16. from dataclasses import dataclass, field
  17. from datetime import date, datetime
  18. from decimal import Decimal, InvalidOperation
  19. from pathlib import Path
  20. from typing import Optional
  21. import openpyxl
  22. ROOT = Path(__file__).resolve().parents[1]
  23. if str(ROOT) not in sys.path:
  24. sys.path.insert(0, str(ROOT))
  25. IMPORT_TAG = "import:fuel-summary-20260916"
  26. DEFAULT_DATA_DIR = ROOT.parent / "加油汇总表(202609161514)"
  27. CATEGORY_MAP = {
  28. "百链": "bailian",
  29. "加油站": "gas_station",
  30. "山峡厂": "shanxia_factory", # 历史 Excel 文案
  31. "山峡": "shanxia_factory",
  32. }
  33. PLATE_MAP = {
  34. "皖H3190": "皖HA3190",
  35. "叉车": "皖H21522",
  36. "吨桶": "皖H21522",
  37. }
  38. REGISTRANT_MAP = {
  39. "皖H13532": "王晓阳",
  40. }
  41. FOLDER = "pl/vehicle-fuel/register"
  42. @dataclass
  43. class ImportRow:
  44. source: str
  45. seq: str
  46. fuel_category: str
  47. register_date: date
  48. registrant_name: str
  49. license_plate: str
  50. odometer_km: Optional[Decimal]
  51. fuel_liters: Decimal
  52. remark: Optional[str] = None
  53. attachment_files: list[Path] = field(default_factory=list)
  54. @property
  55. def import_key(self) -> str:
  56. return f"[{IMPORT_TAG}|{self.source}:{self.seq}]"
  57. def _load_env_file(path: Path) -> None:
  58. if not path.exists():
  59. raise SystemExit(f"环境文件不存在: {path}")
  60. for line in path.read_text(encoding="utf-8").splitlines():
  61. line = line.strip()
  62. if not line or line.startswith("#") or "=" not in line:
  63. continue
  64. key, value = line.split("=", 1)
  65. key = key.strip()
  66. value = value.strip().strip('"').strip("'")
  67. os.environ[key] = value
  68. def _to_decimal(value, *, field_name: str) -> Decimal:
  69. if value is None or str(value).strip() == "":
  70. raise ValueError(f"{field_name} 为空")
  71. try:
  72. return Decimal(str(value).strip())
  73. except (InvalidOperation, ValueError) as exc:
  74. raise ValueError(f"{field_name} 无效: {value!r}") from exc
  75. def _to_date(value) -> date:
  76. if isinstance(value, datetime):
  77. return value.date()
  78. if isinstance(value, date):
  79. return value
  80. text = str(value).strip()
  81. return datetime.strptime(text[:10], "%Y-%m-%d").date()
  82. def _norm_seq(raw) -> str:
  83. text = str(raw).strip()
  84. if text.isdigit():
  85. return f"{int(text):02d}" if len(text) <= 2 else text
  86. return text
  87. def _load_attachment_index(att_dir: Path) -> dict[str, list[Path]]:
  88. index: dict[str, list[Path]] = {}
  89. if not att_dir.is_dir():
  90. return index
  91. for path in sorted(att_dir.iterdir()):
  92. if not path.is_file() or path.name.startswith("."):
  93. continue
  94. m = re.match(r"^(\d+)_", path.name)
  95. if not m:
  96. print(f" 警告: 附件名无法解析序号: {path.name}")
  97. continue
  98. seq = f"{int(m.group(1)):02d}"
  99. index.setdefault(seq, []).append(path)
  100. return index
  101. def _parse_excel(
  102. path: Path,
  103. *,
  104. source: str,
  105. with_attachments: bool,
  106. att_index: dict[str, list[Path]],
  107. ) -> list[ImportRow]:
  108. wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
  109. rows = list(wb.active.iter_rows(values_only=True))
  110. wb.close()
  111. if not rows:
  112. return []
  113. headers = [str(h).strip() if h is not None else "" for h in rows[0]]
  114. out: list[ImportRow] = []
  115. for raw in rows[1:]:
  116. if not any(c is not None and str(c).strip() for c in raw):
  117. continue
  118. item = dict(zip(headers, raw))
  119. seq = _norm_seq(item.get("序号"))
  120. cat_label = str(item.get("加油类别") or "").strip()
  121. if cat_label not in CATEGORY_MAP:
  122. raise ValueError(f"{source} 序号{seq}: 未知加油类别 {cat_label!r}")
  123. name = str(item.get("登记人") or "").strip()
  124. name = REGISTRANT_MAP.get(name, name)
  125. plate = str(item.get("车号") or "").strip()
  126. plate = PLATE_MAP.get(plate, plate)
  127. odo_raw = item.get("期初公里数/Km")
  128. odometer = None
  129. if odo_raw is not None and str(odo_raw).strip() != "":
  130. odometer = _to_decimal(odo_raw, field_name="期初公里数")
  131. liters = _to_decimal(item.get("加油升数/L"), field_name="加油升数")
  132. files: list[Path] = []
  133. if with_attachments:
  134. files = list(att_index.get(seq, []))
  135. if not files:
  136. raise ValueError(f"{source} 序号{seq}: 声明有附件但未找到文件")
  137. out.append(
  138. ImportRow(
  139. source=source,
  140. seq=seq,
  141. fuel_category=CATEGORY_MAP[cat_label],
  142. register_date=_to_date(item.get("日期")),
  143. registrant_name=name,
  144. license_plate=plate,
  145. odometer_km=odometer,
  146. fuel_liters=liters,
  147. attachment_files=files,
  148. )
  149. )
  150. return out
  151. def load_rows(data_dir: Path) -> list[ImportRow]:
  152. att_index = _load_attachment_index(data_dir / "附件")
  153. with_att = _parse_excel(
  154. data_dir / "(总表)表格视图-有附件.xlsx",
  155. source="with",
  156. with_attachments=True,
  157. att_index=att_index,
  158. )
  159. no_att = _parse_excel(
  160. data_dir / "(总表)表格视图——没有附件.xlsx",
  161. source="no",
  162. with_attachments=False,
  163. att_index=att_index,
  164. )
  165. return with_att + no_att
  166. def main() -> None:
  167. parser = argparse.ArgumentParser(description="导入加油汇总表")
  168. parser.add_argument("--env-file", default=".env", help="环境文件路径(相对 backend)")
  169. parser.add_argument(
  170. "--data-dir",
  171. default=str(DEFAULT_DATA_DIR),
  172. help="客户数据目录",
  173. )
  174. parser.add_argument("--dry-run", action="store_true", help="只校验不写库")
  175. parser.add_argument("--tenant-no", default="shanxia2.0")
  176. args = parser.parse_args()
  177. env_path = Path(args.env_file)
  178. if not env_path.is_absolute():
  179. env_path = ROOT / env_path
  180. _load_env_file(env_path)
  181. # 延迟导入,确保 env 已注入
  182. from sqlalchemy.orm import Session
  183. from app.core.database import SessionLocal
  184. from app.core.storage import storage
  185. from app.core.utils import generate_uuid
  186. from app.models.models import SysUser
  187. from app.models.pl_models import PlVehicle, PlVehicleFuelRegister
  188. from app.schemas.pl.vehicle_fuel import calc_fuel_tons
  189. data_dir = Path(args.data_dir)
  190. rows = load_rows(data_dir)
  191. print(f"环境文件: {env_path}")
  192. print(f"数据目录: {data_dir}")
  193. print(f"解析行数: {len(rows)} (附件文件合计 {sum(len(r.attachment_files) for r in rows)})")
  194. print(f"模式: {'DRY-RUN' if args.dry_run else 'WRITE'}")
  195. db: Session = SessionLocal()
  196. try:
  197. users = (
  198. db.query(SysUser)
  199. .filter(SysUser.del_flag == 0, SysUser.status == "1")
  200. .all()
  201. )
  202. by_nick = {}
  203. for u in users:
  204. nick = (u.nick_name or "").strip()
  205. if nick:
  206. by_nick.setdefault(nick, []).append(u)
  207. vehicles = {
  208. (v.license_plate or "").strip(): v
  209. for v in db.query(PlVehicle)
  210. .filter(PlVehicle.tenant_no == args.tenant_no, PlVehicle.del_flag == 0)
  211. .all()
  212. }
  213. existing_remarks = [
  214. (r.remark or "")
  215. for r in db.query(PlVehicleFuelRegister)
  216. .filter(
  217. PlVehicleFuelRegister.tenant_no == args.tenant_no,
  218. PlVehicleFuelRegister.del_flag == 0,
  219. PlVehicleFuelRegister.remark.like(f"%{IMPORT_TAG}%"),
  220. )
  221. .all()
  222. ]
  223. errors: list[str] = []
  224. prepared: list[tuple[ImportRow, SysUser, Optional[str]]] = []
  225. for row in rows:
  226. hits = by_nick.get(row.registrant_name, [])
  227. if len(hits) != 1:
  228. errors.append(
  229. f"{row.source}:{row.seq} 登记人无法唯一匹配: {row.registrant_name!r} hits={len(hits)}"
  230. )
  231. continue
  232. if row.license_plate not in vehicles:
  233. errors.append(
  234. f"{row.source}:{row.seq} 车牌不存在: {row.license_plate}"
  235. )
  236. continue
  237. key = row.import_key
  238. already = any(key in rm for rm in existing_remarks)
  239. if already:
  240. print(f" skip 已导入 {key}")
  241. continue
  242. prepared.append((row, hits[0], None))
  243. if errors:
  244. print("校验失败:")
  245. for e in errors:
  246. print(" ", e)
  247. raise SystemExit(1)
  248. print(f"待写入: {len(prepared)} 行")
  249. if args.dry_run:
  250. for row, user, _ in prepared[:5]:
  251. print(
  252. f" sample {row.source}:{row.seq} {row.register_date} "
  253. f"{row.license_plate} {row.fuel_category} {row.fuel_liters}L "
  254. f"-> {calc_fuel_tons(row.fuel_liters)}t "
  255. f"registrant={user.nick_name} atts={len(row.attachment_files)}"
  256. )
  257. if len(prepared) > 5:
  258. print(f" ... 另有 {len(prepared) - 5} 行")
  259. print("DRY-RUN 完成,未写库")
  260. return
  261. def next_register_no(on_date: date) -> str:
  262. prefix = f"SXJY{on_date:%Y%m%d}"
  263. last = (
  264. db.query(PlVehicleFuelRegister)
  265. .filter(PlVehicleFuelRegister.register_no.like(f"{prefix}%"))
  266. .order_by(PlVehicleFuelRegister.register_no.desc())
  267. .first()
  268. )
  269. seq = int(last.register_no[-6:]) + 1 if last else 1
  270. return f"{prefix}{seq:06d}"
  271. created = 0
  272. for row, user, _ in prepared:
  273. urls: list[str] = []
  274. for fp in row.attachment_files:
  275. content = fp.read_bytes()
  276. full_url = storage.upload_file(content, fp.name, FOLDER)
  277. # 与管理端一致:有 BASE_URL 时存相对路径
  278. from app.core.config import settings
  279. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  280. if base_url and full_url.startswith(base_url + "/"):
  281. urls.append(full_url[len(base_url) + 1 :])
  282. else:
  283. # 存 object key(去掉 endpoint/bucket 前缀)更稳
  284. from app.core.storage import storage as st
  285. if hasattr(st, "_extract_object_key"):
  286. key = st._extract_object_key(full_url)
  287. urls.append(key or full_url)
  288. else:
  289. urls.append(full_url)
  290. now = datetime.now()
  291. remark = row.import_key
  292. entity = PlVehicleFuelRegister(
  293. id=generate_uuid(),
  294. register_no=next_register_no(row.register_date),
  295. fuel_category=row.fuel_category,
  296. register_date=row.register_date,
  297. registrant_id=user.id,
  298. license_plate=row.license_plate,
  299. odometer_km=row.odometer_km,
  300. fuel_liters=row.fuel_liters,
  301. fuel_tons=calc_fuel_tons(row.fuel_liters),
  302. attachment_urls=",".join(urls) if urls else None,
  303. status="normal",
  304. tenant_no=args.tenant_no,
  305. remark=remark,
  306. create_by=user.id,
  307. create_dept_id=user.dept_id or "",
  308. create_time=now,
  309. update_by=user.id,
  310. update_time=now,
  311. del_flag=0,
  312. )
  313. db.add(entity)
  314. db.commit()
  315. created += 1
  316. if created % 10 == 0 or created == len(prepared):
  317. print(f" 已写入 {created}/{len(prepared)}")
  318. print(f"完成: 新建 {created} 条")
  319. total = (
  320. db.query(PlVehicleFuelRegister)
  321. .filter(
  322. PlVehicleFuelRegister.tenant_no == args.tenant_no,
  323. PlVehicleFuelRegister.del_flag == 0,
  324. PlVehicleFuelRegister.remark.like(f"%{IMPORT_TAG}%"),
  325. )
  326. .count()
  327. )
  328. print(f"库中本批次标记记录数: {total}")
  329. finally:
  330. db.close()
  331. if __name__ == "__main__":
  332. main()