From bc0f7161c018b401369a23c66ff1b75de02cde15 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 9 Jun 2026 14:55:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=B4=B9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routes/scrape.py | 30 +++++++- app/models/scrape.py | 2 + app/utils/parse_util.py | 84 +++++++++++++-------- tests/test_parse_util.py | 154 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 34 deletions(-) create mode 100644 tests/test_parse_util.py diff --git a/app/api/routes/scrape.py b/app/api/routes/scrape.py index 77b094a..7af96d7 100644 --- a/app/api/routes/scrape.py +++ b/app/api/routes/scrape.py @@ -23,7 +23,11 @@ from app.models.scrape import ( SearchResultData, TradeMonitorRequest, ) from app.utils.http_utils import generate_signed_headers -from app.utils.parse_util import get_handle_fee, get_shipping_fee +from app.utils.parse_util import ( + get_franchise_shipping_fee, + get_handle_fee, + get_shipping_fee, +) router = APIRouter(prefix="/api", tags=["scrape"]) @@ -139,8 +143,28 @@ async def categories( dependencies=[Depends(require_bearer_token)], ) async def order_get_shipping_fee(payload: OrderShippingFeeRequest) -> ApiResponse[OrderShippingFeeData]: - goods_amount = sum(item.goods_number * item.goods_price for item in payload.item_list) - shipping_fee = get_shipping_fee(goods_amount) + """计算订单运费与手续费。 + + 规则: + - 官方店铺(supplier 为空)运费: 按官方部分商品总额套阶梯价(440/385/0) + * 订单中无任何官方商品时,官方运费 0 + - 加盟店运费(supplier 非空,同名视为同一家): 每家统一 800 日元 + - 通贩手续费: 按全部商品(含官方+加盟店)总额套阶梯价(240/0) + """ + official_amount = 0 + franchise_stores: set[str] = set() + goods_amount = 0 + for item in payload.item_list: + subtotal = item.goods_number * item.goods_price + goods_amount += subtotal + if item.supplier == "": + official_amount += subtotal + else: + franchise_stores.add(item.supplier) + + official_shipping_fee = get_shipping_fee(official_amount) if official_amount > 0 else 0 + franchise_shipping_fee = get_franchise_shipping_fee(len(franchise_stores)) + shipping_fee = official_shipping_fee + franchise_shipping_fee handle_amount = get_handle_fee(goods_amount) order_amount = goods_amount + shipping_fee + handle_amount diff --git a/app/models/scrape.py b/app/models/scrape.py index 916ab85..7d69152 100644 --- a/app/models/scrape.py +++ b/app/models/scrape.py @@ -132,6 +132,8 @@ class OrderItem(BaseModel): goods_id: str = Field(min_length=1, max_length=40) goods_number: int = Field(ge=1, le=9999) goods_price: int = Field(ge=0) + # 店铺标识: 空串 = 官方店铺;非空 = 加盟店标识(同名视为同一家加盟店) + supplier: str = "" class OrderShippingFeeRequest(BaseModel): diff --git a/app/utils/parse_util.py b/app/utils/parse_util.py index 9f058cd..3a05ea3 100644 --- a/app/utils/parse_util.py +++ b/app/utils/parse_util.py @@ -122,42 +122,64 @@ def extract_product_id(url: str | None = None) -> str: return "" -def get_shipping_fee(amount: int) -> int: - """ - 根据定义的运费规则计算运费 +# --- 官方店铺日本境内运费规则 --- +# amount < OFFICIAL_SHIPPING_FEE_LOW_THRESHOLD: OFFICIAL_SHIPPING_FEE_LOW +# OFFICIAL_SHIPPING_FEE_LOW_THRESHOLD <= amount < OFFICIAL_SHIPPING_FEE_HIGH_THRESHOLD: OFFICIAL_SHIPPING_FEE_MID +# amount >= OFFICIAL_SHIPPING_FEE_HIGH_THRESHOLD: 免运费(0) +OFFICIAL_SHIPPING_FEE_LOW_THRESHOLD = 1000 +OFFICIAL_SHIPPING_FEE_HIGH_THRESHOLD = 1500 +OFFICIAL_SHIPPING_FEE_LOW = 440 +OFFICIAL_SHIPPING_FEE_MID = 385 - 日本境内运费统一为固定价格(部分地区除外)。 - • 订单金额低于1000日元:运费440日元 - • 订单金额低于1500日元:运费385日元 - • 订单金额1500日元及以上:免运费 - *北海道、冲绳、离岛及其他部分地区可能需额外支付运费。 - 货到付款需另付550日元货到付款手续费。银行转账付款需由顾客承担转账手续费。信用卡付款无需支付手续费。 - *通过电商平台购买的商品,运费因店铺而异。 +# --- 加盟店日本境内运费规则 --- +# 每 1 家加盟店,无论订单数量与金额,统一收 FRANCHISE_SHIPPING_FEE_PER_STORE 日元 +FRANCHISE_SHIPPING_FEE_PER_STORE = 800 + +# --- 通贩手续费规则 --- +# amount < HANDLE_FEE_FREE_THRESHOLD: HANDLE_FEE_BELOW +# amount >= HANDLE_FEE_FREE_THRESHOLD: 免手续费(0) +HANDLE_FEE_FREE_THRESHOLD = 5000 +HANDLE_FEE_BELOW = 240 + + +def get_shipping_fee(amount: int) -> int: + """计算官方店铺日本境内运费(本州/四国/九州统一价)。 + + 规则: + - amount < 1000 日元: 440 日元 + - 1000 <= amount < 1500 日元: 385 日元 + - amount >= 1500 日元: 0(免运费) + + 备注: 北海道/冲绳/离岛可能需额外支付;此处按本州口径计算。 + 加盟店运费另由 get_franchise_shipping_fee 计算。 """ - if amount > 1500: + if amount >= OFFICIAL_SHIPPING_FEE_HIGH_THRESHOLD: return 0 - elif amount > 1000: - return 440 - else: - return 385 + if amount >= OFFICIAL_SHIPPING_FEE_LOW_THRESHOLD: + return OFFICIAL_SHIPPING_FEE_MID + return OFFICIAL_SHIPPING_FEE_LOW + + +def get_franchise_shipping_fee(franchise_store_count: int) -> int: + """计算加盟店日本境内运费。 + + 每 1 家加盟店统一收 800 日元,与订单数量/金额无关。 + franchise_store_count <= 0 时返回 0。 + """ + if franchise_store_count <= 0: + return 0 + return franchise_store_count * FRANCHISE_SHIPPING_FEE_PER_STORE + def get_handle_fee(amount: int) -> int: - """ - 根据定义的规则计算手续费 - https://www.suruga-ya.jp/man/qa/hanbai_qa/shiharai.html#shiharai_1_2 - A. 运费根据购买金额而定。 - ■本州、四国、九州: - 购买金额低于5,000日元…240日元; - 购买金额5,000日元及以上…免运费。 - ■北海道、冲绳: - 购买金额低于5,000日元…570日元; - 购买金额5,000日元至低于10,000日元…285日元; - 购买金额10,000日元及以上…免 + """计算通贩手续费(本州/四国/九州口径)。 - 运费。偏远岛屿及其他部分地区可能需额外收费。 - 这里按照本州计算手续费。 + 规则: + - amount < 5000 日元: 240 日元 + - amount >= 5000 日元: 0(免手续费) + + 参考: https://www.suruga-ya.jp/man/qa/hanbai_qa/shiharai.html#shiharai_1_2 """ - if amount > 5000: + if amount >= HANDLE_FEE_FREE_THRESHOLD: return 0 - else: - return 240 + return HANDLE_FEE_BELOW diff --git a/tests/test_parse_util.py b/tests/test_parse_util.py new file mode 100644 index 0000000..f96f5d7 --- /dev/null +++ b/tests/test_parse_util.py @@ -0,0 +1,154 @@ +"""parse_util 运费/手续费计算函数以及订单运费路由的单元测试。""" +from __future__ import annotations + +import pytest + +from app.api.routes.scrape import order_get_shipping_fee +from app.models.scrape import OrderItem, OrderShippingFeeRequest +from app.utils.parse_util import ( + FRANCHISE_SHIPPING_FEE_PER_STORE, + HANDLE_FEE_BELOW, + OFFICIAL_SHIPPING_FEE_LOW, + OFFICIAL_SHIPPING_FEE_MID, + get_franchise_shipping_fee, + get_handle_fee, + get_shipping_fee, +) + + +# --- 官方店铺运费规则: 边界与典型值 --- +@pytest.mark.parametrize( + "amount, expected", + [ + (0, OFFICIAL_SHIPPING_FEE_LOW), # < 1000 + (999, OFFICIAL_SHIPPING_FEE_LOW), # < 1000 + (1000, OFFICIAL_SHIPPING_FEE_MID), # = 1000 -> 385 + (1200, OFFICIAL_SHIPPING_FEE_MID), # 1000 <= < 1500 + (1499, OFFICIAL_SHIPPING_FEE_MID), # < 1500 + (1500, 0), # = 1500 -> 免运费 + (2000, 0), # > 1500 + ], +) +def test_get_shipping_fee_boundaries(amount: int, expected: int) -> None: + assert get_shipping_fee(amount) == expected + + +# --- 加盟店运费规则: 按家数 --- +@pytest.mark.parametrize( + "count, expected", + [ + (-1, 0), + (0, 0), + (1, FRANCHISE_SHIPPING_FEE_PER_STORE), + (3, 3 * FRANCHISE_SHIPPING_FEE_PER_STORE), + ], +) +def test_get_franchise_shipping_fee(count: int, expected: int) -> None: + assert get_franchise_shipping_fee(count) == expected + + +# --- 通贩手续费规则: 边界与典型值 --- +@pytest.mark.parametrize( + "amount, expected", + [ + (0, HANDLE_FEE_BELOW), + (4999, HANDLE_FEE_BELOW), + (5000, 0), # = 5000 -> 免手续费 + (10000, 0), + ], +) +def test_get_handle_fee_boundaries(amount: int, expected: int) -> None: + assert get_handle_fee(amount) == expected + + +# ---- 路由 order_get_shipping_fee 端到端 ---- +# 直接调用 async 路由函数,绕开 FastAPI 应用初始化(浏览器/redis 等)。 +def _payload(*items: tuple[int, int, str]) -> OrderShippingFeeRequest: + """items: (goods_number, goods_price, supplier) 三元组列表""" + return OrderShippingFeeRequest( + item_list=[ + OrderItem( + goods_id=f"g{idx}", + goods_number=n, + goods_price=p, + supplier=s, + ) + for idx, (n, p, s) in enumerate(items) + ] + ) + + +async def test_route_all_official_below_threshold() -> None: + # 1 件 999 元官方商品 -> 官方运费 440, 手续费 240 + resp = await order_get_shipping_fee(_payload((1, 999, ""))) + data = resp.data + assert data.goods_amount == 999 + assert data.shipping_fee == 440 + assert data.handle_amount == 240 + assert data.order_amount == 999 + 440 + 240 + + +async def test_route_all_official_at_free_shipping_boundary() -> None: + # 1 件 1500 元官方商品 -> 官方运费 0(>=1500), 手续费 240(<5000) + resp = await order_get_shipping_fee(_payload((1, 1500, ""))) + data = resp.data + assert data.goods_amount == 1500 + assert data.shipping_fee == 0 + assert data.handle_amount == 240 + assert data.order_amount == 1500 + 240 + + +async def test_route_only_franchise_no_official_shipping() -> None: + # 全部来自 1 家加盟店 -> 官方运费 0(无官方商品), 加盟运费 800 + resp = await order_get_shipping_fee(_payload((2, 400, "ShopA"))) + data = resp.data + assert data.goods_amount == 800 + assert data.shipping_fee == 800 + assert data.handle_amount == 240 + assert data.order_amount == 800 + 800 + 240 + + +async def test_route_two_franchise_stores_dedup_by_supplier() -> None: + # 2 家加盟店各 1 件 + 官方满 1500 -> 官方 0 + 加盟 1600 + resp = await order_get_shipping_fee( + _payload( + (1, 1500, ""), + (1, 500, "ShopA"), + (1, 500, "ShopA"), # 同店,不重复计费 + (1, 500, "ShopB"), + ) + ) + data = resp.data + assert data.goods_amount == 3000 + assert data.shipping_fee == 0 + 2 * 800 # 官方 0 + 2 家加盟 + assert data.handle_amount == 240 # 3000 < 5000 + assert data.order_amount == 3000 + 1600 + 240 + + +async def test_route_total_amount_over_5000_handle_fee_free() -> None: + # 全部商品总额(官方+加盟)>= 5000 时手续费免 + resp = await order_get_shipping_fee( + _payload( + (1, 3000, ""), # 官方 3000 + (1, 2000, "ShopA"), # 加盟 2000 + ) + ) + data = resp.data + assert data.goods_amount == 5000 + # 官方 3000 -> 1500<=3000 -> 0 运费; 加盟 1 家 -> 800 + assert data.shipping_fee == 0 + 800 + assert data.handle_amount == 0 # 总额 5000 -> 免 + assert data.order_amount == 5000 + 800 + + +async def test_route_backward_compatible_default_supplier() -> None: + # 旧调用方不传 supplier 字段 -> 默认空串 -> 全部按官方 + payload = OrderShippingFeeRequest.model_validate( + {"item_list": [{"goods_id": "x", "goods_number": 1, "goods_price": 800}]} + ) + resp = await order_get_shipping_fee(payload) + data = resp.data + # 800 < 1000 -> 官方运费 440, 手续费 240 + assert data.shipping_fee == 440 + assert data.handle_amount == 240 + assert data.order_amount == 800 + 440 + 240