引入 PostgreSQL 持久化:到货核销数据入库管道

- 新增 db_store.py:解析 downloads/ 下各站应到/实到/未到 Excel,幂等 UPSERT 入库(统一核心列 + raw JSONB 兜底,单号按文本存)
- 新增 schema.sql:三表(expected/actual/undelivered_record),隔离到专用 schema inbound_verify(CREATE SCHEMA + search_path)
- 韵达实到业务清洗:抛弃「交接单号」为空行 + 按子单号去重
- 同步 config.example.yaml(postgres 配置)与 requirements.txt(psycopg[binary])

本次仅手动入库管道;自动挂载到下载流程留待后续。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-07-22 23:04:21 +08:00
parent bbe9e3e619
commit b9f726c30a
4 changed files with 472 additions and 0 deletions

View File

@@ -68,3 +68,22 @@ anneng:
# 安能 Electron 应用的可执行文件路径。
# 路径含反斜杠/空格/@用单引号包裹即可YAML 单引号串按字面解析)。
app_path: 'D:\SoftWare\SoftWare Installation\@ane-electron-uiapp\安能全网门户.exe'
# ----------------------------------------------------------------------------
# PostgreSQL 数据持久化(到货核销数据入库,详见 db_store.py
# ----------------------------------------------------------------------------
# createdb 会连接名为 postgres 的维护库来创建下方 dbname 指定的数据库。
# 命令行:
# python db_store.py createdb 创建数据库(幂等)
# python db_store.py init 建表(幂等)
# python db_store.py ingest [site] 入库全站或单站(幂等 UPSERT
# python db_store.py all createdb → init → 全站 ingest
postgres:
host: 127.0.0.1
port: 5432
user: postgres
# 连接密码config.yaml 已 gitignore真实凭据只写在那里切勿提交示例值。
password: "YOUR_PASSWORD_HERE"
dbname: CQHXDB
# 承载到货核销表的专用 schema隔离 public表建在此 schema 下。
schema: inbound_verify

398
db_store.py Normal file
View File

@@ -0,0 +1,398 @@
# -*- coding: utf-8 -*-
"""
db_store.py — 到货核销数据持久化PostgreSQL
职责:把 downloads/ 下各站点下载的应到 / 实到 / 未到 Excel 解析后,幂等写入 PostgreSQL。
与下载流程解耦:本模块只读 downloads/ 现有文件入库,不关心谁触发下载、下载了几次。
设计要点:
- 三张表expected_record运单级/ actual_record扫描件级
/ undelivered_record百世站点直供未到明细子单级
- 每行 = 统一核心列 + raw JSONB站点原始全列key=原列名,一字段不丢)
- 幂等:业务唯一键 UPSERT重复下载天然合并、零冗余
- 单号一律按文本读写dtype=str防长数字被科学计数 / 精度丢失
命令行:
python db_store.py createdb 创建数据库(幂等)
python db_store.py init 建表(幂等 CREATE TABLE IF NOT EXISTS
python db_store.py ingest [site] 入库全站或单站(幂等 UPSERT
python db_store.py all createdb → init → 全站 ingest 一条龙
"""
import os
import sys
from datetime import date, datetime
import pandas as pd
import psycopg
import yaml
from psycopg.types.json import Jsonb
from paths import BASE_DIR, CONFIG_PATH, DOWNLOAD_DIR
import expected_undelivered as eu # 复用站点 / 文件名 / 列映射 / 基号口径(单一来源)
SCHEMA_PATH = os.path.join(BASE_DIR, "schema.sql")
# 有应到 / 实到的 4 站(百世只有站点直供的未到明细,单独处理)
ALL_SITES = ["顺心", "中通", "韵达", "安能"]
# ============================== 配置 / 连接 ==============================
def _load_pg_config():
"""从 config.yaml 读 postgres 段;缺失项给默认。"""
if not os.path.exists(CONFIG_PATH):
raise FileNotFoundError(
f"未找到配置文件 {CONFIG_PATH}(请参考 config.example.yaml 创建 config.yaml"
)
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
pg = cfg.get("postgres") or {}
return {
"host": pg.get("host", "127.0.0.1"),
"port": int(pg.get("port", 5432)),
"user": pg.get("user", "postgres"),
"password": pg.get("password", ""),
"dbname": pg.get("dbname", "CQHXDB"),
"schema": pg.get("schema", "inbound_verify"),
}
def _connect(dbname):
"""用关键字参数连接(避开 conninfo 对密码特殊字符的解析)。
options 设 search_path 到专用 schema使无 schema 限定的表名解析到该 schema。"""
c = _load_pg_config()
return psycopg.connect(
host=c["host"],
port=c["port"],
dbname=dbname,
user=c["user"],
password=c["password"],
options=f"-c search_path={c['schema']}",
)
# ============================== 建库 / 建表 ==============================
def create_database():
"""连接维护库 postgres创建目标数据库幂等"""
c = _load_pg_config()
target = c["dbname"]
with _connect("postgres") as conn: # autocommitCREATE DATABASE 不能在事务里
conn.autocommit = True
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (target,))
if cur.fetchone():
print(f">> [db] 数据库 {target} 已存在,跳过创建")
return
cur.execute(f'CREATE DATABASE "{target}"')
print(f">> [db] 已创建数据库 {target}")
def init_schema():
"""在目标库执行 schema.sql幂等"""
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
sql = f.read()
with _connect(_load_pg_config()["dbname"]) as conn:
with conn.cursor() as cur:
cur.execute(sql)
conn.commit()
print(">> [db] 表结构已就绪")
# ============================== 解析辅助 ==============================
# 实到单号列 / 基号列映射(口径取自 expected_undelivered
# piece = 实到表里「每件」的单号列(扫描单号 / 子单号 / 复合串)
# waybill = 与应到运单号对齐的干净列(中通无干净列,由 piece 复合串 v[:-8] 推导)
# scan_time = 扫描时间列(缺失则不填,原始值仍在 raw
# scan_site = 扫描网点列
ACTUAL_COLMAP = {
"中通": {
"piece": "运单号",
"waybill": None,
"scan_time": "扫描时间",
"scan_site": "扫描网点",
},
"顺心": {
"piece": "子单号",
"waybill": "运单号",
"scan_time": "操作时间",
"scan_site": "操作网点",
},
"韵达": {
"piece": "子单号",
"waybill": "主单号",
"scan_time": "扫描时间",
"scan_site": "扫描站点",
},
"安能": {
"piece": "扫描单号",
"waybill": "所属单号",
"scan_time": "扫描时间",
"scan_site": "扫描网点",
},
}
_TIME_FMTS = (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M:%S",
"%Y/%m/%d %H:%M",
"%Y-%m-%d",
"%Y/%m/%d",
)
def _to_int(v):
"""尽力把单元格转 int件数空 / 非数返回 None。"""
s = str(v).strip().replace(",", "")
if s in ("", "-", "nan", "None"):
return None
try:
return int(float(s))
except (TypeError, ValueError):
return None
def _parse_time(v):
"""尽力解析多种时间格式为 datetime失败返回 None原始值在 raw 里)。"""
if v is None:
return None
if isinstance(v, datetime):
return v
s = str(v).strip()
if not s or s in ("nan", "NaT"):
return None
for fmt in _TIME_FMTS:
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
try: # 兜底:交给 pandas 推断
return pd.to_datetime(s).to_pydatetime()
except Exception:
return None
def _parse_date(v):
if not v:
return None
try:
return datetime.strptime(str(v).strip(), "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
def _raw_row(row):
"""把一行原始记录转成 JSONB 兼容 dictkey=原列名;丢空值,保留全部有值字段)。"""
out = {}
for k, v in row.items():
if v is None:
continue
if isinstance(v, float) and pd.isna(v):
continue
s = str(v).strip()
if s == "":
continue
if isinstance(v, (datetime, date)):
out[str(k)] = v.isoformat()
else:
out[str(k)] = v
return out
def _read_business_dates():
"""从状态库读各站本次业务日期(与报告口径一致;读不到返回空 dict"""
try:
return eu._read_business_dates(ALL_SITES + ["百世"]) or {}
except Exception as e:
print(f">> [warn] 读取业务日期失败(不影响入库): {e}")
return {}
# ============================== UPSERT SQL ==============================
_SQL_EXPECTED = """
INSERT INTO expected_record
(site, waybill_no, handover_no, handover_pieces, order_pieces, business_date, raw)
VALUES (%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (site, waybill_no) DO UPDATE SET
handover_no = EXCLUDED.handover_no,
handover_pieces = EXCLUDED.handover_pieces,
order_pieces = EXCLUDED.order_pieces,
business_date = COALESCE(EXCLUDED.business_date, expected_record.business_date),
raw = EXCLUDED.raw,
ingested_at = now()
"""
_SQL_ACTUAL = """
INSERT INTO actual_record
(site, waybill_no, piece_no, scan_time, scan_site, raw)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT (site, piece_no) DO UPDATE SET
waybill_no = COALESCE(EXCLUDED.waybill_no, actual_record.waybill_no),
scan_time = EXCLUDED.scan_time,
scan_site = EXCLUDED.scan_site,
raw = EXCLUDED.raw,
ingested_at = now()
"""
_SQL_UNDELIVERED = """
INSERT INTO undelivered_record
(site, waybill_no, piece_no, biz_type, last_scan, raw)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT (site, piece_no) DO UPDATE SET
waybill_no = COALESCE(EXCLUDED.waybill_no, undelivered_record.waybill_no),
biz_type = EXCLUDED.biz_type,
last_scan = EXCLUDED.last_scan,
raw = EXCLUDED.raw,
ingested_at = now()
"""
# ============================== 入库 ==============================
def _ingest_expected(cur, site, business_date):
"""入库单站应到(运单级,按 waybill_no 去重 keep-first 后 UPSERT"""
cfg = eu._site_cfg(site)
path = os.path.join(DOWNLOAD_DIR, cfg["exp"])
if not os.path.exists(path):
print(f" [跳过] {site} 应到:文件不存在 {cfg['exp']}")
return 0
df = pd.read_excel(path, dtype=str).fillna("")
df = df.drop_duplicates(subset=[cfg["exp_wb"]], keep="first")
biz = _parse_date(business_date)
rows = []
for r in df.to_dict("records"):
wb = str(r.get(cfg["exp_wb"], "")).strip()
if not wb:
continue
rows.append(
(
site,
wb,
str(r.get(cfg["exp_jd"], "")).strip() or None,
_to_int(r.get(cfg["exp_qty"])),
_to_int(r.get("录单件数")),
biz,
Jsonb(_raw_row(r)),
)
)
if rows:
cur.executemany(_SQL_EXPECTED, rows)
print(f" [应到] {site}{len(rows)} 条运单")
return len(rows)
def _ingest_actual(cur, site):
"""入库单站实到(扫描件级,按 piece_no UPSERT"""
cfg = eu._site_cfg(site)
path = os.path.join(DOWNLOAD_DIR, cfg["act"])
if not os.path.exists(path):
print(f" [跳过] {site} 实到:文件不存在 {cfg['act']}")
return 0
cm = ACTUAL_COLMAP[site]
df = pd.read_excel(path, dtype=str).fillna("")
if site == "韵达":
# 韵达业务清洗:抛弃「交接单号」为空的行(派件/签收等其他扫描无交接单号),
# 再按子单号去重一件多扫只留一条清洗后子单号已天然唯一drop 为保险)。
df = df[df["交接单号"].astype(str).str.strip() != ""]
df = df.drop_duplicates(subset=[cm["piece"]], keep="last")
rows = []
for r in df.to_dict("records"):
piece = str(r.get(cm["piece"], "")).strip()
if not piece:
continue
if site == "中通": # 复合串 H+运单号(12)+总数(4)+顺序(4):基号 = v[:-8]
waybill = piece[:-8] if (len(piece) > 8 and piece[-4:].isdigit()) else piece
else:
waybill = str(r.get(cm["waybill"], "")).strip() or None
rows.append(
(
site,
waybill,
piece,
_parse_time(r.get(cm["scan_time"])),
str(r.get(cm["scan_site"], "")).strip() or None,
Jsonb(_raw_row(r)),
)
)
if rows:
cur.executemany(_SQL_ACTUAL, rows)
print(f" [实到] {site}{len(rows)} 条扫描")
return len(rows)
def _ingest_undelivered_baishi(cur):
"""入库百世应到未到明细(子单级,按 (site, piece_no) UPSERT"""
path = os.path.join(DOWNLOAD_DIR, eu.BAISHI_FILE)
if not os.path.exists(path):
print(f" [跳过] 百世 未到:文件不存在 {eu.BAISHI_FILE}")
return 0
df = pd.read_excel(path, dtype=str).fillna("")
rows = []
for r in df.to_dict("records"):
rows.append(
(
"百世",
str(r.get("运单号", "")).strip() or None,
str(r.get("子单号", "")).strip() or None,
str(r.get("类型", "")).strip() or None,
str(r.get("最新扫描记录", "")).strip() or None,
Jsonb(_raw_row(r)),
)
)
if rows:
cur.executemany(_SQL_UNDELIVERED, rows)
print(f" [未到] 百世:{len(rows)}")
return len(rows)
def ingest(site=None):
"""入库:指定 site 则单站(百世只入未到),否则全站。返回总条数。"""
dates = _read_business_dates()
total = 0
with _connect(_load_pg_config()["dbname"]) as conn:
with conn.cursor() as cur:
sites = ALL_SITES if site in (None, "百世") else [site]
for s in sites:
if s not in ALL_SITES:
print(f" [跳过] 不支持应到/实到的站点: {s}")
continue
total += _ingest_expected(cur, s, dates.get(s))
total += _ingest_actual(cur, s)
if site in (None, "百世"):
total += _ingest_undelivered_baishi(cur)
conn.commit()
print(f">> [ingest] 完成,共 {total}")
return total
# ============================== 命令行 ==============================
def _cli():
cmd = sys.argv[1] if len(sys.argv) > 1 else "all"
site = sys.argv[2] if len(sys.argv) > 2 else None
if cmd == "createdb":
create_database()
elif cmd == "init":
init_schema()
elif cmd == "ingest":
ingest(site)
elif cmd == "all":
create_database()
init_schema()
ingest()
else:
print(__doc__)
sys.exit(1)
if __name__ == "__main__":
_cli()

View File

@@ -6,3 +6,4 @@ websocket-client>=1.0.0
fastapi>=0.110.0
uvicorn>=0.27.0
apscheduler>=3.10.0
psycopg[binary]>=3.1

54
schema.sql Normal file
View File

@@ -0,0 +1,54 @@
-- =====================================================================
-- CQHXDB / 到货核销数据持久化 schema
-- 设计:统一核心表 + JSONB raw 兜底(保留站点原始全列,一字段不丢)
-- 幂等:业务唯一键 UPSERT重复下载天然合并、零冗余
-- 表expected_record运单级/ actual_record扫描件级
-- / undelivered_record百世站点直供未到明细子单级
-- =====================================================================
-- 隔离到专用 schema不污染 public表名无需再加前缀。
CREATE SCHEMA IF NOT EXISTS inbound_verify;
SET search_path TO inbound_verify;
-- 应到货物(运单级:一运单一行;按运单号去重 keep-first 后入库)
CREATE TABLE IF NOT EXISTS expected_record (
id BIGSERIAL PRIMARY KEY,
site TEXT NOT NULL, -- 站点:顺心 / 中通 / 韵达 / 安能
waybill_no TEXT NOT NULL, -- 运单基号(业务唯一键,去重键)
handover_no TEXT, -- 交接单号
handover_pieces INTEGER, -- 交接件数(应到件数口径)
order_pieces INTEGER, -- 录单件数
business_date DATE, -- 业务日期(属性,非唯一键;读不到则 NULL
raw JSONB NOT NULL, -- 站点原始全列key=原列名)
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site, waybill_no)
);
CREATE INDEX IF NOT EXISTS idx_expected_site_date ON expected_record (site, business_date);
-- 实到货物(扫描件级:一扫描一行;每扫描一件系统生成一个单号)
CREATE TABLE IF NOT EXISTS actual_record (
id BIGSERIAL PRIMARY KEY,
site TEXT NOT NULL,
waybill_no TEXT NOT NULL, -- 运单基号(由扫描单号推导)
piece_no TEXT NOT NULL, -- 扫描单号 / 子单号(业务唯一键)
scan_time TIMESTAMPTZ, -- 扫描时间(尽力解析,失败存 NULL原始值在 raw
scan_site TEXT, -- 扫描网点
raw JSONB NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site, piece_no)
);
CREATE INDEX IF NOT EXISTS idx_actual_waybill ON actual_record (site, waybill_no);
CREATE INDEX IF NOT EXISTS idx_actual_scan_time ON actual_record (scan_time);
-- 应到未到(百世站点直供未到明细:子单级)
CREATE TABLE IF NOT EXISTS undelivered_record (
id BIGSERIAL PRIMARY KEY,
site TEXT NOT NULL, -- 百世
waybill_no TEXT, -- 运单号
piece_no TEXT, -- 子单号(业务唯一键)
biz_type TEXT, -- 类型
last_scan TEXT, -- 最新扫描记录
raw JSONB NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site, piece_no)
);