"""来源派车与现场日历的匹配、关联生命周期和事务回归。""" import pytest from fastapi import HTTPException from sqlalchemy import event from sqlalchemy.exc import IntegrityError, OperationalError from test_pl_dispatch_calendar import db, create, modules, payload, user @pytest.fixture(autouse=True) def production_session_options(db): db.autoflush = False db.expire_on_commit = False def site_rows(db, tenant='t1', caller=None, month='2026-10'): return modules()[3].list_records(db, tenant, caller or user(tenant=tenant), month=month, calendar_kind='site')['list'] @pytest.mark.parametrize('kind', ['acid', 'alkali', 'other']) @pytest.mark.parametrize('origin,destination,expected', [ ('山峡化工仓库', '武汉', '出库计划'), ('云栋厂区', '武汉', '出库计划'), ('武汉', '山峡化工仓库', '入库计划'), ('武汉', '云栋厂区', '入库计划'), ('山峡仓库', '云栋厂区', '出入库计划'), ('云栋一库', '云栋二库', '出入库计划'), ('武汉', '安庆', None), ('', '', None), ]) def test_new_source_match_matrix(db, kind, origin, destination, expected): source = create(db, calendarKind=kind, origin=origin, destination=destination, quantity='32.50', remark='保持备注') rows = site_rows(db) assert len(rows) == (1 if expected else 0) if expected: target = rows[0] assert target['id'] != source.id assert target['sourceRecordId'] == source.id assert target['documentType'] == expected original = modules()[3]._row_to_dict(source) for key in ('dispatchDate', 'vehicleId', 'licensePlate', 'productId', 'productName', 'origin', 'destination', 'quantity', 'remark'): assert target[key] == original[key] def test_source_update_preserves_identity_and_soft_delete_lifecycle(db): model, _, Update, svc = modules() source = create(db, origin='山峡') assert len(site_rows(db)) == 1 target = db.get(model, site_rows(db)[0]['id']) identity = (target.id, target.create_by, target.create_dept_id, target.create_time) # 不变保存以及更换月份、主数据和类型,均为原地更新。 for _ in range(2): svc.update_record(db, 't1', user(), source.id, Update(**payload(origin='山峡'))) assert len(site_rows(db)) == 1 svc.update_record(db, 't1', user('u2'), source.id, Update(**payload( dispatchDate='2026-11-01', vehicleId='v2', productId='p2', origin='武汉', destination='云栋', quantity='20.00', remark='改备注'))) assert site_rows(db) == [] changed = site_rows(db, month='2026-11')[0] assert changed['id'] == target.id and changed['documentType'] == '入库计划' assert changed['licensePlate'] == '鄂E67890' and changed['productName'] == '盐酸' assert changed['quantity'] == '20.00' and changed['remark'] == '改备注' assert (target.id, target.create_by, target.create_dept_id, target.create_time) == identity assert target.update_by == 'u2' svc.update_record(db, 't1', user(), source.id, Update(**payload())) assert site_rows(db) == [] and target.del_flag == 1 svc.update_record(db, 't1', user(), source.id, Update(**payload(origin='山峡', destination='云栋'))) assert site_rows(db)[0]['id'] == target.id assert target.del_flag == 0 and target.document_type == '出入库计划' assert (target.id, target.create_by, target.create_dept_id, target.create_time) == identity svc.delete_record(db, 't1', user(), source.id) assert source.del_flag == target.del_flag == 1 assert site_rows(db) == [] assert db.query(model).count() == 2 def test_new_nonmatching_source_can_match_later_but_history_never_backfills(db): model, _, Update, svc = modules() source = create(db) assert site_rows(db) == [] svc.update_record(db, 't1', user(), source.id, Update(**payload(destination='云栋'))) assert len(site_rows(db)) == 1 # 模拟迁移前已有记录,数据库新字段使用默认值;不依赖派车日期判断历史。 historical = model(id='legacy', tenant_no='t1', calendar_kind='acid', dispatch_date=source.dispatch_date, vehicle_id='v1', license_plate='鄂E12345', product_id='p1', product_name='液碱', origin='山峡', destination='云栋', create_by='u1', create_dept_id='d1') db.add(historical) db.commit() svc.update_record(db, 't1', user(), historical.id, Update(**payload(origin='山峡', destination='云栋'))) assert len(site_rows(db)) == 1 assert all(row['sourceRecordId'] != historical.id for row in site_rows(db)) svc.delete_record(db, 't1', user(), historical.id) assert len(site_rows(db)) == 1 def test_manual_site_is_independent_and_generated_site_is_read_only(db): model, _, Update, svc = modules() manual = create(db, calendarKind='site', origin='山峡', destination='云栋', documentType='自定义') assert len(site_rows(db)) == 1 create(db, origin='山峡') assert len(site_rows(db)) == 2 generated = next(row for row in site_rows(db) if row['id'] != manual.id) for action in [lambda: svc.update_record(db, 't1', user(), generated['id'], Update(**payload(calendarKind='site'))), lambda: svc.delete_record(db, 't1', user(), generated['id'])]: with pytest.raises(HTTPException) as exc: action() assert exc.value.status_code == 409 and '来源' in exc.value.detail svc.update_record(db, 't1', user(), manual.id, Update(**payload(calendarKind='site', documentType='出库计划'))) svc.delete_record(db, 't1', user(), manual.id) assert len(site_rows(db)) == 1 and db.get(model, generated['id']).del_flag == 0 def test_generated_record_inherits_scope_and_cannot_cross_tenant(db, monkeypatch): _, Create, Update, svc = modules() own = create(db, origin='山峡') other = create(db, caller=user('u2'), origin='山峡') foreign = svc.create_record(db, 't2', user(tenant='t2'), Create(**payload( vehicleId='v3', productId='p3', origin='山峡'))) assert len(site_rows(db)) == 2 assert site_rows(db, tenant='t2')[0]['sourceRecordId'] == foreign.id import app.core.data_scope as scope monkeypatch.setattr(scope, '_resolve_user_role_scopes', lambda *a: [{'data_scope': '4', 'details': []}]) limited = user(role='admin') assert [r['sourceRecordId'] for r in site_rows(db, caller=limited)] == [own.id] for source in (other, foreign): for action in [lambda: svc.update_record(db, 't1', limited, source.id, Update(**payload())), lambda: svc.delete_record(db, 't1', limited, source.id)]: with pytest.raises(HTTPException) as exc: action() assert exc.value.status_code == 404 svc.update_record(db, 't1', limited, own.id, Update(**payload(origin='山峡', destination='云栋'))) assert site_rows(db, caller=limited)[0]['documentType'] == '出入库计划' svc.delete_record(db, 't1', limited, own.id) assert len(site_rows(db)) == 1 and len(site_rows(db, tenant='t2')) == 1 @pytest.mark.parametrize('operation', ['create', 'update', 'delete']) def test_source_and_site_rollback_together(db, operation): model, _, Update, svc = modules() source = None if operation != 'create': source = create(db, origin='山峡') assert len(site_rows(db)) == 1 def reject_site(mapper, connection, target): if target.calendar_kind == 'site': raise RuntimeError('模拟现场写入失败') hook = 'before_insert' if operation == 'create' else 'before_update' event.listen(model, hook, reject_site) try: with pytest.raises(RuntimeError, match='模拟现场写入失败'): if operation == 'create': create(db, origin='山峡') elif operation == 'update': svc.update_record(db, 't1', user(), source.id, Update(**payload(origin='云栋'))) else: svc.delete_record(db, 't1', user(), source.id) finally: event.remove(model, hook, reject_site) # 服务已回滚,会话可直接继续查询。 if source is None: assert db.query(model).count() == 0 else: assert source.origin == '山峡' and source.del_flag == 0 assert len(site_rows(db)) == 1 and site_rows(db)[0]['origin'] == '山峡' def test_database_unique_source_including_soft_deleted_rows(db): model, _, Update, svc = modules() source = create(db, origin='山峡') assert len(site_rows(db)) == 1 target = db.get(model, site_rows(db)[0]['id']) svc.update_record(db, 't1', user(), source.id, Update(**payload())) assert target.del_flag == 1 db.add(model(id='duplicate', tenant_no='t1', source_record_id=source.id, calendar_kind='site', dispatch_date=source.dispatch_date, vehicle_id='v1', license_plate='鄂E12345', product_id='p1', product_name='液碱', origin='山峡', destination='武汉', create_dept_id='d1')) with pytest.raises(IntegrityError): db.commit() db.rollback() def test_identical_source_trips_remain_independent_of_manual_site(db): _, _, _, svc = modules() manual = create(db, calendarKind='site', origin='山峡') first = create(db, origin='山峡') second = create(db, origin='山峡') assert len(site_rows(db)) == 3 assert {row['sourceRecordId'] for row in site_rows(db)} == {None, first.id, second.id} svc.delete_record(db, 't1', user(), first.id) assert len(site_rows(db)) == 2 assert {row['sourceRecordId'] for row in site_rows(db)} == {None, second.id} assert svc.get_record(db, 't1', user(), manual.id).del_flag == 0 @pytest.mark.parametrize('operation', ['create', 'update', 'delete']) def test_mysql_deadlock_retries_entire_transaction(db, operation): model, _, Update, svc = modules() source = create(db, origin='山峡') if operation != 'create' else None attempts = [] def fail_once(mapper, connection, target): if target.calendar_kind == 'site': attempts.append(target.id) if len(attempts) == 1: raise OperationalError('test', {}, Exception(1213, 'Deadlock found')) hook = 'before_insert' if operation == 'create' else 'before_update' event.listen(model, hook, fail_once) try: if operation == 'create': source = create(db, origin='山峡') elif operation == 'update': svc.update_record(db, 't1', user(), source.id, Update(**payload(origin='云栋'))) else: svc.delete_record(db, 't1', user(), source.id) finally: event.remove(model, hook, fail_once) assert len(attempts) == 2 assert db.query(model).count() == 2 if operation == 'delete': assert source.del_flag == 1 and site_rows(db) == [] else: assert len(site_rows(db)) == 1 assert site_rows(db)[0]['sourceRecordId'] == source.id assert site_rows(db)[0]['origin'] == ('云栋' if operation == 'update' else '山峡') @pytest.mark.parametrize('error_code,expected_attempts', [(1213, 3), (1205, 1)]) def test_database_retry_is_bounded_and_only_for_deadlocks(db, error_code, expected_attempts): model, _, _, _ = modules() attempts = [] def reject_site(mapper, connection, target): if target.calendar_kind == 'site': attempts.append(target.id) raise OperationalError('test', {}, Exception(error_code, 'database failure')) event.listen(model, 'before_insert', reject_site) try: with pytest.raises(OperationalError): create(db, origin='山峡') finally: event.remove(model, 'before_insert', reject_site) assert len(attempts) == expected_attempts assert db.query(model).count() == 0 def test_post_commit_refresh_failure_does_not_replay_a_committed_create(db, monkeypatch): model, _, _, _ = modules() refresh = db.refresh calls = [] def fail_first_refresh(*args, **kwargs): calls.append(1) if len(calls) == 1: raise OperationalError('test', {}, Exception(1213, 'post-commit read failed')) return refresh(*args, **kwargs) monkeypatch.setattr(db, 'refresh', fail_first_refresh) try: create(db, origin='山峡') except OperationalError: pass assert db.query(model).count() == 2