# 加购班次:拆仓加车不插行、系统故障短码。 from contextlib import contextmanager from datetime import datetime, timedelta from cart_job import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, AddOutcome, run_cart_batch from cart_match import CartOffer from cart_repo import STAMP_CLAIMING, STAMP_DONE, STAMP_SELECTION, MemoryCartRepo HANGZHOU = "杭州小药药自营旗舰店" GUANGDONG = "广东壹号药业有限公司" YBM = 48 YAOEX = 66 # 构造一条待加购表 2 行。 def _row(**overrides) -> dict: data = { "fid": 1, "oms_id": 2026100201, "platform_type": YAOEX, "supplier_name": GUANGDONG, "bar_code": "06901234567890", "purchase_qty": 7, "status": 1, "fail_reason": None, "push_status": 0, "update_user_name": STAMP_SELECTION, "operate_type": 1, } data.update(overrides) return data # 构造一条能对上仓的加购候选。 def _offer(vendor: str, **overrides) -> CartOffer: data = dict( sku_id="S-" + vendor[:2], vendor_raw=vendor, vendor_norm=vendor, stock=20.0, price=90.0, ) data.update(overrides) return CartOffer(**data) # 杭州 3 + 广东 7 两行都加车,表 2 仍两行。 def test_job_adds_split_rows_without_insert(): repo = MemoryCartRepo( [ _row(fid=1, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), _row(fid=2, platform_type=YAOEX, supplier_name=GUANGDONG, purchase_qty=7), ] ) added: list[tuple[int, str, int]] = [] # 杭州仓有货。 def search_ybm(bar_code: str): assert bar_code == "06901234567890" return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)] # 广东仓有货。 def search_yaoex(bar_code: str): assert bar_code == "06901234567890" return [_offer(GUANGDONG, sku_id="GD", stock=20.0)] # 记下药帮忙加车。 def add_ybm(offer: CartOffer, qty: int): added.append((YBM, offer.sku_id, qty)) return AddOutcome(kind=KIND_OK) # 记下 1 号药城加车。 def add_yaoex(offer: CartOffer, qty: int): added.append((YAOEX, offer.sku_id, qty)) return AddOutcome(kind=KIND_OK) summary = run_cart_batch( repo, searchers={YBM: search_ybm, YAOEX: search_yaoex}, adders={YBM: add_ybm, YAOEX: add_yaoex}, login_errors={}, ) assert summary.claimed == 2 assert summary.added == 2 assert summary.failed == 0 assert added == [(YBM, "HZ", 3), (YAOEX, "GD", 7)] assert list(repo.rows) == [1, 2] assert repo.rows[1]["status"] == 1 assert repo.rows[2]["status"] == 1 assert repo.rows[1]["update_user_name"] == STAMP_DONE assert repo.rows[2]["update_user_name"] == STAMP_DONE assert repo.rows[1]["push_status"] == 0 # 同一 omsId 一仓成功一仓失败:失败行原因带未加齐,成功行不回退。 def test_job_flags_incomplete_oms_when_one_row_fails(): repo = MemoryCartRepo( [ _row(fid=1, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), _row(fid=2, platform_type=YAOEX, supplier_name=GUANGDONG, purchase_qty=7), ] ) # 杭州仓有货。 def search_ybm(bar_code: str): return [_offer(HANGZHOU, stock=5.0)] # 广东仓库存不够。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG, stock=1.0)] summary = run_cart_batch( repo, searchers={YBM: search_ybm, YAOEX: search_yaoex}, adders={ YBM: lambda offer, qty: AddOutcome(kind=KIND_OK), YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK), }, login_errors={}, ) assert summary.added == 1 assert summary.failed == 1 assert repo.rows[1]["status"] == 1 assert repo.rows[1]["fail_reason"] is None assert repo.rows[2]["status"] == 2 assert repo.rows[2]["fail_reason"] == "未加齐:库存不足" # 库存不够时该行 status=2,原因是库存不足。 def test_job_marks_out_of_stock(): repo = MemoryCartRepo([_row(purchase_qty=10)]) # 库存只有 4。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG, stock=4.0)] summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, login_errors={}, ) assert summary.added == 0 assert summary.failed == 1 assert repo.rows[1]["status"] == 2 assert repo.rows[1]["fail_reason"] == "库存不足" assert repo.rows[1]["update_user_name"] == STAMP_DONE assert repo.shift_errors == [] def test_job_marks_login_fail_as_system_error(): """登录失败把戳改回系统管理员,status 保持 1,下一班还能领。""" repo = MemoryCartRepo( [ _row(fid=1, platform_type=YAOEX), _row(fid=2, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), ] ) ybm_added = [] # 杭州仓有货。 def search_ybm(bar_code: str): return [_offer(HANGZHOU)] # 记下药帮忙加车数量。 def add_ybm(offer: CartOffer, qty: int): ybm_added.append(qty) return AddOutcome(kind=KIND_OK) summary = run_cart_batch( repo, searchers={YBM: search_ybm}, adders={YBM: add_ybm}, login_errors={YAOEX: "账号密码错误"}, ) assert summary.added == 1 assert summary.failed == 1 assert repo.rows[1]["status"] == 1 assert repo.rows[1]["update_user_name"] == STAMP_SELECTION assert repo.rows[1]["fail_reason"] == "未加齐:系统故障:LOGIN_FAILED" assert "账号密码错误" not in repo.rows[1]["fail_reason"] assert len(repo.shift_errors) == 1 assert repo.shift_errors[0].shift_name == "加购" assert repo.shift_errors[0].biz_key == "1" assert repo.shift_errors[0].error_code == "LOGIN_FAILED" assert repo.shift_errors[0].error_text == "账号密码错误" assert repo.rows[2]["status"] == 1 assert repo.rows[2]["update_user_name"] == STAMP_DONE assert ybm_added == [3] again = repo.claim_pending() assert [row.fid for row in again] == [1] # 已盖加购班的行本班不领。 def test_job_skips_already_stamped_rows(): repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)]) called = [] # 不应被调用。 def search_yaoex(bar_code: str): called.append(bar_code) return [_offer(GUANGDONG)] summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, login_errors={}, ) assert summary.claimed == 0 assert called == [] assert repo.rows[1]["update_user_name"] == STAMP_DONE # 对不上 supplier_name 时写未找到对应仓。 def test_job_marks_missing_warehouse(): repo = MemoryCartRepo([_row()]) # 搜到的是杭州仓,对不上广东。 def search_yaoex(bar_code: str): return [_offer(HANGZHOU)] summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, login_errors={}, ) assert summary.failed == 1 assert repo.rows[1]["status"] == 2 assert repo.rows[1]["fail_reason"] == "未找到对应仓" # 平台返回业务失败时原样写下原因。 def test_job_records_mall_add_failure(): repo = MemoryCartRepo([_row()]) # 广东仓有货。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG)] # 平台返回中包装加不进。 def add_yaoex(offer: CartOffer, qty: int): return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数") summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: add_yaoex}, login_errors={}, ) assert summary.failed == 1 assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数" # 只跑一个平台时另一平台行仍停在系统管理员。 def test_job_platform_filter_leaves_other_mall_pending(): repo = MemoryCartRepo( [ _row(fid=1, platform_type=YAOEX), _row(fid=2, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), ] ) # 广东仓有货。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG)] summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, login_errors={}, platform_types=[YAOEX], ) assert summary.claimed == 1 assert summary.added == 1 assert repo.rows[1]["update_user_name"] == STAMP_DONE assert repo.rows[2]["update_user_name"] == STAMP_SELECTION assert repo.rows[2]["status"] == 1 def test_job_search_error_is_system_fail(): """查询失败原因列只写 QUERY_FAILED,戳改回系统管理员,status 保持 1。""" repo = MemoryCartRepo([_row()]) # 抛带原文的查询错。 def search_yaoex(bar_code: str): raise RuntimeError("网关 502 secret-traceback") summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, login_errors={}, ) assert summary.failed == 1 reason = repo.rows[1]["fail_reason"] assert reason == "系统故障:QUERY_FAILED" assert repo.rows[1]["status"] == 1 assert repo.rows[1]["update_user_name"] == STAMP_SELECTION assert "网关 502" not in reason assert "secret-traceback" not in reason assert len(repo.shift_errors) == 1 assert repo.shift_errors[0].error_code == "QUERY_FAILED" assert "网关 502" in repo.shift_errors[0].error_text assert "secret-traceback" in repo.shift_errors[0].error_text # 加车抛错时原因列只写 ADD_FAILED,不含异常原文。 def test_job_add_error_writes_short_code_not_exception(): repo = MemoryCartRepo([_row()]) # 广东仓有货。 def search_yaoex(bar_code: str): return [_offer(GUANGDONG)] # 抛带 ORA 原文的加车错。 def add_yaoex(offer, qty: int): raise RuntimeError("ORA-03114 secret-traceback") summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: add_yaoex}, login_errors={}, ) assert summary.failed == 1 reason = repo.rows[1]["fail_reason"] assert reason == "系统故障:ADD_FAILED" assert "ORA-03114" not in reason assert "secret-traceback" not in reason assert len(repo.shift_errors) == 1 assert repo.shift_errors[0].error_code == "ADD_FAILED" assert "ORA-03114" in repo.shift_errors[0].error_text class HoldingCartRepo(MemoryCartRepo): """内存仓库加上可计数的 session,用来断言商城调用期间连接已归还。""" def __init__(self, rows): """在内存行上加 held 计数。""" super().__init__(rows) self.held = 0 @contextmanager def session(self): """借连接时 held 加一,退出时减回。""" self.held += 1 try: yield finally: self.held -= 1 def test_mall_runs_after_connection_is_returned(): """两组加车时连接已归还,盖章时才再借。""" repo = HoldingCartRepo( [ _row(fid=1, oms_id=2026100201), _row(fid=2, oms_id=2026100202), ] ) during_add: list[int] = [] during_mark: list[int] = [] original_claim = repo.claim_pending original_success = repo.mark_success original_errors = repo.save_shift_errors def claim_pending(limit=None, platform_types=None, **kwargs): """领取时必须占着本班的连接。""" assert repo.held == 1 return original_claim(limit=limit, platform_types=platform_types, **kwargs) def mark_success(fid): """盖章时必须占着本班的连接。""" during_mark.append(repo.held) return original_success(fid) def save_shift_errors(items): """审计走连接池,不能在 session 里写。""" assert repo.held == 0 return original_errors(items) def search_yaoex(_bar_code: str): """返回能对上仓的候选。""" return [_offer(GUANGDONG)] def add_yaoex(_offer, _qty: int): """加车时连接必须已经归还。""" during_add.append(repo.held) return AddOutcome(kind=KIND_OK) repo.claim_pending = claim_pending repo.mark_success = mark_success repo.save_shift_errors = save_shift_errors summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: add_yaoex}, login_errors={}, ) assert during_add == [0, 0] assert during_mark == [1, 1] assert repo.held == 0 assert summary.added == 2 def test_rerun_skips_add_when_cart_already_has_the_line(): """对方购物车已有不少于本行的数量、本地仍是加购中时,重跑不加第二份。""" repo = MemoryCartRepo( [ _row( update_user_name=STAMP_CLAIMING, update_time=datetime.now() - timedelta(minutes=31), purchase_qty=7, ) ] ) added: list[int] = [] def search_yaoex(_bar_code: str): """购物车已够时不应再搜。""" raise AssertionError("购物车已够,不应再搜") def add_yaoex(_offer, qty: int): """购物车已够时不应再加。""" added.append(qty) return AddOutcome(kind=KIND_OK) def cart_qty(bar_code: str, supplier: str) -> int: """这一仓比本行多。多出来的数量不当成另一张单。""" assert bar_code == "06901234567890" assert supplier == GUANGDONG return 10 summary = run_cart_batch( repo, searchers={YAOEX: search_yaoex}, adders={YAOEX: add_yaoex}, login_errors={}, cart_readers={YAOEX: cart_qty}, ) assert added == [] assert summary.added == 1 assert summary.failed == 0 assert repo.rows[1]["status"] == 1 assert repo.rows[1]["update_user_name"] == STAMP_DONE assert repo.rows[1]["fail_reason"] is None