fix(business): 行金额乘积显式两位固化,计提比例对负天数钳制
- 明细行金额(数量2位×单价4位可产 6 位小数)直写 DECIMAL(14,2) 被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查读库值不一致); 显式 quantize HALF_UP 固化,舍入方向与 MySQL 一致(业务/发货同改) - 先款后货回款(payment_days 为负)线性外插会算出高于起始值的计提 比例(多算绩效);钳制到 0 天按起始比例计提,payment_days 出参仍 照实输出负值(口径:2026-10-14 用户裁决) - 新增 formula 负天数钳制单测与 quantize 回显/库值一致性集成用例 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
91fc6f6293
commit
e6c2c71d1b
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
|
|
||||||
from sqlalchemy import Row, func, select
|
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,
|
不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0,
|
||||||
由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。
|
由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。
|
||||||
|
乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写
|
||||||
|
DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查
|
||||||
|
读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。
|
||||||
"""
|
"""
|
||||||
rows: list[BusinessItem] = []
|
rows: list[BusinessItem] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
quantity = Decimal(item.quantity)
|
quantity = Decimal(item.quantity)
|
||||||
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
|
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(
|
rows.append(
|
||||||
BusinessItem(
|
BusinessItem(
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
|
|||||||
@ -40,7 +40,12 @@ def salesperson_rate(
|
|||||||
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
|
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
|
||||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||||
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||||
|
|
||||||
|
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
|
||||||
|
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
|
||||||
|
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||||
"""
|
"""
|
||||||
|
days = max(days, 0)
|
||||||
if days > boundary_days:
|
if days > boundary_days:
|
||||||
return floor / 1000
|
return floor / 1000
|
||||||
return (start - days * slope) / 1000
|
return (start - days * slope) / 1000
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@ -99,12 +99,19 @@ def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]:
|
|||||||
|
|
||||||
不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0,
|
不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0,
|
||||||
由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。
|
由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。
|
||||||
|
乘积显式 quantize 两位固化(模式同 business_service._build_item_rows):
|
||||||
|
数量(2位)×单价(4位) 可产 6 位小数,直写 DECIMAL(14,2) 会被 MySQL
|
||||||
|
静默舍入且回显漂移。
|
||||||
"""
|
"""
|
||||||
rows: list[ShipmentItem] = []
|
rows: list[ShipmentItem] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
quantity = Decimal(item.quantity)
|
quantity = Decimal(item.quantity)
|
||||||
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
|
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(
|
rows.append(
|
||||||
ShipmentItem(
|
ShipmentItem(
|
||||||
product_id=item.product_id,
|
product_id=item.product_id,
|
||||||
|
|||||||
@ -225,6 +225,31 @@ class TestCreate:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 400, resp.text
|
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:
|
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||||
customer = _customer(client, normal["headers"])
|
customer = _customer(client, normal["headers"])
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
|
|||||||
@ -69,6 +69,14 @@ class TestSalespersonRate:
|
|||||||
assert _sp_rate(360) == Fraction(6, 1000)
|
assert _sp_rate(360) == Fraction(6, 1000)
|
||||||
assert _sp_rate(360) != Fraction(6)
|
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:
|
class TestProfitRate:
|
||||||
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
|
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user