diff --git a/app/api/routes/scrape.py b/app/api/routes/scrape.py index b7c207a..e54fe48 100644 --- a/app/api/routes/scrape.py +++ b/app/api/routes/scrape.py @@ -176,9 +176,10 @@ async def order_get_shipping_fee(payload: OrderShippingFeeRequest) -> ApiRespons """计算订单运费与手续费。 规则: - - 官方店铺(supplier 为空)运费: 按官方部分商品总额套阶梯价(440/385/0) + - 官方店铺(店铺标识为空)运费: 按官方部分商品总额套阶梯价(440/385/0) * 订单中无任何官方商品时,官方运费 0 - - 加盟店运费(supplier 非空,同名视为同一家): 每家统一 800 日元 + - 加盟店运费(店铺标识非空,同标识视为同一家): 每家统一 800 日元 + * 店铺标识优先取 tenpo_cd(与 other_shop_list 中的 tenpo_cd 一致),未提供时回退取 supplier - 通贩手续费: 按全部商品(含官方+加盟店)总额套阶梯价(240/0) """ official_amount = 0 @@ -187,10 +188,11 @@ async def order_get_shipping_fee(payload: OrderShippingFeeRequest) -> ApiRespons for item in payload.item_list: subtotal = item.goods_number * item.goods_price goods_amount += subtotal - if item.supplier == "": + shop_key = item.tenpo_cd or item.supplier + if shop_key == "": official_amount += subtotal else: - franchise_stores.add(item.supplier) + franchise_stores.add(shop_key) official_shipping_fee = get_shipping_fee(official_amount) if official_amount > 0 else 0 franchise_shipping_fee = get_franchise_shipping_fee(len(franchise_stores)) diff --git a/app/models/scrape.py b/app/models/scrape.py index e22b3a9..6f91260 100644 --- a/app/models/scrape.py +++ b/app/models/scrape.py @@ -42,6 +42,7 @@ class DetailRequest(BaseModel): product_url: HttpUrl | None = None tenpo_cd: str | None = None # 指定店铺代码;跳转查看某个加盟店/市场店铺的报价时传入 branch_number: str | None = None # 指定分支编号,与 tenpo_cd 搭配使用 + include_other_shops: bool = False # 为 True 时,随详情额外抓取并返回 other_shop_list(多付出一次页面请求) class ProductSummary(BaseModel): @@ -104,6 +105,7 @@ class ProductDetailData(BaseModel): sku_list: list[dict[str, Any]] # 规格列表 sku: str = "" # 规格 breadcrumb_list: list[dict[str, Any]] = [] # 面包屑 + other_shop_list: list[OtherShopItem] = [] # 仅当请求 include_other_shops=True 时才会填充,否则为空列表 class OtherShopListData(BaseModel): @@ -126,6 +128,8 @@ class CargoItemRequest(BaseModel): id: str number: int = 1 price_limit: int | None = None + tenpo_cd: str | None = None # 指定店铺代码;跳转加入指定加盟店/市场店铺的商品时传入 + branch_number: str | None = None # 指定分支编号,与 tenpo_cd 搭配使用 class CargoPayload(BaseModel): @@ -160,6 +164,10 @@ class OrderItem(BaseModel): goods_price: int = Field(ge=0) # 店铺标识: 空串 = 官方店铺;非空 = 加盟店标识(同名视为同一家加盟店) supplier: str = "" + # 店铺代码,来自 other_shop_list 中的 tenpo_cd;非空 = 加盟店/市场店铺。 + # 优先于 supplier 用于判定"同一家店铺"(同 tenpo_cd 视为同一家);未提供时回退使用 supplier。 + tenpo_cd: str = "" + branch_number: str = "" # 分支编号,可选,与 tenpo_cd 搭配使用 class OrderShippingFeeRequest(BaseModel): diff --git a/app/services/cargo_service.py b/app/services/cargo_service.py index 31950ef..2a9ca63 100644 --- a/app/services/cargo_service.py +++ b/app/services/cargo_service.py @@ -91,7 +91,7 @@ class CargoService: continue logger.debug("进入商品 %s 详情页,准备购买数量: %s...", product_id, number) - detail_url = product_detail_url(product_id) + detail_url = product_detail_url(product_id, item.tenpo_cd, item.branch_number) await page.goto(detail_url, wait_until="domcontentloaded", timeout=20000) # 判断是否缺货 diff --git a/app/services/surugaya_client.py b/app/services/surugaya_client.py index b5901f4..b270f7f 100644 --- a/app/services/surugaya_client.py +++ b/app/services/surugaya_client.py @@ -265,6 +265,11 @@ class SurugayaClient: # 提取面包屑 breadcrumb_list = self.get_breadcrumb_list(tree) + # 仅当调用方显式要求时,才额外抓取「他のショップ」页面(多付出一次 Cloudflare 保护页面请求) + other_shop_list: list[OtherShopItem] = [] + if payload.include_other_shops: + other_shop_list = await self._fetch_other_shop_list(goods_id) + detail = ProductDetailData( goods_id=goods_id, goods_name=goods_name, @@ -279,6 +284,7 @@ class SurugayaClient: price_note=price_note, shipping_comission_fee=shipping_comission_fee, breadcrumb_list=breadcrumb_list, + other_shop_list=other_shop_list, ) logger.debug("详情页解析完成:goods_id=%s goods_name=%s ", detail.goods_id, detail.goods_name) return detail diff --git a/tests/test_order_shipping_fee.py b/tests/test_order_shipping_fee.py new file mode 100644 index 0000000..bc7d167 --- /dev/null +++ b/tests/test_order_shipping_fee.py @@ -0,0 +1,68 @@ +"""/api/order/get_shipping_fee 中"店铺标识"分桶逻辑的单元测试: + +- 店铺标识优先取 tenpo_cd(与 other_shop_list 一致),未提供时回退取 supplier +- 同 tenpo_cd(或同 supplier)视为同一家加盟店,只收一次 800 日元 +""" +from __future__ import annotations + +import pytest + +from app.api.routes.scrape import order_get_shipping_fee +from app.models.scrape import OrderItem, OrderShippingFeeRequest + + +@pytest.mark.asyncio +async def test_official_only_uses_official_ladder() -> None: + request = OrderShippingFeeRequest(item_list=[OrderItem(goods_id="1", goods_number=1, goods_price=800)]) + resp = await order_get_shipping_fee(request) + assert resp.data.shipping_fee == 440 + assert resp.data.goods_amount == 800 + + +@pytest.mark.asyncio +async def test_tenpo_cd_groups_same_franchise_store_once() -> None: + request = OrderShippingFeeRequest( + item_list=[ + OrderItem(goods_id="1", goods_number=1, goods_price=1000, tenpo_cd="400451"), + OrderItem(goods_id="2", goods_number=1, goods_price=2000, tenpo_cd="400451", branch_number="0003"), + ] + ) + resp = await order_get_shipping_fee(request) + assert resp.data.shipping_fee == 800 # 同一 tenpo_cd 只收一次加盟店运费 + assert resp.data.goods_amount == 3000 + + +@pytest.mark.asyncio +async def test_different_tenpo_cd_charged_separately() -> None: + request = OrderShippingFeeRequest( + item_list=[ + OrderItem(goods_id="1", goods_number=1, goods_price=1000, tenpo_cd="400451"), + OrderItem(goods_id="2", goods_number=1, goods_price=1000, tenpo_cd="400999"), + ] + ) + resp = await order_get_shipping_fee(request) + assert resp.data.shipping_fee == 1600 # 两家不同加盟店各收 800 + + +@pytest.mark.asyncio +async def test_tenpo_cd_takes_precedence_over_supplier() -> None: + request = OrderShippingFeeRequest( + item_list=[ + OrderItem(goods_id="1", goods_number=1, goods_price=1000, supplier="山口大学前店", tenpo_cd="400451"), + OrderItem(goods_id="2", goods_number=1, goods_price=1000, supplier="其他名称", tenpo_cd="400451"), + ] + ) + resp = await order_get_shipping_fee(request) + assert resp.data.shipping_fee == 800 # 尽管 supplier 文本不同,tenpo_cd 相同仍视为同一家 + + +@pytest.mark.asyncio +async def test_falls_back_to_supplier_when_tenpo_cd_missing() -> None: + request = OrderShippingFeeRequest( + item_list=[ + OrderItem(goods_id="1", goods_number=1, goods_price=1000, supplier="老加盟店"), + OrderItem(goods_id="2", goods_number=1, goods_price=1000, supplier="老加盟店"), + ] + ) + resp = await order_get_shipping_fee(request) + assert resp.data.shipping_fee == 800 # 无 tenpo_cd 时按 supplier 分桶,保持向后兼容 diff --git a/tests/test_surugaya_client_shops.py b/tests/test_surugaya_client_shops.py index f177de6..3fa1559 100644 --- a/tests/test_surugaya_client_shops.py +++ b/tests/test_surugaya_client_shops.py @@ -59,3 +59,53 @@ class TestFetchOtherShops: client = _make_client() with pytest.raises(Exception): await client.fetch_other_shops(DetailRequest(id=None)) + + +class TestFetchDetailIncludeOtherShops: + """DetailRequest.include_other_shops 控制 fetch_detail 是否顺带抓取 other_shop_list。""" + + MINIMAL_DETAIL_HTML = '

测试商品

' + + @pytest.mark.asyncio + async def test_flag_false_skips_other_shop_fetch(self, monkeypatch: pytest.MonkeyPatch) -> None: + client = _make_client() + monkeypatch.setattr(client, "_session_manager", _FakeSessionManager(self.MINIMAL_DETAIL_HTML)) + + called = False + + async def fake_fetch_other_shop_list(goods_id: str) -> list[OtherShopItem]: + nonlocal called + called = True + return [] + + monkeypatch.setattr(client, "_fetch_other_shop_list", fake_fetch_other_shop_list) + + detail = await client.fetch_detail(DetailRequest(id="220035970")) + + assert called is False + assert detail.other_shop_list == [] + + @pytest.mark.asyncio + async def test_flag_true_includes_other_shop_list(self, monkeypatch: pytest.MonkeyPatch) -> None: + client = _make_client() + monkeypatch.setattr(client, "_session_manager", _FakeSessionManager(self.MINIMAL_DETAIL_HTML)) + + fake_items = [OtherShopItem(tenpo_cd="400451", shop_name="駿河屋 山口大学前店", price=1370)] + + async def fake_fetch_other_shop_list(goods_id: str) -> list[OtherShopItem]: + assert goods_id == "220035970" + return fake_items + + monkeypatch.setattr(client, "_fetch_other_shop_list", fake_fetch_other_shop_list) + + detail = await client.fetch_detail(DetailRequest(id="220035970", include_other_shops=True)) + + assert detail.other_shop_list == fake_items + + +class _FakeSessionManager: + def __init__(self, html: str) -> None: + self._html = html + + async def fetch_html(self, url: str): + return self._html, None