diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 85ca8d4..00b0f0d 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -16,7 +16,7 @@ import logging from datetime import date -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal from fractions import Fraction from sqlalchemy import Row, func, select @@ -136,12 +136,19 @@ def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[Busi 不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0, 由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。 + 乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写 + DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查 + 读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。 """ rows: list[BusinessItem] = [] for item in items: quantity = Decimal(item.quantity) sale_price = Decimal(item.sale_price) if item.sale_price is not None else None - sale_amount = (quantity * sale_price) if sale_price is not None else Decimal("0") + sale_amount = ( + (quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if sale_price is not None + else Decimal("0.00") + ) rows.append( BusinessItem( business_id=business_id, diff --git a/backend/app/services/formula.py b/backend/app/services/formula.py index 56c9b43..19d4f90 100644 --- a/backend/app/services/formula.py +++ b/backend/app/services/formula.py @@ -40,7 +40,12 @@ def salesperson_rate( start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。 ≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限 (对照表 #4:360 天=6‰,361 天起固定 4‰)。 + + 先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出 + 高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days + 的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。 """ + days = max(days, 0) if days > boundary_days: return floor / 1000 return (start - days * slope) / 1000 diff --git a/backend/app/services/shipment_service.py b/backend/app/services/shipment_service.py index 1ba69f7..5b5c131 100644 --- a/backend/app/services/shipment_service.py +++ b/backend/app/services/shipment_service.py @@ -23,7 +23,7 @@ """ import logging -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -99,12 +99,19 @@ def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]: 不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0, 由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。 + 乘积显式 quantize 两位固化(模式同 business_service._build_item_rows): + 数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL + 静默舍入且回显漂移。 """ rows: list[ShipmentItem] = [] for item in items: quantity = Decimal(item.quantity) sale_price = Decimal(item.sale_price) if item.sale_price is not None else None - amount = (quantity * sale_price) if sale_price is not None else Decimal("0") + amount = ( + (quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if sale_price is not None + else Decimal("0.00") + ) rows.append( ShipmentItem( product_id=item.product_id, diff --git a/backend/tests/integration/test_businesses.py b/backend/tests/integration/test_businesses.py index c37328c..c204048 100644 --- a/backend/tests/integration/test_businesses.py +++ b/backend/tests/integration/test_businesses.py @@ -225,6 +225,31 @@ class TestCreate: ) assert resp.status_code == 400, resp.text + def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None: + """数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的 + DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入, + 创建响应(读内存值)与复查(读库值)前后显示不一致。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="1.55", sale_price="3.3333", cost_amount=None)], + ) + # 1.55 × 3.3333 = 5.166615 → 5.17(HALF_UP;MySQL 同值同向) + item = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]["items"][0] + assert item["sale_amount"] == "5.17" + assert Decimal(data["total_sale_amount"]) == Decimal("5.17") + + # 库值与回显一致(无「内存 5.166615 / 库里 5.17」的漂移) + factory = get_session_factory() + with factory() as session: + db_amount = session.execute( + select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"]) + ).scalar_one() + assert db_amount == Decimal("5.17") + def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: customer = _customer(client, normal["headers"]) resp = client.post( diff --git a/backend/tests/unit/test_formula.py b/backend/tests/unit/test_formula.py index f252d17..7a85a48 100644 --- a/backend/tests/unit/test_formula.py +++ b/backend/tests/unit/test_formula.py @@ -69,6 +69,14 @@ class TestSalespersonRate: assert _sp_rate(360) == Fraction(6, 1000) assert _sp_rate(360) != Fraction(6) + def test_负天数钳制为0天_按起始比例计提(self) -> None: + """先款后货(payment_days 为负)不线性外插:钳到 0 天取起始比例。 + + 修复前 days=-5 会算出 (10 + 5×4/360)‰ —— 高于起始值,多算绩效。 + """ + assert _sp_rate(-5) == _sp_rate(0) + assert _sp_rate(-1) == Fraction(10, 1000) + class TestProfitRate: """毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""