下单接口支持其他店铺
/api/order/get_shipping_fee 与购物车加入逻辑此前只认官方自营详情页,加盟店运费分桶 仅靠自由文本 supplier 判断,未与其他店铺列表的 tenpo_cd 打通。补充 OrderItem/ CargoItemRequest 的 tenpo_cd/branch_number 字段,运费计算优先按 tenpo_cd 分桶 (无则回退 supplier 保持兼容),cargo_service 加购 URL 同步带上店铺参数。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -176,9 +176,10 @@ async def order_get_shipping_fee(payload: OrderShippingFeeRequest) -> ApiRespons
|
|||||||
"""计算订单运费与手续费。
|
"""计算订单运费与手续费。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 官方店铺(supplier 为空)运费: 按官方部分商品总额套阶梯价(440/385/0)
|
- 官方店铺(店铺标识为空)运费: 按官方部分商品总额套阶梯价(440/385/0)
|
||||||
* 订单中无任何官方商品时,官方运费 0
|
* 订单中无任何官方商品时,官方运费 0
|
||||||
- 加盟店运费(supplier 非空,同名视为同一家): 每家统一 800 日元
|
- 加盟店运费(店铺标识非空,同标识视为同一家): 每家统一 800 日元
|
||||||
|
* 店铺标识优先取 tenpo_cd(与 other_shop_list 中的 tenpo_cd 一致),未提供时回退取 supplier
|
||||||
- 通贩手续费: 按全部商品(含官方+加盟店)总额套阶梯价(240/0)
|
- 通贩手续费: 按全部商品(含官方+加盟店)总额套阶梯价(240/0)
|
||||||
"""
|
"""
|
||||||
official_amount = 0
|
official_amount = 0
|
||||||
@@ -187,10 +188,11 @@ async def order_get_shipping_fee(payload: OrderShippingFeeRequest) -> ApiRespons
|
|||||||
for item in payload.item_list:
|
for item in payload.item_list:
|
||||||
subtotal = item.goods_number * item.goods_price
|
subtotal = item.goods_number * item.goods_price
|
||||||
goods_amount += subtotal
|
goods_amount += subtotal
|
||||||
if item.supplier == "":
|
shop_key = item.tenpo_cd or item.supplier
|
||||||
|
if shop_key == "":
|
||||||
official_amount += subtotal
|
official_amount += subtotal
|
||||||
else:
|
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
|
official_shipping_fee = get_shipping_fee(official_amount) if official_amount > 0 else 0
|
||||||
franchise_shipping_fee = get_franchise_shipping_fee(len(franchise_stores))
|
franchise_shipping_fee = get_franchise_shipping_fee(len(franchise_stores))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class DetailRequest(BaseModel):
|
|||||||
product_url: HttpUrl | None = None
|
product_url: HttpUrl | None = None
|
||||||
tenpo_cd: str | None = None # 指定店铺代码;跳转查看某个加盟店/市场店铺的报价时传入
|
tenpo_cd: str | None = None # 指定店铺代码;跳转查看某个加盟店/市场店铺的报价时传入
|
||||||
branch_number: str | None = None # 指定分支编号,与 tenpo_cd 搭配使用
|
branch_number: str | None = None # 指定分支编号,与 tenpo_cd 搭配使用
|
||||||
|
include_other_shops: bool = False # 为 True 时,随详情额外抓取并返回 other_shop_list(多付出一次页面请求)
|
||||||
|
|
||||||
|
|
||||||
class ProductSummary(BaseModel):
|
class ProductSummary(BaseModel):
|
||||||
@@ -104,6 +105,7 @@ class ProductDetailData(BaseModel):
|
|||||||
sku_list: list[dict[str, Any]] # 规格列表
|
sku_list: list[dict[str, Any]] # 规格列表
|
||||||
sku: str = "" # 规格
|
sku: str = "" # 规格
|
||||||
breadcrumb_list: list[dict[str, Any]] = [] # 面包屑
|
breadcrumb_list: list[dict[str, Any]] = [] # 面包屑
|
||||||
|
other_shop_list: list[OtherShopItem] = [] # 仅当请求 include_other_shops=True 时才会填充,否则为空列表
|
||||||
|
|
||||||
|
|
||||||
class OtherShopListData(BaseModel):
|
class OtherShopListData(BaseModel):
|
||||||
@@ -126,6 +128,8 @@ class CargoItemRequest(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
number: int = 1
|
number: int = 1
|
||||||
price_limit: int | None = None
|
price_limit: int | None = None
|
||||||
|
tenpo_cd: str | None = None # 指定店铺代码;跳转加入指定加盟店/市场店铺的商品时传入
|
||||||
|
branch_number: str | None = None # 指定分支编号,与 tenpo_cd 搭配使用
|
||||||
|
|
||||||
|
|
||||||
class CargoPayload(BaseModel):
|
class CargoPayload(BaseModel):
|
||||||
@@ -160,6 +164,10 @@ class OrderItem(BaseModel):
|
|||||||
goods_price: int = Field(ge=0)
|
goods_price: int = Field(ge=0)
|
||||||
# 店铺标识: 空串 = 官方店铺;非空 = 加盟店标识(同名视为同一家加盟店)
|
# 店铺标识: 空串 = 官方店铺;非空 = 加盟店标识(同名视为同一家加盟店)
|
||||||
supplier: str = ""
|
supplier: str = ""
|
||||||
|
# 店铺代码,来自 other_shop_list 中的 tenpo_cd;非空 = 加盟店/市场店铺。
|
||||||
|
# 优先于 supplier 用于判定"同一家店铺"(同 tenpo_cd 视为同一家);未提供时回退使用 supplier。
|
||||||
|
tenpo_cd: str = ""
|
||||||
|
branch_number: str = "" # 分支编号,可选,与 tenpo_cd 搭配使用
|
||||||
|
|
||||||
|
|
||||||
class OrderShippingFeeRequest(BaseModel):
|
class OrderShippingFeeRequest(BaseModel):
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class CargoService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug("进入商品 %s 详情页,准备购买数量: %s...", product_id, number)
|
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)
|
await page.goto(detail_url, wait_until="domcontentloaded", timeout=20000)
|
||||||
|
|
||||||
# 判断是否缺货
|
# 判断是否缺货
|
||||||
|
|||||||
@@ -265,6 +265,11 @@ class SurugayaClient:
|
|||||||
# 提取面包屑
|
# 提取面包屑
|
||||||
breadcrumb_list = self.get_breadcrumb_list(tree)
|
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(
|
detail = ProductDetailData(
|
||||||
goods_id=goods_id,
|
goods_id=goods_id,
|
||||||
goods_name=goods_name,
|
goods_name=goods_name,
|
||||||
@@ -279,6 +284,7 @@ class SurugayaClient:
|
|||||||
price_note=price_note,
|
price_note=price_note,
|
||||||
shipping_comission_fee=shipping_comission_fee,
|
shipping_comission_fee=shipping_comission_fee,
|
||||||
breadcrumb_list=breadcrumb_list,
|
breadcrumb_list=breadcrumb_list,
|
||||||
|
other_shop_list=other_shop_list,
|
||||||
)
|
)
|
||||||
logger.debug("详情页解析完成:goods_id=%s goods_name=%s ", detail.goods_id, detail.goods_name)
|
logger.debug("详情页解析完成:goods_id=%s goods_name=%s ", detail.goods_id, detail.goods_name)
|
||||||
return detail
|
return detail
|
||||||
|
|||||||
@@ -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 分桶,保持向后兼容
|
||||||
@@ -59,3 +59,53 @@ class TestFetchOtherShops:
|
|||||||
client = _make_client()
|
client = _make_client()
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
await client.fetch_other_shops(DetailRequest(id=None))
|
await client.fetch_other_shops(DetailRequest(id=None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchDetailIncludeOtherShops:
|
||||||
|
"""DetailRequest.include_other_shops 控制 fetch_detail 是否顺带抓取 other_shop_list。"""
|
||||||
|
|
||||||
|
MINIMAL_DETAIL_HTML = '<html><body><h1 id="item_title">测试商品</h1></body></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
|
||||||
|
|||||||
Reference in New Issue
Block a user