From 6d612daf30da1834405ba190d3770331ae2eb8f4 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Wed, 14 Oct 2026 17:30:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(business):=20=E7=BC=96=E8=BE=91/=E6=B5=81?= =?UTF-8?q?=E8=BD=AC/=E5=BD=92=E5=B1=9E=E5=8A=A0=E9=94=81=E4=B8=8E?= =?UTF-8?q?=E7=BB=88=E6=80=81=E7=BC=96=E8=BE=91=E9=97=A8=E6=A7=9B=EF=BC=8C?= =?UTF-8?q?has=5Fdocs=20=E5=8D=95=E7=82=B9=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../app/repositories/business_repository.py | 38 +++++++++-- backend/app/services/business_service.py | 64 +++++++++++-------- backend/tests/integration/test_businesses.py | 25 ++++++++ .../src/views/business/businesses/index.vue | 14 +++- 4 files changed, 108 insertions(+), 33 deletions(-) diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py index 50950db..d67f74a 100644 --- a/backend/app/repositories/business_repository.py +++ b/backend/app/repositories/business_repository.py @@ -7,7 +7,7 @@ models/customer.py 定义)。 from datetime import date -from sqlalchemy import Row, Select, delete, func, or_, select +from sqlalchemy import Row, Select, delete, exists, func, or_, select from sqlalchemy.orm import Session, aliased from app.models import ( @@ -17,18 +17,35 @@ from app.models import ( Customer, OwnershipChangeLog, Personnel, + Receipt, + Shipment, ) from app.models.customer import OWNER_TYPE_BUSINESS def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: - """按主键取业务行;for_update 供删除保护与状态机使用。""" + """按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。""" stmt = select(Business).where(Business.id == business_id) if for_update: stmt = stmt.with_for_update() return session.execute(stmt).scalars().first() +def has_docs(session: Session, business_id: int) -> bool: + """业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。 + + 单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件, + 编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。 + """ + stmt = select( + or_( + exists().where(Shipment.business_id == business_id), + exists().where(Receipt.business_id == business_id), + ) + ) + return bool(session.execute(stmt).scalar()) + + def create(session: Session, business: Business) -> None: """新增业务行(不 commit,flush 让自增 id 就位)。""" session.add(business) @@ -36,19 +53,26 @@ def create(session: Session, business: Business) -> None: def get_with_names( - session: Session, business_id: int + session: Session, business_id: int, *, for_update: bool = False ) -> tuple[Business, str, str | None, str | None] | None: - """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。""" + """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。 + + for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的 + get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥, + 「有发货禁换客户」的快照读校验不再被并发新增旁路。 + """ owner = aliased(Personnel) assistant = aliased(Personnel) - row = session.execute( + stmt = ( select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name) .join(Customer, Customer.id == Business.customer_id) .outerjoin(owner, owner.id == Business.owner_personnel_id) .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) .where(Business.id == business_id) - ).first() - return row + ) + if for_update: + stmt = stmt.with_for_update() + return session.execute(stmt).first() def _apply_filters( diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index fc906a6..5261f9b 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -363,9 +363,9 @@ def update_business( 发货/回款语义断裂(明细同理随业务走)。 :raises NotFoundError: 业务不存在 - :raises BadRequestError: 归属人员不合法 / 有发货或回款时换客户 + :raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户 """ - row = business_repository.get_with_names(session, business_id) + row = business_repository.get_with_names(session, business_id, for_update=True) business = row[0] if row else None with audit_service.audit( session, @@ -380,18 +380,17 @@ def update_business( ): if row is None: raise NotFoundError("业务不存在") - business, _, _, _ = row + business, company_name, owner_name, assistant_name = row + + # 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线, + # 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务) + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止编辑业务") if payload.customer_id != business.customer_id: - has_docs = session.query(Shipment.id).filter( - Shipment.business_id == business_id - ).first() is not None or ( - session.query(Receipt.id).filter(Receipt.business_id == business_id).first() - is not None - ) - if has_docs: + if business_repository.has_docs(session, business_id): raise BadRequestError("业务已有发货或回款记录,不能更换客户") - _ensure_customer(session, payload.customer_id) + customer = _ensure_customer(session, payload.customer_id) if payload.owner_personnel_id is not None: _ensure_personnel_active(session, payload.owner_personnel_id, "负责") if payload.assistant_personnel_id is not None: @@ -408,7 +407,9 @@ def update_business( session, business_id, _build_item_rows(business_id, payload.items) ) _recalc_totals(session, business) - out = _to_out(business_repository.get_with_names(session, business_id)) + # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 + # 从首查 row 取(换客户时 _ensure_customer 已给出新公司名) + out = _to_out((business, customer.company_name, owner_name, assistant_name)) session.commit() logger.info("修改业务 | business_id=%s", business_id) return out @@ -442,12 +443,7 @@ def delete_business( ): if business is None: raise NotFoundError("业务不存在") - if ( - session.query(Shipment.id).filter(Shipment.business_id == business_id).first() - is not None - or session.query(Receipt.id).filter(Receipt.business_id == business_id).first() - is not None - ): + if business_repository.has_docs(session, business_id): raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") business_no = business.business_no @@ -484,7 +480,7 @@ def change_status( :raises NotFoundError: 业务不存在 :raises BadRequestError: 非执行中状态流转 / 目标与当前相同 """ - row = business_repository.get_with_names(session, business_id) + row = business_repository.get_with_names(session, business_id, for_update=True) with audit_service.audit( session, operator=operator, @@ -522,7 +518,8 @@ def change_status( operator_name=operator.username, ), ) - out = _to_out(business_repository.get_with_names(session, business_id)) + # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态 + out = _to_out(row) session.commit() logger.info( "业务状态变更 | business_id=%s | %s -> %s", @@ -549,7 +546,7 @@ def change_ownership( :raises NotFoundError: 业务不存在 :raises BadRequestError: 归属人员不存在或已离职 / 归属未变化 """ - row = business_repository.get_with_names(session, business_id) + row = business_repository.get_with_names(session, business_id, for_update=True) with audit_service.audit( session, operator=operator, @@ -563,11 +560,19 @@ def change_ownership( ): if row is None: raise NotFoundError("业务不存在") - business, _, _, _ = row - if payload.new_owner_personnel_id is not None: + business, _, old_owner_name, old_assistant_name = row + # 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前 + # 固化标量,新归属人名必须按新 ID 重取 + new_owner = ( _ensure_personnel_active(session, payload.new_owner_personnel_id, "负责") - if payload.new_assistant_personnel_id is not None: + if payload.new_owner_personnel_id is not None + else None + ) + new_assistant = ( _ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助") + if payload.new_assistant_personnel_id is not None + else None + ) owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id @@ -592,7 +597,16 @@ def change_ownership( operator_name=operator.username, ), ) - out = _to_out(business_repository.get_with_names(session, business_id)) + # 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名 + # 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照 + out = _to_out( + ( + business, + row[1], + new_owner.personnel_name if new_owner is not None else old_owner_name, + new_assistant.personnel_name if new_assistant is not None else old_assistant_name, + ) + ) session.commit() logger.info( "调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s", diff --git a/backend/tests/integration/test_businesses.py b/backend/tests/integration/test_businesses.py index 6c5b423..c37328c 100644 --- a/backend/tests/integration/test_businesses.py +++ b/backend/tests/integration/test_businesses.py @@ -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: """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index de64eb6..a566b61 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -627,7 +627,19 @@ onMounted(() => {