feat(investigations): add event impact and work-order feedback flows

Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates.

Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
This commit is contained in:
2026-11-14 15:14:02 +08:00
parent 471ad92a47
commit 2e4f6cb93b
49 changed files with 1826 additions and 93 deletions
+54
View File
@@ -807,6 +807,60 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke
asyncio.run(run())
@pytest.mark.parametrize("create_tasks", [False, True])
def test_pollution_detection_creates_own_investigation_even_during_period_diagnosis(monkeypatch, create_tasks):
from app.services.scada_normal_profile import diagnostic_signal, pollution_signal
from app.services.scada_replay_processing import detect_frame
_, service, *_ = setup(monkeypatch)
push = AsyncMock()
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
advance = AsyncMock()
monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object())
monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push))
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
lambda *_, **_kwargs: SimpleNamespace(create=create_task, advance_replay_investigation=advance))
config = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1),
device_ids=["Q1"], rules=[{"rule_id": "chlorine", "device_ids": ["Q1"],
"kind": "bounds", "lower": 0.1, "unit": "mg/L", "metric_name": "余氯"}])
document = {"replay_id": str(uuid4()), "config": config.model_dump(mode="json"),
"devices": [{"device_id": "Q1", "device_type": "quality", "transmission_frequency": "0:15:00"}]}
async def run():
nonlocal document
# One continuous quality episode creates exactly one task, without any
# pressure roles, burst score or complaint.
for step, value in enumerate((0.05, 0.04, 0.2, 0.3)):
at = START + timedelta(minutes=15 * step)
document, changes = detect_frame(document, [{"device_id": "Q1", "time": at.isoformat(),
"quality": "valid", "cleaned_value": value, "monitored_value": value}], config, at)
for event in changes:
assert pollution_signal(document, event)
assert not diagnostic_signal(document, event)
await ScadaReplayService._create_investigations(service, document, changes,
{"sequence": step + 1, "at": at.isoformat()}, create_tasks=create_tasks)
create_task.assert_awaited_once()
command = create_task.await_args.args[0]
assert command.workflow == command.source.event_kind == "water_pollution"
assert command.task_type == "event_investigation"
assert command.source.replay_context.batch_sequence == 1
assert command.trigger.kind == "event"
assert "投诉" not in command.title and "爆管" not in command.summary
assert [call.args[0].event_type for call in push.await_args_list] == ["raised", "resolved"]
assert all(call.args[0].condition_id == f"operational-task:{create_task.return_value.task_id}"
for call in push.await_args_list)
advance.assert_awaited_once()
asyncio.run(run())
def test_invalid_quality_data_is_not_a_pollution_detection():
from app.services.scada_normal_profile import pollution_signal
document = {"devices": [{"device_id": "Q1", "device_type": "quality"}]}
assert not pollution_signal(document, {"category": "data_quality", "device_ids": ["Q1"]})
assert not pollution_signal(document, {"device_ids": ["unknown"]})
def test_failed_incident_publication_rolls_back_state_and_retries_same_task_request(monkeypatch):
repo, service, _, _, create = setup(monkeypatch)
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))