From bfb611b2e14a0227b54a6f80f7bb2ff090518fb9 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 12 Nov 2026 16:34:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(crm):=20=E5=90=88=E5=90=8C=E6=94=B9?= =?UTF-8?q?=E5=8F=B7=E5=90=8E=E5=90=8C=E6=AD=A5=E8=BF=90=E8=BE=93=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E5=90=88=E5=90=8C=E5=8F=B7=E5=BF=AB=E7=85=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 归档列表读 pl_plan_contract 快照,改 CRM 合同编号时同事务回写,避免列表与详情不一致。 --- backend/app/crud/crm/contract.py | 13 ++ .../app/services/pl_plan_contract_no_sync.py | 66 +++++++ .../test_sync_plan_contract_no_snapshots.py | 174 ++++++++++++++++++ ...-11-12-sync-plan-contract-no-snapshots.sql | 39 ++++ 4 files changed, 292 insertions(+) create mode 100644 backend/app/services/pl_plan_contract_no_sync.py create mode 100644 backend/tests/test_sync_plan_contract_no_snapshots.py create mode 100644 docs/sql/migrations/2026-11-12-sync-plan-contract-no-snapshots.sql diff --git a/backend/app/crud/crm/contract.py b/backend/app/crud/crm/contract.py index 20c1c7511..2aa155c78 100644 --- a/backend/app/crud/crm/contract.py +++ b/backend/app/crud/crm/contract.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import Query, Session from app.core.delete_guard import assert_row_deletable_by_creator from app.core.utils import generate_uuid from app.services.wechat_approval_notices import sync_approval_notifications +from app.services.pl_plan_contract_no_sync import sync_plan_contract_no_snapshots from app.crud.crm.data_scope_helpers import apply_crm_owner_scope, ensure_crm_owner_row_in_scope, apply_crm_create_by_scope, ensure_crm_create_by_row_in_scope from app.crud.crm.contact import get_contact from app.crud.crm.contract_approval import ( @@ -1497,6 +1498,7 @@ def update_contract( if product_rows is not None: _resolve_contract_product_ids(db, tenant_no=tenant_no, products=product_rows, user_id=user_id) + prev_contract_no = (old.contract_no or "").strip() sign_date = old.sign_date or date.today() submitted_no = (_get_field(data, "contract_no", "contractNo") or "").strip() or ( old.contract_no or "" @@ -1540,6 +1542,17 @@ def update_contract( contract_type=contract_type, user_id=user_id, dept_id=dept_id, prepared=prepared_products) + new_contract_no = (old.contract_no or "").strip() + if new_contract_no != prev_contract_no: + # 归档列表读计划侧 contract_no 快照;改号后必须同事务回写 + sync_plan_contract_no_snapshots( + db, + tenant_no=tenant_no, + contract_id=contract_id, + contract_no=new_contract_no, + user_id=user_id, + ) + db.flush() sync_approval_notifications( db, "crm_contract", contract_id, tenant_no, diff --git a/backend/app/services/pl_plan_contract_no_sync.py b/backend/app/services/pl_plan_contract_no_sync.py new file mode 100644 index 000000000..129f1d4db --- /dev/null +++ b/backend/app/services/pl_plan_contract_no_sync.py @@ -0,0 +1,66 @@ +"""CRM 合同编号变更时,回写运输计划侧合同号快照。""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.pl_models import PlPlan, PlPlanContract + + +def sync_plan_contract_no_snapshots( + db: Session, + *, + tenant_no: str, + contract_id: str, + contract_no: str, + user_id: Optional[str] = None, +) -> int: + """按 contract_id 刷新 pl_plan_contract / pl_plan 上的 contract_no 快照。 + + 归档列表等读快照字段;仅改 CRM 主表会导致列表与详情不一致。 + 返回实际改写的行数(关联表 + 主表遗留字段合计)。 + """ + cid = (contract_id or "").strip() + if not cid: + return 0 + new_no = (contract_no or "").strip() + now = datetime.now() + changed = 0 + + link_rows = ( + db.query(PlPlanContract) + .filter( + PlPlanContract.tenant_no == tenant_no, + PlPlanContract.contract_id == cid, + PlPlanContract.del_flag == 0, + ) + .all() + ) + for row in link_rows: + if (row.contract_no or "") == new_no: + continue + row.contract_no = new_no or None + row.update_by = user_id + row.update_time = now + changed += 1 + + plan_rows = ( + db.query(PlPlan) + .filter( + PlPlan.tenant_no == tenant_no, + PlPlan.contract_id == cid, + PlPlan.del_flag == 0, + ) + .all() + ) + for row in plan_rows: + if (row.contract_no or "") == new_no: + continue + row.contract_no = new_no or None + row.update_by = user_id + row.update_time = now + changed += 1 + + return changed diff --git a/backend/tests/test_sync_plan_contract_no_snapshots.py b/backend/tests/test_sync_plan_contract_no_snapshots.py new file mode 100644 index 000000000..ef145b92e --- /dev/null +++ b/backend/tests/test_sync_plan_contract_no_snapshots.py @@ -0,0 +1,174 @@ +"""CRM 合同改号后,运输计划合同号快照应同步。""" +from datetime import date +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.models import pl_models as m + + +@pytest.fixture +def db(): + engine = create_engine("sqlite://") + m.PlPlan.__table__.create(engine, checkfirst=True) + m.PlPlanContract.__table__.create(engine, checkfirst=True) + with Session(engine, autoflush=False) as session: + yield session + engine.dispose() + + +def _seed(db: Session): + plan = m.PlPlan( + id="plan-1", + plan_no="PO20261012005", + create_date=date(2026, 10, 12), + belongs_company="shanxia", + plan_type="direct", + contract_id="contract-1", + contract_no="SX/X-JT20261009002", + tenant_no="t1", + ) + other_plan = m.PlPlan( + id="plan-2", + plan_no="PO20261012099", + create_date=date(2026, 10, 12), + belongs_company="shanxia", + plan_type="direct", + contract_id="contract-other", + contract_no="OTHER-001", + tenant_no="t1", + ) + link = m.PlPlanContract( + id="link-1", + plan_id="plan-1", + side="delivery", + stop_id="stop-1", + contract_id="contract-1", + contract_no="SX/X-JT20261009002", + tenant_no="t1", + ) + deleted = m.PlPlanContract( + id="link-del", + plan_id="plan-1", + side="delivery", + contract_id="contract-1", + contract_no="SX/X-JT20261009002", + tenant_no="t1", + del_flag=1, + ) + other_tenant = m.PlPlanContract( + id="link-tenant", + plan_id="plan-x", + side="delivery", + contract_id="contract-1", + contract_no="SX/X-JT20261009002", + tenant_no="other", + ) + db.add_all([plan, other_plan, link, deleted, other_tenant]) + db.flush() + return plan, link + + +def test_sync_refreshes_plan_link_and_legacy_header(db): + from app.services.pl_plan_contract_no_sync import sync_plan_contract_no_snapshots + + plan, link = _seed(db) + changed = sync_plan_contract_no_snapshots( + db, + tenant_no="t1", + contract_id="contract-1", + contract_no="P0-2026-10-0016", + user_id="u1", + ) + db.flush() + db.expire_all() + + assert changed >= 2 + assert db.get(m.PlPlanContract, "link-1").contract_no == "P0-2026-10-0016" + assert db.get(m.PlPlan, "plan-1").contract_no == "P0-2026-10-0016" + assert db.get(m.PlPlanContract, "link-1").update_by == "u1" + assert db.get(m.PlPlan, "plan-2").contract_no == "OTHER-001" + assert db.get(m.PlPlanContract, "link-del").contract_no == "SX/X-JT20261009002" + assert db.get(m.PlPlanContract, "link-tenant").contract_no == "SX/X-JT20261009002" + + +def test_sync_noop_when_already_current(db): + from app.services.pl_plan_contract_no_sync import sync_plan_contract_no_snapshots + + _seed(db) + sync_plan_contract_no_snapshots( + db, + tenant_no="t1", + contract_id="contract-1", + contract_no="P0-2026-10-0016", + user_id="u1", + ) + db.flush() + first_updated = db.get(m.PlPlanContract, "link-1").update_time + changed = sync_plan_contract_no_snapshots( + db, + tenant_no="t1", + contract_id="contract-1", + contract_no="P0-2026-10-0016", + user_id="u2", + ) + db.flush() + db.expire_all() + assert changed == 0 + assert db.get(m.PlPlanContract, "link-1").update_by == "u1" + assert db.get(m.PlPlanContract, "link-1").update_time == first_updated + + +def test_update_contract_calls_sync_when_contract_no_changes(): + import app.crud.crm.contract as contract_crud + + old = MagicMock() + old.status = "executing" + old.contract_type = "sale" + old.contract_no = "SX/X-JT20261009002" + old.contract_name = "临时" + old.party_a = "A" + old.party_b = "B" + old.supplier_id = "s" + old.customer_id = "c" + old.customer_name = "客" + old.sign_date = date(2026, 10, 9) + old.id = "contract-1" + + with patch.object(contract_crud, "_get_contract_row_in_scope", return_value=old), patch.object( + contract_crud, "invalidate_all_approvals" + ), patch.object(contract_crud, "_validate_sale_purchase_contract_amount"), patch.object( + contract_crud, "_apply_contract_entity_fields" + ), patch.object(contract_crud, "assert_products_allowed_for_supplier"), patch.object( + contract_crud, "list_contract_products", return_value=[] + ), patch.object( + contract_crud, "align_contract_no_to_sign_date", return_value="P0-2026-10-0016" + ), patch.object( + contract_crud, "contract_no_exists", return_value=False + ), patch.object( + contract_crud, "_build_contract_name_from_parties", return_value="正式" + ), patch.object( + contract_crud, "get_contract_detail", return_value={"id": "contract-1"} + ), patch.object(contract_crud, "sync_contract_products"), patch( + "app.crud.crm.contract.sync_plan_contract_no_snapshots" + ) as sync_snap, patch( + "app.crud.crm.contract.sync_approval_notifications" + ): + db = MagicMock() + contract_crud.update_contract( + db, + "contract-1", + tenant_no="t1", + data={"contractNo": "P0-2026-10-0016"}, + user_id="u1", + ) + sync_snap.assert_called_once_with( + db, + tenant_no="t1", + contract_id="contract-1", + contract_no="P0-2026-10-0016", + user_id="u1", + ) + assert old.contract_no == "P0-2026-10-0016" diff --git a/docs/sql/migrations/2026-11-12-sync-plan-contract-no-snapshots.sql b/docs/sql/migrations/2026-11-12-sync-plan-contract-no-snapshots.sql new file mode 100644 index 000000000..13f97f501 --- /dev/null +++ b/docs/sql/migrations/2026-11-12-sync-plan-contract-no-snapshots.sql @@ -0,0 +1,39 @@ +-- 一次性数据修复:将 pl_plan_contract / pl_plan 合同号快照对齐到 CRM 现网编号。 +-- 背景:合同页直接改 contract_no 后,计划归档列表仍读快照,导致列表与详情不一致。 +-- 范围:全库 del_flag=0 且快照与 crm_contract.contract_no 不一致的关联/遗留主表行。 +-- 含客户反馈三单:PO20261008011 / PO20261009018 / PO20261012005(临时号 SX/X-JT20261009002 → P0-2026-10-0016)。 +-- MySQL 5.7。可重复执行(已对齐行不再更新)。 +-- !!! 生产执行须单独授权;本文件不随应用启动自动执行。 + +SET NAMES utf8mb4; + +-- 预览(执行前建议先跑): +-- SELECT p.plan_no, pc.id AS link_id, pc.side, pc.contract_id, +-- pc.contract_no AS snapshot_no, c.contract_no AS live_no +-- FROM pl_plan_contract pc +-- JOIN pl_plan p ON p.id = pc.plan_id AND p.del_flag = 0 +-- JOIN crm_contract c ON c.id = pc.contract_id AND c.tenant_no = pc.tenant_no AND c.del_flag = 0 +-- WHERE pc.del_flag = 0 +-- AND IFNULL(pc.contract_no, '') <> IFNULL(c.contract_no, ''); + +UPDATE pl_plan_contract pc +INNER JOIN crm_contract c + ON c.id = pc.contract_id + AND c.tenant_no = pc.tenant_no + AND c.del_flag = 0 +SET pc.contract_no = c.contract_no, + pc.update_time = NOW() +WHERE pc.del_flag = 0 + AND IFNULL(pc.contract_no, '') <> IFNULL(c.contract_no, ''); + +UPDATE pl_plan p +INNER JOIN crm_contract c + ON c.id = p.contract_id + AND c.tenant_no = p.tenant_no + AND c.del_flag = 0 +SET p.contract_no = c.contract_no, + p.update_time = NOW() +WHERE p.del_flag = 0 + AND p.contract_id IS NOT NULL + AND p.contract_id <> '' + AND IFNULL(p.contract_no, '') <> IFNULL(c.contract_no, '');