From 65aabf63815e0d264b63ebc18829338efe1a1535 Mon Sep 17 00:00:00 2001 From: kvillers Date: Thu, 30 Apr 2026 16:39:15 +0200 Subject: [PATCH] ok --- .env.example | 3 + .github/copilot-instructions.md | 72 ++++++++++++++++ .gitignore | 4 + Cargo.lock | 105 +++++++++++++++++++++++ Cargo.toml | 3 + src/api/client.rs | 6 ++ src/api/mod.rs | 3 + src/main.rs | 142 ++++++++++---------------------- src/tools/excel.rs | 107 ++++++++++++++++++++++++ src/tools/logger.rs | 61 ++++++++++++++ src/tools/mod.rs | 5 ++ 11 files changed, 414 insertions(+), 97 deletions(-) create mode 100644 .env.example create mode 100644 .github/copilot-instructions.md create mode 100644 src/api/client.rs create mode 100644 src/api/mod.rs create mode 100644 src/tools/excel.rs create mode 100644 src/tools/logger.rs create mode 100644 src/tools/mod.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..454e9e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +ANTARES_USERCODE=given_usercode +ANTARES_PASSWORD=given_password +OUT=out diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d798d65 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,72 @@ +# Copilot Instructions + +## Build, test, and lint + +- `cargo build` +- `cargo run` - requires a local `.env` file with `ANTARES_USERCODE` and `ANTARES_PASSWORD` +- `cargo test` +- `cargo test ` to run one test +- `cargo test -- --list` to discover test names; the repository currently has no defined tests +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets -- -D warnings` + +## High-level architecture + +This is a modular single-binary Rust utility that fetches Antares B2B product data and exports it to Excel. The codebase is organized into separate concerns: + +**Modules:** +- `src/main.rs` - orchestrates the pipeline: credential loading, API calls, JSON deserialization, and delegating to tools +- `src/api/client.rs` - handles URL construction for the Antares B2B API +- `src/tools/excel.rs` - implements Excel export with selective field mapping and business rule validation +- `src/tools/logger.rs` - provides dual output (terminal + file) logging to `log/{YYYY-MM-DD}.log` +- `src/template/antares.rs` - serde models with `#[serde(rename_all = "PascalCase")]`; top-level is `Antares` alias for `Vec` + +**Pipeline:** +1. Initialize logger (creates `log/` dir if needed) +2. Load credentials from `.env` via `dotenv` +3. Build Antares API URL using `src/api/client::make_url()` +4. Fetch with 300-second timeout via blocking `reqwest::blocking::Client` +5. Deserialize into typed `Antares` structs; if schema mismatch, preserve response as JSON/text +6. Write `antares.json` with pretty formatting +7. Export filtered rows to `out/antares_export.xlsx` via `src/tools/excel::export_to_excel()` +8. Log success/failure at each step + +**Logging:** +- All significant events logged to terminal and `log/{YYYY-MM-DD}.log` +- Specialized methods: `log_api_success()`, `log_api_failure()`, `log_export_success()`, `log_export_failure()` +- Directory created automatically if missing; logs appended daily by date + +## Key conventions + +**Credentials:** +- Load only via `ANTARES_USERCODE` and `ANTARES_PASSWORD` env vars from `.env` +- `.env.example` documents required variables +- No hardcoded credentials; missing vars cause graceful failure with instructions + +**Artifacts:** +- `antares.json` - saved at repo root after each successful API fetch +- `out/antares_export.xlsx` - Excel export; `out/` directory created auto if missing +- `log/{YYYY-MM-DD}.log` - daily logs; `log/` directory created auto if missing +- `.gitignore` excludes: `*.json`, `*.xlsx`, `.env`, `/out`, `/log` + +**Excel export (`src/tools/excel.rs`):** +- Not a generic dump; maps selected Antares fields to fixed column layout per business rules +- Rows skipped if `cikkszam` empty OR both name fields (`cikk_megnevezes_rovid`, `cikk_megnevezes`) empty +- `BESZCIKKNEV` prefers `cikk_megnevezes_rovid`, falls back to `cikk_megnevezes` +- `EGYSEGAR` prefers `netto_kisker_ar`; if `0.0`, searches `cikk_jellemzok` for entry with `jellemzo_nev == "Alap ár"` +- Headers are bold-formatted + +**API integration:** +- When extending the schema, add/modify structs in `src/template/antares.rs` first +- Avoid ad hoc JSON traversal in `main.rs`; keep deserialization flow centered on typed structs +- Preserve response on schema mismatch (save as JSON or text) unless deliberately changing error handling + +**Modularity:** +- Extend by adding new files to `src/tools/` (e.g., `csv.rs`, `database.rs`) +- Each tool should be self-contained and exposed via `src/tools/mod.rs` +- HTTP client logic stays in `src/api/`; reuse `make_request()` from `main.rs` for consistency + +**Blocking HTTP:** +- Uses `reqwest::blocking::Client` with 300-second timeout +- No async runtime; synchronous flow is intentional +- Only change to async if architecture overhaul is deliberate diff --git a/.gitignore b/.gitignore index f4ea39a..6a5f39b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.DS_Store /target *.json *.xlsx +.env +/out +/log diff --git a/Cargo.lock b/Cargo.lock index 22d8e94..9f45236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,10 +17,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "antares_get_data" version = "0.1.0" dependencies = [ + "chrono", + "dotenv", + "log", "reqwest", "rust_xlsxwriter", "serde", @@ -39,6 +51,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -85,6 +103,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -137,6 +168,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -449,6 +486,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -690,6 +751,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1460,6 +1530,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 00a7222..e11d808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,7 @@ reqwest = { version = "0.12", features = ["blocking"] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } rust_xlsxwriter = "0.66" +dotenv = "0.15" +chrono = "0.4" +log = "0.4" diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 0000000..34a8d04 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,6 @@ +pub fn make_url(base: &str, usercode: &str, password: &str, cikkszam: &str) -> String { + format!( + "{}&USERCODE={}&PASSWORD={}&PIN=&cikkszam={}", + base, usercode, password, cikkszam + ) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..5c2d311 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::make_url; diff --git a/src/main.rs b/src/main.rs index b44e96a..9f0bbe7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,102 +1,52 @@ +use reqwest::blocking::Client; use std::error::Error; use std::fs; use std::time::Duration; -use rust_xlsxwriter::*; +mod api; mod template; +mod tools; + +use api::make_url; use template::antares::Antares; +use tools::{Logger, export_to_excel}; -fn make_url(base: &str, usercode: &str, password: &str, cikkszam: &str) -> String { - format!( - "{}&USERCODE={}&PASSWORD={}&PIN=&cikkszam={}", - base, usercode, password, cikkszam - ) -} - -fn export_to_excel(items: &Antares, filename: &str) -> Result<(), Box> { - let mut workbook = Workbook::new(); - let sheet = workbook.add_worksheet(); - - let header_format = Format::new().set_bold(); - - // Write headers - sheet.write_string_with_format(0, 0, "BESZCIKKSZAM", &header_format)?; - sheet.write_string_with_format(0, 1, "GYCIKKSZAM", &header_format)?; - sheet.write_string_with_format(0, 2, "BESZCIKKNEV", &header_format)?; - sheet.write_string_with_format(0, 3, "GYARTO", &header_format)?; - sheet.write_string_with_format(0, 4, "CIKKAZON", &header_format)?; - sheet.write_string_with_format(0, 5, "KESZLET", &header_format)?; - sheet.write_string_with_format(0, 6, "ME", &header_format)?; - sheet.write_string_with_format(0, 7, "EGYSEGAR", &header_format)?; - - let mut row = 1u32; - for item in items { - // Skip if BESZCIKKSZAM is empty - let cikkszam = match &item.cikkszam { - Some(cs) if !cs.is_empty() => cs.clone(), - _ => continue, - }; - - // BESZCIKKNEV: cikk_megnevezes_rovid or cikk_megnevezes - let megnevezes = item - .cikk_megnevezes_rovid - .clone() - .or_else(|| item.cikk_megnevezes.clone()) - .unwrap_or_default(); - - // Skip if BESZCIKKNEV is empty - if megnevezes.is_empty() { - continue; - } - - // EGYSEGAR: netto_kisker_ar or find in cikk_jellemzok where jellemzo_nev = "Alap ár" - let mut unit_price = item.netto_kisker_ar.unwrap_or(0.0); - if unit_price == 0.0 { - if let Some(ref jellemzok) = item.cikk_jellemzok { - for jellemzo in jellemzok { - if let Some(ref nev) = jellemzo.jellemzo_nev { - if nev == "Alap ár" { - if let Some(ref ertek) = jellemzo.jellemzo_ertek { - unit_price = ertek.parse().unwrap_or(0.0); - break; - } - } - } - } - } - } - - // Write row data - sheet.write_string(row, 0, &cikkszam)?; - sheet.write_string(row, 1, &cikkszam)?; - sheet.write_string(row, 2, &megnevezes)?; - sheet.write_string(row, 3, "EGYEB")?; - sheet.write_string(row, 4, item.vonalkod.as_deref().unwrap_or(""))?; - sheet.write_number(row, 5, item.szabad_keszlet.unwrap_or(0) as f64)?; - sheet.write_string(row, 6, item.mennyisegi_egyseg_kod.as_deref().unwrap_or(""))?; - sheet.write_number(row, 7, unit_price)?; - - row += 1; - } - - workbook.save(filename)?; - println!("Excel file '{}' created with {} rows", filename, row - 1); - Ok(()) +fn make_request(url: &str) -> Result> { + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .build()?; + Ok(client.get(url).send()?.text()?) } fn main() -> Result<(), Box> { + let logger = Logger::init()?; + logger.log_info("Starting Antares data export"); + + dotenv::dotenv().ok(); + + let usercode = std::env::var("ANTARES_USERCODE") + .map_err(|_| "Missing environment variable: ANTARES_USERCODE. Create a .env file with ANTARES_USERCODE, ANTARES_PASSWORD, and OUT. See .env.example for reference.")?; + let password = std::env::var("ANTARES_PASSWORD") + .map_err(|_| "Missing environment variable: ANTARES_PASSWORD. Create a .env file with ANTARES_USERCODE, ANTARES_PASSWORD, and OUT. See .env.example for reference.")?; + let out_path = std::env::var("OUT") + .map_err(|_| "Missing environment variable: OUT. Create a .env file with ANTARES_USERCODE, ANTARES_PASSWORD, and OUT. See .env.example for reference.")?; + // Separate values let base = "https://b2b.antares.hu/I4stechproductionWebInt/IntAntaresWebCikkDataService.svc/webhttps/Get_CikkInfokWeb?SCHEMA=ANTARESINT"; - let usercode = "orinkkft"; - let password = "8E7DCB55F3B4ECC52D0451A1F8D851D0EF2193FCE4B83E528C18A8F68F8F2658EFCF2EA08563EC702A1C701934C3FBF5F6880BE894A16387326C7180A9A4C361"; let cikkszam = ""; // supply a value if needed - let url = make_url(base, usercode, password, cikkszam); + let url = make_url(base, &usercode, &password, cikkszam); - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(300)) - .build()?; - let response = client.get(&url).send()?.text()?; + let response = match make_request(&url) { + Ok(resp) => { + logger.log_info("API request sent successfully"); + resp + } + Err(e) => { + logger.log_api_failure(&e.to_string()); + return Err(e); + } + }; // Try to deserialize into strongly-typed `Antares` list. let items: Antares = match serde_json::from_str::(&response) { @@ -104,7 +54,7 @@ fn main() -> Result<(), Box> { // Re-serialize via the structs to ensure consistent formatting let pretty = serde_json::to_string_pretty(&items)?; fs::write("antares.json", pretty)?; - println!("Parsed {} items and saved to antares.json", items.len()); + logger.log_api_success(items.len()); items } Err(_) => { @@ -112,28 +62,26 @@ fn main() -> Result<(), Box> { if let Ok(json_val) = serde_json::from_str::(&response) { let pretty = serde_json::to_string_pretty(&json_val)?; fs::write("antares.json", pretty)?; - eprintln!( - "Warning: response didn't match expected struct but was valid JSON; saved pretty JSON to antares.json" - ); + logger.log_info("Response saved as JSON (schema mismatch)"); } else { fs::write("antares.json", &response)?; - eprintln!( - "Warning: response is not valid JSON; saved raw response to antares.json" - ); + logger.log_info("Response saved as raw text (not valid JSON)"); } Vec::new() } }; // Export to Excel - export_to_excel(&items, "antares_export.xlsx")?; - - // Example: print first item's Cikkszam if available - if let Some(first) = items.get(0) { - if let Some(ref cs) = first.cikkszam { - println!("First item Cikkszam: {}", cs); + match export_to_excel(&items, &out_path) { + Ok(_) => { + logger.log_export_success(&out_path, items.len()); + } + Err(e) => { + logger.log_export_failure(&e.to_string()); + return Err(e); } } + logger.log_info("Export process completed successfully"); Ok(()) } diff --git a/src/tools/excel.rs b/src/tools/excel.rs new file mode 100644 index 0000000..45d5eeb --- /dev/null +++ b/src/tools/excel.rs @@ -0,0 +1,107 @@ +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; +use rust_xlsxwriter::*; +use crate::template::antares::Antares; + +fn resolve_excel_path(path_str: &str) -> Result> { + let path = Path::new(path_str); + + // If path ends with .xlsx, treat it as a file path + if path_str.ends_with(".xlsx") { + // Create parent directories if needed + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + } + return Ok(path.to_path_buf()); + } + + // If path has an extension but it's not .xlsx, error out + if path.extension().is_some() { + return Err(format!( + "Invalid file extension. Expected .xlsx, got: {}", + path_str + ) + .into()); + } + + // Otherwise, treat as directory and create antares.xlsx inside + fs::create_dir_all(path)?; + Ok(path.join("antares.xlsx")) +} + +pub fn export_to_excel(items: &Antares, path_str: &str) -> Result<(), Box> { + let output_path = resolve_excel_path(path_str)?; + + let mut workbook = Workbook::new(); + let sheet = workbook.add_worksheet(); + + let header_format = Format::new().set_bold(); + + // Write headers + sheet.write_string_with_format(0, 0, "BESZCIKKSZAM", &header_format)?; + sheet.write_string_with_format(0, 1, "GYCIKKSZAM", &header_format)?; + sheet.write_string_with_format(0, 2, "BESZCIKKNEV", &header_format)?; + sheet.write_string_with_format(0, 3, "GYARTO", &header_format)?; + sheet.write_string_with_format(0, 4, "CIKKAZON", &header_format)?; + sheet.write_string_with_format(0, 5, "KESZLET", &header_format)?; + sheet.write_string_with_format(0, 6, "ME", &header_format)?; + sheet.write_string_with_format(0, 7, "EGYSEGAR", &header_format)?; + + let mut row = 1u32; + for item in items { + // Skip if BESZCIKKSZAM is empty + let cikkszam = match &item.cikkszam { + Some(cs) if !cs.is_empty() => cs.clone(), + _ => continue, + }; + + // BESZCIKKNEV: cikk_megnevezes_rovid or cikk_megnevezes + let megnevezes = item + .cikk_megnevezes_rovid + .clone() + .or_else(|| item.cikk_megnevezes.clone()) + .unwrap_or_default(); + + // Skip if BESZCIKKNEV is empty + if megnevezes.is_empty() { + continue; + } + + // EGYSEGAR: netto_kisker_ar or find in cikk_jellemzok where jellemzo_nev = "Alap ár" + let mut unit_price = item.netto_kisker_ar.unwrap_or(0.0); + if unit_price == 0.0 { + if let Some(ref jellemzok) = item.cikk_jellemzok { + for jellemzo in jellemzok { + if let Some(ref nev) = jellemzo.jellemzo_nev { + if nev == "Alap ár" { + if let Some(ref ertek) = jellemzo.jellemzo_ertek { + unit_price = ertek.parse().unwrap_or(0.0); + break; + } + } + } + } + } + } + + // Write row data + sheet.write_string(row, 0, &cikkszam)?; + sheet.write_string(row, 1, &cikkszam)?; + sheet.write_string(row, 2, &megnevezes)?; + sheet.write_string(row, 3, "EGYEB")?; + sheet.write_string(row, 4, item.vonalkod.as_deref().unwrap_or(""))?; + sheet.write_number(row, 5, item.szabad_keszlet.unwrap_or(0) as f64)?; + sheet.write_string(row, 6, item.mennyisegi_egyseg_kod.as_deref().unwrap_or(""))?; + sheet.write_number(row, 7, unit_price)?; + + row += 1; + } + + workbook.save(&output_path)?; + let display_path = output_path.display(); + println!("Excel file '{}' created with {} rows", display_path, row - 1); + Ok(()) +} diff --git a/src/tools/logger.rs b/src/tools/logger.rs new file mode 100644 index 0000000..9dedf92 --- /dev/null +++ b/src/tools/logger.rs @@ -0,0 +1,61 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use chrono::Local; + +pub struct Logger { + log_file: PathBuf, +} + +impl Logger { + pub fn init() -> std::io::Result { + let log_dir = PathBuf::from("log"); + fs::create_dir_all(&log_dir)?; + + let today = Local::now().format("%Y-%m-%d").to_string(); + let log_file = log_dir.join(format!("{}.log", today)); + + Ok(Logger { log_file }) + } + + pub fn log(&self, message: &str) { + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let formatted = format!("[{}] {}", timestamp, message); + + println!("{}", formatted); + + if let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.log_file) + { + let _ = writeln!(file, "{}", formatted); + } + } + + pub fn log_api_success(&self, item_count: usize) { + self.log(&format!( + "✓ API call successful - Fetched {} items", + item_count + )); + } + + pub fn log_api_failure(&self, error: &str) { + self.log(&format!("✗ API call failed: {}", error)); + } + + pub fn log_export_success(&self, filepath: &str, row_count: usize) { + self.log(&format!( + "✓ Export successful - {} rows exported to {}", + row_count, filepath + )); + } + + pub fn log_export_failure(&self, error: &str) { + self.log(&format!("✗ Export failed: {}", error)); + } + + pub fn log_info(&self, message: &str) { + self.log(&format!("ℹ {}", message)); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..8f09027 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,5 @@ +pub mod excel; +pub mod logger; + +pub use excel::export_to_excel; +pub use logger::Logger;