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:
Misaka
2026-08-03 22:52:28 +08:00
parent bccf7cd396
commit 36b204abe5
8 changed files with 734 additions and 20 deletions

View File

@@ -265,7 +265,7 @@ class CompareRequest(BaseModel):
@app.post("/compare")
def run_compare(req: CompareRequest):
"""DB 差缺比对:以实到扫描日期为锚点,反推交接批次,展开全量比对。
"""DB 差缺比对应到驱动以批次归属日batch_out_date为锚,展开全量比对。
返回统计指标 + 差缺明细。
"""
# 合法性校验
@@ -284,11 +284,11 @@ def run_compare(req: CompareRequest):
if target_date > today:
raise HTTPException(status_code=400, detail=f"date 不可为未来日期: {req.date}")
result = db_compare.compare_site_date(req.site, req.date)
result = db_compare.compare_site_outdate(req.site, req.date)
if result is None:
raise HTTPException(
status_code=404,
detail=f"{req.site} {req.date}: 当天无实到数据,无法比对",
detail=f"{req.site} {req.date}: 当日无应到批次,无法比对",
)
return {

View File

@@ -224,6 +224,83 @@ def compare_site_date(site: str, target_date: str) -> CompareResult | None:
return None
def compare_site_outdate(site: str, target_date: str) -> CompareResult | None:
"""应到驱动差缺比对以「批次归属日batch_out_date」为准取应到。
与 compare_site_date实到驱动区别
1. 应到来源 = expected_record WHERE batch_out_date = target_date
2. 不再依赖实到锚点反推;应到空时返回 None明确"当日无应到"
3. 提前提交的批次按其出库日归属,自动归入正确日期
Args:
site: 站点名("顺心"/"中通"/"韵达"/"安能"
target_date: 目标业务日期 "YYYY-MM-DD"
Returns:
CompareResult 或 None当日无应到批次
"""
cfg = SITE_COMPARE_CONFIG.get(site)
if cfg is None:
print(f"[db_compare] 不支持的站点: {site}")
return None
try:
conn = _connect()
cur = conn.cursor()
# ── Step 1: 取目标日应到批次(按批次归属日)──
cur.execute(
"""
SELECT DISTINCT handover_no FROM expected_record
WHERE site = %s AND batch_out_date = %s
ORDER BY handover_no
""",
(site, target_date),
)
batches = [r[0] for r in cur.fetchall()]
if not batches:
print(f"[db_compare] {site} {target_date}: 当日无应到批次")
conn.close()
return None
# ── Step 2: 展开批次全量应到 ──
cur.execute(
"""
SELECT waybill_no, handover_no, handover_pieces
FROM expected_record
WHERE site = %s AND handover_no = ANY(%s)
ORDER BY handover_no, waybill_no
""",
(site, batches),
)
exp_rows = cur.fetchall()
if not exp_rows:
conn.close()
return None
all_wbs = [r[0] for r in exp_rows]
# ── Step 3: 取批次全量实到 ──
cur.execute(
"""
SELECT waybill_no, piece_no FROM actual_record
WHERE site = %s AND waybill_no = ANY(%s)
ORDER BY waybill_no, piece_no
""",
(site, all_wbs),
)
act_rows = cur.fetchall()
conn.close()
# ── Step 4: 逐运单比对 ──
return _do_compare(site, target_date, batches, exp_rows, act_rows, cfg)
except Exception as e:
print(f"[db_compare] {site} {target_date} 应到驱动比对异常: {e}")
return None
def compare_site_batch(site: str, handover_no: str) -> CompareResult | None:
"""按指定交接单号执行全批次比对(不依赖实到锚点)。
@@ -499,11 +576,9 @@ def _stats_to_dict(s: CompareStats) -> dict:
def _target_date_for(site: str) -> str:
"""4 站比对锚点today - actual_offset以实到扫描日为锚与 _site_undelivered_handler 一致)。"""
from inbound_verify import state_store # 懒导入,避免成环
offset = state_store.get_offset(site, "actual")
return (date.today() - timedelta(days=offset)).strftime("%Y-%m-%d")
"""4 站比对锚点(应到驱动):批次归属日默认取今天。
各站统一以出库日batch_out_date为准不再依赖站点偏移配置。"""
return date.today().strftime("%Y-%m-%d")
def _baishi_from_pg(cur, target: str):
@@ -555,7 +630,7 @@ def _baishi_from_pg(cur, target: str):
def build_full_report(date=None) -> str:
"""DB 版全站汇总报表4 站走 DB 比对、百世走 PG复用 compare.build_summary 渲染。
产出 output/应到未到数据.xlsx/report 下载。date=None 时各站按 actual_offset 算锚点(以实到扫描日为锚)。
产出 output/应到未到数据.xlsx/report 下载。date=None 时各站取今天为批次归属锚点(应到驱动)。
返回输出路径。"""
from inbound_verify import compare # 复用 build_summary / write_station / OUTFILE
@@ -584,7 +659,7 @@ def build_full_report(date=None) -> str:
continue
target = date or _target_date_for(name)
site_targets[name] = target
result = compare_site_date(name, target)
result = compare_site_outdate(name, target)
if result is not None:
results.append((name, _stats_to_dict(result.stats)))
_write_sheet(wb.create_sheet(name), result)

View File

@@ -492,23 +492,20 @@ def _site_undelivered_handler(site):
except Exception as e:
print(f">> [入库] {site} 前置入库失败(不影响比对尝试): {e}")
# ── DB 比对(替代旧 Excel 比对)──
# ── DB 比对(应到驱动:以批次归属日 batch_out_date 为锚)──
try:
from inbound_verify import db_compare # 懒导入,避免成环
if date:
target_date = date
else:
offset = state_store.get_offset(site, "actual")
target_date = (datetime.now().date() - timedelta(days=offset)).strftime(
"%Y-%m-%d"
)
target_date = datetime.now().date().strftime("%Y-%m-%d")
result = db_compare.compare_site_date(site, target_date)
result = db_compare.compare_site_outdate(site, target_date)
if result is not None:
db_compare.write_result_excel(result)
else:
print(f">> [未到] {site} {target_date}: 当天无实到数据,跳过比对")
print(f">> [未到] {site} {target_date}: 当日无应到批次,跳过比对")
except Exception as e:
print(f">> [未到] {site} DB 比对异常(不影响下载结果): {e}")

View File

@@ -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)