use std::collections::{BTreeMap, HashMap}; use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use calamine::{Data, Reader, open_workbook_auto}; use chrono::{DateTime, NaiveDateTime, Utc}; use clap::Parser; use filetime::{FileTime, set_file_times}; use fs2::FileExt; use regex::Regex; use reqwest::blocking::Client; use serde::Deserialize; use serde_json::{Map, Value, json}; use sha1::{Digest, Sha1}; const DATASETS: &[&str] = &[ "documents", "postings", "business_events", "document_changes", "companies", "reglog", "audit", "host", ]; #[derive(Debug, Parser)] #[command(about = "Rust AW-rus file-1C ingest cycle for ClickHouse")] struct Cli { #[arg(long, env = "AW_1C_ROOT")] root: Option, #[arg(long)] config: Option, #[arg(long)] dataset: Option, #[arg(long, default_value_t = 30, env = "AW_1C_INGEST_TIMEOUT_SECONDS")] timeout_seconds: u64, #[arg(long)] dry_run: bool, #[arg(long)] skip_post_refresh: bool, #[arg(long)] skip_briefs: bool, #[arg(long)] json: bool, } #[derive(Debug, Deserialize)] struct RawConfig { clickhouse: ClickHouseConfig, landing: BTreeMap, #[serde(default)] formats: BTreeMap, archive_dir: Option, #[serde(default)] delete_after_load: bool, #[serde(default = "default_min_file_age_seconds")] min_file_age_seconds: u64, } #[derive(Debug, Deserialize)] struct ClickHouseConfig { host: String, #[serde(default = "default_clickhouse_port")] port: u16, #[serde(default = "default_clickhouse_user")] username: String, #[serde(default)] password: String, #[serde(default = "default_clickhouse_database")] database: String, } #[derive(Debug, Default)] struct RunSummary { files_seen: usize, files_loaded: usize, rows_loaded: usize, files_skipped_young: usize, files_archived: usize, sql_statements: usize, } #[derive(Debug, Clone)] struct DocumentMeta { company_entity_key: String, organization: String, department: String, document_id: String, document_number: String, document_type: String, operation_type: String, user: String, counterparty: String, } type DocumentIndex = HashMap<(String, String), DocumentMeta>; fn main() { let code = match run() { Ok(code) => code, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result { let cli = Cli::parse(); let root = cli .root .clone() .or_else(|| std::env::var_os("AW_1C_ROOT").map(PathBuf::from)) .unwrap_or_else(|| PathBuf::from("/opt/activitywatch/clickhouse-1c")); let config_path = cli .config .clone() .unwrap_or_else(|| root.join("etl/config.yml")); let lock_path = root.join(".ingest.lock"); let lock_file = File::create(&lock_path).with_context(|| format!("open lock {}", lock_path.display()))?; if lock_file.try_lock_exclusive().is_err() { println!("ingest cycle already running"); return Ok(0); } let config = load_config(&config_path)?; let client = ClickHouseClient::new(&config.clickhouse, cli.timeout_seconds)?; let mut summary = RunSummary::default(); build_business_exports(&config, cli.dry_run, &mut summary)?; load_datasets( &config, &client, cli.dataset.as_deref(), cli.dry_run, &mut summary, )?; load_registry(&config, &client, &root, cli.dry_run, &mut summary)?; if !cli.dry_run { run_sql_file( &client, &root.join("detections/build_entity_timeline.sql"), &mut summary, )?; run_sql_file( &client, &root.join("clickhouse/init/04_company_intelligence.sql"), &mut summary, )?; if !cli.skip_post_refresh { run_optional_script(&root.join("ops/run_company_registry_bindings_refresh.sh"))?; run_optional_script(&root.join("ops/run_company_intelligence_refresh.sh"))?; } run_sql_file( &client, &root.join("detections/insert_detections.sql"), &mut summary, )?; run_sql_file( &client, &root.join("detections/open_cases_from_detections.sql"), &mut summary, )?; if !cli.skip_briefs { let _ = run_optional_script(&root.join("ops/run_manager_brief.sh")); let _ = run_optional_script(&root.join("ops/run_recovery_brief.sh")); } let _ = run_optional_script(&root.join("ops/check_ingest_freshness.sh")); } if cli.json { println!( "{}", serde_json::to_string_pretty(&json!({ "ok": true, "dry_run": cli.dry_run, "files_seen": summary.files_seen, "files_loaded": summary.files_loaded, "rows_loaded": summary.rows_loaded, "files_skipped_young": summary.files_skipped_young, "files_archived": summary.files_archived, "sql_statements": summary.sql_statements, }))? ); } else { println!( "aw-1c-ingest-rust ok dry_run={} files_loaded={} rows_loaded={} skipped_young={} sql_statements={}", cli.dry_run, summary.files_loaded, summary.rows_loaded, summary.files_skipped_young, summary.sql_statements ); } Ok(0) } fn default_clickhouse_port() -> u16 { 8123 } fn default_clickhouse_user() -> String { "default".to_string() } fn default_clickhouse_database() -> String { "analytics_1c".to_string() } fn default_min_file_age_seconds() -> u64 { 180 } fn load_config(path: &Path) -> Result { let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; serde_yaml::from_str(&text).with_context(|| format!("parse {}", path.display())) } struct ClickHouseClient { client: Client, base_url: String, username: String, password: String, database: String, } impl ClickHouseClient { fn new(config: &ClickHouseConfig, timeout_seconds: u64) -> Result { let host = config.host.trim_end_matches('/'); let base_url = if host.starts_with("http://") || host.starts_with("https://") { host.to_string() } else { format!("http://{}:{}", host, config.port) }; let client = Client::builder() .timeout(Duration::from_secs(timeout_seconds)) .build() .context("build ClickHouse HTTP client")?; Ok(Self { client, base_url, username: config.username.clone(), password: config.password.clone(), database: config.database.clone(), }) } fn execute(&self, sql: &str) -> Result<()> { if sql.trim().is_empty() { return Ok(()); } let mut req = self .client .post(&self.base_url) .query(&[("database", self.database.as_str())]) .body(sql.to_string()); if !self.username.trim().is_empty() { req = req.basic_auth(self.username.clone(), Some(self.password.clone())); } let response = req.send().context("ClickHouse request")?; let status = response.status(); let body = response.text().unwrap_or_default(); if !status.is_success() { bail!("ClickHouse HTTP {status}: {}", body.trim()); } Ok(()) } fn insert_json_each_row(&self, table: &str, columns: &[&str], rows: &[Value]) -> Result<()> { if rows.is_empty() { return Ok(()); } let mut payload = String::new(); payload.push_str(&format!( "INSERT INTO {} ({}) FORMAT JSONEachRow\n", table, columns.join(", ") )); for row in rows { payload.push_str(&serde_json::to_string(row)?); payload.push('\n'); } self.execute(&payload) } } fn dataset_format(config: &RawConfig, dataset: &str) -> String { config .formats .get(dataset) .or_else(|| config.formats.get("default")) .cloned() .unwrap_or_else(|| "jsonl".to_string()) } fn ready_files(config: &RawConfig, dataset: &str) -> Result> { let Some(root) = config.landing.get(dataset) else { return Ok(Vec::new()); }; if !root.exists() { return Ok(Vec::new()); } let mut out = Vec::new(); for entry in fs::read_dir(root).with_context(|| format!("read {}", root.display()))? { let path = entry?.path(); if path.is_file() { out.push(path); } } out.sort(); Ok(out) } fn file_age_seconds(path: &Path) -> Result { let meta = fs::metadata(path).with_context(|| format!("stat {}", path.display()))?; let modified = meta .modified() .with_context(|| format!("mtime {}", path.display()))?; Ok(modified.elapsed().unwrap_or_default().as_secs()) } fn file_is_ready(path: &Path, config: &RawConfig) -> Result { Ok(file_age_seconds(path)? >= config.min_file_age_seconds) } fn read_rows(path: &Path, fmt: &str) -> Result>> { match fmt { "jsonl" => { let text = read_text_lossy(path)?; text.lines() .filter(|line| !line.trim().is_empty()) .map(|line| parse_object_line(line, path)) .collect() } "json" => { let value: Value = serde_json::from_str(&read_text_lossy(path)?) .with_context(|| format!("parse json {}", path.display()))?; match value { Value::Array(items) => items .into_iter() .map(|item| match item { Value::Object(map) => Ok(map), _ => bail!("json item is not object in {}", path.display()), }) .collect(), Value::Object(map) => Ok(vec![map]), _ => bail!("json root is not object/list in {}", path.display()), } } "csv" => { let mut reader = csv::Reader::from_path(path) .with_context(|| format!("open csv {}", path.display()))?; let headers = reader.headers()?.clone(); let mut rows = Vec::new(); for record in reader.records() { let record = record?; let mut map = Map::new(); for (idx, value) in record.iter().enumerate() { if let Some(header) = headers.get(idx) { map.insert(header.to_string(), Value::String(value.to_string())); } } rows.push(map); } Ok(rows) } other => bail!("unsupported format {other} for {}", path.display()), } } fn read_text_lossy(path: &Path) -> Result { let mut bytes = Vec::new(); File::open(path) .with_context(|| format!("open {}", path.display()))? .read_to_end(&mut bytes)?; if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { bytes.drain(0..3); } String::from_utf8(bytes).with_context(|| format!("decode utf-8 {}", path.display())) } fn parse_object_line(line: &str, path: &Path) -> Result> { match serde_json::from_str::(line) .with_context(|| format!("parse jsonl {}", path.display()))? { Value::Object(map) => Ok(map), _ => bail!("jsonl row is not object in {}", path.display()), } } fn load_datasets( config: &RawConfig, client: &ClickHouseClient, only_dataset: Option<&str>, dry_run: bool, summary: &mut RunSummary, ) -> Result<()> { let datasets: Vec<&str> = match only_dataset { Some(dataset) => vec![dataset], None => DATASETS.to_vec(), }; for dataset in datasets { let fmt = dataset_format(config, dataset); for path in ready_files(config, dataset)? { summary.files_seen += 1; if !file_is_ready(&path, config)? { summary.files_skipped_young += 1; println!( "skip {dataset}: {} age={}s < min_file_age_seconds={}", path.file_name().and_then(|v| v.to_str()).unwrap_or("-"), file_age_seconds(&path)?, config.min_file_age_seconds ); continue; } let rows = read_rows(&path, &fmt)?; if rows.is_empty() { archive_or_delete(config, dataset, &path, dry_run, summary)?; continue; } if !dry_run { let source_file = file_name(&path); let raw_rows: Vec = rows .iter() .map(|row| { json!({ "source_file": source_file, "payload": serde_json::to_string(row).unwrap_or_else(|_| "{}".to_string()), }) }) .collect(); client.insert_json_each_row( raw_table(dataset)?, &["source_file", "payload"], &raw_rows, )?; let core_rows: Vec = rows .iter() .map(|row| map_core_row(dataset, &source_file, row)) .collect::>>()?; client.insert_json_each_row( core_table(dataset)?, core_columns(dataset)?, &core_rows, )?; } summary.files_loaded += 1; summary.rows_loaded += rows.len(); archive_or_delete(config, dataset, &path, dry_run, summary)?; println!("loaded {dataset}: {} rows={}", file_name(&path), rows.len()); } } Ok(()) } fn raw_table(dataset: &str) -> Result<&'static str> { Ok(match dataset { "documents" => "raw_1c_documents", "postings" => "raw_1c_postings", "business_events" => "raw_1c_business_events", "document_changes" => "raw_1c_document_changes", "companies" => "raw_1c_companies", "reglog" => "raw_reglog", "audit" => "raw_audit", "host" => "raw_host_metrics", _ => bail!("unknown dataset {dataset}"), }) } fn core_table(dataset: &str) -> Result<&'static str> { Ok(match dataset { "documents" => "documents", "postings" => "postings", "business_events" => "business_events", "document_changes" => "document_change_events", "companies" => "companies", "reglog" => "reglog_events", "audit" => "audit_events", "host" => "host_events", _ => bail!("unknown dataset {dataset}"), }) } fn core_columns(dataset: &str) -> Result<&'static [&'static str]> { Ok(match dataset { "documents" => &[ "ts", "infobase", "organization", "department", "doc_type", "doc_id", "doc_number", "author", "counterparty", "operation_type", "amount", "status", "posted", "source_file", ], "postings" => &[ "ts", "infobase", "registrar", "operation_type", "account_dt", "account_ct", "amount", "source_file", ], "business_events" => &[ "ts", "event_id", "infobase", "company_entity_key", "organization", "department", "document_id", "document_number", "document_type", "registrar", "operation_type", "event_kind", "user", "counterparty", "counterparty_inn", "debit_account", "credit_account", "amount", "currency", "line_no", "evidence_ref", "source_file", ], "document_changes" => &[ "ts", "change_id", "infobase", "company_entity_key", "organization", "document_id", "document_number", "document_type", "change_kind", "field_name", "user", "before_value", "after_value", "risk_tag", "evidence_ref", "source_file", ], "companies" => &[ "ts", "infobase", "company_name", "organization", "owner_user", "base_id", "base_path", "status", "db_size_bytes", "reglog_size_bytes", "active_locks", "temp_db_present", "scheduler_touched", "activity_score", "source_file", ], "reglog" => &[ "ts", "infobase", "user", "host", "app", "event_name", "level", "duration_ms", "message", "source_file", ], "audit" => &[ "ts", "infobase", "user", "object_type", "object_id", "action", "before_hash", "after_hash", "risk_tag", "source_file", ], "host" => &[ "ts", "host", "cpu_pct", "ram_pct", "disk_free_gb", "disk_latency_ms", "smb_errors", "rdp_sessions", "backup_ok", "source_file", ], _ => bail!("unknown dataset {dataset}"), }) } fn map_core_row(dataset: &str, source_file: &str, row: &Map) -> Result { let value = match dataset { "documents" => json!({ "ts": ts_value(row, &["ts", "posted_at", "created_at"]), "infobase": s(row, "infobase"), "organization": s(row, "organization"), "department": s(row, "department"), "doc_type": s(row, "doc_type"), "doc_id": s(row, "doc_id"), "doc_number": s(row, "doc_number"), "author": s(row, "author"), "counterparty": s(row, "counterparty"), "operation_type": s(row, "operation_type"), "amount": f(row, "amount"), "status": s(row, "status"), "posted": u(row, "posted"), "source_file": source_file, }), "postings" => json!({ "ts": ts_value(row, &["ts"]), "infobase": s(row, "infobase"), "registrar": s(row, "registrar"), "operation_type": s(row, "operation_type"), "account_dt": s(row, "account_dt"), "account_ct": s(row, "account_ct"), "amount": f(row, "amount"), "source_file": source_file, }), "business_events" => json!({ "ts": ts_value(row, &["ts", "event_time"]), "event_id": s(row, "event_id"), "infobase": s(row, "infobase"), "company_entity_key": s(row, "company_entity_key"), "organization": s(row, "organization"), "department": s(row, "department"), "document_id": s_alt(row, &["document_id", "doc_id"]), "document_number": s_alt(row, &["document_number", "doc_number"]), "document_type": s_alt(row, &["document_type", "doc_type"]), "registrar": s(row, "registrar"), "operation_type": s(row, "operation_type"), "event_kind": s(row, "event_kind"), "user": s_alt(row, &["user", "author"]), "counterparty": s(row, "counterparty"), "counterparty_inn": s(row, "counterparty_inn"), "debit_account": s_alt(row, &["debit_account", "account_dt"]), "credit_account": s_alt(row, &["credit_account", "account_ct"]), "amount": f(row, "amount"), "currency": s_default(row, "currency", "RUB"), "line_no": u(row, "line_no"), "evidence_ref": s(row, "evidence_ref"), "source_file": source_file, }), "document_changes" => json!({ "ts": ts_value(row, &["ts", "change_time"]), "change_id": s(row, "change_id"), "infobase": s(row, "infobase"), "company_entity_key": s(row, "company_entity_key"), "organization": s(row, "organization"), "document_id": s_alt(row, &["document_id", "doc_id"]), "document_number": s_alt(row, &["document_number", "doc_number"]), "document_type": s_alt(row, &["document_type", "doc_type"]), "change_kind": s(row, "change_kind"), "field_name": s(row, "field_name"), "user": s_alt(row, &["user", "author"]), "before_value": s(row, "before_value"), "after_value": s(row, "after_value"), "risk_tag": s(row, "risk_tag"), "evidence_ref": s(row, "evidence_ref"), "source_file": source_file, }), "companies" => json!({ "ts": ts_value(row, &["ts"]), "infobase": s(row, "infobase"), "company_name": s_alt(row, &["company_name", "counterparty", "infobase"]), "organization": s(row, "organization"), "owner_user": s_alt(row, &["owner_user", "author"]), "base_id": s_alt(row, &["base_id", "doc_id"]), "base_path": s(row, "base_path"), "status": s(row, "status"), "db_size_bytes": u(row, "db_size_bytes"), "reglog_size_bytes": u(row, "reglog_size_bytes"), "active_locks": u(row, "active_locks"), "temp_db_present": u(row, "temp_db_present"), "scheduler_touched": u(row, "scheduler_touched"), "activity_score": f_alt(row, &["activity_score", "amount"]), "source_file": source_file, }), "reglog" => json!({ "ts": ts_value(row, &["ts"]), "infobase": s(row, "infobase"), "user": s(row, "user"), "host": s(row, "host"), "app": s(row, "app"), "event_name": s(row, "event_name"), "level": s_default(row, "level", "info"), "duration_ms": u(row, "duration_ms"), "message": s(row, "message"), "source_file": source_file, }), "audit" => json!({ "ts": ts_value(row, &["ts"]), "infobase": s(row, "infobase"), "user": s(row, "user"), "object_type": s(row, "object_type"), "object_id": s(row, "object_id"), "action": s(row, "action"), "before_hash": s(row, "before_hash"), "after_hash": s(row, "after_hash"), "risk_tag": s(row, "risk_tag"), "source_file": source_file, }), "host" => json!({ "ts": ts_value(row, &["ts"]), "host": s(row, "host"), "cpu_pct": f(row, "cpu_pct"), "ram_pct": f(row, "ram_pct"), "disk_free_gb": f(row, "disk_free_gb"), "disk_latency_ms": f(row, "disk_latency_ms"), "smb_errors": u(row, "smb_errors"), "rdp_sessions": u(row, "rdp_sessions"), "backup_ok": u(row, "backup_ok"), "source_file": source_file, }), _ => bail!("unknown dataset {dataset}"), }; Ok(value) } fn build_business_exports( config: &RawConfig, dry_run: bool, summary: &mut RunSummary, ) -> Result<()> { let company_index = load_company_index(config)?; let (by_id, by_number) = build_document_index(config, &company_index)?; for path in ready_files(config, "documents")? { if !file_is_ready(&path, config)? { continue; } let rows = read_rows(&path, &dataset_format(config, "documents"))?; let events = build_document_events(&rows, &file_name(&path), &company_index); let out = output_path( config, "business_events", &path, "business-events-documents", )?; if !dry_run { write_generated_jsonl(&out, &events, config.min_file_age_seconds)?; } summary.files_seen += 1; } for path in ready_files(config, "postings")? { if !file_is_ready(&path, config)? { continue; } let rows = read_rows(&path, &dataset_format(config, "postings"))?; let events = build_posting_events(&rows, &file_name(&path), &company_index, &by_id, &by_number); let out = output_path(config, "business_events", &path, "business-events-postings")?; if !dry_run { write_generated_jsonl(&out, &events, config.min_file_age_seconds)?; } summary.files_seen += 1; } for path in ready_files(config, "audit")? { if !file_is_ready(&path, config)? { continue; } let rows = read_rows(&path, &dataset_format(config, "audit"))?; let events = build_document_changes(&rows, &file_name(&path), &company_index, &by_id, &by_number); let out = output_path(config, "document_changes", &path, "document-changes-audit")?; if !dry_run { write_generated_jsonl(&out, &events, config.min_file_age_seconds)?; } summary.files_seen += 1; } Ok(()) } fn load_company_index(config: &RawConfig) -> Result> { let mut latest: HashMap = HashMap::new(); for path in ready_files(config, "companies")? { if !file_is_ready(&path, config)? { continue; } for row in read_rows(&path, &dataset_format(config, "companies"))? { let infobase = s(&row, "infobase"); if infobase.is_empty() { continue; } let key = canonical_company_entity_key(&s(&row, "base_id"), &s(&row, "base_path"), &infobase); let ts = ts_value(&row, &["ts"]); match latest.get(&infobase) { Some((current_ts, _)) if current_ts > &ts => {} _ => { latest.insert(infobase, (ts, key)); } } } } Ok(latest.into_iter().map(|(k, (_, v))| (k, v)).collect()) } fn build_document_index( config: &RawConfig, company_index: &HashMap, ) -> Result<(DocumentIndex, DocumentIndex)> { let mut by_id = HashMap::new(); let mut by_number = HashMap::new(); for path in ready_files(config, "documents")? { if !file_is_ready(&path, config)? { continue; } for row in read_rows(&path, &dataset_format(config, "documents"))? { let infobase = s(&row, "infobase"); if infobase.is_empty() { continue; } let document_id = s_alt(&row, &["doc_id", "document_id"]); let document_number = s_alt(&row, &["doc_number", "document_number"]); let meta = DocumentMeta { company_entity_key: derive_company_entity_key(&row, company_index), organization: s(&row, "organization"), department: s(&row, "department"), document_id: document_id.clone(), document_number: document_number.clone(), document_type: s_alt(&row, &["doc_type", "document_type"]), operation_type: s(&row, "operation_type"), user: s_alt(&row, &["author", "user"]), counterparty: s(&row, "counterparty"), }; if !document_id.is_empty() { by_id.insert((infobase.clone(), document_id), meta.clone()); } if !document_number.is_empty() { by_number.insert((infobase, document_number), meta); } } } Ok((by_id, by_number)) } fn build_document_events( rows: &[Map], source_file: &str, company_index: &HashMap, ) -> Vec { rows.iter() .map(|row| { let ts = ts_value(row, &["ts", "posted_at", "created_at"]); let infobase = s(row, "infobase"); let document_id = s_alt(row, &["doc_id", "document_id"]); let document_number = s_alt(row, &["doc_number", "document_number"]); json!({ "ts": ts, "event_id": stable_id("document_snapshot", &[source_file, &infobase, &document_id, &document_number, &ts]), "infobase": infobase, "company_entity_key": derive_company_entity_key(row, company_index), "organization": s(row, "organization"), "department": s(row, "department"), "document_id": document_id, "document_number": document_number, "document_type": s_alt(row, &["doc_type", "document_type"]), "registrar": s_alt(row, &["doc_id", "doc_number", "document_id", "document_number"]), "operation_type": s(row, "operation_type"), "event_kind": "document_snapshot", "user": s_alt(row, &["author", "user"]), "counterparty": s(row, "counterparty"), "counterparty_inn": s(row, "counterparty_inn"), "debit_account": "", "credit_account": "", "amount": f(row, "amount"), "currency": s_default(row, "currency", "RUB"), "line_no": 0, "evidence_ref": format!("document:{}", s_alt(row, &["doc_id", "doc_number", "document_id", "document_number"])), }) }) .collect() } fn build_posting_events( rows: &[Map], source_file: &str, company_index: &HashMap, by_id: &HashMap<(String, String), DocumentMeta>, by_number: &HashMap<(String, String), DocumentMeta>, ) -> Vec { rows.iter() .enumerate() .map(|(idx, row)| { let ts = ts_value(row, &["ts"]); let infobase = s(row, "infobase"); let registrar = s(row, "registrar"); let meta = lookup_document_meta(row, by_id, by_number); let line_no = u_alt(row, &["line_no"]).unwrap_or((idx + 1) as u64); json!({ "ts": ts, "event_id": stable_id("posting", &[source_file, &infobase, ®istrar, &line_no.to_string(), &ts]), "infobase": infobase, "company_entity_key": meta.map(|m| m.company_entity_key.clone()).unwrap_or_else(|| derive_company_entity_key(row, company_index)), "organization": meta.map(|m| m.organization.clone()).unwrap_or_else(|| s(row, "organization")), "department": meta.map(|m| m.department.clone()).unwrap_or_else(|| s(row, "department")), "document_id": meta.map(|m| m.document_id.clone()).unwrap_or_else(|| registrar.clone()), "document_number": meta.map(|m| m.document_number.clone()).unwrap_or_else(|| s(row, "document_number")), "document_type": meta.map(|m| m.document_type.clone()).unwrap_or_else(|| s(row, "document_type")), "registrar": registrar, "operation_type": s_default(row, "operation_type", &meta.map(|m| m.operation_type.clone()).unwrap_or_default()), "event_kind": "posting", "user": meta.map(|m| m.user.clone()).unwrap_or_else(|| s_alt(row, &["user", "author"])), "counterparty": meta.map(|m| m.counterparty.clone()).unwrap_or_else(|| s(row, "counterparty")), "counterparty_inn": s(row, "counterparty_inn"), "debit_account": s_alt(row, &["account_dt", "debit_account"]), "credit_account": s_alt(row, &["account_ct", "credit_account"]), "amount": f(row, "amount"), "currency": s_default(row, "currency", "RUB"), "line_no": line_no, "evidence_ref": format!("posting:{}:{}", s(row, "registrar"), line_no), }) }) .collect() } fn build_document_changes( rows: &[Map], source_file: &str, company_index: &HashMap, by_id: &HashMap<(String, String), DocumentMeta>, by_number: &HashMap<(String, String), DocumentMeta>, ) -> Vec { rows.iter() .map(|row| { let ts = ts_value(row, &["ts"]); let infobase = s(row, "infobase"); let object_type = s(row, "object_type"); let object_id = s(row, "object_id"); let meta = lookup_document_meta(row, by_id, by_number); json!({ "ts": ts, "change_id": stable_id("change", &[source_file, &infobase, &object_type, &object_id, &s(row, "action"), &ts]), "infobase": infobase, "company_entity_key": meta.map(|m| m.company_entity_key.clone()).unwrap_or_else(|| derive_company_entity_key(row, company_index)), "organization": meta.map(|m| m.organization.clone()).unwrap_or_else(|| s(row, "organization")), "document_id": meta.map(|m| m.document_id.clone()).unwrap_or_else(|| if object_type == "document" { object_id.clone() } else { s(row, "document_id") }), "document_number": meta.map(|m| m.document_number.clone()).unwrap_or_else(|| s(row, "document_number")), "document_type": meta.map(|m| m.document_type.clone()).unwrap_or_else(|| s(row, "document_type")), "change_kind": s_alt(row, &["change_kind", "action", "object_type"]), "field_name": s_alt(row, &["field_name", "object_type"]), "user": s_alt(row, &["user", "author"]), "before_value": s_alt(row, &["before_value", "before_hash"]), "after_value": s_alt(row, &["after_value", "after_hash"]), "risk_tag": s(row, "risk_tag"), "evidence_ref": format!("audit:{}:{}", object_type, object_id), }) }) .collect() } fn lookup_document_meta<'a>( row: &Map, by_id: &'a HashMap<(String, String), DocumentMeta>, by_number: &'a HashMap<(String, String), DocumentMeta>, ) -> Option<&'a DocumentMeta> { let infobase = s(row, "infobase"); let registrar = s_alt(row, &["registrar", "document_id", "doc_id"]); let number = s_alt(row, &["document_number", "doc_number"]); by_id .get(&(infobase.clone(), registrar)) .or_else(|| by_number.get(&(infobase, number))) } fn output_path(config: &RawConfig, dataset: &str, source: &Path, prefix: &str) -> Result { let root = config .landing .get(dataset) .ok_or_else(|| anyhow!("missing landing root for {dataset}"))?; let stem = source .file_stem() .and_then(|v| v.to_str()) .unwrap_or("input"); Ok(root.join(format!("{prefix}-{stem}.jsonl"))) } fn write_generated_jsonl(path: &Path, rows: &[Value], min_file_age_seconds: u64) -> Result<()> { if rows.is_empty() { let _ = fs::remove_file(path); return Ok(()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap_or_else(|| Path::new(".")))?; for row in rows { writeln!(tmp, "{}", serde_json::to_string(row)?)?; } tmp.persist(path) .map_err(|err| anyhow!("persist {}: {}", path.display(), err))?; let aged = FileTime::from_unix_time( Utc::now().timestamp() - (min_file_age_seconds as i64 + 1).max(5), 0, ); set_file_times(path, aged, aged).with_context(|| format!("set mtime {}", path.display()))?; Ok(()) } fn load_registry( config: &RawConfig, client: &ClickHouseClient, root: &Path, dry_run: bool, summary: &mut RunSummary, ) -> Result<()> { let landing = root.join("landing/registry"); if !landing.exists() { return Ok(()); } for entry in fs::read_dir(&landing)? { let path = entry?.path(); if !path.is_file() || path .extension() .and_then(|v| v.to_str()) .unwrap_or("") .to_lowercase() != "xlsx" { continue; } summary.files_seen += 1; if !file_is_ready(&path, config)? { summary.files_skipped_young += 1; continue; } let rows = parse_registry_xlsx(&path)?; if !dry_run && !rows.is_empty() { let raw_rows: Vec = rows .iter() .map(|row| { json!({ "source_file": row.get("source_file").and_then(Value::as_str).unwrap_or(""), "source_sheet": row.get("source_sheet").and_then(Value::as_str).unwrap_or(""), "payload": serde_json::to_string(row).unwrap_or_else(|_| "{}".to_string()), }) }) .collect(); client.insert_json_each_row( "raw_1c_company_registry", &["source_file", "source_sheet", "payload"], &raw_rows, )?; client.insert_json_each_row( "company_registry", &[ "ts", "source_file", "source_sheet", "company_name", "company_key", "assignee_name", "registry_status", "share_text", "key_contour", "inn", "kpp", ], &rows, )?; } summary.files_loaded += 1; summary.rows_loaded += rows.len(); archive_or_delete(config, "registry", &path, dry_run, summary)?; println!("loaded registry: {} rows={}", file_name(&path), rows.len()); } Ok(()) } fn parse_registry_xlsx(path: &Path) -> Result> { let mut workbook = open_workbook_auto(path).with_context(|| format!("open xlsx {}", path.display()))?; let now = Utc::now() .naive_utc() .format("%Y-%m-%d %H:%M:%S") .to_string(); let mut tax_map: HashMap = HashMap::new(); if let Ok(range) = workbook.worksheet_range("Лист2") { for row in range.rows().skip(2) { let company_name = cell_text(row.get(1)); if company_name.is_empty() { continue; } tax_map.insert( normalize_company_key(&company_name), (cell_text(row.get(2)), cell_text(row.get(3))), ); } } let mut out = Vec::new(); let Ok(range) = workbook.worksheet_range("ОСНОВНОЙ") else { return Ok(out); }; let rows: Vec<_> = range.rows().collect(); if rows.len() < 3 { return Ok(out); } let top_headers: Vec = rows[0].iter().map(|v| cell_text(Some(v))).collect(); let manager_headers: Vec = rows[1].iter().map(|v| cell_text(Some(v))).collect(); let mut specs = Vec::<(usize, String, Option, String, String)>::new(); let mut current: Option<(usize, String, String)> = None; for idx in 1..manager_headers.len() { let manager = manager_headers.get(idx).cloned().unwrap_or_default(); let top = top_headers.get(idx).cloned().unwrap_or_default(); if !manager.is_empty() { current = Some((idx, manager.clone(), "active".to_string())); specs.push((idx, manager, None, String::new(), "active".to_string())); } else if top.to_lowercase().contains("исключ") { current = Some((idx, String::new(), "excluded".to_string())); specs.push(( idx, String::new(), None, String::new(), "excluded".to_string(), )); } else if let Some((col, assignee, status)) = current.take() { if let Some(last) = specs.last_mut() { if last.0 == col && last.1 == assignee && last.4 == status { last.2 = Some(idx); last.3 = top; } } } } for row in rows.into_iter().skip(2) { for (col, assignee, meta_col, meta_label, status) in &specs { let company_name = cell_text(row.get(*col)); if company_name.is_empty() { continue; } let meta = meta_col .map(|idx| cell_text(row.get(idx))) .unwrap_or_default(); let company_key = normalize_company_key(&company_name); let (inn, kpp) = tax_map.get(&company_key).cloned().unwrap_or_default(); let key_contour = if meta.to_uppercase() == "ЕСТЬ" && meta_label.to_lowercase().contains("ключ") { 1 } else { 0 }; out.push(json!({ "ts": now, "source_file": file_name(path), "source_sheet": "ОСНОВНОЙ", "company_name": company_name, "company_key": company_key, "assignee_name": assignee, "registry_status": status, "share_text": if meta_label.to_lowercase().contains("ключ") { "" } else { &meta }, "key_contour": key_contour, "inn": inn, "kpp": kpp, })); } } Ok(out) } fn cell_text(value: Option<&Data>) -> String { match value { Some(Data::String(v)) => v.trim().to_string(), Some(Data::Float(v)) => { if v.fract() == 0.0 { format!("{}", *v as i64) } else { v.to_string() } } Some(Data::Int(v)) => v.to_string(), Some(Data::Bool(v)) => v.to_string(), Some(Data::DateTime(v)) => v.to_string(), Some(Data::DateTimeIso(v)) => v.trim().to_string(), Some(Data::DurationIso(v)) => v.trim().to_string(), Some(Data::Empty) | None => String::new(), Some(other) => other.to_string(), } } fn archive_or_delete( config: &RawConfig, dataset: &str, path: &Path, dry_run: bool, summary: &mut RunSummary, ) -> Result<()> { if dry_run { return Ok(()); } if let Some(root) = &config.archive_dir { let archive_root = root.join(dataset); fs::create_dir_all(&archive_root) .with_context(|| format!("create {}", archive_root.display()))?; let dest = unique_archive_path(&archive_root.join(file_name(path))); fs::rename(path, &dest) .or_else(|_| { fs::copy(path, &dest)?; fs::remove_file(path) }) .with_context(|| format!("archive {} -> {}", path.display(), dest.display()))?; summary.files_archived += 1; } else if config.delete_after_load { fs::remove_file(path).with_context(|| format!("delete {}", path.display()))?; } Ok(()) } fn unique_archive_path(path: &Path) -> PathBuf { if !path.exists() { return path.to_path_buf(); } let parent = path.parent().unwrap_or_else(|| Path::new(".")); let stem = path.file_stem().and_then(|v| v.to_str()).unwrap_or("file"); let ext = path.extension().and_then(|v| v.to_str()).unwrap_or(""); let suffix = Utc::now().format("%Y%m%d%H%M%S"); if ext.is_empty() { parent.join(format!("{stem}.{suffix}")) } else { parent.join(format!("{stem}.{suffix}.{ext}")) } } fn run_sql_file(client: &ClickHouseClient, path: &Path, summary: &mut RunSummary) -> Result<()> { let sql = fs::read_to_string(path).with_context(|| format!("read SQL {}", path.display()))?; for statement in split_sql_statements(&sql) { client .execute(&statement) .with_context(|| format!("execute SQL {}", path.display()))?; summary.sql_statements += 1; } Ok(()) } fn split_sql_statements(sql: &str) -> Vec { let cleaned = sql .lines() .filter(|line| !line.trim_start().starts_with("--")) .collect::>() .join("\n"); cleaned .split(';') .map(str::trim) .filter(|item| !item.is_empty()) .map(|item| format!("{item};")) .collect() } fn run_optional_script(path: &Path) -> Result<()> { if !path.exists() { return Ok(()); } let status = Command::new(path) .status() .with_context(|| format!("run {}", path.display()))?; if !status.success() { bail!("{} exited with {}", path.display(), status); } Ok(()) } fn derive_company_entity_key(row: &Map, index: &HashMap) -> String { let explicit = s(row, "company_entity_key"); if !explicit.is_empty() { return explicit; } let infobase = s(row, "infobase"); index .get(&infobase) .cloned() .filter(|v| !v.is_empty()) .unwrap_or_else(|| { canonical_company_entity_key(&s(row, "base_id"), &s(row, "base_path"), &infobase) }) } fn canonical_company_entity_key(base_id: &str, base_path: &str, infobase: &str) -> String { if !base_id.trim().is_empty() { return format!("baseid:{}", base_id.trim()); } if !base_path.trim().is_empty() { let normalized = normalize_base_path(base_path); if !normalized.is_empty() { return format!("basepath:{normalized}"); } } let normalized = normalize_infobase_key(infobase); if normalized.is_empty() { String::new() } else { format!("infobase:{normalized}") } } fn normalize_base_path(value: &str) -> String { let re_slashes = Regex::new(r"/+").unwrap(); let re_bad = Regex::new(r"[^0-9A-ZА-ЯЁ:/._ -]+").unwrap(); let text = value.to_uppercase().replace('\\', "/"); collapse_ws(&re_bad.replace_all(&re_slashes.replace_all(&text, "/"), " ")) } fn normalize_infobase_key(value: &str) -> String { let re_year = Regex::new(r"(^|\s)20[0-9]{2}($|\s)").unwrap(); let re_bad = Regex::new(r"[^0-9A-ZА-ЯЁ]+").unwrap(); let text = value.to_uppercase(); collapse_ws(&re_bad.replace_all(&re_year.replace_all(&text, " "), " ")) } fn normalize_company_key(value: &str) -> String { let re_year = Regex::new(r"(^|\s)20\d{2}($|\s)").unwrap(); let re_bad = Regex::new(r"[^0-9A-ZА-Я]+").unwrap(); let text = value.to_uppercase().replace('Ё', "Е"); collapse_ws(&re_bad.replace_all(&re_year.replace_all(&text, " "), " ")) } fn collapse_ws(value: &str) -> String { value.split_whitespace().collect::>().join(" ") } fn stable_id(prefix: &str, parts: &[&str]) -> String { let mut hasher = Sha1::new(); hasher.update(parts.join("|").as_bytes()); let digest = hasher.finalize(); format!("{prefix}:{:x}", digest)[..prefix.len() + 1 + 16].to_string() } fn ts_value(row: &Map, keys: &[&str]) -> String { let raw = keys .iter() .find_map(|key| row.get(*key)) .cloned() .unwrap_or(Value::Null); normalize_ts(&raw) } fn normalize_ts(value: &Value) -> String { let raw = value_to_string(value); if raw.trim().is_empty() { return Utc::now() .naive_utc() .format("%Y-%m-%d %H:%M:%S") .to_string(); } if let Ok(ts) = DateTime::parse_from_rfc3339(&raw.replace('Z', "+00:00")) { return ts.naive_utc().format("%Y-%m-%d %H:%M:%S").to_string(); } for fmt in [ "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%d.%m.%Y %H:%M:%S", ] { if let Ok(ts) = NaiveDateTime::parse_from_str(&raw, fmt) { return ts.format("%Y-%m-%d %H:%M:%S").to_string(); } } raw } fn s(row: &Map, key: &str) -> String { row.get(key) .map(value_to_string) .unwrap_or_default() .trim() .to_string() } fn s_alt(row: &Map, keys: &[&str]) -> String { keys.iter() .map(|key| s(row, key)) .find(|value| !value.is_empty()) .unwrap_or_default() } fn s_default(row: &Map, key: &str, default: &str) -> String { let value = s(row, key); if value.is_empty() { default.to_string() } else { value } } fn f(row: &Map, key: &str) -> f64 { row.get(key).and_then(value_to_f64).unwrap_or(0.0) } fn f_alt(row: &Map, keys: &[&str]) -> f64 { keys.iter() .find_map(|key| row.get(*key).and_then(value_to_f64)) .unwrap_or(0.0) } fn u(row: &Map, key: &str) -> u64 { row.get(key).and_then(value_to_u64).unwrap_or(0) } fn u_alt(row: &Map, keys: &[&str]) -> Option { keys.iter() .find_map(|key| row.get(*key).and_then(value_to_u64)) } fn value_to_string(value: &Value) -> String { match value { Value::Null => String::new(), Value::String(v) => v.clone(), Value::Number(v) => v.to_string(), Value::Bool(v) => { if *v { "1".to_string() } else { "0".to_string() } } other => other.to_string(), } } fn value_to_f64(value: &Value) -> Option { match value { Value::Number(v) => v.as_f64(), Value::String(v) => v.replace(',', ".").parse().ok(), Value::Bool(v) => Some(if *v { 1.0 } else { 0.0 }), _ => None, } } fn value_to_u64(value: &Value) -> Option { match value { Value::Number(v) => v.as_u64().or_else(|| v.as_f64().map(|v| v.max(0.0) as u64)), Value::String(v) => v.parse().ok(), Value::Bool(v) => Some(if *v { 1 } else { 0 }), _ => None, } } fn file_name(path: &Path) -> String { path.file_name() .and_then(|v| v.to_str()) .unwrap_or("unknown") .to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn canonical_company_key_matches_python_shape() { assert_eq!( canonical_company_entity_key("", "c:\\base\\ООО Тест 2026", ""), "basepath:C:/BASE/ООО ТЕСТ 2026" ); assert_eq!( canonical_company_entity_key("", "", "НОРД 2026"), "infobase:НОРД" ); } #[test] fn core_document_row_maps_defaults() { let mut row = Map::new(); row.insert("infobase".to_string(), json!("БАЗА")); row.insert("amount".to_string(), json!("12.50")); let mapped = map_core_row("documents", "docs.jsonl", &row).unwrap(); assert_eq!(mapped["infobase"], "БАЗА"); assert_eq!(mapped["amount"], 12.5); assert_eq!(mapped["source_file"], "docs.jsonl"); } #[test] fn split_sql_ignores_empty_chunks() { assert_eq!(split_sql_statements("SELECT 1; ; SELECT 2;").len(), 2); } }