use std::collections::HashMap;
use mako_engine::types::Pruefidentifikator;
use mako_engine::{
deadline::Deadline,
envelope::EventEnvelope,
error::WorkflowError,
fristen::{APERAK_STROM_WINDOW_LABEL, aperak_strom_due_at},
ids::DeadlineId,
outbox::PendingOutbox,
projection::Projection,
types::{MaLo, MarktpartnerCode, MessageRef},
workflow::{CommandPayload, EventPayload, PendingDeadline, Workflow, WorkflowOutput},
};
pub const WORKFLOW_NAME: &str = "gpke-supplier-change";
pub const UTILMD_PIDS: &[u32] = &[
55001, 55002, 55016, 55077, 55557, ];
pub const IFTSTA_PIDS: &[u32] = &[
21_024, 21_025, 21_026, 21_027, 21_028, 21_033,
21_035, 21_045, 21_047, ];
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum SupplierChangeEvent {
Initiated {
location_id: MaLo,
new_supplier: MarktpartnerCode,
grid_operator: MarktpartnerCode,
document_date: String,
#[serde(default)]
process_date: String,
message_ref: MessageRef,
pruefidentifikator: Pruefidentifikator,
},
ValidationPassed {
message_ref: MessageRef,
},
AntwortGesendet {
response_pid: Option<Pruefidentifikator>,
accepted: bool,
reason: Option<String>,
},
Activated,
AperakFehlerDispatched {
aperak_pid: Pruefidentifikator,
reason: String,
outbound_ref: MessageRef,
},
Rejected {
reason: String,
},
DeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
VollzugsmeldungReceived {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
},
}
impl EventPayload for SupplierChangeEvent {
fn event_type(&self) -> &'static str {
match self {
Self::Initiated { .. } => "SupplierChangeInitiated",
Self::ValidationPassed { .. } => "SupplierChangeValidationPassed",
Self::AntwortGesendet { .. } => "SupplierChangeAntwortGesendet",
Self::Activated => "SupplierChangeActivated",
Self::AperakFehlerDispatched { .. } => "SupplierChangeAperakFehlerDispatched",
Self::Rejected { .. } => "SupplierChangeRejected",
Self::DeadlineExpired { .. } => "SupplierChangeDeadlineExpired",
Self::VollzugsmeldungReceived { .. } => "SupplierChangeVollzugsmeldungReceived",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InitiatedData {
pub location_id: MaLo,
pub new_supplier: MarktpartnerCode,
pub grid_operator: MarktpartnerCode,
pub document_date: String,
#[serde(default)]
pub process_date: String,
pub pruefidentifikator: Pruefidentifikator,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status", content = "data")]
#[derive(Default)]
pub enum SupplierChangeState {
#[default]
New,
Initiated(InitiatedData),
ValidationPassed(InitiatedData),
AntwortGesendet {
data: InitiatedData,
response_pid: Option<Pruefidentifikator>,
},
Active(InitiatedData),
Rejected {
reason: String,
},
}
impl SupplierChangeState {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::Initiated(_) => "Initiated",
Self::ValidationPassed(_) => "ValidationPassed",
Self::AntwortGesendet { .. } => "AntwortGesendet",
Self::Active(_) => "Active",
Self::Rejected { .. } => "Rejected",
}
}
#[must_use]
pub fn initiated_data(&self) -> Option<&InitiatedData> {
match self {
Self::Initiated(d) | Self::ValidationPassed(d) | Self::Active(d) => Some(d),
Self::AntwortGesendet { data, .. } => Some(data),
Self::New | Self::Rejected { .. } => None,
}
}
}
#[derive(Clone)]
pub enum SupplierChangeCommand {
ReceiveUtilmd {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
location_id: MaLo,
document_date: String,
process_date: String,
bilanzierungsgebiet: Option<String>,
bilanzierungsmethode: Option<String>,
fallgruppe: Option<String>,
message_ref: MessageRef,
received_at: time::OffsetDateTime,
validation_passed: bool,
validation_errors: Vec<String>,
},
SendAntwort {
accepted: bool,
reason: Option<String>,
obligations: Vec<PendingOutbox>,
},
Activate,
DispatchAperakFehler {
reason: String,
outbound_ref: MessageRef,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
ReceiveVollzugsmeldung {
pid: Pruefidentifikator,
sender: MarktpartnerCode,
receiver: MarktpartnerCode,
message_ref: MessageRef,
validation_passed: bool,
validation_errors: Vec<String>,
},
}
impl CommandPayload for SupplierChangeCommand {}
fn response_pid_for(anfrage_pid: u32, accepted: bool) -> Option<Pruefidentifikator> {
let code: u32 = match anfrage_pid {
55001 => {
if accepted {
55003
} else {
55004
}
}
55002 => {
if accepted {
55005
} else {
55006
}
}
55016 => {
if accepted {
55017 } else {
55018 }
}
55077 => {
if accepted {
55078 } else {
55080 }
}
_ => return None,
};
Pruefidentifikator::new(code).ok()
}
pub const GPKE_PROCESS_RESPONSE_LABEL: &str = "gpke-response-24h-window";
pub struct GpkeSupplierChangeWorkflow;
impl Workflow for GpkeSupplierChangeWorkflow {
type State = SupplierChangeState;
type Event = SupplierChangeEvent;
type Command = SupplierChangeCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
match (deadline.label(), state) {
(GPKE_PROCESS_RESPONSE_LABEL, SupplierChangeState::Initiated(_))
| (GPKE_PROCESS_RESPONSE_LABEL, SupplierChangeState::ValidationPassed(_)) => {
Some(SupplierChangeCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
})
}
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
SupplierChangeEvent::Initiated {
location_id,
new_supplier,
grid_operator,
document_date,
process_date,
pruefidentifikator,
..
} => SupplierChangeState::Initiated(InitiatedData {
location_id: location_id.clone(),
new_supplier: new_supplier.clone(),
grid_operator: grid_operator.clone(),
document_date: document_date.clone(),
process_date: process_date.clone(),
pruefidentifikator: *pruefidentifikator,
}),
SupplierChangeEvent::ValidationPassed { .. } => {
match state {
SupplierChangeState::Initiated(data) => {
SupplierChangeState::ValidationPassed(data)
}
other => other, }
}
SupplierChangeEvent::AntwortGesendet {
accepted,
response_pid,
..
} => {
if *accepted {
match state {
SupplierChangeState::ValidationPassed(data) => {
SupplierChangeState::AntwortGesendet {
response_pid: *response_pid,
data,
}
}
other => other,
}
} else {
SupplierChangeState::Rejected {
reason: "Anfrage abgelehnt".to_owned(),
}
}
}
SupplierChangeEvent::Activated => match state {
SupplierChangeState::AntwortGesendet { data, .. } => {
SupplierChangeState::Active(data)
}
other => other,
},
SupplierChangeEvent::AperakFehlerDispatched { reason, .. } => {
SupplierChangeState::Rejected {
reason: format!("APERAK 29001: {reason}"),
}
}
SupplierChangeEvent::Rejected { reason } => SupplierChangeState::Rejected {
reason: reason.clone(),
},
SupplierChangeEvent::DeadlineExpired { label, .. } => {
match state {
SupplierChangeState::Active(_) | SupplierChangeState::Rejected { .. } => state,
_ => SupplierChangeState::Rejected {
reason: format!("deadline expired: {label}"),
},
}
}
SupplierChangeEvent::VollzugsmeldungReceived { .. } => state,
}
}
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
SupplierChangeCommand::ReceiveUtilmd {
pid,
sender,
receiver,
location_id,
document_date,
process_date,
bilanzierungsgebiet,
bilanzierungsmethode,
fallgruppe,
message_ref,
received_at,
validation_passed,
validation_errors,
} => {
if !matches!(state, SupplierChangeState::New) {
return Err(WorkflowError::invalid_state("New", state.label()));
}
if !UTILMD_PIDS.contains(&pid.as_u32()) {
return Err(WorkflowError::rejected(format!(
"expected an inbound ANFRAGE PID (55001, 55002, or 55016), \
got {pid}. Response PIDs (55003–55006, 55017, 55018) \
are outbound only. ORDERS Sperrung (17115/17116/17117) \
routes to GpkeSperrungWorkflow.",
)));
}
let mut events = vec![SupplierChangeEvent::Initiated {
location_id: location_id.clone(),
new_supplier: sender.clone(),
grid_operator: receiver.clone(),
document_date,
process_date: process_date.clone(),
message_ref: message_ref.clone(),
pruefidentifikator: pid,
}];
if validation_passed {
events.push(SupplierChangeEvent::ValidationPassed { message_ref });
} else {
events.push(SupplierChangeEvent::Rejected {
reason: validation_errors.join("; "),
});
}
let outbox = if validation_passed {
vec![
PendingOutbox::new(
"ProcessInitiated",
receiver.as_str(),
serde_json::json!({
"pid": pid.as_u32(),
"malo_id": location_id.as_str(),
"new_supplier": sender.as_str(),
"grid_operator": receiver.as_str(),
"process_date": process_date,
"bilanzierungsgebiet": bilanzierungsgebiet,
"bilanzierungsmethode": bilanzierungsmethode,
"fallgruppe": fallgruppe,
}),
)
.caused_by(1),
PendingOutbox::new(
"APERAK",
sender.as_str(),
serde_json::json!({
"sender": receiver.as_str(),
"receiver": sender.as_str(),
"pid": 29001_u32,
"document_code": "312",
}),
)
.caused_by(1),
]
} else {
vec![
PendingOutbox::new(
"APERAK",
sender.as_str(),
serde_json::json!({
"sender": receiver.as_str(),
"receiver": sender.as_str(),
"pid": 29001_u32,
"error_code": mako_engine::erc::codes::Z29,
"reason": validation_errors.join("; "),
}),
)
.caused_by(0),
]
};
let aperak_deadline = PendingDeadline::new(
APERAK_STROM_WINDOW_LABEL,
aperak_strom_due_at(received_at),
);
Ok(WorkflowOutput::with_outbox_and_deadline(
events,
outbox,
aperak_deadline,
))
}
SupplierChangeCommand::SendAntwort {
accepted,
reason,
obligations,
} => {
let data = match state {
SupplierChangeState::ValidationPassed(d) => d,
_ => {
return Err(WorkflowError::invalid_state(
"ValidationPassed",
state.label(),
));
}
};
let response_pid = response_pid_for(data.pruefidentifikator.as_u32(), accepted);
let events = vec![SupplierChangeEvent::AntwortGesendet {
response_pid,
accepted,
reason,
}];
let mut outbox: Vec<PendingOutbox> = vec![];
if let Some(rpid) = response_pid {
outbox.push(PendingOutbox::new(
"UTILMD",
data.new_supplier.as_str(),
serde_json::json!({
"pid": rpid.as_u32(),
"sender": data.grid_operator.as_str(),
"receiver": data.new_supplier.as_str(),
"malo": data.location_id.as_str(),
"process_date": data.process_date,
}),
));
}
if accepted {
outbox.extend(obligations);
}
Ok(WorkflowOutput::with_outbox(events, outbox))
}
SupplierChangeCommand::Activate => {
if !matches!(state, SupplierChangeState::AntwortGesendet { .. }) {
return Err(WorkflowError::invalid_state(
"AntwortGesendet",
state.label(),
));
}
Ok(vec![SupplierChangeEvent::Activated].into())
}
SupplierChangeCommand::DispatchAperakFehler {
reason,
outbound_ref,
} => {
match state {
SupplierChangeState::New => {
return Err(WorkflowError::invalid_state(
"Initiated or ValidationPassed",
state.label(),
));
}
SupplierChangeState::Active(_) | SupplierChangeState::Rejected { .. } => {
return Err(WorkflowError::invalid_state(
"Initiated or ValidationPassed",
state.label(),
));
}
_ => {}
}
let aperak_pid = Pruefidentifikator::new(29001)
.map_err(|_| WorkflowError::other("invalid APERAK PID 29001"))?;
let events = vec![SupplierChangeEvent::AperakFehlerDispatched {
aperak_pid,
reason: reason.clone(),
outbound_ref,
}];
let outbox = if let Some(data) = state.initiated_data() {
vec![
PendingOutbox::new(
"APERAK",
data.new_supplier.as_str(),
serde_json::json!({
"sender": data.grid_operator.as_str(),
"receiver": data.new_supplier.as_str(),
"pid": 29001_u32,
"error_code": mako_engine::erc::codes::Z29,
"reason": reason,
}),
)
.caused_by(0),
]
} else {
vec![]
};
Ok(WorkflowOutput::with_outbox(events, outbox))
}
SupplierChangeCommand::TimeoutExpired { deadline_id, label } => {
if matches!(
state,
SupplierChangeState::Active(_) | SupplierChangeState::Rejected { .. }
) {
return Ok(WorkflowOutput::events(vec![]));
}
let mut outbox: Vec<PendingOutbox> = vec![];
if let Some(data) = state.initiated_data() {
outbox.push(PendingOutbox::new(
"AperakTimeout",
data.new_supplier.as_str(),
serde_json::json!({
"pid": data.pruefidentifikator.as_u32(),
"malo": data.location_id.as_str(),
"new_supplier": data.new_supplier.as_str(),
"grid_operator": data.grid_operator.as_str(),
"deadline_label": label.as_ref(),
"deadline_id": deadline_id,
}),
));
}
let event = SupplierChangeEvent::DeadlineExpired { deadline_id, label };
if outbox.is_empty() {
Ok(vec![event].into())
} else {
Ok(WorkflowOutput::with_outbox(vec![event], outbox))
}
}
SupplierChangeCommand::ReceiveVollzugsmeldung {
pid,
sender,
receiver,
message_ref,
..
} => {
Ok(vec![SupplierChangeEvent::VollzugsmeldungReceived {
pid,
sender,
receiver,
message_ref,
}]
.into())
}
}
}
}
#[derive(Debug, Clone)]
pub struct InitiatedDetails {
pub location_id: MaLo,
pub new_supplier: MarktpartnerCode,
pub grid_operator: MarktpartnerCode,
pub pruefidentifikator: Pruefidentifikator,
}
#[derive(Debug)]
pub struct SupplierChangeRecord {
pub status: &'static str,
pub details: Option<InitiatedDetails>,
pub event_count: usize,
}
impl Default for SupplierChangeRecord {
fn default() -> Self {
Self {
status: "New",
details: None,
event_count: 0,
}
}
}
#[derive(Debug, Default)]
pub struct SupplierChangeProjection {
pub records: HashMap<String, SupplierChangeRecord>,
pub last_seq: u64,
}
impl Projection for SupplierChangeProjection {
fn name(&self) -> &'static str {
"SupplierChangeProjection"
}
fn handle_event(&mut self, envelope: &EventEnvelope) {
self.last_seq = self.last_seq.max(envelope.sequence_number);
let record = self
.records
.entry(envelope.stream_id.as_str().to_owned())
.or_default();
record.event_count += 1;
let Ok(event) = envelope.decode::<SupplierChangeEvent>() else {
return;
};
match event {
SupplierChangeEvent::Initiated {
location_id,
new_supplier,
grid_operator,
pruefidentifikator,
..
} => {
record.status = "Initiated";
record.details = Some(InitiatedDetails {
location_id,
new_supplier,
grid_operator,
pruefidentifikator,
});
}
SupplierChangeEvent::ValidationPassed { .. } => {
record.status = "ValidationPassed";
}
SupplierChangeEvent::AntwortGesendet { accepted, .. } => {
record.status = if accepted {
"AntwortGesendet"
} else {
"Rejected"
};
}
SupplierChangeEvent::Activated => {
record.status = "Active";
}
SupplierChangeEvent::AperakFehlerDispatched { .. } => {
record.status = "Rejected";
}
SupplierChangeEvent::Rejected { .. } => {
record.status = "Rejected";
}
SupplierChangeEvent::DeadlineExpired { .. } => {
record.status = "Rejected";
}
SupplierChangeEvent::VollzugsmeldungReceived { .. } => {
}
}
}
fn last_sequence(&self) -> Option<u64> {
if self.last_seq == 0 {
None
} else {
Some(self.last_seq)
}
}
}