"""子表按行同步:更新原行、新增缺行、只软删去掉的行。""" import unittest from datetime import datetime from sqlalchemy import Column, DateTime, Integer, SmallInteger, String, create_engine from sqlalchemy.orm import sessionmaker from app.core.database import Base from app.crud.child_row_sync import sync_child_rows class DemoChild(Base): __tablename__ = "demo_child_row" id = Column(String(64), primary_key=True) parent_id = Column(String(64), nullable=False) tenant_no = Column(String(64), nullable=False) item_code = Column(String(64)) item_name = Column(String(128)) order_num = Column(Integer) create_time = Column(DateTime) update_by = Column(String(64)) update_time = Column(DateTime) del_flag = Column(SmallInteger, nullable=False, default=0) def _session(): engine = create_engine("sqlite://") DemoChild.__table__.create(bind=engine) return sessionmaker(bind=engine)() def _row(**kwargs): now = datetime(2026, 10, 27, 9, 0, 0) data = dict( parent_id="parent-1", tenant_no="tenant-a", item_code="R-1", item_name="试剂", order_num=0, create_time=now, update_time=now, del_flag=0, ) data.update(kwargs) return DemoChild(**data) def _sync(db, items): now = datetime(2026, 10, 27, 10, 0, 0) def build_row(row_id): return DemoChild( id=row_id, parent_id="parent-1", tenant_no="tenant-a", create_time=now, ) def apply_row(row, item, idx, _now): row.item_code = item.get("item_code") row.item_name = item.get("item_name") row.order_num = idx return sync_child_rows( db, DemoChild, parent_attr="parent_id", parent_id="parent-1", tenant_no="tenant-a", items=items, user_id="user-1", now=now, build_row=build_row, apply_fields=apply_row, natural_key=lambda item: (item.get("item_code") or "").strip(), row_natural_key=lambda row: (row.item_code or "").strip(), ) class TestChildRowSync(unittest.TestCase): def test_id_keeps_row_when_fields_change(self): db = _session() db.add(_row(id="row-keep", item_name="旧名称")) db.commit() saved = _sync(db, [{"id": "row-keep", "item_code": "R-2", "item_name": "新名称"}]) self.assertEqual(saved[0].id, "row-keep") self.assertEqual(saved[0].item_name, "新名称") self.assertEqual(db.query(DemoChild).filter_by(del_flag=0).count(), 1) def test_natural_key_keeps_id_when_payload_omits_id(self): db = _session() db.add(_row(id="row-keep", item_code="R-1")) db.commit() saved = _sync(db, [{"item_code": "R-1", "item_name": "改名"}]) self.assertEqual([row.id for row in saved], ["row-keep"]) self.assertEqual(saved[0].item_name, "改名") def test_insert_update_and_soft_delete_removed(self): db = _session() db.add(_row(id="row-keep", item_code="R-1")) db.add(_row(id="row-drop", item_code="R-9", item_name="去掉")) db.commit() saved = _sync( db, [ {"id": "row-keep", "item_code": "R-1", "item_name": "留下"}, {"item_code": "R-NEW", "item_name": "新增"}, ], ) self.assertEqual(saved[0].id, "row-keep") self.assertNotEqual(saved[1].id, "row-drop") rows = {row.id: row for row in db.query(DemoChild).all()} self.assertEqual(rows["row-keep"].del_flag, 0) self.assertEqual(rows["row-drop"].del_flag, 1) self.assertEqual(rows[saved[1].id].del_flag, 0) def test_id_from_another_parent_is_rejected(self): db = _session() db.add(_row(id="row-other", parent_id="parent-2")) db.commit() with self.assertRaises(ValueError): _sync(db, [{"id": "row-other", "item_code": "R-1", "item_name": "占用"}]) other = db.query(DemoChild).filter_by(id="row-other").one() self.assertEqual(other.parent_id, "parent-2") self.assertEqual(other.del_flag, 0) def test_readded_business_key_restores_deleted_row(self): db = _session() db.add(_row(id="row-old", item_code="R-1", del_flag=1)) db.commit() now = datetime(2026, 10, 27, 10, 0, 0) def build_row(row_id): return DemoChild( id=row_id, parent_id="parent-1", tenant_no="tenant-a", create_time=now, ) def apply_row(row, item, idx, _now): row.item_code = item.get("item_code") row.item_name = item.get("item_name") row.order_num = idx saved = sync_child_rows( db, DemoChild, parent_attr="parent_id", parent_id="parent-1", tenant_no="tenant-a", items=[{"item_code": "R-1", "item_name": "恢复"}], user_id="user-1", now=now, build_row=build_row, apply_fields=apply_row, natural_key=lambda item: (item.get("item_code") or "").strip(), row_natural_key=lambda row: (row.item_code or "").strip(), reuse_deleted=True, ) db.commit() self.assertEqual(saved[0].id, "row-old") self.assertEqual(saved[0].del_flag, 0) self.assertEqual(db.query(DemoChild).count(), 1) def test_removing_live_row_clears_older_tombstone(self): db = _session() db.add(_row(id="row-live", item_code="R-1")) db.add(_row(id="row-tomb", item_code="R-1", del_flag=1)) db.commit() now = datetime(2026, 10, 27, 10, 0, 0) sync_child_rows( db, DemoChild, parent_attr="parent_id", parent_id="parent-1", tenant_no="tenant-a", items=[], user_id="user-1", now=now, build_row=lambda row_id: DemoChild( id=row_id, parent_id="parent-1", tenant_no="tenant-a", create_time=now ), apply_fields=lambda row, item, idx, _now: None, natural_key=lambda item: (item.get("item_code") or "").strip(), row_natural_key=lambda row: (row.item_code or "").strip(), reuse_deleted=True, ) db.commit() self.assertIsNone(db.query(DemoChild).filter_by(id="row-tomb").first()) live = db.query(DemoChild).filter_by(id="row-live").one() self.assertEqual(live.del_flag, 1) if __name__ == "__main__": unittest.main()