Add documentation comments to codebase
Prioritize AI agent-friendly comments explaining: - Data model rationale (customers, cards, transactions relationships) - Business rules (anonymized cards, customer_number requirements) - Config file loading order and environment mapping - Import pipeline phases and CSV column mapping - Database operation behaviors (upsert, reset implications) - SQL query rationale and data filtering rules
This commit is contained in:
@@ -2,12 +2,25 @@ use crate::config::Config;
|
|||||||
use crate::db::Repository;
|
use crate::db::Repository;
|
||||||
use sqlx::mysql::MySqlPoolOptions;
|
use sqlx::mysql::MySqlPoolOptions;
|
||||||
|
|
||||||
|
/// Sets up the database for the specified environment.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This creates:
|
||||||
|
/// 1. The database (if not exists)
|
||||||
|
/// 2. customers table - stores fleet customers
|
||||||
|
/// 3. cards table - stores known cards linked to customers
|
||||||
|
/// 4. transactions table - stores all transactions
|
||||||
|
///
|
||||||
|
/// Uses CREATE TABLE IF NOT EXISTS, so it's idempotent.
|
||||||
|
/// Note: We connect to the server without specifying a database first,
|
||||||
|
/// then create the database, then create tables in that database.
|
||||||
pub async fn run_db_setup(repo: &Repository, config: &Config) -> anyhow::Result<()> {
|
pub async fn run_db_setup(repo: &Repository, config: &Config) -> anyhow::Result<()> {
|
||||||
let env = &config.env;
|
let env = &config.env;
|
||||||
println!("Setting up database for environment: {}", env.as_str());
|
println!("Setting up database for environment: {}", env.as_str());
|
||||||
println!("Database: {}", env.database_name());
|
println!("Database: {}", env.database_name());
|
||||||
|
|
||||||
let database_url = &config.database.connection_url();
|
let database_url = &config.database.connection_url();
|
||||||
|
// Strip database name to connect to server without selecting a database
|
||||||
|
// AI AGENT NOTE: MariaDB requires connecting without a database to create one
|
||||||
let base_url = database_url.trim_end_matches(env.database_name());
|
let base_url = database_url.trim_end_matches(env.database_name());
|
||||||
|
|
||||||
let setup_pool = MySqlPoolOptions::new()
|
let setup_pool = MySqlPoolOptions::new()
|
||||||
@@ -26,6 +39,7 @@ pub async fn run_db_setup(repo: &Repository, config: &Config) -> anyhow::Result<
|
|||||||
|
|
||||||
drop(setup_pool);
|
drop(setup_pool);
|
||||||
|
|
||||||
|
// Now connect to the created database and create tables
|
||||||
println!("Creating tables...");
|
println!("Creating tables...");
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -95,6 +109,14 @@ pub async fn run_db_setup(repo: &Repository, config: &Config) -> anyhow::Result<
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the database by dropping and recreating it.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This is a destructive operation that:
|
||||||
|
/// 1. Drops the database if it exists (loses all data!)
|
||||||
|
/// 2. Creates a fresh database
|
||||||
|
/// 3. Does NOT create tables (run db setup afterwards)
|
||||||
|
///
|
||||||
|
/// Use this when schema changes require a fresh database.
|
||||||
pub async fn run_db_reset(config: &Config) -> anyhow::Result<()> {
|
pub async fn run_db_reset(config: &Config) -> anyhow::Result<()> {
|
||||||
let env = &config.env;
|
let env = &config.env;
|
||||||
println!("Resetting database for environment: {}", env.as_str());
|
println!("Resetting database for environment: {}", env.as_str());
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ use std::collections::HashMap;
|
|||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Imports transactions from a CSV file into the database.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This is the main data import function. It handles:
|
||||||
|
///
|
||||||
|
/// 1. PARSING: Reads tab-separated CSV and extracts transaction data
|
||||||
|
/// 2. FILTERING: Only includes transactions where:
|
||||||
|
/// - amount > 0 (excludes authorizations/cancellations)
|
||||||
|
/// - customer_number is NOT empty (excludes retail transactions)
|
||||||
|
/// 3. COLLECTION: Gathers unique customers and known cards first
|
||||||
|
/// 4. UPSERT: Creates/updates customer and card records
|
||||||
|
/// 5. BATCH INSERT: Inserts transactions in batches of 500
|
||||||
|
///
|
||||||
|
/// Business Rules:
|
||||||
|
/// - Transactions with empty customer_number are stored but not linked to customers
|
||||||
|
/// - Only "known" cards (with full card numbers) are stored in the cards table
|
||||||
|
/// - Anonymized cards (with asterisks) are stored only in transactions.card_number
|
||||||
pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()> {
|
pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()> {
|
||||||
println!("Reading CSV file: {:?}", csv_path);
|
println!("Reading CSV file: {:?}", csv_path);
|
||||||
|
|
||||||
@@ -17,21 +33,30 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
|
|||||||
.from_reader(file);
|
.from_reader(file);
|
||||||
|
|
||||||
let mut transactions = Vec::new();
|
let mut transactions = Vec::new();
|
||||||
|
// Tracks unique customers with their card_report_group
|
||||||
|
// Key: customer_number, Value: card_report_group
|
||||||
let mut seen_customers: HashMap<String, u8> = HashMap::new();
|
let mut seen_customers: HashMap<String, u8> = HashMap::new();
|
||||||
|
// Tracks unique known cards and their customer
|
||||||
|
// Key: card_number, Value: customer_number
|
||||||
|
// AI AGENT NOTE: Only full card numbers are stored here, not anonymized ones
|
||||||
let mut seen_cards: HashMap<String, String> = HashMap::new();
|
let mut seen_cards: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
for result in rdr.records() {
|
for result in rdr.records() {
|
||||||
let record = result?;
|
let record = result?;
|
||||||
if let Some(tx) = parse_record(&record)? {
|
if let Some(tx) = parse_record(&record)? {
|
||||||
|
// Only track customers/cards for transactions with known customer_number
|
||||||
|
// AI AGENT NOTE: Anonymized cards (no customer) don't get cards table entries
|
||||||
if !tx.customer_number.is_empty() {
|
if !tx.customer_number.is_empty() {
|
||||||
let card_report_group: u8 = tx.card_report_group_number.parse().unwrap_or(0);
|
let card_report_group: u8 = tx.card_report_group_number.parse().unwrap_or(0);
|
||||||
if !seen_customers.contains_key(&tx.customer_number) {
|
if !seen_customers.contains_key(&tx.customer_number) {
|
||||||
seen_customers.insert(tx.customer_number.clone(), card_report_group);
|
seen_customers.insert(tx.customer_number.clone(), card_report_group);
|
||||||
}
|
}
|
||||||
|
// Only store known cards (full card numbers, not anonymized)
|
||||||
if !seen_cards.contains_key(&tx.card_number) {
|
if !seen_cards.contains_key(&tx.card_number) {
|
||||||
seen_cards.insert(tx.card_number.clone(), tx.customer_number.clone());
|
seen_cards.insert(tx.card_number.clone(), tx.customer_number.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ALL transactions are stored, including anonymized ones
|
||||||
transactions.push(tx);
|
transactions.push(tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +65,7 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
|
|||||||
println!("Unique customers: {}", seen_customers.len());
|
println!("Unique customers: {}", seen_customers.len());
|
||||||
println!("Unique known cards: {}", seen_cards.len());
|
println!("Unique known cards: {}", seen_cards.len());
|
||||||
|
|
||||||
|
// Phase 1: Import customers
|
||||||
println!("\nImporting customers...");
|
println!("\nImporting customers...");
|
||||||
let mut customer_ids: HashMap<String, u32> = HashMap::new();
|
let mut customer_ids: HashMap<String, u32> = HashMap::new();
|
||||||
for (customer_number, card_report_group) in &seen_customers {
|
for (customer_number, card_report_group) in &seen_customers {
|
||||||
@@ -52,6 +78,8 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
|
|||||||
println!(" Customer {} -> id {}", customer_number, id);
|
println!(" Customer {} -> id {}", customer_number, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2: Import cards (only known cards)
|
||||||
|
// AI AGENT NOTE: This links cards to customers. Anonymized cards are NOT inserted.
|
||||||
println!("\nImporting cards...");
|
println!("\nImporting cards...");
|
||||||
let mut card_ids: HashMap<String, u32> = HashMap::new();
|
let mut card_ids: HashMap<String, u32> = HashMap::new();
|
||||||
for (card_number, customer_number) in &seen_cards {
|
for (card_number, customer_number) in &seen_cards {
|
||||||
@@ -66,12 +94,16 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3: Import transactions
|
||||||
|
// AI AGENT NOTE: All transactions are imported, but only those with known customers
|
||||||
|
// have a customer_id. Anonymized transactions have customer_id = NULL.
|
||||||
println!("\nImporting transactions...");
|
println!("\nImporting transactions...");
|
||||||
let batch_size = 500;
|
let batch_size = 500;
|
||||||
let mut total_inserted = 0u64;
|
let mut total_inserted = 0u64;
|
||||||
let mut batch: Vec<NewTransaction> = Vec::with_capacity(batch_size);
|
let mut batch: Vec<NewTransaction> = Vec::with_capacity(batch_size);
|
||||||
|
|
||||||
for tx in transactions {
|
for tx in transactions {
|
||||||
|
// customer_id is None if customer_number was empty (anonymized transaction)
|
||||||
let customer_id = customer_ids.get(&tx.customer_number).copied();
|
let customer_id = customer_ids.get(&tx.customer_number).copied();
|
||||||
|
|
||||||
let new_tx = NewTransaction {
|
let new_tx = NewTransaction {
|
||||||
@@ -82,13 +114,13 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
|
|||||||
price: tx.price,
|
price: tx.price,
|
||||||
quality_code: tx.quality,
|
quality_code: tx.quality,
|
||||||
quality_name: tx.quality_name,
|
quality_name: tx.quality_name,
|
||||||
card_number: tx.card_number,
|
card_number: tx.card_number, // Always stored, even for anonymized cards
|
||||||
station: tx.station,
|
station: tx.station,
|
||||||
terminal: tx.terminal,
|
terminal: tx.terminal,
|
||||||
pump: tx.pump,
|
pump: tx.pump,
|
||||||
receipt: tx.receipt,
|
receipt: tx.receipt,
|
||||||
control_number: if tx.control_number.is_empty() { None } else { Some(tx.control_number) },
|
control_number: if tx.control_number.is_empty() { None } else { Some(tx.control_number) },
|
||||||
customer_id,
|
customer_id, // NULL for anonymized transactions
|
||||||
};
|
};
|
||||||
|
|
||||||
batch.push(new_tx);
|
batch.push(new_tx);
|
||||||
@@ -101,6 +133,7 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert remaining batch
|
||||||
if !batch.is_empty() {
|
if !batch.is_empty() {
|
||||||
let inserted = repo.insert_transactions_batch(&batch).await?;
|
let inserted = repo.insert_transactions_batch(&batch).await?;
|
||||||
total_inserted += inserted;
|
total_inserted += inserted;
|
||||||
@@ -112,6 +145,11 @@ pub async fn run_import(csv_path: &Path, repo: &Repository) -> anyhow::Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a parsed transaction from CSV.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This is an intermediate struct for CSV parsing.
|
||||||
|
/// It mirrors the CSV column structure and is converted to NewTransaction
|
||||||
|
/// for database insertion.
|
||||||
struct CsvTransaction {
|
struct CsvTransaction {
|
||||||
date: NaiveDateTime,
|
date: NaiveDateTime,
|
||||||
batch_number: String,
|
batch_number: String,
|
||||||
@@ -134,14 +172,39 @@ fn get_field(record: &csv::StringRecord, index: usize) -> &str {
|
|||||||
record.get(index).unwrap_or("")
|
record.get(index).unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a single record from the CSV file.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Returns None if:
|
||||||
|
/// - amount <= 0 (excludes authorizations/cancellations)
|
||||||
|
/// - date parsing fails
|
||||||
|
///
|
||||||
|
/// CSV Column Mapping (0-indexed):
|
||||||
|
/// 0: Date (multiple formats supported)
|
||||||
|
/// 1: Batch number
|
||||||
|
/// 2: Amount
|
||||||
|
/// 3: Volume
|
||||||
|
/// 4: Price
|
||||||
|
/// 5: Quality code
|
||||||
|
/// 6: Quality name
|
||||||
|
/// 7: Card number
|
||||||
|
/// 8: Card type (ignored - redundant)
|
||||||
|
/// 9: Customer number
|
||||||
|
/// 10: Station
|
||||||
|
/// 11: Terminal
|
||||||
|
/// 12: Pump
|
||||||
|
/// 13: Receipt
|
||||||
|
/// 14: Card report group number
|
||||||
|
/// 15: Control number
|
||||||
fn parse_record(record: &csv::StringRecord) -> anyhow::Result<Option<CsvTransaction>> {
|
fn parse_record(record: &csv::StringRecord) -> anyhow::Result<Option<CsvTransaction>> {
|
||||||
let date_str = get_field(record, 0);
|
let date_str = get_field(record, 0);
|
||||||
|
// Try multiple date formats since source data may vary
|
||||||
let date = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S")
|
let date = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S")
|
||||||
.or_else(|_| NaiveDateTime::parse_from_str(date_str, "%m/%d/%Y %I:%M:%S %p"))
|
.or_else(|_| NaiveDateTime::parse_from_str(date_str, "%m/%d/%Y %I:%M:%S %p"))
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to parse date '{}': {}", date_str, e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to parse date '{}': {}", date_str, e))?;
|
||||||
|
|
||||||
let amount: f64 = get_field(record, 2).parse().unwrap_or(0.0);
|
let amount: f64 = get_field(record, 2).parse().unwrap_or(0.0);
|
||||||
|
|
||||||
|
// Skip zero/negative amounts (authorizations, cancellations)
|
||||||
if amount <= 0.0 {
|
if amount <= 0.0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Environment selection for multi-database setup.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This enum controls which database configuration is loaded.
|
||||||
|
/// Each environment maps to a different database name:
|
||||||
|
/// - Prod: rusty_petroleum (production data)
|
||||||
|
/// - Dev: rusty_petroleum_dev (development)
|
||||||
|
/// - Test: rusty_petroleum_test (testing)
|
||||||
|
///
|
||||||
|
/// The environment is set via the --env CLI flag and defaults to Prod.
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub enum Env {
|
pub enum Env {
|
||||||
|
/// Production environment - default for safety (requires explicit --env for dev/test)
|
||||||
#[default]
|
#[default]
|
||||||
Prod,
|
Prod,
|
||||||
|
/// Development environment - rusty_petroleum_dev
|
||||||
Dev,
|
Dev,
|
||||||
|
/// Testing environment - rusty_petroleum_test
|
||||||
Test,
|
Test,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Env {
|
impl Env {
|
||||||
|
/// Returns the environment name as a string for CLI/config file naming.
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Env::Prod => "prod",
|
Env::Prod => "prod",
|
||||||
@@ -18,6 +31,12 @@ impl Env {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the database name for this environment.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Database naming convention:
|
||||||
|
/// - Production: rusty_petroleum (no suffix)
|
||||||
|
/// - Development: rusty_petroleum_dev
|
||||||
|
/// - Testing: rusty_petroleum_test
|
||||||
pub fn database_name(&self) -> &str {
|
pub fn database_name(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Env::Prod => "rusty_petroleum",
|
Env::Prod => "rusty_petroleum",
|
||||||
@@ -30,6 +49,8 @@ impl Env {
|
|||||||
impl std::str::FromStr for Env {
|
impl std::str::FromStr for Env {
|
||||||
type Err = String;
|
type Err = String;
|
||||||
|
|
||||||
|
/// Parses environment from CLI argument.
|
||||||
|
/// Accepts both short and long forms for flexibility.
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"prod" | "production" => Ok(Env::Prod),
|
"prod" | "production" => Ok(Env::Prod),
|
||||||
@@ -40,12 +61,14 @@ impl std::str::FromStr for Env {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Root configuration struct containing environment and database settings.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub env: Env,
|
pub env: Env,
|
||||||
pub database: DatabaseConfig,
|
pub database: DatabaseConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Database connection configuration.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DatabaseConfig {
|
pub struct DatabaseConfig {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -56,6 +79,10 @@ pub struct DatabaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DatabaseConfig {
|
impl DatabaseConfig {
|
||||||
|
/// Builds a MySQL connection URL from configuration.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Handles empty password by omitting it from URL.
|
||||||
|
/// This allows connections without passwords (e.g., local development).
|
||||||
pub fn connection_url(&self) -> String {
|
pub fn connection_url(&self) -> String {
|
||||||
if self.password.is_empty() {
|
if self.password.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
@@ -72,6 +99,17 @@ impl DatabaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
/// Loads configuration for the specified environment.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Config file loading order (first existing file wins):
|
||||||
|
/// 1. config.toml - local override (gitignored, for personal overrides)
|
||||||
|
/// 2. config.<env>.toml - environment-specific (gitignored)
|
||||||
|
/// 3. config.example.toml - fallback template (tracked in git)
|
||||||
|
///
|
||||||
|
/// This allows:
|
||||||
|
/// - Committed example config as reference
|
||||||
|
/// - Environment-specific configs for different developers
|
||||||
|
/// - Local overrides without modifying tracked files
|
||||||
pub fn load(env: Env) -> anyhow::Result<Self> {
|
pub fn load(env: Env) -> anyhow::Result<Self> {
|
||||||
let config_path = Path::new("config.toml");
|
let config_path = Path::new("config.toml");
|
||||||
let example_path = Path::new("config.example.toml");
|
let example_path = Path::new("config.example.toml");
|
||||||
@@ -94,6 +132,7 @@ impl Config {
|
|||||||
Self::load_from_path(path, env)
|
Self::load_from_path(path, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads configuration from a specific file path.
|
||||||
pub fn load_from_path(path: &Path, env: Env) -> anyhow::Result<Self> {
|
pub fn load_from_path(path: &Path, env: Env) -> anyhow::Result<Self> {
|
||||||
let contents = fs::read_to_string(path)
|
let contents = fs::read_to_string(path)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read config file {:?}: {}", path, e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to read config file {:?}: {}", path, e))?;
|
||||||
@@ -107,6 +146,8 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Intermediate struct for TOML deserialization.
|
||||||
|
/// AI AGENT NOTE: This mirrors the [database] section of config.toml.
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct TomlConfig {
|
struct TomlConfig {
|
||||||
database: TomlDatabaseConfig,
|
database: TomlDatabaseConfig,
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ use chrono::{DateTime, NaiveDateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
/// Represents a fleet/corporate customer in the system.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Customers are identified by customer_number and have
|
||||||
|
/// associated cards. Not all transactions have a customer (retail/anonymous).
|
||||||
|
/// The card_report_group indicates customer classification:
|
||||||
|
/// - 1: Fleet customers (have customer_number)
|
||||||
|
/// - 3, 4: Retail customers (no customer_number)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
pub struct Customer {
|
pub struct Customer {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -12,12 +19,22 @@ pub struct Customer {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input struct for creating a new customer during import.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewCustomer {
|
pub struct NewCustomer {
|
||||||
pub customer_number: String,
|
pub customer_number: String,
|
||||||
pub card_report_group: u8,
|
pub card_report_group: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a fuel card belonging to a customer.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This table stores the authoritative mapping from card_number
|
||||||
|
/// to customer. Only "known" cards (cards belonging to fleet customers) are
|
||||||
|
/// stored here. Anonymized cards (with asterisks like "554477******9952") are
|
||||||
|
/// NOT stored in this table - they appear directly in transactions.card_number.
|
||||||
|
///
|
||||||
|
/// Design rationale: Cards table contains ONLY known cards. This keeps the
|
||||||
|
/// cards table small and ensures every card has a valid customer relationship.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
pub struct Card {
|
pub struct Card {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -27,12 +44,24 @@ pub struct Card {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input struct for creating a new card during import.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewCard {
|
pub struct NewCard {
|
||||||
pub card_number: String,
|
pub card_number: String,
|
||||||
pub customer_id: u32,
|
pub customer_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a fuel transaction in the database.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This table stores ALL transactions, both anonymous and known:
|
||||||
|
/// - card_number: Always populated (even for anonymized cards)
|
||||||
|
/// - customer_id: NULL for anonymous transactions, FK to customers for fleet
|
||||||
|
///
|
||||||
|
/// To find a customer's transactions, use:
|
||||||
|
/// SELECT * FROM transactions WHERE customer_id = <customer_id>
|
||||||
|
///
|
||||||
|
/// To find all transactions for a card:
|
||||||
|
/// SELECT * FROM transactions WHERE card_number = '<card_number>'
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
pub struct Transaction {
|
pub struct Transaction {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -49,10 +78,14 @@ pub struct Transaction {
|
|||||||
pub pump: String,
|
pub pump: String,
|
||||||
pub receipt: String,
|
pub receipt: String,
|
||||||
pub control_number: Option<String>,
|
pub control_number: Option<String>,
|
||||||
pub customer_id: Option<u32>,
|
pub customer_id: Option<u32>, // NULL for anonymized transactions
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input struct for inserting a new transaction.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Uses f64 for numeric fields during construction (from CSV parsing),
|
||||||
|
/// but BigDecimal is used in the database for precision.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewTransaction {
|
pub struct NewTransaction {
|
||||||
pub transaction_date: NaiveDateTime,
|
pub transaction_date: NaiveDateTime,
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ use crate::db::models::{Card, Customer, NewCard, NewCustomer, NewTransaction, Tr
|
|||||||
use bigdecimal::BigDecimal;
|
use bigdecimal::BigDecimal;
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
|
/// Repository for database operations.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This is the main data access layer. All database operations
|
||||||
|
/// should go through this struct. It wraps a MySQL connection pool and provides
|
||||||
|
/// methods for CRUD operations on customers, cards, and transactions.
|
||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
pool: MySqlPool,
|
pool: MySqlPool,
|
||||||
}
|
}
|
||||||
@@ -15,6 +20,11 @@ impl Repository {
|
|||||||
&self.pool
|
&self.pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upserts a customer by customer_number.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Uses ON DUPLICATE KEY UPDATE to handle re-imports.
|
||||||
|
/// If customer exists, only card_report_group is updated (it's derived from
|
||||||
|
/// transaction data and may differ between batches).
|
||||||
pub async fn upsert_customer(&self, customer: &NewCustomer) -> anyhow::Result<u32> {
|
pub async fn upsert_customer(&self, customer: &NewCustomer) -> anyhow::Result<u32> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -40,6 +50,7 @@ impl Repository {
|
|||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds a customer by their customer_number.
|
||||||
pub async fn find_customer_by_number(
|
pub async fn find_customer_by_number(
|
||||||
&self,
|
&self,
|
||||||
customer_number: &str,
|
customer_number: &str,
|
||||||
@@ -56,6 +67,13 @@ impl Repository {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upserts a card by card_number.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Cards are only created for known customers (fleet accounts).
|
||||||
|
/// Anonymized cards are NOT inserted here - they only appear in transactions.
|
||||||
|
///
|
||||||
|
/// Design: This ensures cards.customer_id is always NOT NULL, enforcing
|
||||||
|
/// the business rule that every card must belong to a customer.
|
||||||
pub async fn upsert_card(&self, card: &NewCard) -> anyhow::Result<u32> {
|
pub async fn upsert_card(&self, card: &NewCard) -> anyhow::Result<u32> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -81,6 +99,10 @@ impl Repository {
|
|||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds a card by card_number.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Returns None for anonymized cards (e.g., "554477******9952")
|
||||||
|
/// since these are not stored in the cards table.
|
||||||
pub async fn find_card_by_number(&self, card_number: &str) -> anyhow::Result<Option<Card>> {
|
pub async fn find_card_by_number(&self, card_number: &str) -> anyhow::Result<Option<Card>> {
|
||||||
let result = sqlx::query_as(
|
let result = sqlx::query_as(
|
||||||
"SELECT id, card_number, customer_id, created_at, updated_at
|
"SELECT id, card_number, customer_id, created_at, updated_at
|
||||||
@@ -94,6 +116,14 @@ impl Repository {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts multiple transactions in a single batch for performance.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Uses bulk INSERT for efficiency. The batch size is
|
||||||
|
/// controlled by the caller (typically 500 rows per batch).
|
||||||
|
///
|
||||||
|
/// IMPORTANT: This constructs raw SQL with escaped values. While sqlx doesn't
|
||||||
|
/// support parameterized bulk insert, we escape single quotes to prevent SQL
|
||||||
|
/// injection in string fields.
|
||||||
pub async fn insert_transactions_batch(
|
pub async fn insert_transactions_batch(
|
||||||
&self,
|
&self,
|
||||||
transactions: &[NewTransaction],
|
transactions: &[NewTransaction],
|
||||||
@@ -134,6 +164,10 @@ impl Repository {
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieves all transactions for a customer within a date range.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Only returns transactions for known customers (customer_id IS NOT NULL).
|
||||||
|
/// Anonymous transactions are excluded from invoices.
|
||||||
pub async fn get_customer_invoice(
|
pub async fn get_customer_invoice(
|
||||||
&self,
|
&self,
|
||||||
customer_number: &str,
|
customer_number: &str,
|
||||||
@@ -162,6 +196,10 @@ impl Repository {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets sales summary grouped by product (quality_name).
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Includes ALL transactions (both anonymous and known).
|
||||||
|
/// Useful for overall sales reporting.
|
||||||
pub async fn get_sales_summary_by_product(
|
pub async fn get_sales_summary_by_product(
|
||||||
&self,
|
&self,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
@@ -183,6 +221,10 @@ impl Repository {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets sales summary grouped by customer.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Only includes known customers (JOIN with customers table).
|
||||||
|
/// Anonymous transactions are excluded since they have no customer_id.
|
||||||
pub async fn get_sales_summary_by_customer(
|
pub async fn get_sales_summary_by_customer(
|
||||||
&self,
|
&self,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
@@ -207,6 +249,9 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Summary of sales by product (quality_name).
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Used for reporting total sales per product type.
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct ProductSummary {
|
pub struct ProductSummary {
|
||||||
pub quality_name: String,
|
pub quality_name: String,
|
||||||
@@ -215,6 +260,9 @@ pub struct ProductSummary {
|
|||||||
pub total_volume: BigDecimal,
|
pub total_volume: BigDecimal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Summary of sales by customer.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Used for reporting total sales per fleet customer.
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct CustomerSummary {
|
pub struct CustomerSummary {
|
||||||
pub customer_number: String,
|
pub customer_number: String,
|
||||||
|
|||||||
13
src/main.rs
13
src/main.rs
@@ -18,6 +18,10 @@ fn fmt(v: f64) -> String {
|
|||||||
format!("{:.2}", v)
|
format!("{:.2}", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalizes CSV date format and cleans the data.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: Input CSV may have dates in different formats (MM/DD/YYYY or YYYY-MM-DD).
|
||||||
|
/// This function standardizes to YYYY-MM-DD HH:MM:SS format for consistent parsing.
|
||||||
fn clean_csv_file(
|
fn clean_csv_file(
|
||||||
input_path: &Path,
|
input_path: &Path,
|
||||||
output_path: &Path,
|
output_path: &Path,
|
||||||
@@ -233,6 +237,11 @@ struct CustomerTemplate {
|
|||||||
generated_date: String,
|
generated_date: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses the --env flag from CLI arguments.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: The --env flag can appear anywhere in the argument list.
|
||||||
|
/// Returns the environment and the index of the "--env" flag (for removal).
|
||||||
|
/// Defaults to Prod if not specified.
|
||||||
fn parse_env_flag(args: &[String]) -> (Env, usize) {
|
fn parse_env_flag(args: &[String]) -> (Env, usize) {
|
||||||
for (i, arg) in args.iter().enumerate() {
|
for (i, arg) in args.iter().enumerate() {
|
||||||
if arg == "--env" && i + 1 < args.len() {
|
if arg == "--env" && i + 1 < args.len() {
|
||||||
@@ -248,6 +257,10 @@ fn parse_env_flag(args: &[String]) -> (Env, usize) {
|
|||||||
(Env::default(), 0)
|
(Env::default(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes --env and its value from argument list.
|
||||||
|
///
|
||||||
|
/// AI AGENT NOTE: This allows the --env flag to appear anywhere in the
|
||||||
|
/// command without affecting positional argument parsing.
|
||||||
fn remove_env_flags(args: &[String]) -> Vec<String> {
|
fn remove_env_flags(args: &[String]) -> Vec<String> {
|
||||||
let (_, env_idx) = parse_env_flag(args);
|
let (_, env_idx) = parse_env_flag(args);
|
||||||
let mut result = Vec::with_capacity(args.len());
|
let mut result = Vec::with_capacity(args.len());
|
||||||
|
|||||||
Reference in New Issue
Block a user