fix(business): 编辑/流转/归属加锁与终态编辑门槛,has_docs 单点收口

- get_with_names 加 for_update 参数:update/change_status/change_ownership
  以锁定读取行,与发货/回款写路径同一锁口径——「有发货禁换客户」的
  快照读校验不再被并发新增发货旁路(此前 delete 有锁、唯独编辑无锁)
- update_business 补终态门槛(明细/汇总是 M3 核算基线,与发货/回款
  服务禁写终态同口径);前端编辑按钮按终态禁用并加 tooltip
- 新增 business_repository.has_docs(单条 OR exists):替换 update
  换客户与 delete 两处内联双查询,M3 新增挂靠单据类型时只改一处
- 出参组装复用首查 row:change_ownership 的姓名侧按「变更项取校验
  函数返回的新人名、未变项沿用首查名」处理(首查名是变更前快照)
- 新增终态编辑 400 且明细未动的集成用例

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-14 17:30:21 +08:00
1 parent a0086554b1
commit 6d612daf30
4 files changed
+108 -33

No files matched your search

@@ -362,6 +362,31 @@ class TestEdit:
]
assert logs["total"] == 0, "编辑端点改归属却无留痕"
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None:
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
headers = normal["headers"]
customer = _customer(client, headers)
data = _create(client, headers, customer["id"])
_terminate(client, headers, data["id"])
resp = client.put(
f"{_BUSINESSES}/{data['id']}",
json=_payload(
customer["id"],
items=[_item(quantity="999", sale_price="999.00")],
),
headers=headers,
)
assert resp.status_code == 400, resp.text
assert "已完成或已终止" in resp.json()["message"]
# 明细与汇总未被改写(护栏:校验在 replace_items 之前生效)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
assert Decimal(detail["total_sale_amount"]) == Decimal("10000")
assert len(detail["items"]) == 1
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
class TestStatus:
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""