真实模型验收用的虚构零售库:60 天订单截止昨天,埋退款行、下单日和支付日错开、取消单三个口径陷阱,华南每周都高于华东(供反方审稿推翻错话),手机号列供遮罩验收;append_day 给定时任务验收制造「只数据变」;测试守住陷阱确实在、同一天生成的库一样

This commit is contained in:
ThearchyHelios
2026-10-06 11:44:58 +08:00
parent 833d206fb2
commit d7f6b4d756
2 changed files with 189 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
"""真实模型验收用的虚构零售库。表名、店名、客户、手机号、金额都是编的,和任何真实业务无关。
python -m tests.fixtures.acceptance.retail <输出 .db 路径> [--today YYYY-MM-DD]
生成「今天」之前 60 天的订单(截止昨天),随机数种子固定:同一个「今天」生成的库逐字节一样。埋了几个口径陷阱,
验收时用它们检验双盲复算、反方审稿能不能识破:
- 退款行:约 8% 的订单 status = 'refunded'(paid_date 有值)。口径要的销售额不含退款;漏掉这个条件,金额偏高。
- 下单日和支付日:约 15% 的订单隔 1–2 天才支付。口径按支付日(paid_date)归属;按下单日(order_date)统计会把
跨日、跨周的单算错地方。
- 取消的单:约 3% status = 'cancelled'、paid_date 为空,按支付日统计时自然不在内。
- 华南的销售额每周都明显高于华东(门店多、客单价高):验收时让报告写「华东销售额最高」这句错话,看反方能不能推翻。
customers.phone 是手机号,验收时把它设成遮罩列,检验质询、证据面板、可验证报告不露原值。append_day 给定时任务
验收用:两次触发之间往「昨天」追加几单,制造「只数据变」。
"""
from __future__ import annotations
import argparse
import random
import sqlite3
from datetime import date, timedelta
from pathlib import Path
DAYS = 60
SEED = 20261006
#: (门店 id, 店名, 地区, 城市):华南门店多、客单价高,华东次之
STORES = [
(1, "星河一号店", "华东", "临川"), (2, "星河二号店", "华东", "临川"), (3, "星河三号店", "华东", "望江"),
(4, "星河四号店", "华南", "南屿"), (5, "星河五号店", "华南", "南屿"), (6, "星河六号店", "华南", "海澄"),
(7, "星河七号店", "华南", "海澄"), (8, "星河八号店", "华北", "北岭"), (9, "星河九号店", "西南", "云坪"),
]
#: 各地区客单价的大致区间(元)
PRICE = {"华东": (60, 260), "华南": (90, 380), "华北": (40, 200), "西南": (30, 180)}
#: 每家店每天的订单数区间
DAILY = {"华东": (4, 8), "华南": (6, 10), "华北": (3, 6), "西南": (2, 5)}
CUSTOMERS = 400
SCHEMA = """
CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT NOT NULL, region TEXT NOT NULL, city TEXT NOT NULL);
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, phone TEXT NOT NULL, joined_on DATE NOT NULL);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id),
customer_id INTEGER NOT NULL REFERENCES customers(id),
order_date DATE NOT NULL,
paid_date DATE,
amount REAL NOT NULL,
status TEXT NOT NULL,
channel TEXT NOT NULL
);
"""
def _phone(rng: random.Random) -> str:
# 199 号段加 8 位随机数:看起来像手机号,不对应任何真实号码
return "199" + "".join(str(rng.randrange(10)) for _ in range(8))
def _orders_for(rng: random.Random, day: date, start_id: int, scale: float = 1.0) -> list[tuple]:
rows = []
next_id = start_id
for store_id, _, region, _ in STORES:
low, high = DAILY[region]
for _ in range(max(1, round(rng.randint(low, high) * scale))):
amount = round(rng.uniform(*PRICE[region]), 2)
roll = rng.random()
if roll < 0.03:
status, paid = "cancelled", None
else:
status = "refunded" if roll < 0.11 else "paid"
lag = rng.choices((0, 1, 2), weights=(85, 10, 5))[0]
paid = (day + timedelta(days=lag)).isoformat()
rows.append((next_id, store_id, rng.randint(1, CUSTOMERS), day.isoformat(), paid, amount, status,
rng.choice(("online", "offline"))))
next_id += 1
return rows
def build(path: Path | str, today: date | None = None) -> Path:
"""生成到 path(已存在就覆盖)。today 缺省取运行这一天;订单截止 today 的前一天,支付日可能落在之后。"""
path = Path(path)
if path.exists():
path.unlink()
today = today or date.today()
rng = random.Random(SEED)
db = sqlite3.connect(path)
db.executescript(SCHEMA)
db.executemany("INSERT INTO stores VALUES (?, ?, ?, ?)", STORES)
first = today - timedelta(days=DAYS)
db.executemany("INSERT INTO customers VALUES (?, ?, ?, ?)", [
(i, f"客户{i:04d}", _phone(rng), (first - timedelta(days=rng.randint(0, 400))).isoformat())
for i in range(1, CUSTOMERS + 1)])
rows: list[tuple] = []
for offset in range(DAYS):
rows += _orders_for(rng, first + timedelta(days=offset), len(rows) + 1)
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows)
db.commit()
db.close()
return path
def append_day(path: Path | str, day: date, *, seed: int, scale: float = 0.5) -> int:
"""往 day 这一天追加一批订单(都已支付、当天支付),返回追加的单数。定时任务验收用来制造「只数据变」。"""
rng = random.Random(seed)
db = sqlite3.connect(path)
start = (db.execute("SELECT COALESCE(MAX(id), 0) FROM orders").fetchone()[0] or 0) + 1
rows = [(*row[:4], day.isoformat(), row[5], "paid", row[7]) for row in _orders_for(rng, day, start, scale)]
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows)
db.commit()
db.close()
return len(rows)
def main() -> None:
parser = argparse.ArgumentParser(description="生成真实模型验收用的虚构零售库")
parser.add_argument("path")
parser.add_argument("--today", type=date.fromisoformat, default=None)
args = parser.parse_args()
out = build(args.path, args.today)
print(out)
if __name__ == "__main__":
main()
@@ -0,0 +1,63 @@
"""真实模型验收用的虚构零售库:陷阱确实埋上了、同一个「今天」生成的库一样、追加一天只动那一天。"""
from __future__ import annotations
import sqlite3
from datetime import date, timedelta
from tests.fixtures.acceptance import retail
TODAY = date(2026, 10, 6)
def _rows(path):
with sqlite3.connect(path) as db:
return db.execute("SELECT * FROM orders ORDER BY id").fetchall()
def _week_sum(db, region, start, *, by="paid_date", refunds=False):
end = (start + timedelta(days=6)).isoformat()
status = "" if refunds else "AND o.status <> 'refunded'"
return db.execute(
f"SELECT ROUND(SUM(o.amount), 2) FROM orders o JOIN stores s ON s.id = o.store_id "
f"WHERE s.region = ? AND o.{by} BETWEEN ? AND ? {status}", (region, start.isoformat(), end)).fetchone()[0]
def test_the_same_day_builds_the_same_database(tmp_path):
a = retail.build(tmp_path / "a.db", TODAY)
b = retail.build(tmp_path / "b.db", TODAY)
assert _rows(a) == _rows(b) and len(_rows(a)) > 1000
def test_the_traps_are_in_place(tmp_path):
path = retail.build(tmp_path / "retail.db", TODAY)
with sqlite3.connect(path) as db:
total, refunded, cancelled, lagged, null_paid, last = db.execute(
"SELECT COUNT(*), SUM(status = 'refunded'), SUM(status = 'cancelled'), "
"SUM(paid_date IS NOT NULL AND paid_date <> order_date), SUM(paid_date IS NULL), MAX(order_date) "
"FROM orders").fetchone()
# 退款约 8%、取消约 3%(取消的没有支付日)、约 15% 隔天支付;订单截止昨天
assert 0.05 < refunded / total < 0.12 and 0.01 < cancelled / total < 0.06
assert null_paid == cancelled and 0.08 < lagged / total < 0.25
assert last == (TODAY - timedelta(days=1)).isoformat()
# 每个整周:华南都高于华东(反方审稿用的错话「华东最高」站不住);三种错法的金额和口径要的都不一样
monday = date.fromisoformat(db.execute("SELECT MIN(order_date) FROM orders").fetchone()[0])
monday += timedelta(days=(7 - monday.weekday()) % 7)
weeks = 0
while monday + timedelta(days=8) < TODAY:
assert _week_sum(db, "华南", monday) > _week_sum(db, "华东", monday)
right = _week_sum(db, "华东", monday)
assert right != _week_sum(db, "华东", monday, refunds=True)
weeks += 1
monday += timedelta(days=7)
assert weeks >= 6
assert db.execute("SELECT COUNT(DISTINCT phone) FROM customers").fetchone()[0] == retail.CUSTOMERS
def test_appending_a_day_only_touches_that_day(tmp_path):
path = retail.build(tmp_path / "retail.db", TODAY)
before = _rows(path)
yesterday = TODAY - timedelta(days=1)
added = retail.append_day(path, yesterday, seed=7)
after = _rows(path)
assert added > 0 and after[:len(before)] == before and len(after) == len(before) + added
assert all(r[3] == r[4] == yesterday.isoformat() and r[6] == "paid" for r in after[len(before):])