Files
AWatch-rus/adk-rust/crates/aw-1c-ingest/src/main.rs
T

1511 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
dataset: Option<String>,
#[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<String, PathBuf>,
#[serde(default)]
formats: BTreeMap<String, String>,
archive_dir: Option<PathBuf>,
#[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<i32> {
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<RawConfig> {
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<Self> {
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<Vec<PathBuf>> {
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<u64> {
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<bool> {
Ok(file_age_seconds(path)? >= config.min_file_age_seconds)
}
fn read_rows(path: &Path, fmt: &str) -> Result<Vec<Map<String, Value>>> {
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<String> {
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<Map<String, Value>> {
match serde_json::from_str::<Value>(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<Value> = 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<Value> = rows
.iter()
.map(|row| map_core_row(dataset, &source_file, row))
.collect::<Result<Vec<_>>>()?;
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<String, Value>) -> Result<Value> {
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<HashMap<String, String>> {
let mut latest: HashMap<String, (String, String)> = 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<String, String>,
) -> 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<String, Value>],
source_file: &str,
company_index: &HashMap<String, String>,
) -> Vec<Value> {
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<String, Value>],
source_file: &str,
company_index: &HashMap<String, String>,
by_id: &HashMap<(String, String), DocumentMeta>,
by_number: &HashMap<(String, String), DocumentMeta>,
) -> Vec<Value> {
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, &registrar, &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<String, Value>],
source_file: &str,
company_index: &HashMap<String, String>,
by_id: &HashMap<(String, String), DocumentMeta>,
by_number: &HashMap<(String, String), DocumentMeta>,
) -> Vec<Value> {
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<String, Value>,
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<PathBuf> {
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<Value> = 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<Vec<Value>> {
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<String, (String, String)> = 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<String> = rows[0].iter().map(|v| cell_text(Some(v))).collect();
let manager_headers: Vec<String> = rows[1].iter().map(|v| cell_text(Some(v))).collect();
let mut specs = Vec::<(usize, String, Option<usize>, 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<String> {
let cleaned = sql
.lines()
.filter(|line| !line.trim_start().starts_with("--"))
.collect::<Vec<_>>()
.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<String, Value>, index: &HashMap<String, String>) -> 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::<Vec<_>>().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<String, Value>, 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<String, Value>, key: &str) -> String {
row.get(key)
.map(value_to_string)
.unwrap_or_default()
.trim()
.to_string()
}
fn s_alt(row: &Map<String, Value>, keys: &[&str]) -> String {
keys.iter()
.map(|key| s(row, key))
.find(|value| !value.is_empty())
.unwrap_or_default()
}
fn s_default(row: &Map<String, Value>, key: &str, default: &str) -> String {
let value = s(row, key);
if value.is_empty() {
default.to_string()
} else {
value
}
}
fn f(row: &Map<String, Value>, key: &str) -> f64 {
row.get(key).and_then(value_to_f64).unwrap_or(0.0)
}
fn f_alt(row: &Map<String, Value>, keys: &[&str]) -> f64 {
keys.iter()
.find_map(|key| row.get(*key).and_then(value_to_f64))
.unwrap_or(0.0)
}
fn u(row: &Map<String, Value>, key: &str) -> u64 {
row.get(key).and_then(value_to_u64).unwrap_or(0)
}
fn u_alt(row: &Map<String, Value>, keys: &[&str]) -> Option<u64> {
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<f64> {
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<u64> {
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);
}
}