feat(1c): scaffold business event extraction layer
This commit is contained in:
+18
-1
@@ -26,6 +26,8 @@ File 1C + reglog + host telemetry
|
||||
├─ raw_*
|
||||
├─ documents
|
||||
├─ postings
|
||||
├─ business_events
|
||||
├─ document_change_events
|
||||
├─ reglog_events
|
||||
├─ audit_events
|
||||
├─ host_events
|
||||
@@ -45,6 +47,8 @@ File 1C + reglog + host telemetry
|
||||
- `clickhouse/init/*.sql` — схема БД.
|
||||
- `etl/load_1c_exports.py` — loader CSV/JSON выгрузок в raw/core таблицы.
|
||||
- `etl/config.example.yml` — пример ETL-конфига.
|
||||
- `docs/1C_BUSINESS_EVENT_LAYER_RU.md` — production contract следующего шага:
|
||||
canonical business-event слой для документов/проводок/изменений.
|
||||
- `detections/rules.yml` — каталог правил detections.
|
||||
- `detections/insert_detections.sql` — SQL-шаблоны rule-based detections.
|
||||
- `grafana/dashboard-catalog.md` — целевая структура дашбордов.
|
||||
@@ -95,7 +99,7 @@ docker compose up -d
|
||||
3. Инициализировать landing-каталоги и ETL config:
|
||||
|
||||
```bash
|
||||
mkdir -p landing/{documents,postings,reglog,audit,host}
|
||||
mkdir -p landing/{documents,postings,business_events,document_changes,companies,reglog,audit,host}
|
||||
cp etl/config.example.yml etl/config.yml
|
||||
```
|
||||
|
||||
@@ -156,6 +160,8 @@ Read-only API для руководителя:
|
||||
|
||||
- выгрузки 1С по документам;
|
||||
- выгрузки движений/проводок;
|
||||
- выгрузки canonical business events;
|
||||
- выгрузки изменений документов и реквизитов;
|
||||
- read-only `companies` snapshot по файловым базам;
|
||||
- журнал регистрации 1С;
|
||||
- audit/export критичных изменений;
|
||||
@@ -184,3 +190,14 @@ Read-only API для руководителя:
|
||||
- case/timeline слой считается вне 1С.
|
||||
- это прогноз активности компании/базы, а не финансовых проводок;
|
||||
- если `counterparty` в live-выгрузках пустой, company-forecast слой останется корректно пустым.
|
||||
|
||||
## Следующий production шаг
|
||||
|
||||
В репо уже заложен scaffold под следующий слой:
|
||||
|
||||
- `business_events` — единый event stream бухгалтерских событий;
|
||||
- `document_change_events` — изменения документов/реквизитов;
|
||||
- ETL уже умеет принимать эти datasets в `landing/business_events` и
|
||||
`landing/document_changes`;
|
||||
- дальше нужен только read-only extractor, который будет наполнять их из 1С
|
||||
или внешних безопасных выгрузок без записи в базу.
|
||||
|
||||
@@ -16,6 +16,24 @@ CREATE TABLE IF NOT EXISTS analytics_1c.raw_1c_postings
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (ingested_at, source_file);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_1c.raw_1c_business_events
|
||||
(
|
||||
ingested_at DateTime DEFAULT now(),
|
||||
source_file String,
|
||||
payload String
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (ingested_at, source_file);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_1c.raw_1c_document_changes
|
||||
(
|
||||
ingested_at DateTime DEFAULT now(),
|
||||
source_file String,
|
||||
payload String
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (ingested_at, source_file);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_1c.raw_1c_companies
|
||||
(
|
||||
ingested_at DateTime DEFAULT now(),
|
||||
|
||||
@@ -32,6 +32,56 @@ CREATE TABLE IF NOT EXISTS analytics_1c.postings
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (infobase, ts, registrar);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_1c.business_events
|
||||
(
|
||||
ts DateTime,
|
||||
event_id String,
|
||||
infobase LowCardinality(String),
|
||||
company_entity_key String,
|
||||
organization String,
|
||||
department String,
|
||||
document_id String,
|
||||
document_number String,
|
||||
document_type LowCardinality(String),
|
||||
registrar String,
|
||||
operation_type String,
|
||||
event_kind LowCardinality(String),
|
||||
user String,
|
||||
counterparty String,
|
||||
counterparty_inn String,
|
||||
debit_account String,
|
||||
credit_account String,
|
||||
amount Decimal(18, 2),
|
||||
currency LowCardinality(String),
|
||||
line_no UInt32,
|
||||
evidence_ref String,
|
||||
source_file String
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (infobase, ts, document_id, line_no, event_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_1c.document_change_events
|
||||
(
|
||||
ts DateTime,
|
||||
change_id String,
|
||||
infobase LowCardinality(String),
|
||||
company_entity_key String,
|
||||
organization String,
|
||||
document_id String,
|
||||
document_number String,
|
||||
document_type LowCardinality(String),
|
||||
change_kind LowCardinality(String),
|
||||
field_name String,
|
||||
user String,
|
||||
before_value String,
|
||||
after_value String,
|
||||
risk_tag String,
|
||||
evidence_ref String,
|
||||
source_file String
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (infobase, ts, document_id, change_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_1c.companies
|
||||
(
|
||||
ts DateTime,
|
||||
|
||||
@@ -8,6 +8,8 @@ clickhouse:
|
||||
landing:
|
||||
documents: ./landing/documents
|
||||
postings: ./landing/postings
|
||||
business_events: ./landing/business_events
|
||||
document_changes: ./landing/document_changes
|
||||
companies: ./landing/companies
|
||||
reglog: ./landing/reglog
|
||||
audit: ./landing/audit
|
||||
@@ -17,6 +19,8 @@ formats:
|
||||
default: jsonl
|
||||
documents: jsonl
|
||||
postings: jsonl
|
||||
business_events: jsonl
|
||||
document_changes: jsonl
|
||||
companies: jsonl
|
||||
reglog: jsonl
|
||||
audit: jsonl
|
||||
|
||||
@@ -17,6 +17,8 @@ from dateutil import parser as date_parser
|
||||
RAW_TABLES = {
|
||||
"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",
|
||||
@@ -26,6 +28,8 @@ RAW_TABLES = {
|
||||
CORE_TABLES = {
|
||||
"documents": "documents",
|
||||
"postings": "postings",
|
||||
"business_events": "business_events",
|
||||
"document_changes": "document_change_events",
|
||||
"companies": "companies",
|
||||
"reglog": "reglog_events",
|
||||
"audit": "audit_events",
|
||||
@@ -46,7 +50,7 @@ class Config:
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Load file-based 1C exports into ClickHouse")
|
||||
p.add_argument("--config", required=True, help="Path to YAML config")
|
||||
p.add_argument("--dataset", choices=["documents", "postings", "companies", "reglog", "audit", "host"], help="Load only one dataset")
|
||||
p.add_argument("--dataset", choices=["documents", "postings", "business_events", "document_changes", "companies", "reglog", "audit", "host"], help="Load only one dataset")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
@@ -129,6 +133,50 @@ def map_core_row(dataset: str, source_file: str, row: dict[str, Any]) -> list[An
|
||||
float(row.get("amount", 0) or 0),
|
||||
source_file,
|
||||
]
|
||||
if dataset == "business_events":
|
||||
return [
|
||||
normalize_ts(row.get("ts") or row.get("event_time")),
|
||||
row.get("event_id", ""),
|
||||
row.get("infobase", ""),
|
||||
row.get("company_entity_key", ""),
|
||||
row.get("organization", ""),
|
||||
row.get("department", ""),
|
||||
row.get("document_id", row.get("doc_id", "")),
|
||||
row.get("document_number", row.get("doc_number", "")),
|
||||
row.get("document_type", row.get("doc_type", "")),
|
||||
row.get("registrar", ""),
|
||||
row.get("operation_type", ""),
|
||||
row.get("event_kind", ""),
|
||||
row.get("user", row.get("author", "")),
|
||||
row.get("counterparty", ""),
|
||||
row.get("counterparty_inn", ""),
|
||||
row.get("debit_account", row.get("account_dt", "")),
|
||||
row.get("credit_account", row.get("account_ct", "")),
|
||||
float(row.get("amount", 0) or 0),
|
||||
row.get("currency", "RUB"),
|
||||
int(row.get("line_no", 0) or 0),
|
||||
row.get("evidence_ref", ""),
|
||||
source_file,
|
||||
]
|
||||
if dataset == "document_changes":
|
||||
return [
|
||||
normalize_ts(row.get("ts") or row.get("change_time")),
|
||||
row.get("change_id", ""),
|
||||
row.get("infobase", ""),
|
||||
row.get("company_entity_key", ""),
|
||||
row.get("organization", ""),
|
||||
row.get("document_id", row.get("doc_id", "")),
|
||||
row.get("document_number", row.get("doc_number", "")),
|
||||
row.get("document_type", row.get("doc_type", "")),
|
||||
row.get("change_kind", ""),
|
||||
row.get("field_name", ""),
|
||||
row.get("user", row.get("author", "")),
|
||||
row.get("before_value", ""),
|
||||
row.get("after_value", ""),
|
||||
row.get("risk_tag", ""),
|
||||
row.get("evidence_ref", ""),
|
||||
source_file,
|
||||
]
|
||||
if dataset == "companies":
|
||||
return [
|
||||
normalize_ts(row.get("ts")),
|
||||
@@ -194,6 +242,10 @@ def core_columns(dataset: str) -> list[str]:
|
||||
return ["ts", "infobase", "organization", "department", "doc_type", "doc_id", "doc_number", "author", "counterparty", "operation_type", "amount", "status", "posted", "source_file"]
|
||||
if dataset == "postings":
|
||||
return ["ts", "infobase", "registrar", "operation_type", "account_dt", "account_ct", "amount", "source_file"]
|
||||
if dataset == "business_events":
|
||||
return ["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"]
|
||||
if dataset == "document_changes":
|
||||
return ["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"]
|
||||
if dataset == "companies":
|
||||
return ["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"]
|
||||
if dataset == "reglog":
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.modules.setdefault("clickhouse_connect", SimpleNamespace(get_client=lambda **_: None))
|
||||
|
||||
import load_1c_exports as loader
|
||||
|
||||
|
||||
class Load1CExportsTests(unittest.TestCase):
|
||||
def test_business_events_mapping(self) -> None:
|
||||
row = {
|
||||
"event_time": "2026-05-22T12:00:00Z",
|
||||
"event_id": "evt-1",
|
||||
"infobase": "ИБ-1",
|
||||
"company_entity_key": "baseid:abc",
|
||||
"organization": "ООО Тест",
|
||||
"department": "Продажи",
|
||||
"doc_id": "doc-1",
|
||||
"doc_number": "0001",
|
||||
"doc_type": "Реализация",
|
||||
"registrar": "DOC-1",
|
||||
"operation_type": "sale",
|
||||
"event_kind": "posting",
|
||||
"author": "user1",
|
||||
"counterparty": "Контрагент",
|
||||
"counterparty_inn": "7700000000",
|
||||
"account_dt": "62.01",
|
||||
"account_ct": "90.01",
|
||||
"amount": "125000.50",
|
||||
"line_no": "2",
|
||||
"evidence_ref": "reglog:1",
|
||||
}
|
||||
mapped = loader.map_core_row("business_events", "events.jsonl", row)
|
||||
self.assertEqual(mapped[1], "evt-1")
|
||||
self.assertEqual(mapped[3], "baseid:abc")
|
||||
self.assertEqual(mapped[7], "0001")
|
||||
self.assertEqual(mapped[15], "62.01")
|
||||
self.assertEqual(mapped[17], 125000.5)
|
||||
self.assertEqual(mapped[19], 2)
|
||||
self.assertEqual(mapped[21], "events.jsonl")
|
||||
|
||||
def test_document_changes_mapping(self) -> None:
|
||||
row = {
|
||||
"change_time": "2026-05-22T12:00:00Z",
|
||||
"change_id": "chg-1",
|
||||
"infobase": "ИБ-1",
|
||||
"company_entity_key": "basepath:c:/1c/base",
|
||||
"organization": "ООО Тест",
|
||||
"document_id": "doc-2",
|
||||
"document_number": "0002",
|
||||
"document_type": "Поступление",
|
||||
"change_kind": "requisites_change",
|
||||
"field_name": "Контрагент",
|
||||
"user": "user2",
|
||||
"before_value": "Старый",
|
||||
"after_value": "Новый",
|
||||
"risk_tag": "counterparty_change",
|
||||
"evidence_ref": "audit:2",
|
||||
}
|
||||
mapped = loader.map_core_row("document_changes", "changes.jsonl", row)
|
||||
self.assertEqual(mapped[1], "chg-1")
|
||||
self.assertEqual(mapped[3], "basepath:c:/1c/base")
|
||||
self.assertEqual(mapped[8], "requisites_change")
|
||||
self.assertEqual(mapped[12], "Новый")
|
||||
self.assertEqual(mapped[15], "changes.jsonl")
|
||||
|
||||
def test_new_dataset_columns_shape(self) -> None:
|
||||
self.assertEqual(loader.RAW_TABLES["business_events"], "raw_1c_business_events")
|
||||
self.assertEqual(loader.CORE_TABLES["document_changes"], "document_change_events")
|
||||
self.assertEqual(len(loader.core_columns("business_events")), 22)
|
||||
self.assertEqual(len(loader.core_columns("document_changes")), 16)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,8 @@ ROOT="${AW_1C_ROOT:-/opt/activitywatch/clickhouse-1c}"
|
||||
mkdir -p \
|
||||
"${ROOT}/landing/documents" \
|
||||
"${ROOT}/landing/postings" \
|
||||
"${ROOT}/landing/business_events" \
|
||||
"${ROOT}/landing/document_changes" \
|
||||
"${ROOT}/landing/companies" \
|
||||
"${ROOT}/landing/registry" \
|
||||
"${ROOT}/landing/reglog" \
|
||||
@@ -13,6 +15,8 @@ mkdir -p \
|
||||
"${ROOT}/landing/host" \
|
||||
"${ROOT}/archive/documents" \
|
||||
"${ROOT}/archive/postings" \
|
||||
"${ROOT}/archive/business_events" \
|
||||
"${ROOT}/archive/document_changes" \
|
||||
"${ROOT}/archive/companies" \
|
||||
"${ROOT}/archive/registry" \
|
||||
"${ROOT}/archive/reglog" \
|
||||
|
||||
Reference in New Issue
Block a user