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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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