use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::Assertion;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum StepProcedureType {
#[default]
Inspection,
Observation,
Inquiry,
Confirmation,
Recalculation,
Reperformance,
AnalyticalProcedure,
Vouching,
Scanning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
#[default]
Planned,
InProgress,
Complete,
Deferred,
NotApplicable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum StepResult {
#[default]
Pass,
Fail,
Exception,
Inconclusive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditProcedureStep {
pub step_id: Uuid,
pub step_ref: String,
pub workpaper_id: Uuid,
pub engagement_id: Uuid,
pub step_number: u32,
pub description: String,
pub procedure_type: StepProcedureType,
pub assertion: Assertion,
pub planned_date: Option<NaiveDate>,
pub performed_date: Option<NaiveDate>,
pub performed_by: Option<String>,
pub performed_by_name: Option<String>,
pub status: StepStatus,
pub result: Option<StepResult>,
pub exception_noted: bool,
pub exception_description: Option<String>,
pub sample_id: Option<Uuid>,
pub evidence_ids: Vec<Uuid>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl AuditProcedureStep {
pub fn new(
workpaper_id: Uuid,
engagement_id: Uuid,
step_number: u32,
description: impl Into<String>,
procedure_type: StepProcedureType,
assertion: Assertion,
) -> Self {
let now = Utc::now();
let step_ref = format!("STEP-{}-{:02}", &workpaper_id.to_string()[..8], step_number,);
Self {
step_id: Uuid::new_v4(),
step_ref,
workpaper_id,
engagement_id,
step_number,
description: description.into(),
procedure_type,
assertion,
planned_date: None,
performed_date: None,
performed_by: None,
performed_by_name: None,
status: StepStatus::Planned,
result: None,
exception_noted: false,
exception_description: None,
sample_id: None,
evidence_ids: Vec::new(),
created_at: now,
updated_at: now,
}
}
pub fn with_sample(mut self, sample_id: Uuid) -> Self {
self.sample_id = Some(sample_id);
self
}
pub fn with_evidence(mut self, evidence_ids: Vec<Uuid>) -> Self {
self.evidence_ids = evidence_ids;
self
}
pub fn perform(&mut self, by: String, by_name: String, date: NaiveDate, result: StepResult) {
self.performed_by = Some(by);
self.performed_by_name = Some(by_name);
self.performed_date = Some(date);
self.result = Some(result);
self.exception_noted = matches!(result, StepResult::Exception);
self.status = StepStatus::Complete;
self.updated_at = Utc::now();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn make_step() -> AuditProcedureStep {
AuditProcedureStep::new(
Uuid::new_v4(),
Uuid::new_v4(),
1,
"Inspect invoices for proper authorisation",
StepProcedureType::Inspection,
Assertion::Occurrence,
)
}
#[test]
fn test_new_step() {
let step = make_step();
assert_eq!(step.step_number, 1);
assert_eq!(step.status, StepStatus::Planned);
assert!(step.result.is_none());
assert!(!step.exception_noted);
assert!(step.step_ref.starts_with("STEP-"));
assert!(step.step_ref.ends_with("-01"));
}
#[test]
fn test_perform_sets_fields() {
let mut step = make_step();
let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
step.perform("u123".into(), "Alice Audit".into(), date, StepResult::Pass);
assert_eq!(step.status, StepStatus::Complete);
assert_eq!(step.result, Some(StepResult::Pass));
assert_eq!(step.performed_by.as_deref(), Some("u123"));
assert_eq!(step.performed_by_name.as_deref(), Some("Alice Audit"));
assert_eq!(step.performed_date, Some(date));
assert!(!step.exception_noted);
}
#[test]
fn test_perform_exception_noted() {
let mut step = make_step();
let date = NaiveDate::from_ymd_opt(2026, 3, 2).unwrap();
step.perform(
"u456".into(),
"Bob Check".into(),
date,
StepResult::Exception,
);
assert!(step.exception_noted);
assert_eq!(step.result, Some(StepResult::Exception));
}
#[test]
fn test_with_sample() {
let sample_id = Uuid::new_v4();
let step = make_step().with_sample(sample_id);
assert_eq!(step.sample_id, Some(sample_id));
}
#[test]
fn test_with_evidence() {
let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
let step = make_step().with_evidence(ids.clone());
assert_eq!(step.evidence_ids, ids);
}
#[test]
fn test_step_status_serde() {
let statuses = [
StepStatus::Planned,
StepStatus::InProgress,
StepStatus::Complete,
StepStatus::Deferred,
StepStatus::NotApplicable,
];
for s in &statuses {
let json = serde_json::to_string(s).unwrap();
let back: StepStatus = serde_json::from_str(&json).unwrap();
assert_eq!(back, *s);
}
}
#[test]
fn test_step_result_serde() {
let results = [
StepResult::Pass,
StepResult::Fail,
StepResult::Exception,
StepResult::Inconclusive,
];
for r in &results {
let json = serde_json::to_string(r).unwrap();
let back: StepResult = serde_json::from_str(&json).unwrap();
assert_eq!(back, *r);
}
}
#[test]
fn test_procedure_type_serde() {
let types = [
StepProcedureType::Inspection,
StepProcedureType::Observation,
StepProcedureType::Inquiry,
StepProcedureType::Confirmation,
StepProcedureType::Recalculation,
StepProcedureType::Reperformance,
StepProcedureType::AnalyticalProcedure,
StepProcedureType::Vouching,
StepProcedureType::Scanning,
];
for t in &types {
let json = serde_json::to_string(t).unwrap();
let back: StepProcedureType = serde_json::from_str(&json).unwrap();
assert_eq!(back, *t);
}
}
}