refactor(db_compare): 差缺统计改为应到驱动,以出库日为批次归属日
将未到统计从实到驱动(实到锚点反推批次)改为应到驱动(batch_out_date 归属日直接取应到批次),以吸收应到任务提前 1~2 天提交的扰动(韵达固定 +1、中通偶发 +1)。 - expected_record 新增 out_date / batch_out_date + 索引;入库解析出库时间并聚合批次归属日,支持历史回填 - 新增 compare_site_outdate 应到驱动入口,保留 compare_site_date 实到驱动作对照 - 未到任务 / POST /compare / 全站汇总切换到应到驱动 - 附现状梳理、设计、实现总结三篇文档
This commit is contained in:
@@ -173,6 +173,33 @@ def _to_int(v):
|
||||
return None
|
||||
|
||||
|
||||
def _batch_out_date_map(df, cfg, out_col="出库时间"):
|
||||
"""按交接单号分组,计算批次归属日:出库日期众数,并列取较早日期。
|
||||
返回 {handover_no: date};无出库时间/无交接单号的行不参与。"""
|
||||
from collections import Counter
|
||||
|
||||
jd_col = cfg.get("exp_jd", "交接单号")
|
||||
if jd_col not in df.columns or out_col not in df.columns:
|
||||
return {}
|
||||
counter: dict[str, Counter] = {}
|
||||
for _, r in df.iterrows():
|
||||
hn = str(r.get(jd_col, "")).strip()
|
||||
if not hn or hn == "nan":
|
||||
continue
|
||||
d = _parse_time(r.get(out_col))
|
||||
if d is None:
|
||||
continue
|
||||
counter.setdefault(hn, Counter())[d.date()] += 1
|
||||
out = {}
|
||||
for hn, cnt in counter.items():
|
||||
if not cnt:
|
||||
continue
|
||||
max_n = max(cnt.values())
|
||||
earliest = min(d for d, n in cnt.items() if n == max_n)
|
||||
out[hn] = earliest
|
||||
return out
|
||||
|
||||
|
||||
def _parse_time(v):
|
||||
"""尽力解析多种时间格式为 datetime;失败返回 None(原始值在 raw 里)。"""
|
||||
if v is None:
|
||||
@@ -233,13 +260,16 @@ def _read_business_dates():
|
||||
|
||||
_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)
|
||||
(site, waybill_no, handover_no, handover_pieces, order_pieces,
|
||||
business_date, out_date, batch_out_date, raw)
|
||||
VALUES (%s,%s,%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),
|
||||
out_date = COALESCE(EXCLUDED.out_date, expected_record.out_date),
|
||||
batch_out_date = COALESCE(EXCLUDED.batch_out_date, expected_record.batch_out_date),
|
||||
raw = EXCLUDED.raw,
|
||||
ingested_at = now()
|
||||
"""
|
||||
@@ -294,19 +324,31 @@ def _ingest_expected(cur, site, business_date):
|
||||
df = pd.read_excel(path, dtype=str).fillna("")
|
||||
df = df.drop_duplicates(subset=[cfg["exp_wb"]], keep="first")
|
||||
biz = _parse_date(business_date)
|
||||
out_col = "出库时间"
|
||||
has_out_col = out_col in df.columns
|
||||
# 批次归属日:同交接单号出库日众数,并列取较早
|
||||
batch_out_date = _batch_out_date_map(df, cfg, out_col)
|
||||
rows = []
|
||||
for r in df.to_dict("records"):
|
||||
wb = str(r.get(cfg["exp_wb"], "")).strip()
|
||||
if not wb:
|
||||
continue
|
||||
out_date = None
|
||||
if has_out_col:
|
||||
out_dt = _parse_time(r.get(out_col))
|
||||
if out_dt is not None:
|
||||
out_date = out_dt.date()
|
||||
hn = str(r.get(cfg["exp_jd"], "")).strip() or None
|
||||
rows.append(
|
||||
(
|
||||
site,
|
||||
wb,
|
||||
str(r.get(cfg["exp_jd"], "")).strip() or None,
|
||||
hn,
|
||||
_to_int(r.get(cfg["exp_qty"])),
|
||||
_to_int(r.get("录单件数")),
|
||||
biz,
|
||||
out_date,
|
||||
batch_out_date.get(hn),
|
||||
Jsonb(_raw_row(r)),
|
||||
)
|
||||
)
|
||||
@@ -463,6 +505,69 @@ def ingest_task(site, kind):
|
||||
return total
|
||||
|
||||
|
||||
def backfill_out_date(site=None):
|
||||
"""历史数据回填:从 raw->>'出库时间' 解析出库日,写入 out_date;
|
||||
再按交接单号聚合出库日众数,回填 batch_out_date。
|
||||
site 为空时处理全部站点。返回回填 out_date 条数。"""
|
||||
sites = [site] if site else ALL_SITES
|
||||
total = 0
|
||||
with _connect(_load_pg_config()["dbname"]) as conn:
|
||||
with conn.cursor() as cur:
|
||||
for s in sites:
|
||||
# ── Step 1: 回填 out_date(仅 NULL 行)──
|
||||
cur.execute(
|
||||
"SELECT id, raw FROM expected_record "
|
||||
"WHERE site=%s AND out_date IS NULL",
|
||||
(s,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
updates = []
|
||||
for rid, raw in rows:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
out_dt = _parse_time(raw.get("出库时间"))
|
||||
if out_dt is None:
|
||||
continue
|
||||
updates.append((out_dt.date(), rid))
|
||||
if updates:
|
||||
cur.executemany(
|
||||
"UPDATE expected_record SET out_date=%s WHERE id=%s",
|
||||
updates,
|
||||
)
|
||||
total += len(updates)
|
||||
print(f" [回填] {s}:{len(updates)}/{len(rows)} 条")
|
||||
|
||||
# ── Step 2: 回填 batch_out_date(仅 NULL 行)──
|
||||
cur.execute(
|
||||
"SELECT id, handover_no, out_date FROM expected_record "
|
||||
"WHERE site=%s AND batch_out_date IS NULL",
|
||||
(s,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
from collections import Counter
|
||||
|
||||
cnt: dict[str, Counter] = {}
|
||||
for _, hn, od in rows:
|
||||
if not hn or od is None:
|
||||
continue
|
||||
cnt.setdefault(hn, Counter())[od] += 1
|
||||
batch_map = {}
|
||||
for hn, c in cnt.items():
|
||||
max_n = max(c.values())
|
||||
batch_map[hn] = min(d for d, n in c.items() if n == max_n)
|
||||
if batch_map:
|
||||
cur.executemany(
|
||||
"UPDATE expected_record SET batch_out_date=%s "
|
||||
"WHERE site=%s AND handover_no=%s",
|
||||
[(d, s, hn) for hn, d in batch_map.items()],
|
||||
)
|
||||
print(f" [回填] {s} batch_out_date:{len(batch_map)} 个批次")
|
||||
conn.commit()
|
||||
print(f">> [回填] out_date 完成,共 {total} 条")
|
||||
return total
|
||||
|
||||
|
||||
def get_existing_handover_nos(site):
|
||||
"""查该站点已落库的交接单号集合(expected_record.handover_no)。
|
||||
供"提交导出任务前"去重:已落库的交接单号不再重复提交导出任务。
|
||||
@@ -553,6 +658,8 @@ def main():
|
||||
sys.exit(1)
|
||||
total = ingest_task(site, kind)
|
||||
print(f">> [ingest-one] {site}/{kind} 入库 {total} 条")
|
||||
elif cmd == "backfill-out-date":
|
||||
backfill_out_date(site)
|
||||
else:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user