This commit is contained in:
Villers Krisztián 2026-04-30 16:39:15 +02:00
parent e652289ac4
commit 65aabf6381
11 changed files with 414 additions and 97 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
ANTARES_USERCODE=given_usercode
ANTARES_PASSWORD=given_password
OUT=out

72
.github/copilot-instructions.md vendored Normal file
View File

@ -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 <test_name>` 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<AntaresItem>`
**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

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
.DS_Store
/target
*.json
*.xlsx
.env
/out
/log

105
Cargo.lock generated
View File

@ -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"

View File

@ -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"

6
src/api/client.rs Normal file
View File

@ -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
)
}

3
src/api/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod client;
pub use client::make_url;

View File

@ -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<dyn Error>> {
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<String, Box<dyn Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(300))
.build()?;
Ok(client.get(url).send()?.text()?)
}
fn main() -> Result<(), Box<dyn Error>> {
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::<Antares>(&response) {
@ -104,7 +54,7 @@ fn main() -> Result<(), Box<dyn Error>> {
// 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<dyn Error>> {
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&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(())
}

107
src/tools/excel.rs Normal file
View File

@ -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<PathBuf, Box<dyn Error>> {
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<dyn Error>> {
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(())
}

61
src/tools/logger.rs Normal file
View File

@ -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<Self> {
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));
}
}

5
src/tools/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod excel;
pub mod logger;
pub use excel::export_to_excel;
pub use logger::Logger;