Files
shanxia2.0/backend/tests/test_pl_dispatch_calendar_site_sync.py
wuzilong fcbf8a9b65 feat: 为现场日历增加单据类型并同步派车记录,减少重复录入。
按起止地关键词生成现场出入库计划,同步来源修改和删除,保留历史记录不补录。调整卡片及复制文本的单据类型格式,并记录测试库和生产库迁移结果。
2026-10-30 14:04:24 +08:00

266 lines
12 KiB
Python

"""来源派车与现场日历的匹配、关联生命周期和事务回归。"""
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