///|
/// A contact log event is entered by an operator after a real outreach step.
/// It records no email or phone number and does not dispatch a message.
pub(all) struct ResponseEvent {
shipment_id : String
status : String
recorded_at : Int64
reference : String
note : String
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct ResponseRow {
shipment_id : String
consignee : String
lot_id : String
quantity_g : Int64
status : String
history : Array[ResponseEvent]
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct ResponseReport {
recall_id : String
rows : Array[ResponseRow]
pending_count : Int32
attempted_count : Int32
reached_count : Int32
acknowledged_count : Int32
failed_count : Int32
} derive(Eq, Debug, ToJson, FromJson)
///|
pub fn parse_response_events_csv(
text : String,
) -> Result[Array[ResponseEvent], ValidationReport] {
let table = match parse_csv(text) {
Err(diagnostic) => return Err(report_from([diagnostic]))
Ok(value) => value
}
if !table.has_exact_headers([
"shipment_id", "status", "recorded_at", "reference", "note",
]) {
return Err(
report_from([
issue(
"error", "response-csv-schema", "responses.csv", "expected shipment_id,status,recorded_at,reference,note",
),
]),
)
}
let events : Array[ResponseEvent] = []
let findings : Array[Diagnostic] = []
for row in table.rows {
match
csv_int(row.cells[2], "responses.csv", row.line, "recorded_at", findings) {
None => ()
Some(recorded_at) =>
events.push({
shipment_id: row.cells[0],
status: row.cells[1],
recorded_at,
reference: row.cells[3],
note: row.cells[4],
})
}
}
if findings.is_empty() {
Ok(events)
} else {
Err(report_from(findings))
}
}
///|
fn valid_response_status(status : String) -> Bool {
status == "attempted" ||
status == "reached" ||
status == "acknowledged" ||
status == "failed"
}
///|
fn response_transition(previous : String, next : String) -> Bool {
match previous {
"pending" => next == "attempted" || next == "reached" || next == "failed"
"attempted" => next == "attempted" || next == "reached" || next == "failed"
"failed" => next == "attempted" || next == "reached"
"reached" => next == "reached" || next == "acknowledged" || next == "failed"
"acknowledged" => next == "acknowledged"
_ => false
}
}
///|
/// Replay events in file order for each affected shipment. Acknowledgement
/// requires a prior reached event; all timestamps must be monotonic per
/// shipment. Unknown shipment IDs and future ledger shipments fail closed.
pub fn review_responses(
ledger : Ledger,
request : RecallRequest,
events : Array[ResponseEvent],
) -> Result[ResponseReport, ValidationReport] {
let impact = match analyze(ledger, request) {
Err(validation) => return Err(validation)
Ok(value) => value
}
let allowed : Map[String, Bool] = Map([])
for shipment in impact.affected_shipments {
allowed[shipment.shipment_id] = true
}
let history : Map[String, Array[ResponseEvent]] = Map([])
let latest : Map[String, String] = Map([])
let latest_time : Map[String, Int64] = Map([])
let findings : Array[Diagnostic] = []
if events.length() > 100000 {
findings.push(
issue(
"error", "response-limit", "responses", "at most 100000 response events are supported",
),
)
}
for event in events {
if !allowed.contains(event.shipment_id) {
findings.push(
issue(
"error",
"unknown-response-shipment",
event.shipment_id,
"shipment is not affected at the recall cutoff",
),
)
continue
}
if !valid_response_status(event.status) {
findings.push(
issue(
"error",
"invalid-response-status",
event.shipment_id,
"status must be attempted, reached, acknowledged, or failed",
),
)
continue
}
if !valid_time(event.recorded_at) || event.recorded_at < request.as_of {
findings.push(
issue(
"error",
"invalid-response-time",
event.shipment_id,
"contact event must be at or after recall cutoff",
),
)
continue
}
if !valid_label(event.reference) {
findings.push(
issue(
"error",
"invalid-response-reference",
event.shipment_id,
"a source reference is required",
),
)
continue
}
if event.note.length() > 1024 ||
event.note.contains("\n") ||
event.note.contains("\r") {
findings.push(
issue(
"error",
"invalid-response-note",
event.shipment_id,
"note must be one line of at most 1024 characters",
),
)
continue
}
let previous = latest.get(event.shipment_id).unwrap_or("pending")
if !response_transition(previous, event.status) {
findings.push(
issue(
"error",
"invalid-response-transition",
event.shipment_id,
"status cannot follow \{previous}",
),
)
continue
}
if event.recorded_at <
latest_time.get(event.shipment_id).unwrap_or(request.as_of) {
findings.push(
issue(
"error",
"response-time-order",
event.shipment_id,
"events for one shipment must be chronological",
),
)
continue
}
let entries = history.get(event.shipment_id).unwrap_or([])
entries.push(event)
history[event.shipment_id] = entries
latest[event.shipment_id] = event.status
latest_time[event.shipment_id] = event.recorded_at
}
if !findings.is_empty() {
return Err(report_from(findings))
}
let rows : Array[ResponseRow] = []
let mut pending_count = 0
let mut attempted_count = 0
let mut reached_count = 0
let mut acknowledged_count = 0
let mut failed_count = 0
for shipment in impact.affected_shipments {
let status = latest.get(shipment.shipment_id).unwrap_or("pending")
match status {
"pending" => pending_count += 1
"attempted" => attempted_count += 1
"reached" => reached_count += 1
"acknowledged" => acknowledged_count += 1
"failed" => failed_count += 1
_ => ()
}
rows.push({
shipment_id: shipment.shipment_id,
consignee: shipment.consignee,
lot_id: shipment.lot_id,
quantity_g: shipment.quantity_g,
status,
history: history.get(shipment.shipment_id).unwrap_or([]),
})
}
Ok({
recall_id: request.id,
rows,
pending_count,
attempted_count,
reached_count,
acknowledged_count,
failed_count,
})
}
///|
pub fn render_responses_markdown(report : ResponseReport) -> String {
let out = StringBuilder()
out.write_string("# Contact progress: \{markdown_cell(report.recall_id)}\n\n")
out.write_string(
"These statuses summarize operator supplied evidence. No notice was sent by this tool.\n\n",
)
out.write_string(
"| Shipment | Consignee | Lot | Quantity (g) | Status | Last reference |\n",
)
out.write_string("| --- | --- | --- | ---: | --- | --- |\n")
for row in report.rows {
let last = if row.history.is_empty() {
""
} else {
row.history[row.history.length() - 1].reference
}
out.write_string(
"| \{markdown_cell(row.shipment_id)} | \{markdown_cell(row.consignee)} | \{markdown_cell(row.lot_id)} | \{row.quantity_g} | \{row.status} | \{markdown_cell(last)} |\n",
)
}
out.write_string(
"\nPending: \{report.pending_count}; attempted: \{report.attempted_count}; reached: \{report.reached_count}; acknowledged: \{report.acknowledged_count}; failed: \{report.failed_count}.\n",
)
out.to_string()
}