diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4697ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main, "agent/**"] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test-and-reconcile: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: python -m pip install -r requirements.txt + + - name: Run test suite + run: python -m pytest -q + + - name: Rebuild canonical scenario + run: | + python -m src.pipeline \ + --config config/scenario_base.yaml \ + --output-dir "$RUNNER_TEMP/b2b/synthetic" \ + --validated-dir "$RUNNER_TEMP/b2b/validated" \ + --marts-dir "$RUNNER_TEMP/b2b/marts" \ + --manifest-dir "$RUNNER_TEMP/b2b/manifest" + + - name: Reconcile deterministic artifacts and KPI controls + run: | + python -m src.reconcile \ + --expected-manifest data/manifest/run_manifest.json \ + --candidate-manifest "$RUNNER_TEMP/b2b/manifest/run_manifest.json" \ + --expected-controls data/manifest/kpi_controls.json \ + --candidate-controls "$RUNNER_TEMP/b2b/manifest/kpi_controls.json" diff --git a/.gitignore b/.gitignore index 4a2ee8f..fd4053e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,13 @@ # Power BI temporary lock files /powerbi/~$* +/powerbi/source/export/**/.pbi/ -# Documentation is private except the manual chat instructions +# Publish only the approved, sanitized contracts and manual chat instructions /docs/* +!/docs/PROJECT-CONTROL.md +!/docs/MVP-SPEC.md +!/docs/DATA-CONTRACT.md +!/docs/KPI-POWERBI-SPEC.md !/docs/chats_instruction/ !/docs/chats_instruction/** diff --git a/AGENTS.md b/AGENTS.md index b37bc41..81ad777 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,11 @@ --- doc_id: "MVP-D04" -title: "Codex Operating Contract — V1.0 Core" -version: "0.1" +title: "Repository Operating Contract — V1.0 Core" +version: "0.3" status: "approved" type: "operating_contract" created: "2026-08-13" -updated: "2026-08-13" +updated: "2026-08-16" depends_on: - "MVP-D00" - "MVP-D01" @@ -13,7 +13,7 @@ depends_on: - "MVP-D03" --- -# Codex Operating Contract — V1.0 Core +# Repository Operating Contract — V1.0 Core ## 1. Propósito @@ -61,23 +61,27 @@ Si código, pruebas y contrato discrepan: Cambiar un grain, PK, FK, campo, fórmula, restricción o rol de fecha exige aprobación del contrato correspondiente. -## 3. Estado y prohibición de Git +## 3. Estado de release y control de versiones ```yaml target_release: "V1.0" -release_status: "PRE_RELEASE" -implementation_mode: "local_codex_step_by_step" -git_policy: "DISABLED_UNTIL_V1_0_APPROVAL" +release_status: "V1.0" +implementation_mode: "local_step_by_step" +git_policy: "ENABLED_BY_USER_AFTER_V1_0_APPROVAL" ``` -Hasta que el usuario apruebe la consolidación V1.0: +El usuario aprobó la consolidación para iniciar Git y publicar el repositorio. Desde +2026-08-16: -- no ejecutar comandos Git; -- no crear repositorios, commits, ramas ni tags; -- no utilizar Git para restaurar, comparar o borrar archivos; -- no asumir que V1.0 está aprobada por el solo hecho de pasar pruebas. +- Git puede usarse para ramas, commits, revisión y publicación; +- todo cambio debe preservar contratos, evidencia y cambios ajenos; +- el tag y GitHub Release `v1.0.0` pueden crearse después de integrar el commit + validado en `main`; +- cualquier ampliación funcional posterior requiere un contrato nuevo. -Incluso después de aprobar V1.0, comenzar a usar Git requiere una instrucción explícita adicional del usuario. +El usuario aceptó explícitamente el 2026-08-16 el PBIX construido manualmente en +Power BI Desktop y promovió el release a `V1.0`. PBIP/PBIR queda como mejora de +versionado posterior; el artefacto ejecutable aceptado de V1.0 es el PBIX. ## 4. Forma de trabajo @@ -325,18 +329,28 @@ La Definition of Done del release completo es la de D00. Ningún agente puede pr ```yaml PROJECT_PROGRESS: target_release: "V1.0" - release_status: "PRE_RELEASE" - stage: "IMPLEMENTATION" - current_work_item: "MVP-I01" - name: "EXECUTABLE FOUNDATION" - status: "NOT_STARTED" + release_status: "V1.0" + stage: "RELEASED" + current_work_item: "V1.0-RELEASE" + name: "V1.0 CORE" + status: "COMPLETE" completed: - "MVP-D00" - "MVP-D01" - "MVP-D02" - "MVP-D03" - "MVP-D04" - next: "SYNTHETIC GENERATOR" + - "MVP-I01" + - "MVP-I02" + - "MVP-I03" + - "MVP-I04" + - "PBI-00" + - "PBI-01" + - "PBI-02" + - "PBI-03" + - "PBI-04" + - "PBI-05" + next: "POST_V1_BACKLOG_REQUIRES_NEW_CONTRACT" blockers: [] ``` @@ -345,3 +359,5 @@ PROJECT_PROGRESS: |Fecha|Versión documento|Estado|Cambio| |---|---|---|---| |2026-08-13|0.1|approved|Se aprueba el contrato operativo para traducir D00–D03 a una implementación reproducible, validada y sin Git hasta V1.0.| +|2026-08-16|0.2|approved|Se sincroniza el estado RELEASE_CANDIDATE, se habilita Git por autorización del usuario y se registran los gates técnicos cerrados y los gates Power BI pendientes.| +|2026-08-16|0.3|approved|El usuario acepta el PBIX construido manualmente, cierra PBI-04/PBI-05 y promueve el release a V1.0; PBIP/PBIR queda como mejora posterior.| diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..765ed35 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 robertoafn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..acf3514 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# System, b2b-prototipe + +[![CI](https://github.com/robertoafn/System-b2b-prototipe/actions/workflows/ci.yml/badge.svg)](https://github.com/robertoafn/System-b2b-prototipe/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +![Release](https://img.shields.io/badge/release-v1.0.0-brightgreen) +![Data](https://img.shields.io/badge/data-100%25%20synthetic-blue) + +Sistema analítico B2B reproducible que conecta operación comercial, cumplimiento +logístico, inventario y cost-to-serve. Genera datos sintéticos deterministas, +aplica contratos y controles de calidad, publica marts en esquema estrella y los +expone en un dashboard Power BI de cuatro páginas. + +> **Estado:** `V1.0`. El usuario aceptó el PBIX construido manualmente en Power BI +> Desktop como artefacto final; pipeline, controles, documentación y CI están +> cerrados para el tag `v1.0.0`. + +## Problema que resuelve + +En un contexto B2B, ventas, entregas, inventario y costos suelen vivir en grains +distintos. Unirlos directamente produce doble conteo y métricas ambiguas. Este +prototipo demuestra una ruta auditable para responder preguntas como: + +- ¿cuánto valor económico queda después del costo variable y el cost-to-serve? +- ¿qué clientes combinan rentabilidad y nivel de servicio favorables? +- ¿dónde aparecen quiebres de stock y cómo evoluciona la cobertura? +- ¿cómo cambian OTIF, Fill Rate, distancia y utilización logística? + +No usa datos reales ni representa el desempeño de una empresa. Todos los datos +operacionales son sintéticos; la geografía es sintética o referencial y está +etiquetada como tal. + +## Arquitectura + +```mermaid +flowchart LR + C["Contratos + configuración"] --> G["Generador sintético"] + G --> CSV["11 CSV sintéticos"] + CSV --> V["Validación: 93 gates"] + V --> PQ["11 Parquet validados"] + PQ --> M["Marts: 6 dimensiones + 6 hechos"] + M --> Q["91 gates de marts + 17 controles KPI"] + Q --> X["Manifest y reportes QA"] + M --> P["Power BI: 4 páginas Core"] +``` + +El comando contractual ejecuta toda la cadena de forma transaccional: si una +etapa falla, no reemplaza la última corrida válida. + +## Modelo estrella + +```mermaid +flowchart TB + DC["dim_customer"] --> FO["fact_orders"] + DC --> FOL["fact_order_lines"] + DC --> FD["fact_deliveries"] + DC --> FDL["fact_delivery_lines"] + DC --> FCTS["fact_cost_to_serve"] + DP["dim_product"] --> FOL + DP --> FDL + DP --> FI["fact_inventory"] + DL["dim_location"] --> FO + DL --> FD + DL --> FDL + DL --> FCTS + DS["dim_operational_site"] --> FD + DS --> FDL + DS --> FI + DV["dim_vehicle"] --> FD + DV --> FDL + DV --> FCTS + DD["dim_date"] --> FO + DD --> FOL + DD --> FD + DD --> FDL + DD --> FI + DD --> FCTS +``` + +Power BI contiene 34 relaciones dimensión `1:N`, filtro simple: 24 activas y 10 +inactivas para roles de fecha. No existen relaciones fact-to-fact, many-to-many +ni bidireccionales. `meta_run` y `qa_results` son tablas técnicas desconectadas. + +## Dashboard Power BI + +### Executive + +![Executive dashboard](docs/chats_instruction/assets/executive.png) + +### Customer 360 + +![Customer 360 dashboard](docs/chats_instruction/assets/customer-360.png) + +### Service & Inventory + +![Service and Inventory dashboard](docs/chats_instruction/assets/service-inventory.png) + +### Profitability & Geography + +![Profitability and Geography dashboard](docs/chats_instruction/assets/profitability-geography.png) + +El artefacto actual está en [`powerbi/b2b_v1.pbix`](powerbi/b2b_v1.pbix). La guía +de construcción y aceptación manual está en +[`docs/chats_instruction/POWER-BI-INTERACTIVE-GUIDE.md`](docs/chats_instruction/POWER-BI-INTERACTIVE-GUIDE.md). + +## KPI + +| Área | KPI oficiales | +|---|---| +| Economía | Net Sales, Variable Product Cost, Contribution Margin, CTS, MC_CTS, Average Order Value | +| Comercial | Orders, Customer Frequency, Customers | +| Servicio | OTIF, Fill Rate, Order-to-Delivery Lead Time | +| Inventario | Stockout Rate, Available Inventory EOP | +| Logística | Delivery Distance, Transport Cost, Vehicle Utilization | + +El modelo agrega 2 medidas de soporte (`Eligible Orders`, `OTIF Orders`) y 20 +medidas auxiliares para visuales, trazabilidad y QA: 39 medidas DAX en total. + +Reglas clave: + +- ventas y costo variable se reconocen sobre cantidad entregada; +- `MC_CTS = Net Sales - Variable Product Cost - CTS`; +- CTS y MC_CTS no se atribuyen a SKU en V1.0; +- OTIF se calcula a grain pedido elegible; +- Fill Rate es ratio de sumas; +- inventario EOP usa la última fecha visible y no suma snapshots. + +Las definiciones completas están en +[`docs/KPI-POWERBI-SPEC.md`](docs/KPI-POWERBI-SPEC.md). + +## Ejecución local + +Requiere Python 3.12 o compatible. + +```powershell +python -m pip install -r requirements.txt +python -m src.pipeline --config config/scenario_base.yaml +python -m pytest -q +``` + +El segundo comando ejecuta: + +```text +generación → validación → marts → controles → manifest +``` + +Contrato de salida: + +- código `0`: todas las etapas y gates obligatorios pasaron; +- código distinto de `0`: existe un error o gate fallido; +- una falla conserva la última publicación válida; +- no requiere red, credenciales ni datos reales. + +## Evidencia de QA + +Escenario canónico `B2B-V1-BASE`: + +| Control | Resultado | +|---|---:| +| `dataset_build_id` | `BLD-9102B6E8BF1ABFFC5F32` | +| Datasets sintéticos | 11 | +| Gates de fuente | 93/93 passed | +| Tablas de marts | 14 | +| Gates de marts | 91/91 passed | +| Controles KPI Python | 17 | +| Pruebas automatizadas | 46 passed | +| Pedidos / líneas | 4.000 / 10.000 | +| Entregas / líneas de entrega | 5.000 / 12.483 | + +CI ejecuta pruebas, reconstruye el escenario en una carpeta temporal y compara +los hashes deterministas y controles KPI con el baseline versionado. `meta_run` +se excluye del checksum combinado determinista porque contiene la hora y el ID de +cada ejecución. + +## Contratos y estructura + +```text +config/ escenario canónico +data/synthetic/ CSV sintéticos +data/validated/ Parquet validados +data/marts/ esquema estrella y tablas técnicas +data/manifest/ manifest, QA y controles KPI +schemas/ contratos de esquema +src/ generación, validación, marts y pipeline +tests/ unitarias, contrato, negativas, golden e integración +powerbi/ PBIX y fuente versionable auxiliar +docs/ contratos públicos sanitizados +``` + +Orden de autoridad para agentes y LLM: + +1. [`docs/PROJECT-CONTROL.md`](docs/PROJECT-CONTROL.md) +2. [`docs/MVP-SPEC.md`](docs/MVP-SPEC.md) +3. [`docs/DATA-CONTRACT.md`](docs/DATA-CONTRACT.md) +4. [`docs/KPI-POWERBI-SPEC.md`](docs/KPI-POWERBI-SPEC.md) +5. [`AGENTS.md`](AGENTS.md) + +## Limitaciones conocidas + +- Los datos son sintéticos y no sirven como benchmark empresarial. +- No incluye ERP, CRM, facturación, pagos, forecasting, routing ni optimización. +- El mapa ArcGIS puede requerir conectividad y disponibilidad del visual. +- El artefacto Power BI aceptado de V1.0 es PBIX. El repositorio incluye un script + TMDL de medidas y un contrato legible; una exportación PBIP/PBIR completa queda + como mejora de versionado posterior. + +## Próximos pasos posibles + +1. opcionalmente guardar `b2b_v1.pbix` como PBIP con TMDL y PBIR desde Power BI + Desktop; +2. revisar y versionar el diff generado, excluyendo caches locales `.pbi`; +3. abrir contratos nuevos antes de incorporar cualquier alcance post-V1.0. + +## Licencia + +[MIT](LICENSE). Los datos sintéticos y el software pueden reutilizarse bajo sus +términos; Power BI y ArcGIS conservan sus licencias y marcas respectivas. diff --git a/data/manifest/run_manifest.json b/data/manifest/run_manifest.json index 328ef3a..eca4829 100644 --- a/data/manifest/run_manifest.json +++ b/data/manifest/run_manifest.json @@ -2,6 +2,7 @@ "artifacts": { "kpi_controls_sha256": "D9A6B766E81CA92B57E7A19B4C907C3C9604CDB2CB3BFE594A5C8339303E90B6", "marts_combined_sha256": "695B212124321199B65C090F6251C98083424383BF9CB4834848D48667CDC661", + "marts_deterministic_combined_sha256": "BEBE947BFB219ECE297F558691FFB1518C2E18B98932020B5B28B5206F831E07", "marts_parquet_sha256": { "dim_customer": "0516E4B34AD4A14D6F9373A30D7FBD3959E26A1C4516C7F42C08941246D5171E", "dim_date": "4A0721175358280EDFB625F52CAFBFCCD69D566AFA233EC20A5F6D4562A5CB9C", diff --git a/docs/DATA-CONTRACT.md b/docs/DATA-CONTRACT.md new file mode 100644 index 0000000..f1f91a1 --- /dev/null +++ b/docs/DATA-CONTRACT.md @@ -0,0 +1,1035 @@ +--- +doc_id: "MVP-D02" +title: "V1.0 Core Data Contract" +version: "0.4" +status: "approved" +type: "data_specification" +created: "2026-08-13" +updated: "2026-08-16" +depends_on: + - "MVP-D00" + - "MVP-D01" +--- + +# V1.0 Core Data Contract + +## 1. Propósito + +Definir el contrato mínimo que deberán respetar: + +```text +synthetic generator +→ validation +→ analytical marts +→ Power BI +``` + +Este documento es la autoridad para: + +- granularidad; +- claves primarias; +- claves foráneas; +- campos mínimos; +- tipos lógicos; +- restricciones; +- semántica sintética. + +No define todavía una base de datos productiva. + +--- + +## 2. Invariantes + +```text +1 row = 1 declared grain + +PK != NULL +PK = UNIQUE + +FK → valid parent + +facts with different grain remain separate + +synthetic operational data: +is_synthetic = true + +semantic changes require contract update +``` + +La implementación no deberá modificar silenciosamente: + +```text +grain +PK +FK +field meaning +temporal rules +synthetic-data semantics +``` + +### 2.1 Convenciones comunes + +|Concepto|Contrato| +|---|---| +|Timezone|`America/Santiago`| +|Datetime|ISO 8601 con offset UTC explícito| +|Date|ISO 8601 `YYYY-MM-DD`| +|Moneda|CLP, neta de IVA| +|Redondeo monetario|2 decimales, half-up| +|Cantidad|decimal con hasta 3 decimales| +|Encoding CSV|UTF-8| +|Null|sólo permitido cuando se indique explícitamente| + +Todos los datasets tabulares incluyen, además de sus campos específicos: + +|Campo|Tipo lógico|Regla| +|---|---|---| +|`scenario_id`|string|`B2B-V1-BASE` para aceptación| +|`dataset_build_id`|string|determinista para configuración, seed y versiones| +|`is_synthetic`|boolean|`true`, salvo localización pública clasificada| + +`dataset_build_id` no incorpora el timestamp de ejecución. Dos ejecuciones equivalentes deben producir archivos tabulares idénticos. + +--- + +# 3. Datasets de V1.0 Core + +```text +customers +products +locations +operational_sites +vehicles +orders +order_lines +inventory +deliveries +delivery_lines +cost_activities +``` + +Salida prevista: + +```text +data/synthetic/.csv +data/validated/.parquet +data/manifest/run_manifest.json +``` + +CSV constituye la salida source sintética; Parquet es la salida validada preferida. El manifiesto no es un hecho de negocio y registra versiones, conteos, calidad y checksums. + +Posteriormente estos datasets serán transformados en dimensiones y hechos para Power BI. + +--- + +# 4. `customers` + +**Grain** + +```text +1 row = 1 customer +``` + +**PK** + +```text +customer_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `customer_id` | string | PK | +| `customer_name` | string | required | +| `customer_segment` | string | required | +| `location_id` | string | FK → locations | +| `created_date` | date | required | +| `is_active` | boolean | required | +| `is_synthetic` | boolean | true | + +ID: + +```text +CUS-SYN-### +``` + +--- + +# 5. `products` + +**Grain** + +```text +1 row = 1 SKU +``` + +**PK** + +```text +sku_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `sku_id` | string | PK | +| `sku_name` | string | required | +| `product_family` | string | required | +| `unit` | string | required | +| `reference_list_unit_price` | number | > 0 | +| `reference_variable_unit_cost` | number | >= 0 | +| `weight_kg` | number | > 0 | +| `volume_m3` | number | > 0 | +| `is_active` | boolean | required | +| `is_synthetic` | boolean | true | + +Los precios y costos aquí son valores de referencia. + +El valor transaccional utilizado para un pedido se conserva en `order_lines`. + +--- + +# 6. `locations` + +**Grain** + +```text +1 row = 1 geographical location +``` + +**PK** + +```text +location_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `location_id` | string | PK | +| `location_type` | string | `site` / `customer` | +| `commune` | string | required | +| `region` | string | required | +| `country_code` | string | `CL` | +| `latitude` | number | [-90, 90] | +| `longitude` | number | [-180, 180] | +| `data_class` | string | `synthetic` / `reference_location` | +| `is_synthetic` | boolean | consistent with `data_class` | + +El nodo operacional: + +```text +SOR-01 +``` + +puede utilizar una localización geográfica de referencia con `data_class = reference_location` e `is_synthetic = false`. + +Las ubicaciones de clientes del piloto serán sintéticas y deberán usar `data_class = synthetic` e `is_synthetic = true`. + +--- + +# 6A. `operational_sites` + +**Grain** + +```text +1 row = 1 operational site +``` + +**PK** + +```text +site_id +``` + +**FK** + +```text +location_id → locations.location_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `site_id` | string | `SOR-01` en V1.0 | +| `location_id` | string | FK a location tipo `site` | +| `site_type` | string | `operational_logistics_node` | +| `is_active` | boolean | true en escenario base | +| `is_synthetic` | boolean | true | + +La operación del sitio es hipotética aunque su ubicación pueda ser una referencia pública. + +--- + +# 7. `vehicles` + +**Grain** + +```text +1 row = 1 vehicle +``` + +**PK** + +```text +vehicle_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `vehicle_id` | string | PK | +| `vehicle_type` | string | required | +| `capacity_kg` | number | > 0 | +| `capacity_m3` | number | > 0 | +| `fixed_cost_day` | number | >= 0 | +| `variable_cost_km` | number | >= 0 | +| `is_active` | boolean | required | +| `is_synthetic` | boolean | true | + +ID: + +```text +VEH-SYN-## +``` + +--- + +# 8. `orders` + +**Grain** + +```text +1 row = 1 order +``` + +**PK** + +```text +order_id +``` + +**FK** + +```text +customer_id → customers.customer_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `order_id` | string | PK | +| `customer_id` | string | FK | +| `order_datetime` | datetime | required | +| `requested_delivery_datetime` | datetime | nullable; when present `>= order_datetime` | +| `promised_delivery_datetime` | datetime | `>= order_datetime` | +| `order_status` | string | controlled value | +| `is_synthetic` | boolean | true | + +Estados V1.0 Core: + +```text +confirmed +partially_fulfilled +fulfilled +``` + +Cancelaciones no se generan en `B2B-V1-BASE`. El estado se reconciliará con las cantidades acumuladas entregadas. + +ID: + +```text +ORD-SYN-###### +``` + +--- + +# 9. `order_lines` + +**Grain** + +```text +1 row = 1 SKU within 1 order +``` + +**PK** + +```text +order_line_id +``` + +**FK** + +```text +order_id → orders.order_id +sku_id → products.sku_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `order_line_id` | string | PK; `OLN-SYN-#######` | +| `order_id` | string | FK | +| `sku_id` | string | FK | +| `ordered_quantity` | number | > 0 | +| `list_unit_price` | number | > 0 | +| `synthetic_discount_per_unit` | number | [0, list_unit_price] | +| `net_unit_price` | number | derived; >= 0 | +| `unit_variable_cost` | number | >= 0 | +| `ordered_net_sales` | number | derived | +| `is_synthetic` | boolean | true | + +Derivación: + +```text +net_unit_price += +list_unit_price - synthetic_discount_per_unit +``` + +```text +ordered_net_sales += +ordered_quantity × net_unit_price +``` + +`ordered_net_sales` es informativo. Ventas reconocidas y costo variable realizado se calcularán sobre cantidad efectivamente entregada. + +--- + +# 10. `inventory` + +**Grain** + +```text +1 row += +1 SKU +× 1 operational site +× 1 snapshot date +``` + +**PK compuesta** + +```text +snapshot_date ++ site_id ++ sku_id +``` + +**FK** + +```text +site_id → operational_sites.site_id +sku_id → products.sku_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `snapshot_date` | date | PK component | +| `site_id` | string | `SOR-01` | +| `sku_id` | string | PK/FK | +| `on_hand_quantity` | number | >= 0 | +| `allocated_quantity` | number | >= 0 | +| `available_quantity` | number | >= 0 | +| `safety_stock_quantity` | number | >= 0 | +| `reorder_point_quantity` | number | >= safety_stock_quantity | +| `is_stockout` | boolean | derived | +| `is_low_inventory` | boolean | derived | +| `is_synthetic` | boolean | true | + +Reglas V1.0 Core: + +```text +available_quantity += +on_hand_quantity - allocated_quantity +``` + +```text +allocated_quantity <= on_hand_quantity +``` + +```text +is_stockout += +available_quantity == 0 +``` + +```text +is_low_inventory += +0 < available_quantity <= reorder_point_quantity +``` + +El escenario base generará una observación diaria por cada combinación SOR-01 × SKU durante todo el período: 10.950 filas para 365 fechas × 30 SKU × 1 sitio. + +Estas fórmulas son reglas del piloto, no políticas universales de inventario. + +--- + +# 11. `deliveries` + +**Grain** + +```text +1 row = 1 delivery event for 1 order +``` + +Un pedido puede tener una o más entregas. Cada entrega pertenece a un solo pedido en V1.0 Core. + +**PK** + +```text +delivery_id +``` + +**FK** + +```text +order_id → orders.order_id +vehicle_id → vehicles.vehicle_id +origin_site_id → operational_sites.site_id +destination_location_id → locations.location_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `delivery_id` | string | PK; `DEL-SYN-######` | +| `order_id` | string | FK | +| `vehicle_id` | string | FK | +| `origin_site_id` | string | FK; `SOR-01` | +| `destination_location_id` | string | FK a ubicación del cliente del pedido | +| `delivery_sequence` | integer | > 0; unique dentro del pedido | +| `dispatch_datetime` | datetime | >= order_datetime | +| `delivery_datetime` | datetime | >= dispatch_datetime | +| `distance_km` | number | >= 0 | +| `delivery_status` | string | `delivered` / `delivered_with_incident` | +| `is_synthetic` | boolean | true | + +# 11A. `delivery_lines` + +**Grain** + +```text +1 row = quantity of 1 order line included in 1 delivery +``` + +**PK** + +```text +delivery_line_id +``` + +**FK** + +```text +delivery_id → deliveries.delivery_id +order_line_id → order_lines.order_line_id +``` + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `delivery_line_id` | string | PK; `DLN-SYN-#######` | +| `delivery_id` | string | FK | +| `order_line_id` | string | FK | +| `delivered_quantity` | number | > 0 | +| `is_synthetic` | boolean | true | + +La misma línea de pedido puede aparecer en varias entregas. Para cada fila, `deliveries.order_id` debe coincidir con `order_lines.order_id`. + +```text +Σ delivered_quantity by order_line_id +<= ordered_quantity +``` + +No se permiten sobreentregas. + +## Servicio derivable a nivel de pedido + +```text +Order In Full += +all order lines have cumulative delivered quantity >= ordered quantity +``` + +```text +completion_datetime += +latest delivery_datetime required to complete all order lines +``` + +```text +Order On Time += +Order In Full AND completion_datetime <= promised_delivery_datetime +``` + +```text +Order OTIF += +Order In Full AND Order On Time +``` + +Para pedidos incompletos, la clasificación `partial_on_time` o `partial_late` seguirá `MVP-SPEC.md`. La fórmula KPI final permanece gobernada por `KPI-POWERBI-SPEC.md`. + +--- + +# 12. `cost_activities` + +**Grain** + +```text +1 row += +1 cost component +attributed to 1 order +``` + +**PK** + +```text +cost_activity_id +``` + +**FK** + +```text +order_id → orders.order_id +delivery_id → deliveries.delivery_id +``` + +`delivery_id` puede ser nulo cuando el costo corresponde al pedido completo. + +| Campo | Tipo lógico | Regla | +|---|---|---| +| `cost_activity_id` | string | PK; `CST-SYN-########` | +| `order_id` | string | FK | +| `delivery_id` | string | nullable FK | +| `activity_datetime` | datetime | >= order_datetime | +| `cost_type` | string | controlled value | +| `cost_amount` | number | >= 0 | +| `currency_code` | string | `CLP` | +| `cost_driver_value` | number | nullable; when present >= 0 | +| `cost_driver_unit` | string | nullable; required when driver exists | +| `allocation_method` | string | controlled value | +| `is_synthetic` | boolean | true | + +Tipos iniciales: + +```text +order_processing +picking +handling +transport +delivery +incident +``` + +Métodos de asignación iniciales: + +```text +direct_order +direct_delivery +proportional_weight +proportional_volume +proportional_distance +``` + +`transport` y `delivery` requieren `delivery_id`. Cada actividad se registra una sola vez; sumar CTS no debe duplicar costos por joins con líneas. + +La separación de costos por actividad permite evolucionar posteriormente hacia TDABC sin exigirlo en V1.0 Core. + +--- + +# 13. Métricas económicas derivables + +## Net Sales + +```text +Net Sales += +Σ( + delivery_lines.delivered_quantity + × order_lines.net_unit_price +) +``` + +## Variable Product Cost + +```text +Variable Product Cost += +Σ( + delivery_lines.delivered_quantity + × order_lines.unit_variable_cost +) +``` + +Las cantidades no entregadas no reconocen ventas ni costo variable. `ordered_net_sales` no forma parte de Net Sales oficial. + +## Contribution Margin + +```text +Contribution Margin += +Net Sales +- +Variable Product Cost +``` + +## Cost-to-Serve + +```text +CTS += +Σ cost_amount +``` + +## MC_CTS + +```text +MC_CTS += +Contribution Margin +- +CTS +``` + +`MC_CTS` no debe denominarse margen neto. + +El CTS se modela por actividades porque el costo de atender clientes puede modificar materialmente la rentabilidad observada a partir del margen de producto. + +--- + +# 14. Relaciones mínimas + +```text +Customer 1 ── N Order + +Location 1 ── N Customer + +Location 1 ── N OperationalSite + +OperationalSite 1 ── N InventorySnapshot + +OperationalSite 1 ── N Delivery + +Order 1 ── N OrderLine + +Order 1 ── 0..N Delivery + +Product 1 ── N OrderLine + +Product 1 ── N InventorySnapshot + +Delivery 1 ── N DeliveryLine + +OrderLine 1 ── 0..N DeliveryLine + +Vehicle 1 ── N Delivery + +Location 1 ── N Delivery + +Order 1 ── N CostActivity + +Delivery 1 ── 0..N CostActivity +``` + +--- + +# 15. Quality Gates + +La implementación deberá validar como mínimo: + +## Schema + +```text +required columns exist +logical types are valid +``` + +## PK + +```text +PK has no NULL +PK is UNIQUE +``` + +## FK + +```text +orphan FK count = 0 +``` + +## Quantities + +```text +ordered_quantity > 0 + +delivered_quantity > 0 + +sum(delivered_quantity by order_line_id) <= ordered_quantity + +inventory quantities >= 0 + +allocated_quantity <= on_hand_quantity + +available_quantity = on_hand_quantity - allocated_quantity +``` + +## Economics + +```text +list_unit_price > 0 +0 <= synthetic_discount_per_unit <= list_unit_price +net_unit_price = list_unit_price - synthetic_discount_per_unit +unit_variable_cost >= 0 +cost_amount >= 0 +``` + +## Time + +```text +order_datetime <= requested_delivery_datetime, when present + +order_datetime <= promised_delivery_datetime + +order_datetime <= dispatch_datetime + +dispatch_datetime <= delivery_datetime +``` + +## Cross-dataset business rules + +```text +delivery.order_id = order_line.order_id for every delivery_line +delivery.destination_location_id = customer.location_id for the delivery order +cost_activity.order_id = delivery.order_id when delivery_id is present +transport/delivery cost activities require delivery_id +order_status reconciles with cumulative delivered quantities +daily inventory coverage exists for every site × SKU × date +order_datetime >= customer.created_date +UNIQUE(order_id, sku_id) in order_lines +UNIQUE(order_id, delivery_sequence) in deliveries +UNIQUE(delivery_id, order_line_id) in delivery_lines +delivery weight and volume do not exceed vehicle capacity +``` + +## Geography + +```text +-90 <= latitude <= 90 + +-180 <= longitude <= 180 +``` + +## Synthetic provenance + +Para datos operacionales generados: + +```text +is_synthetic = true +``` + +La única excepción permitida es una fila de `locations` clasificada como `reference_location`. + +--- + +# 16. Reproducibilidad + +El generador deberá registrar como mínimo: + +```yaml +scenario_id: "B2B-V1-BASE" +data_class: "synthetic" +seed: 20260813 +generator_version: "0.1.0" +schema_version: "1.0.0-rc.1" +config_sha256: "required" +dataset_build_id: "required" +``` + +Misma configuración + mismas versiones + misma semilla debe producir los mismos IDs, filas, valores ordenados y checksums tabulares. + +El manifiesto registrará además: + +```text +execution_id +executed_at +input and output paths +rows read, written and rejected by dataset +rejection reasons +artifact SHA-256 +quality-gate results +execution duration and status +sanitized error, if any +``` + +La implementación deberá generar deliberadamente variabilidad suficiente para representar: + +```text +stockouts +on-time / late deliveries +full / partial deliveries +heterogeneous CTS +different customer profitability +territorial variation +``` + +Gates mínimos para `B2B-V1-BASE`: + +```text +customers = 150 +products = 30 +vehicles = 6 +operational_sites = 1 +orders within 4000 ± 5% +order_lines within 10000 ± 10% +complete_on_time_orders >= 60% of eligible orders +each remaining service class >= 5% of eligible orders +stockout observations >= 1% +low-inventory observations >= 5% +customers in each sales/CTS quadrant >= 5 +``` + +sin presentar dichas relaciones sintéticas como evidencia empresarial real. + +--- + +# 17. Salida analítica + +Este contrato gobierna los datasets sintéticos. + +La transformación posterior deberá producir el modelo establecido en `KPI-POWERBI-SPEC.md`: + +```text +DIMENSIONS + +dim_customer +dim_product +dim_location +dim_operational_site +dim_vehicle +dim_date +``` + +```text +FACTS + +fact_orders +fact_order_lines +fact_deliveries +fact_delivery_lines +fact_inventory +fact_cost_to_serve +``` + +No crear una mega-tabla que combine hechos de granularidades diferentes. + +`KPI-POWERBI-SPEC.md` deberá conservar estos grains o documentar explícitamente una proyección equivalente que no pierda entregas divididas ni amplifique joins. + +--- + +# 18. Acceptance Gate + +El contrato estará implementado cuando tests deterministas demuestren: + +```text +[ ] schemas válidos +[ ] PK no nulas +[ ] PK únicas +[ ] FK sin huérfanos +[ ] rangos válidos +[ ] fechas consistentes +[ ] generación reproducible +[ ] is_synthetic preservado +[ ] pedido reconstruible end-to-end +[ ] múltiples entregas y líneas parciales reconciliables +[ ] cero sobreentregas acumuladas +[ ] Fill Rate reproducible +[ ] OTIF reproducible a grain Order +[ ] CTS reconciliable +[ ] MC_CTS reconciliable +[ ] marts conservan el grain definido +[ ] escenarios mínimos de B2B-V1-BASE presentes +[ ] manifiesto y checksums emitidos +[ ] fixtures inválidos son rechazados por el gate correspondiente +``` + +La validación debe poder expresarse posteriormente mediante: + +```text +Python ++ pandas ++ pytest +``` + +antes de introducir frameworks adicionales. + +--- + +# 19. Regla para agentes y Codex + +Cuando: + +```text +implementation != DATA-CONTRACT.md +``` + +el agente debe: + +```text +1. reportar la divergencia +2. identificar archivos/tests afectados +3. preservar el contrato vigente +4. no realizar migración semántica silenciosa +``` + +Un cambio de: + +```text +grain +PK +FK +campo +semántica +restricción +``` + +requiere modificar explícitamente este documento. + +--- + +# 20. Estado + +```yaml +PROJECT_PROGRESS: + target_release: "V1.0" + release_status: "V1.0" + stage: "RELEASED" + current_work_item: "V1.0-RELEASE" + status: "COMPLETE" + completed: + - "MVP-D00" + - "MVP-D01" + - "MVP-D02" + - "MVP-D03" + - "MVP-D04" + - "MVP-I01" + - "MVP-I02" + - "MVP-I03" + - "MVP-I04" + - "PBI-00" + - "PBI-01" + - "PBI-02" + - "PBI-03" + - "PBI-04" + - "PBI-05" + next: "POST_V1_BACKLOG_REQUIRES_NEW_CONTRACT" + blockers: [] +``` + +--- + +# 21. Registro de cambios + +|Fecha|Versión documento|Estado|Cambio| +|---|---|---|---| +|2026-08-13|0.1|draft|Contrato ampliado por el usuario desde el borrador incompleto.| +|2026-08-13|0.2|approved|Se reconcilia y aprueba contra D00/D01: sitios, entregas múltiples, líneas de entrega, reconocimiento por cantidad entregada, manifiesto, calidad y reproducibilidad V1.0.| +|2026-08-16|0.3|approved|Se sincroniza el estado RELEASE_CANDIDATE sin alterar grains, claves, campos, restricciones ni semántica económica.| +|2026-08-16|0.4|approved|Se promueve el contrato implementado a V1.0 por aceptación explícita del usuario, sin cambios semánticos adicionales.| diff --git a/docs/KPI-POWERBI-SPEC.md b/docs/KPI-POWERBI-SPEC.md new file mode 100644 index 0000000..9c508f4 --- /dev/null +++ b/docs/KPI-POWERBI-SPEC.md @@ -0,0 +1,531 @@ +--- +doc_id: "MVP-D03" +title: "V1.0 KPI and Power BI Specification" +version: "0.4" +status: "approved" +type: "analytics_specification" +created: "2026-08-13" +updated: "2026-08-16" +depends_on: + - "MVP-D01" + - "MVP-D02" +--- + +# V1.0 KPI and Power BI Specification + +## 1. Objetivo + +Definir el modelo estrella, las medidas computables, los contextos de filtro y el diseño funcional de las cuatro páginas Power BI de V1.0 Core. + +La métrica primaria es `MC_CTS`. Net Sales, Contribution Margin y CTS explican su movimiento; OTIF, Fill Rate y Stockout Rate actúan como guardrails de servicio e inventario. + +No se fijan metas empresariales en V1.0 porque el dataset es sintético y no existe baseline operacional real. Los umbrales de D01/D02 son gates de cobertura y calidad, no objetivos de negocio. + +## 2. Modelo estrella + +### 2.1 Dimensiones conformadas + +|Tabla|Grain|Clave|Uso| +|---|---|---|---| +|`dim_customer`|1 cliente|`customer_key`|segmento y Customer 360| +|`dim_product`|1 SKU|`product_key`|familia, unidad y mix| +|`dim_location`|1 ubicación|`location_key`|comuna, región y mapa| +|`dim_operational_site`|1 sitio|`site_key`|inventario y origen| +|`dim_vehicle`|1 vehículo|`vehicle_key`|capacidad y utilización| +|`dim_date`|1 fecha|`date_key`|calendario único| + +Las dimensiones tendrán surrogate key entera y conservarán el identificador contractual como business key. + +### 2.2 Hechos + +|Tabla|Grain|Propósito| +|---|---|---| +|`fact_orders`|1 pedido|resultado de servicio y economía reconciliada por pedido| +|`fact_order_lines`|1 línea de pedido|demanda, precio, cantidad pedida y cantidad entregada acumulada| +|`fact_deliveries`|1 evento de entrega|distancia, vehículo, carga y fechas| +|`fact_delivery_lines`|1 línea de pedido incluida en 1 entrega|cantidad y economía reconocida por SKU| +|`fact_inventory`|1 fecha × sitio × SKU|snapshot diario de inventario| +|`fact_cost_to_serve`|1 actividad de costo|detalle CTS y driver| + +`fact_orders` contendrá los totales reconciliados `recognized_net_sales`, `variable_product_cost`, `contribution_margin`, `cts_amount` y `mc_cts_amount`. El detalle debe cuadrar con `fact_delivery_lines` y `fact_cost_to_serve`. + +Columnas analíticas mínimas adicionales: + +|Fact|Claves/fechas|Medidas y flags mínimos| +|---|---|---| +|`fact_orders`|order, customer, location, order/promised/completion date|eligible, in-full, on-time, OTIF, service class, quantities, lead time, economía reconciliada| +|`fact_order_lines`|order line, order, customer, product, location, order/promised date|ordered quantity, delivered quantity, list/net price, ordered net sales| +|`fact_deliveries`|delivery, order, customer, location, site, vehicle, order/dispatch/delivery/promised date|distance, delivered weight/volume, capacities, weight/volume utilization| +|`fact_delivery_lines`|delivery line, delivery, order line, order, customer, product, location, site, vehicle, order/delivery/promised date|delivered quantity, recognized net sales, variable cost, contribution margin| +|`fact_inventory`|snapshot date, site, product|on-hand, allocated, available, safety stock, reorder point, stockout/low flags| +|`fact_cost_to_serve`|cost activity, order, optional delivery, customer, location, optional vehicle, order/activity/delivery date|cost type, amount, driver, allocation method| + +### 2.3 Relaciones + +Reglas obligatorias: + +```text +dimension 1 → N fact +filter direction = single +facts do not relate directly to other facts +no many-to-many relationships +one and only one dim_date +``` + +Matriz de relaciones activas no temporales: + +|Dimensión|Facts relacionados| +|---|---| +|`dim_customer`|orders, order_lines, deliveries, delivery_lines, cost_to_serve| +|`dim_product`|order_lines, delivery_lines, inventory| +|`dim_location`|orders, deliveries, delivery_lines, cost_to_serve| +|`dim_operational_site`|deliveries, delivery_lines, inventory| +|`dim_vehicle`|deliveries, delivery_lines; cost_to_serve cuando exista entrega| + +Las claves de dimensión opcionales en `fact_cost_to_serve` podrán ser `Unknown/Not applicable`, nunca FK huérfanas. + +Los IDs `order_id`, `order_line_id` y `delivery_id` se conservarán como dimensiones degeneradas para trazabilidad y drill-through, no como relaciones entre hechos. + +### 2.4 Roles de fecha + +|Hecho|Relación activa|Relaciones inactivas/alternativas| +|---|---|---| +|`fact_orders`|`order_date_key`|`promised_date_key`, `completion_date_key`| +|`fact_order_lines`|`order_date_key`|`promised_date_key`| +|`fact_deliveries`|`delivery_date_key`|`order_date_key`, `dispatch_date_key`, `promised_date_key`| +|`fact_delivery_lines`|`order_date_key`|`delivery_date_key`, `promised_date_key`| +|`fact_inventory`|`snapshot_date_key`|ninguna| +|`fact_cost_to_serve`|`order_date_key`|`activity_date_key`, `delivery_date_key`| + +Medidas de servicio por compromiso utilizarán `promised_date_key`. Las medidas económicas utilizarán `order_date_key`. Tendencias físicas de entregas utilizarán `delivery_date_key`. + +### 2.5 Seguridad de agregación + +- `fact_inventory` es semi-aditivo en el tiempo y nunca se suma entre fechas. +- CTS se suma desde una actividad única o desde el total reconciliado por pedido, nunca desde ambos a la vez. +- `MC_CTS` es válido por pedido, cliente, ubicación de cliente y período de pedido. +- `MC_CTS` no es válido por SKU en V1.0 porque CTS no se asigna a producto. +- Una medida MC_CTS bajo filtro de producto deberá retornar `BLANK()` para evitar una atribución falsa. + +### 2.6 Tablas técnicas desconectadas + +|Tabla|Grain|Uso| +|---|---|---| +|`meta_run`|1 fila para el build cargado|build ID, ejecución, fechas máximas, checksums y estado QA| +|`qa_results`|1 fila por quality gate|nombre, estado, violaciones y mensaje sanitizado| + +Estas tablas provienen del manifiesto/reporte QA y no participan en relaciones del modelo estrella. + +## 3. KPIs + +### 3.1 Jerarquía de medición + +|Rol|Métricas|Decisión| +|---|---|---| +|Primary outcome|MC_CTS|priorizar clientes y políticas con contribución económica después de servir| +|Value drivers|Net Sales, Contribution Margin, CTS|explicar el resultado económico| +|Guardrails|OTIF, Fill Rate, Stockout Rate|evitar interpretar ahorro o margen con deterioro de servicio| +|Diagnostics|Orders, AOV, Lead Time, Inventory, Frequency, Distance, Transport Cost, Vehicle Utilization, Customers|localizar causas y segmentos| + +### 3.2 Convenciones de medida + +```text +percentage storage = decimal in [0,1] +percentage display = 0.0% +currency display = CLP with 2 decimals +distance = km +lead time = hours +zero denominator = BLANK, never 0 or infinity +missing observation = BLANK unless contract defines zero +``` + +Los pedidos elegibles y las cuatro clases de servicio siguen D01. Los pedidos cancelados no se generan en `B2B-V1-BASE`. + +### 3.3 Diccionario computable + +|ID|KPI|Fórmula contractual|Fuente oficial|Fecha|Grain válido| +|---|---|---|---|---|---| +|KPI-01|Net Sales|`SUM(recognized_net_sales)`|`fact_orders`|order date|pedido, cliente, ubicación, período| +|KPI-02|Variable Product Cost|`SUM(variable_product_cost)`|`fact_orders`|order date|pedido, cliente, ubicación, período| +|KPI-03|Contribution Margin|`Net Sales - Variable Product Cost`|medidas KPI-01/02|order date|pedido, cliente, ubicación, período| +|KPI-04|CTS|`SUM(cts_amount)`|`fact_orders`|order date|pedido, cliente, ubicación, período| +|KPI-05|MC_CTS|`Contribution Margin - CTS`|medidas KPI-03/04|order date|pedido, cliente, ubicación, período; no SKU| +|KPI-06|Orders|`DISTINCTCOUNT(order_id)`|`fact_orders`|order date|cliente, ubicación, período| +|KPI-07|Average Order Value|`Net Sales / Orders`|KPI-01/06|order date|cliente, ubicación, período| +|KPI-08|OTIF|`OTIF Orders / Eligible Orders`|`fact_orders`|promised date|pedido, cliente, ubicación, período| +|KPI-09|Fill Rate|`SUM(delivered_quantity) / SUM(ordered_quantity)`|`fact_order_lines`|promised date|SKU, cliente, ubicación, período| +|KPI-10|Order-to-Delivery Lead Time|`AVERAGE(lead_time_hours)` para pedidos completos|`fact_orders`|promised date|cliente, ubicación, período| +|KPI-11|Stockout Rate|`Stockout Observations / Inventory Observations`|`fact_inventory`|snapshot date|SKU, sitio, período| +|KPI-12|Available Inventory EOP|inventario disponible en la última fecha visible|`fact_inventory`|snapshot date|SKU, sitio, fecha de cierre| +|KPI-13|Customer Frequency|`Orders / Customers`|KPI-06/17|order date|segmento, ubicación, período| +|KPI-14|Delivery Distance|`SUM(distance_km)`|`fact_deliveries`|delivery date|vehículo, cliente, ubicación, período| +|KPI-15|Transport Cost|`SUM(cost_amount)` donde `cost_type = transport`|`fact_cost_to_serve`|order date|pedido, cliente, ubicación, período| +|KPI-16|Vehicle Utilization|promedio del recurso limitante por entrega|`fact_deliveries`|delivery date|vehículo, período| +|KPI-17|Customers|clientes distintos con al menos un pedido|`fact_orders`|order date|segmento, ubicación, período| + +### 3.4 Reglas detalladas + +**Net Sales y economía por SKU** + +La medida oficial ejecutiva usa `fact_orders`. Para mix y detalle de SKU se utilizará: + +```text +Net Sales by SKU += +SUM(fact_delivery_lines.recognized_net_sales) +``` + +Sin filtro de producto deberá reconciliar exactamente con KPI-01. Contribution Margin by SKU se permite; CTS y MC_CTS by SKU no se permiten en V1.0. + +```text +MC_CTS += +IF( + product filter is active, + BLANK(), + Contribution Margin - CTS +) +``` + +**OTIF** + +```text +Eligible Orders += +COUNTROWS(fact_orders where eligible_order_flag = true) + +OTIF Orders += +COUNTROWS(fact_orders where eligible_order_flag = true and order_otif_flag = true) + +OTIF += +DIVIDE(OTIF Orders, Eligible Orders) +``` + +El filtro temporal usa fecha prometida. El filtro SKU no modifica el OTIF oficial. + +**Fill Rate** + +```text +Fill Rate += +DIVIDE( + SUM(fact_order_lines.delivered_quantity), + SUM(fact_order_lines.ordered_quantity) +) +``` + +Se calcula como ratio de sumas, nunca como promedio de ratios por línea. + +**Lead Time** + +```text +lead_time_hours += +completion_datetime - order_datetime +``` + +Sólo pedidos `in_full` tienen lead time oficial. Pedidos incompletos permanecen `BLANK()` y se controlan mediante Fill Rate y clases de servicio. + +**Available Inventory EOP** + +```text +last_visible_date = MAX(dim_date[date]) + +Available Inventory EOP += +SUM(fact_inventory.available_quantity at last_visible_date) +``` + +No sumar snapshots entre fechas. + +**Vehicle Utilization** + +```text +weight_utilization = delivered_weight_kg / capacity_kg +volume_utilization = delivered_volume_m3 / capacity_m3 +delivery_utilization = MAX(weight_utilization, volume_utilization) +Vehicle Utilization = AVERAGE(delivery_utilization) +``` + +Los porcentajes de peso y volumen deberán exponerse también como diagnósticos para mostrar cuál capacidad limita cada entrega. + +### 3.5 Restricciones de interpretación + +- KPIs sintéticos demuestran funcionamiento, no desempeño real. +- MC_CTS no es margen neto. +- OTIF no se promedia desde líneas. +- Fill Rate no prueba puntualidad. +- Stockout Rate depende de la cadencia diaria del contrato. +- AOV usa todos los pedidos del período; pedidos sin entrega aportan cero ventas reconocidas. +- Customers representa clientes con pedidos, no el total maestro de 150. + +## 4. Dashboard + +### 4.1 Brief + +```yaml +surface: "Power BI Desktop" +mode: "local analytical dashboard" +primary_audience: + - Management + - Commercial + - Operations +review_cadence: "monthly, with exploratory drill-through" +data_source: "validated analytical marts" +source_parameter: "pDataRoot" +``` + +Cada página mostrará `dataset_build_id`, última ejecución exitosa, fecha máxima disponible y estado QA. Power BI no consumirá archivos de snapshot o scratch fuera de los marts aprobados. + +Filtros globales permitidos: período, segmento de cliente y región. Filtros adicionales se limitan a la página donde sean semánticamente válidos. + +### Page 1 — Executive + +**Pregunta:** ¿cómo evoluciona el resultado económico y qué guardrails requieren atención? + +Hero cards: + +```text +MC_CTS +Net Sales +CTS +OTIF +Fill Rate +Orders +Customers +``` + +Visuales: + +|Visual|Tipo|Campos|Lectura| +|---|---|---|---| +|Economía mensual|líneas, 12 meses|Net Sales, MC_CTS, mes|movimiento económico| +|Servicio mensual|líneas, 12 meses|OTIF, Fill Rate, mes|guardrails comparables| +|Clientes por MC_CTS|barras horizontales ordenadas|customer, MC_CTS|top y bottom con cero visible| + +Los indicadores no usarán colores de cumplimiento contra metas inexistentes. Sólo el estado QA podrá usar semáforo explícito. + +### Page 2 — Customer 360 + +**Pregunta:** ¿qué valor, servicio y costo explican la situación de un cliente? + +Filtro obligatorio: un cliente. Si no existe selección, la página mostrará una instrucción y no una falsa vista agregada. + +Hero cards: + +```text +Net Sales +Contribution Margin +CTS +MC_CTS +Orders +Customer Frequency +OTIF +Fill Rate +Lead Time +``` + +Visuales: + +|Visual|Tipo|Campos|Lectura| +|---|---|---|---| +|Economía del cliente|líneas mensuales|Net Sales, Contribution Margin, CTS, MC_CTS|evolución y presión de servicio| +|Mix SKU|barras horizontales|SKU, Net Sales by SKU|composición reconocida| +|Servicio por pedido|tabla de detalle|order_id, promise, completion, class, Fill Rate, CTS, MC_CTS|lookup y drill-through| + +No mostrar `MC_CTS by SKU`. + +### Page 3 — Service & Inventory + +**Pregunta:** ¿dónde se originan incumplimientos y riesgo de inventario? + +Filtros: período, sitio y SKU. El filtro SKU no afectará el OTIF oficial; esa tarjeta y su tendencia deberán indicarlo en el subtítulo. + +Hero cards: + +```text +OTIF +Fill Rate +Average Lead Time +Stockout Rate +Available Inventory EOP +``` + +Visuales: + +|Visual|Tipo|Campos|Lectura| +|---|---|---|---| +|Servicio mensual|líneas|OTIF, Fill Rate, promised month|movimiento con denominador visible| +|Fill Rate por SKU|barras horizontales ordenadas|SKU, Fill Rate, ordered quantity|comparación con volumen| +|Stockout SKU × mes|heatmap|SKU, mes, Stockout Rate|patrones densos| +|Inventario seleccionado|línea diaria|available, safety stock, reorder point, date|trayectoria y umbrales| +|Clases de servicio|barra 100% apilada|complete/partial × on-time/late|composición de pedidos elegibles| + +### Page 4 — Profitability & Geography + +**Pregunta:** ¿qué clientes y zonas combinan valor, costo y distancia de manera desfavorable? + +Filtros: período, segmento, región y comuna. + +Hero cards: + +```text +MC_CTS +CTS +Transport Cost +Delivery Distance +Vehicle Utilization +``` + +Visuales: + +|Visual|Tipo|Campos|Lectura| +|---|---|---|---| +|Mapa de clientes|mapa de puntos|lat/lon, Net Sales, MC_CTS, customer|distribución territorial| +|Ventas vs CTS|scatter a grain cliente|Net Sales, CTS, segment, customer|relación, clusters y outliers| +|MC_CTS por cliente|barras horizontales ordenadas|customer, MC_CTS|ranking con cero visible| +|Rentabilidad y logística|tabla|customer, Net Sales, CTS, MC_CTS, distance, transport cost, OTIF|seguimiento exacto| + +El scatter requiere al menos 20 clientes visibles; con menos de 8 se reemplazará por tabla o barras. El mapa usará tamaño por Net Sales y una codificación divergente restringida para el signo de MC_CTS, sin semántica rojo/verde. + +### 4.2 Diseño visual transversal + +- jerarquía: hero metrics → tendencia → diagnóstico → detalle; +- fondos claros, texto oscuro y grillas discretas; +- una raíz cromática para series simples y máximo dos para valores con signo; +- no depender exclusivamente del color: usar etiquetas, orden, cero y tooltips; +- barras de magnitud parten en cero; +- títulos descriptivos y subtítulos con unidad, período y denominador; +- mínimo 8–12 puntos para líneas temporales; +- tablas sólo para lookup, trazabilidad o detalle operativo. + +## 5. Regla + +Power BI consume tablas analíticas. + +La lógica estructural y validación debe resolverse antes de Power BI. + +### 5.1 Modelo semántico + +- las medidas residirán en una tabla dedicada `_Measures`; +- display folders: `Economic`, `Commercial`, `Service`, `Inventory`, `Logistics`, `Quality`; +- claves técnicas y columnas auxiliares permanecerán ocultas; +- nombres de medidas seguirán este documento y no crearán sinónimos divergentes; +- no se crearán columnas calculadas para reemplazar transformaciones del pipeline; +- DAX se limitará a agregación, contextos de filtro, roles de fecha y presentación; +- no habrá relaciones bidireccionales, many-to-many ni rutas ambiguas; +- no se requiere RLS en V1.0 porque todos los datos son sintéticos. + +### 5.2 Trazabilidad + +Cada medida deberá registrar: + +```text +KPI ID +measure name +source fact +numerator and denominator +date role +valid dimensions +format +Python control value +``` + +`fact_orders` será la tabla oficial de reconciliación económica por pedido. Los facts detallados deberán sumar los mismos totales sin duplicación. + +## 6. Acceptance + +### 6.1 Modelo y refresh + +```text +[ ] b2b_v1.pbix abre sin error +[ ] pDataRoot permite apuntar a una reconstrucción local limpia +[ ] refresh completa desde marts validados +[ ] relaciones dimensión 1:N y filtro single +[ ] cero relaciones many-to-many o bidireccionales +[ ] cero rutas de filtro ambiguas +[ ] una única dim_date con rango completo +[ ] surrogate y business keys de dimensiones son únicas +[ ] cada fact conserva su grain sin duplicados +[ ] conteos de pedidos, líneas, entregas, costos e inventario reconcilian con marts +[ ] business keys visibles para drill-through y surrogate keys ocultas +``` + +### 6.2 Medidas + +```text +[x] los 17 KPI y las 2 medidas de soporte tienen medida documentada +[ ] importes cuadran con Python a 0,01 CLP +[ ] cantidades cuadran a 0,001 unidades +[ ] ratios cuadran con tolerancia absoluta 1e-9 +[ ] denominador cero retorna BLANK +[ ] Net Sales by SKU reconcilia con Net Sales sin filtro de producto +[ ] CTS detallado reconcilia con fact_orders +[ ] MC_CTS = Net Sales - Variable Product Cost - CTS +[ ] MC_CTS retorna BLANK bajo filtro de producto +[ ] OTIF usa pedidos elegibles y fecha prometida +[ ] Fill Rate usa ratio de sumas +[ ] inventario usa última fecha visible, no suma snapshots +[ ] fixtures golden producen resultados esperados +``` + +### 6.3 Experiencia de uso + +```text +[ ] existen exactamente cuatro páginas Core +[ ] vista inicial responde sin interacción obligatoria, excepto Customer 360 +[ ] Customer 360 exige un cliente y filtra economía, servicio y pedidos +[ ] filtros de período, segmento y geografía son consistentes +[ ] alcance del filtro SKU está indicado donde no aplica a OTIF o MC_CTS +[ ] mapa usa coordenadas válidas y muestra procedencia sintética/referencial +[ ] drill-through rastrea customer → order → delivery → CTS → MC_CTS +[ ] todas las páginas muestran build, freshness y QA +[ ] no existen visuales decorativos o sin pregunta de decisión +``` + +La restricción de secuencia fue satisfecha: D03 se aprobó y los marts se validaron +antes de construir manualmente el `.pbix` aceptado de V1.0. + +--- + +## 7. Estado + +```yaml +PROJECT_PROGRESS: + target_release: "V1.0" + release_status: "V1.0" + stage: "RELEASED" + current_work_item: "V1.0-RELEASE" + status: "COMPLETE" + completed: + - "MVP-D00" + - "MVP-D01" + - "MVP-D02" + - "MVP-D03" + - "MVP-D04" + - "MVP-I01" + - "MVP-I02" + - "MVP-I03" + - "MVP-I04" + - "PBI-00" + - "PBI-01" + - "PBI-02" + - "PBI-03" + - "PBI-04" + - "PBI-05" + next: "POST_V1_BACKLOG_REQUIRES_NEW_CONTRACT" + blockers: [] +``` + +--- + +## 8. Registro de cambios + +|Fecha|Versión documento|Estado|Cambio| +|---|---|---|---| +|2026-08-13|0.1|draft|Borrador previo al trabajo con Codex.| +|2026-08-13|0.2|approved|Se completa y aprueba modelo estrella, diccionario KPI, roles de fecha, filtros, páginas, diseño visual y acceptance de Power BI V1.0.| +|2026-08-16|0.3|approved|Se registra la construcción y reconciliación de 17 KPI, 2 medidas de soporte y 20 auxiliares; el release permanece candidato hasta cerrar PBI-04 y PBI-05.| +|2026-08-16|0.4|approved|El usuario acepta el PBIX construido manualmente, cierra PBI-04/PBI-05 y promueve el dashboard a V1.0.| diff --git a/docs/MVP-SPEC.md b/docs/MVP-SPEC.md new file mode 100644 index 0000000..73f2130 --- /dev/null +++ b/docs/MVP-SPEC.md @@ -0,0 +1,693 @@ +--- +doc_id: "MVP-D01" +title: "V1.0 Core Specification" +version: "0.4" +status: "approved" +type: "specification" +created: "2026-08-13" +updated: "2026-08-16" +depends_on: + - "MVP-D00" +--- + +# V1.0 Core Specification + +## 1. Objetivo + +Construir la primera versión funcional local de un **Sistema Comercial–Operacional B2B** utilizando datos exclusivamente sintéticos/hipotéticos y Power BI como capa final de análisis. + +V1.0 Core debe demostrar que es posible conectar: + +```text +Customer +→ Order +→ SKU +→ Inventory +→ Delivery +→ Service +→ Cost-to-Serve +→ MC_CTS +→ Decision +``` + +El objetivo no es reproducir un ERP completo, sino demostrar un flujo mínimo, trazable, reproducible y ampliable. + +Este documento define el comportamiento observable de V1.0 Core. `DATA-CONTRACT.md` definirá después tablas, campos, tipos, claves y granularidades físicas. + +--- + +## 2. Sitio operacional + +```yaml +site_id: "SOR-01" +location_reference: "Las Esteras Sur, Quilicura, Región Metropolitana, Chile" +type: "operational_logistics_node" +timezone: "America/Santiago" +currency: "CLP" +data_class: "reference_location" +``` + +SOR-01 representa hipotéticamente: + +- almacenamiento; + +- inventario; + +- picking; + +- preparación; + +- consolidación; + +- despacho; + +- distribución B2B. + +Las coordenadas operacionales utilizadas por el dataset serán sintéticas o de referencia pública y deberán clasificarse explícitamente. La dirección de referencia no autoriza el uso de datos corporativos reales. + + +--- + +## 3. Escenario sintético + +Configuración base de aceptación: + +```yaml +scenario_id: "B2B-V1-BASE" +seed: 20260813 +period_start: "2025-08-01" +period_end: "2026-07-31" +timezone: "America/Santiago" +currency: "CLP" +customers_exact: 150 +skus_exact: 30 +vehicles_exact: 6 +operational_sites_exact: 1 +orders_target: 4000 +orders_tolerance_pct: 5 +order_lines_target: 10000 +order_lines_tolerance_pct: 10 +``` + +Otra configuración podrá utilizarse para desarrollo, pero la aceptación V1.0 se ejecutará con `B2B-V1-BASE`. + +Los datos deberán contener variabilidad suficiente para producir: + +- clientes frecuentes y esporádicos; + +- pedidos grandes y pequeños; + +- diferentes combinaciones de SKU; + +- distintos márgenes; + +- stockouts; + +- entregas tardías; + +- entregas parciales; + +- distintos costos logísticos; + +- diferentes zonas geográficas; + +- clientes con CTS alto y bajo. + +Cobertura mínima del escenario base: + +```text +complete_on_time_orders >= 60% de pedidos elegibles +complete_late_orders >= 5% de pedidos elegibles +partial_on_time_orders >= 5% de pedidos elegibles +partial_late_orders >= 5% de pedidos elegibles +stockout_observations >= 1% de observaciones de inventario +low_inventory_observations >= 5% de observaciones de inventario +customers_in_each_sales_cts_quadrant >= 5 +``` + +Las categorías pueden solaparse cuando corresponda. Los porcentajes son gates de cobertura sintética, no afirmaciones sobre una operación real. + + +--- + +## 4. Entidades mínimas + +V1.0 Core requiere como mínimo: + +```text +Operational Site +Customer +Product / SKU +Order +Order Line +Inventory +Delivery +Delivery Line +Vehicle +Location +Cost Activity +``` + +Relaciones conceptuales: + +```text +Customer + └── Order + └── Order Line + └── SKU + +Operational Site + └── Inventory + └── SKU + +Order + └── Delivery [1..N] + ├── Delivery Line + │ └── Order Line + ├── Vehicle + └── Location + +Delivery + └── Cost Activity +``` + +Reglas funcionales de cardinalidad para V1.0: + +- un pedido pertenece a un solo cliente; +- un pedido contiene una o más líneas; +- una línea corresponde a un solo SKU; +- un pedido puede resolverse mediante una o más entregas; +- una entrega pertenece a un solo pedido en V1.0 Core; +- una entrega contiene una o más líneas de entrega; +- una línea de entrega aplica cantidad a una línea del mismo pedido; +- un vehículo puede realizar muchas entregas; +- una actividad CTS se registra una sola vez y debe poder atribuirse al pedido o entrega que la originó. + +`Service` no será una entidad independiente en V1.0. Será el resultado derivado de fechas prometidas/reales y cantidades pedidas/entregadas. + +La definición física de tablas y claves se realizará en `DATA-CONTRACT.md`. + +--- + +## 5. Flujo operacional mínimo + +```text +1. Cliente genera pedido +2. Pedido recibe fecha/hora prometida y contiene una o más líneas SKU +3. Se consulta disponibilidad por sitio y SKU +4. Se asigna inventario disponible +5. Se prepara despacho total o parcial +6. Se asigna vehículo +7. Se ejecuta una o más entregas por pedido +8. Se registran cantidades entregadas por línea y fecha/hora real +9. Se deriva el resultado de servicio +10. Se registran actividades de Cost-to-Serve +11. Se calcula MC_CTS sobre cantidades económicamente reconocidas +12. Power BI consume las tablas analíticas +``` + +No es necesario implementar sistemas transaccionales reales en esta etapa. + +El flujo debe preservar la secuencia temporal mínima: + +```text +order_datetime +<= promised_delivery_datetime + +order_datetime +<= dispatch_datetime +<= actual_delivery_datetime +``` + +Cuando existan múltiples entregas, cada evento conservará su propia fecha/hora y cantidad. El cierre del pedido se evaluará con la entrega acumulada de todas sus líneas. + +--- + +## 6. Casos que el dataset debe permitir observar + +### Servicio + +La unidad de evaluación de OTIF será el pedido elegible. Un pedido es elegible cuando no está cancelado, su fecha prometida está dentro del período del escenario y existe información suficiente para evaluar su resultado al cierre del dataset. + +Definiciones funcionales: + +```text +in_full = cada línea tiene cumulative_delivered_quantity >= ordered_quantity +on_time = in_full y completion_datetime <= promised_delivery_datetime +otif = in_full y on_time +``` + +Clasificación final y mutuamente excluyente: + +- `complete_on_time`: todas las líneas completas antes o en la fecha/hora prometida; +- `complete_late`: todas las líneas completas después de la fecha/hora prometida; +- `partial_on_time`: pedido incompleto al cierre, pero con cantidad positiva entregada antes o en la fecha/hora prometida; +- `partial_late`: pedido incompleto al cierre y sin cantidad positiva entregada dentro de la promesa. + +No se permitirán sobreentregas en V1.0 Core. El tratamiento de cancelaciones, devoluciones y notas de crédito queda fuera del escenario base. + +### Inventario + +El escenario base generará una observación diaria de cierre por sitio y SKU. + +```text +available_quantity = on_hand_quantity - allocated_quantity + +stockout: available_quantity = 0 +low_inventory: 0 < available_quantity <= reorder_point_quantity +normal_inventory: available_quantity > reorder_point_quantity +allocated_inventory: allocated_quantity > 0 +``` + +`allocated_inventory` es un indicador que puede coexistir con cualquiera de los otros estados. + +### Cliente + +Los cuadrantes de cliente se calcularán para todo el período base usando las medianas de ventas reconocidas y CTS como cortes sintéticos: + +```text +high sales + low CTS +high sales + high CTS +low sales + low CTS +low sales + high CTS +``` + +Cada cuadrante deberá contener al menos cinco clientes. Esta clasificación busca demostrar que volumen de ventas y rentabilidad no son equivalentes; no representa una segmentación corporativa real. + +## 7. Cost-to-Serve mínimo + +El CTS inicial será sintético y explicable. + +Componentes mínimos: + +```text +order_processing_cost +picking_cost +handling_cost +transport_cost +delivery_cost +return_or_incident_cost +``` + +Forma conceptual: + +```text +CTS += +Order Processing ++ Picking ++ Handling ++ Transport ++ Delivery ++ Incidents +``` + +No es obligatorio implementar todavía un modelo TDABC completo. + +TDABC queda como método de evolución posterior del CTS. + +Reglas mínimas: + +- todos los importes estarán expresados en CLP, sin IVA; +- `transport_cost` representa desplazamiento del vehículo atribuible por distancia/tiempo; +- `delivery_cost` representa la parada y el servicio físico en el cliente; +- `return_or_incident_cost` se utilizará en V1.0 sólo para incidentes sintéticos; las devoluciones operacionales quedan fuera del Core; +- cada actividad de costo tendrá una identidad única y se sumará una sola vez; +- todo costo compartido deberá declarar su método de asignación; +- CTS deberá reconciliar desde actividad hacia entrega, pedido, cliente y período; +- no se permitirán costos negativos en el escenario base. + +--- + +## 8. Función económica + +Métrica principal: + +```text +MC_CTS += +Net Sales +- +Variable Product Cost +- +Cost-to-Serve +``` + +No denominar `MC_CTS` como margen neto. + +Reconocimiento económico V1.0: + +```text +Net Unit Price += +List Unit Price +- Synthetic Discount + +Recognized Net Sales += +Delivered Quantity +× Net Unit Price + +Variable Product Cost += +Delivered Quantity +× Unit Variable Cost + +Contribution Margin += +Recognized Net Sales +- Variable Product Cost + +MC_CTS += +Contribution Margin +- Cost-to-Serve +``` + +Los precios serán netos de IVA. Las cantidades no entregadas no reconocerán ventas ni costo variable. Cancelaciones, devoluciones, impuestos, notas de crédito y costos financieros quedan fuera del Core. + +Debe poder calcularse al menos por: + +```text +Customer +Order +Period +``` + +Los totales deberán reconciliar entre el nivel de pedido, cliente y período sin duplicación por joins. + +--- + +## 9. KPIs mínimos de V1.0 + +V1.0 debe poder obtener: + +```text +Net Sales +Contribution Margin +CTS +MC_CTS +Orders +Average Order Value +OTIF +Fill Rate +Lead Time +Stockout Rate +Inventory +Customer Frequency +Delivery Distance +Transport Cost +Vehicle Utilization +Customers +``` + +`KPI-POWERBI-SPEC.md` deberá definir para cada KPI: + +```text +business question +formula +numerator +denominator +grain +date role +eligible population +filters and exclusions +null and zero treatment +unit +rounding +expected fixture result +``` + +Las agregaciones porcentuales deberán usar ratio de sumas cuando corresponda; no se aceptará el promedio simple de porcentajes por fila sin justificación contractual. + +--- + +## 10. Power BI + +El dashboard V1.0 tendrá cuatro páginas. + +### Executive + +Visión global de: + +```text +Sales +MC_CTS +CTS +OTIF +Fill Rate +Orders +Customers +``` + +### Customer 360 + +Permitir seleccionar un cliente y visualizar: + +```text +sales +orders +SKU mix +frequency +service +CTS +MC_CTS +``` + +### Service & Inventory + +Analizar: + +```text +OTIF +Fill Rate +Lead Time +stockouts +inventory +SKU +``` + +### Profitability & Geography + +Analizar: + +```text +customer profitability +CTS +delivery cost +distance +geographical distribution +``` + +Debe existir al menos una visualización cartográfica. + +Comportamiento mínimo: + +- una única dimensión calendario gobernará los filtros de período; +- Customer 360 permitirá seleccionar un cliente y filtrar pedidos, entregas, servicio, CTS y rentabilidad; +- las páginas mostrarán la última ejecución exitosa y el estado de calidad del dataset; +- los totales visibles deberán reconciliar con tablas de control calculadas fuera de Power BI; +- no se usarán relaciones many-to-many salvo excepción documentada y aprobada; +- la lógica de validación, clasificación de servicio, CTS y KPIs permanecerá aguas arriba o en medidas documentadas, nunca dispersa en visuales. + +--- + +## 11. Arquitectura de V1.0 Core + +```text +Contracts + Configuration + ↓ +Python Generator + ↓ +Synthetic Data + ↓ +Validation / QC + ↓ +Validated Canonical Tables + ↓ +Analytical Marts + ↓ +Star Schema + ↓ +Power BI Desktop +``` + +Power BI no será la fuente maestra de reglas de negocio. + +Cada ejecución end-to-end deberá emitir un manifiesto y reporte de calidad. CSV y Parquet serán formatos de intercambio; DuckDB será opcional y no podrá convertirse en dependencia oculta. + +--- + +## 12. Herramientas de V1.0 Core + +### Core + +```text +Markdown +Python +pandas +pytest +CSV / Parquet +Power BI Desktop +``` + +### Opcional + +```text +DuckDB +Jupyter +``` + +Git está habilitado por autorización explícita del usuario. La reproducibilidad +continúa dependiendo de configuración, semilla, versiones, pruebas y hashes, no +del historial Git. + +No forman parte de V1.0 Core: + +```text +ERPNext +CRM +n8n +PostgreSQL +dbt +StatsForecast +SciPy +Statsmodels +OSRM +VROOM +OR-Tools +``` + +--- + +## 13. Reproducibilidad + +El dataset final deberá poder regenerarse mediante código. + +La generación deberá registrar como mínimo: + +```yaml +scenario_id: "B2B-V1-BASE" +data_class: "synthetic" +seed: 20260813 +generator_version: "definida por la implementación" +schema_version: "definida en DATA-CONTRACT.md" +config_sha256: "calculado en ejecución" +run_id: "calculado en ejecución" +``` + +La misma configuración, semilla, versión de generador y versión de esquema deberá producir los mismos IDs, filas, valores ordenados y totales de control. + +El manifiesto de ejecución registrará: + +```text +timestamps +input and output paths +row counts +rejected row counts and reasons +artifact checksums +quality results +execution status +sanitized error, if any +``` + +--- + +## 14. Criterios de aceptación + +V1.0 Core cumplirá esta especificación cuando: + +```text +[ ] un comando documentado regenere B2B-V1-BASE con exit code 0 +[ ] clientes, SKU, vehículos y sitios coincidan con sus cantidades exactas +[ ] pedidos y líneas estén dentro de sus tolerancias +[ ] la repetición con misma configuración produzca los mismos checksums +[ ] existan 0 PK nulas o duplicadas y 0 FK huérfanas +[ ] existan 0 violaciones de cantidades, costos y secuencia temporal +[ ] se cumplan los gates de cobertura sintética de la sección 3 +[ ] pueda reconstruirse un pedido con todas sus líneas, entregas y costos sin duplicación +[ ] fixtures conocidos reproduzcan OTIF, Fill Rate, CTS y MC_CTS esperados +[ ] todos los KPIs mínimos tengan prueba de reconciliación +[ ] Power BI actualice desde los marts sin transformación estructural oculta +[ ] Customer 360 filtre de cliente a pedido, entrega, servicio, CTS y MC_CTS +[ ] exista una vista geográfica con coordenadas válidas y sintéticas/referenciales +[ ] pueda compararse servicio y rentabilidad en un mismo contexto de cliente +[ ] el manifiesto y reporte QA identifiquen la ejecución y sus artefactos +``` + +La validación deberá incluir pruebas positivas y negativas. Un validador que sólo pasa con datos correctos pero no rechaza fixtures deliberadamente inválidos no satisface la aceptación. + +--- + +## 15. Fuera de alcance + +V1.0 Core no requiere: + +```text +datos corporativos reales +mensajería real +telefonía real +cotización productiva +facturación y pagos +crédito de cliente +devoluciones y notas de crédito +reclamos productivos +forecasting productivo +optimización VRP +machine learning +ERP +CRM +automatización n8n +despliegue cloud +TDABC avanzado +Hypothesis Lab +feedback automatizado +``` + +Estas funciones podrán incorporarse después de aprobar V1.0 mediante nuevos contratos y criterios de aceptación. + +--- + +## 16. Siguiente artefacto + +```yaml +PROJECT_PROGRESS: + target_release: "V1.0" + release_status: "V1.0" + stage: "RELEASED" + current_work_item: "V1.0-RELEASE" + status: "COMPLETE" + completed: + - "MVP-D00" + - "MVP-D01" + - "MVP-D02" + - "MVP-D03" + - "MVP-D04" + - "MVP-I01" + - "MVP-I02" + - "MVP-I03" + - "MVP-I04" + - "PBI-00" + - "PBI-01" + - "PBI-02" + - "PBI-03" + - "PBI-04" + - "PBI-05" + next: "POST_V1_BACKLOG_REQUIRES_NEW_CONTRACT" + blockers: [] +``` + +La generación, validación, marts y construcción manual de Power BI fueron +ejecutadas y aceptadas como V1.0. No se autoriza ampliar este alcance sin un +contrato post-V1 explícito. + +--- + +## 17. Registro de cambios + +|Fecha|Versión documento|Estado|Cambio| +|---|---|---|---| +|2026-08-13|0.1|draft|Borrador previo al trabajo con Codex.| +|2026-08-13|0.2|approved|Se especifican y aprueban escenario base, cardinalidades funcionales, servicio, inventario, reconocimiento económico, reproducibilidad y aceptación de V1.0 Core.| +|2026-08-16|0.3|approved|Se sincroniza la especificación con el estado RELEASE_CANDIDATE y el cierre de implementación, manteniendo pendientes los gates finales de Power BI.| +|2026-08-16|0.4|approved|El usuario acepta el PBIX manual, cierra PBI-04/PBI-05 y promueve la especificación implementada a V1.0.| diff --git a/docs/PROJECT-CONTROL.md b/docs/PROJECT-CONTROL.md new file mode 100644 index 0000000..c698861 --- /dev/null +++ b/docs/PROJECT-CONTROL.md @@ -0,0 +1,581 @@ +--- +doc_id: "MVP-D00" +title: "Project Control — V1.0" +version: "0.4" +status: "approved" +type: "governance" +created: "2026-08-13" +updated: "2026-08-16" +--- + +# Project Control — V1.0 + +## 1. Proyecto + +**Sistema Comercial–Operacional B2B — objetivo V1.0** + +Objetivo: construir con Codex, localmente y paso a paso, un piloto reproducible con **datos sintéticos/hipotéticos**, finalizarlo en un modelo analítico y dashboard Power BI, y consolidarlo como la primera versión funcional **V1.0**. + +La prioridad es: + +```text +V1.0 funcional +> documentación extensa +``` + +El estado anterior de los documentos se considera **material de preparación**. Desde esta revisión, los documentos contractuales serán corregidos y aprobados secuencialmente mientras Codex implementa el proyecto. + +### 1.1 Estado de release + +```yaml +target_release: "V1.0" +release_status: "V1.0" +document_version: "0.4" +execution_environment: "local" +implementation_mode: "local_step_by_step" +git_policy: "ENABLED_BY_USER_AFTER_V1_0_APPROVAL" +``` + +`V1.0` se declaró después de cumplir la evidencia técnica y recibir la aceptación +explícita del usuario. Los avances parciales anteriores no promovieron +automáticamente el release. + +### 1.2 Precedencia documental + +Ante una contradicción, se aplicará el siguiente orden: + +1. instrucción explícita vigente del usuario; +2. `PROJECT-CONTROL.md`; +3. `MVP-SPEC.md`; +4. `DATA-CONTRACT.md`; +5. `KPI-POWERBI-SPEC.md`; +6. `AGENTS.md`, una vez creado; +7. documentos históricos, guías, planes base e investigaciones. + +Los documentos históricos orientan el roadmap, pero no amplían silenciosamente el alcance de V1.0. + +--- + +## 2. Principio rector + +El sistema debe permitir recorrer: + +```text +Cliente +→ Pedido +→ SKU +→ Inventario +→ Despacho +→ Entrega +→ Servicio +→ Cost-to-Serve +→ MC_CTS +→ Decisión +``` + +Power BI será la capa de consumo. + +La lógica crítica deberá permanecer fuera del dashboard siempre que sea razonable: + +```text +datos +→ validación +→ transformación +→ modelo analítico +→ Power BI +``` + +--- + +## 3. Sitio operacional de referencia + +```yaml +site_id: "SOR-01" +type: "operational_logistics_node" +address_reference: "Las Esteras Sur, Quilicura, Región Metropolitana, Chile" +coordinates: "TBD" +``` + +SOR-01 representa hipotéticamente: + +- recepción; + +- almacenamiento; + +- inventario; + +- picking; + +- consolidación; + +- despacho; + +- distribución B2B. + + +No se utilizarán nombres corporativos dentro del MVP. + +--- + +## 4. Naturaleza de los datos + +Todo dato operacional del piloto será: + +```text +SYNTHETIC +``` + +salvo información geográfica o contextual explícitamente identificada como referencia pública. + +Identificadores sugeridos: + +```text +CUS-SYN-001 +SKU-SYN-001 +ORD-SYN-000001 +VEH-SYN-01 +``` + +Nunca presentar datos sintéticos como observaciones reales. + +--- + +## 5. Escenario mínimo objetivo + +Parámetros iniciales ajustables: + +```yaml +customers: 150 +skus: 30 +operational_sites: 1 +vehicles: 6 +history_months: 12 +orders_approx: 4000 +order_lines_approx: 10000 +``` + +El generador deberá producir suficiente variabilidad para observar: + +- clientes de distinto valor; + +- pedidos grandes y pequeños; + +- mix de SKU; + +- stockouts; + +- entregas tardías; + +- entregas incompletas; + +- diferentes zonas geográficas; + +- diferencias de CTS; + +- clientes rentables y poco rentables. + + +--- + +## 6. Alcance V1.0 Core + +### Obligatorio + +```text +Customers +Products / SKU +Orders +Order Lines +Inventory +Deliveries +Vehicles +Locations +Cost-to-Serve +``` + +Analítica mínima: + +```text +Net Sales +Contribution Margin +CTS +MC_CTS +OTIF +Fill Rate +Lead Time +Inventory +Stockout +Orders +Customer Frequency +Distance / Delivery Cost +``` + +Power BI mínimo: + +```text +Executive +Customer 360 +Service & Inventory +Profitability & Geography +``` + +--- + +## 7. Fuera de V1.0 Core + +No son requisitos para considerar terminado el primer piloto: + +```text +ERPNext +Twenty CRM +n8n +StatsForecast +SciPy / Statsmodels +OSRM +VROOM +OR-Tools +PostgreSQL +dbt +machine learning +real messaging +real customer data +``` + +Son extensiones posteriores a V1.0 Core. + +No deben retrasar la primera versión funcional de Power BI. + +--- + +## 8. Arquitectura mínima + +```text +Synthetic Generator + ↓ +Synthetic Data + ↓ +Validation / QC + ↓ +Analytical Tables + ↓ +Star Schema + ↓ +Power BI + ↓ +Decision +``` + +Implementación local preferida para V1.0: + +```text +Python + pandas +CSV/Parquet +pytest +DuckDB opcional +Power BI Desktop +``` + +La ejecución inicial será manual mediante comandos Python documentados. n8n, MCP y otros orquestadores no forman parte del Core V1.0. + +--- + +## 9. Documentación contractual mínima + +Cinco documentos forman el contrato de construcción: + +|Orden|Documento|Función| +|---|---|---| +|D00|`PROJECT-CONTROL.md`|alcance, estado y reglas| +|D01|`MVP-SPEC.md`|comportamiento esperado del piloto| +|D02|`DATA-CONTRACT.md`|datos, entidades, campos, relaciones y QC| +|D03|`KPI-POWERBI-SPEC.md`|KPIs, modelo estrella y dashboard| +|D04|`AGENTS.md`|instrucciones operativas para Codex| + +Los demás documentos existentes se clasifican como **referencia histórica o roadmap**. No prevalecen sobre D00–D04 y no obligan a implementar funciones fuera del Core. + +No crear documentación adicional sin una función verificable. + +--- + +## 10. Artefactos ejecutables previstos + +Después de D00–D03: + +```text +data/ +├── synthetic/ +├── validated/ +└── marts/ + +src/ +├── synthetic/ +├── validation/ +└── marts/ + +schemas/ + +tests/ + +config/ + +powerbi/ +└── b2b_v1.pbix +``` + +Los nombres podrán ser refinados durante implementación. + +Cada ejecución completa deberá producir un manifiesto con semilla, versiones, conteos, resultados de calidad y checksums de artefactos. + +--- + +## 11. Reglas de calidad + +Como mínimo: + +```text +PK no nula +PK única +FK válida +cantidad >= 0 +precio >= 0 +costo >= 0 +delivery_date >= order_date +shipped_quantity <= ordered_quantity +SKU existente +Customer existente +is_synthetic = true +``` + +Las reglas exactas se especificarán en `DATA-CONTRACT.md`. + +--- + +## 12. Criterio económico + +La métrica rectora será: + +```text +MC_CTS += +Net Sales +- +Variable Product Cost +- +Cost-to-Serve +``` + +No denominar `MC_CTS` como margen neto. + +CTS deberá poder explicarse mediante componentes identificables, aunque el primer modelo utilice parámetros sintéticos simplificados. + +--- + +## 13. Criterio Power BI + +Power BI debe consumir hechos y dimensiones con granularidad explícita. + +No construir una única mega-tabla que mezcle: + +```text +pedido +inventario +entrega +cliente +ruta +costo +``` + +si éstos representan granularidades distintas. + +La especificación definitiva se establecerá en `KPI-POWERBI-SPEC.md`. + +--- + +## 14. Clasificación mínima + +Usar únicamente cuando aporte claridad: + +```text +FACT +DESIGN +ASSUMPTION +SYNTHETIC +TBD +``` + +No crear un sistema documental más complejo para estas categorías durante V1.0 Core. + +--- + +## 15. Flujo de trabajo con Codex + +Para cada documento: + +```text +analizar +→ crear o corregir +→ revisar +→ corregir +→ presentar al usuario +→ aprobar explícitamente +→ siguiente +``` + +Codex podrá: + +- leer el repositorio; + +- detectar inconsistencias; + +- generar código; + +- crear tests; + +- consolidar documentación; + +- refactorizar; + +- optimizar pipelines. + +Se trabajará con **un artefacto principal por iteración**. Un documento o contrato no se marcará `approved` sin confirmación explícita del usuario. + +### 15.1 Política de Git posterior a la aprobación + +El usuario aprobó la consolidación para Git y la publicación el 2026-08-16: + +```text +usar ramas y commits pequeños y verificables +publicar contratos sanitizados y evidencia reproducible +no depender de Git para la reproducibilidad de datos +reservar el tag y GitHub Release v1.0.0 hasta cerrar todos los gates +``` + +La trazabilidad técnica se conserva mediante: + +- versión y estado dentro de cada contrato; +- registro de cambios documental; +- manifiestos de ejecución; +- hashes SHA-256 de artefactos consolidados; +- aprobación explícita de cada gate. + +La autorización para Git no promovió por sí sola el release. La promoción a +`V1.0` ocurrió después de la validación técnica y de la aceptación explícita del +PBIX por el usuario el 2026-08-16. + + +--- + +## 16. Definition of Done de V1.0 + +V1.0 estará terminada cuando: + +1. los datos sintéticos puedan regenerarse reproduciblemente; + +2. las relaciones PK/FK sean válidas; + +3. los principales KPIs puedan recalcularse; + +4. Power BI tenga un modelo estrella funcional; + +5. pueda seleccionarse un cliente y rastrear: + + +```text +Customer +→ Orders +→ Delivery +→ Service +→ CTS +→ MC_CTS +``` + +6. exista al menos una vista geográfica; + +7. los datos sintéticos estén inequívocamente identificados; + +8. otra IA pueda comprender el repositorio leyendo `AGENTS.md` y los cuatro contratos públicos D00–D03. + +9. la misma configuración, semilla y versión del generador produzcan los mismos registros y totales de control; + +10. todas las pruebas de esquema, PK, FK, rangos, temporalidad y reglas económicas pasen; + +11. existan fixtures inválidos que demuestren que los validadores detectan errores; + +12. los KPIs se recalculen fuera de Power BI y se reconcilien con el dashboard; + +13. CTS pueda descomponerse en componentes identificables sin doble conteo; + +14. una ejecución limpia produzca datos sintéticos, datos validados, marts, KPIs, manifiesto y reporte de calidad; + +15. `PROJECT-CONTROL.md`, `MVP-SPEC.md`, `DATA-CONTRACT.md`, `KPI-POWERBI-SPEC.md` y `AGENTS.md` estén aprobados; + +16. no existan decisiones críticas marcadas como `TBD`; + +17. `powerbi/b2b_v1.pbix` abra, actualice sus datos y muestre los totales de control esperados; + +18. todos los datos operacionales estén identificados inequívocamente como sintéticos; + +19. el pipeline pueda ejecutarse sin datos reales, secretos, servicios cloud ni acceso de red obligatorio. + + +--- + +## 17. Control de avance + +```yaml +PROJECT_PROGRESS: + target_release: "V1.0" + release_status: "V1.0" + stage: "RELEASED" + current_work_item: "V1.0-RELEASE" + status: "COMPLETE" + completed: + - "MVP-D00" + - "MVP-D01" + - "MVP-D02" + - "MVP-D03" + - "MVP-D04" + - "MVP-I01" + - "MVP-I02" + - "MVP-I03" + - "MVP-I04" + - "PBI-00" + - "PBI-01" + - "PBI-02" + - "PBI-03" + - "PBI-04" + - "PBI-05" + next: "POST_V1_BACKLOG_REQUIRES_NEW_CONTRACT" + blockers: [] +``` + +--- + +## 18. Regla de versión + +El objetivo de release es: + +```text +V1.0 +``` + +La Definition of Done fue aceptada por el usuario. El estado promovido es: + +```text +V1.0 +``` + +Las versiones de documentos, contrato de datos, esquema, generador y release son independientes y deben registrarse por separado. + +No se crearán versiones globales intermedias por cada incremento funcional. La promoción a V1.0 exige aprobación explícita del usuario. + +--- + +## 19. Registro de cambios + +|Fecha|Versión documento|Estado|Cambio| +|---|---|---|---| +|2026-08-13|0.1|draft|Borrador previo al trabajo con Codex.| +|2026-08-13|0.2|approved|Se establece y aprueba el objetivo V1.0, la precedencia contractual, la construcción con Codex, el Core acotado y la política sin Git.| +|2026-08-16|0.3|approved|Se sincroniza el estado RELEASE_CANDIDATE, la autorización de Git y el cierre técnico, manteniendo pendientes los gates finales de Power BI y el release v1.0.0.| +|2026-08-16|0.4|approved|El usuario acepta el PBIX construido en Power BI Desktop, cierra los gates finales y promueve el release a V1.0/v1.0.0.| diff --git a/docs/chats_instruction/POWER-BI-INTERACTIVE-GUIDE.md b/docs/chats_instruction/POWER-BI-INTERACTIVE-GUIDE.md index a38caa7..25ad055 100644 --- a/docs/chats_instruction/POWER-BI-INTERACTIVE-GUIDE.md +++ b/docs/chats_instruction/POWER-BI-INTERACTIVE-GUIDE.md @@ -1,10 +1,10 @@ --- doc_id: "PBI-CHAT-01" title: "Guía interactiva ChatGPT + Power BI — V1.0 Core" -version: "3.13" -status: "active_manual_build" +version: "3.15" +status: "released_v1_0_0" created: "2026-08-14" -updated: "2026-08-14" +updated: "2026-08-16" depends_on: - "MVP-D03" - "MVP-I04" @@ -53,7 +53,7 @@ Tu misión es guiarme manualmente, un checkpoint a la vez, para construir y vali 1. carga de 14 marts Parquet; 2. modelo estrella con relaciones controladas; 3. tabla dedicada de medidas; -4. 17 KPI contractuales y medidas auxiliares; +4. 17 KPI, 2 medidas de soporte y 20 medidas auxiliares; 5. exactamente cuatro páginas Core; 6. reconciliación contra controles Python; 7. guardado final de b2b_v1.pbix. @@ -83,7 +83,7 @@ Reglas obligatorias: - Los datos son sintéticos; nunca los presentes como desempeño real. - No uses data/synthetic ni data/validated. Power BI consume solamente data/marts. - No declares el dashboard terminado basándote sólo en una captura. Exige las validaciones de este documento. -- No uses Git. +- No realices operaciones Git desde este chat manual; el versionado se gestiona fuera de Power BI. Contexto fijo: @@ -96,7 +96,7 @@ Contexto fijo: - QA de fuente: 93/93 - QA de marts: 91/91 - Tablas Parquet: 14 -- KPI contractuales: 17 +- Medidas: 17 KPI + 2 de soporte + 20 auxiliares = 39 - Moneda: CLP neta de IVA - Zona horaria contractual: America/Santiago - Período base: 2025-08-01 a 2026-07-31 @@ -112,9 +112,9 @@ Empieza preguntándome por el checkpoint indicado. Si no indico uno, comienza en | PBI-00 | Archivo guardado y opciones base | PASSED | b2b_v1.pbix guardado; en Archivo actual, Auto date/time y las tres opciones de Relaciones están desactivadas; ValueFilterBehavior Independiente conservado. | | PBI-01 | 14 marts cargados desde pDataRoot | PASSED | 14 tablas aplicadas sin errores y conteos reconciliados. PBIX guardado: 2.588.451 bytes, 2026-08-14 22:51:33. | | PBI-02 | Modelo estrella con 34 relaciones | PASSED | Validado en Power BI: 34 relaciones totales, 24 activas y 10 inactivas; sin relaciones para meta_run ni qa_results. | -| PBI-03 | 17 KPI y auxiliares reconciliados | PENDING | Pendiente | -| PBI-04 | Cuatro páginas Core construidas | PENDING | Pendiente | -| PBI-05 | Refresh, experiencia y aceptación | PENDING | Pendiente | +| PBI-03 | 39 medidas creadas y reconciliadas | PASSED | 17 KPI, 2 medidas de soporte y 20 auxiliares; sin errores DAX; QA Failed Gates=0, QA Status=passed; pruebas sin filtro y con SKU reconciliadas. | +| PBI-04 | Cuatro páginas Core construidas | PASSED | Las cuatro páginas están funcionales, guardadas y documentadas con capturas; el usuario acepta el resultado manual de Power BI Desktop para V1.0. | +| PBI-05 | Refresh, experiencia y aceptación | PASSED | El usuario confirma y acepta el PBIX construido y guardado manualmente en Power BI Desktop como artefacto final de V1.0. | Estados válidos: @@ -124,6 +124,9 @@ Estados válidos: - FAILED: existe error o diferencia pendiente. - BLOCKED: requiere una decisión o recurso no disponible. +Estado de release: `V1.0`. PBI-04 y PBI-05 están en `PASSED` por aceptación +explícita del usuario; corresponde publicar el tag y GitHub Release `v1.0.0`. + ## 5. Fuente aprobada y controles ### 5.1 Única carpeta de carga @@ -752,7 +755,7 @@ Comprobar todos los valores de la sección 5.3. Pruebas de filtro obligatorias: -1. Sin filtros, los 17 KPI reconcilian. +1. Sin filtros, los 17 KPI y las 2 medidas de soporte reconcilian. 2. Seleccionar un SKU: - MC_CTS devuelve BLANK; - OTIF no cambia; @@ -1047,7 +1050,7 @@ PASS sólo si existen exactamente las cuatro páginas y cada una responde su pre ### 11.2 Medidas -- 17 KPI documentados. +- 17 KPI, 2 medidas de soporte y 20 auxiliares documentados. - Moneda dentro de 0,01 CLP. - Cantidades dentro de 0,001. - Ratios dentro de 1e-9. @@ -1296,3 +1299,5 @@ Documentación oficial Microsoft: |2026-08-16|3.11|PBI-04|IN_PROGRESS|Customer 360 validada visualmente: selector y mensaje dinámico visibles, nueve KPI en cuadrícula 5+4 sin recortes, economía temporal corregida, mix SKU, tabla de servicio y metadata en dos columnas legibles.| |2026-08-16|3.12|PBI-04|IN_PROGRESS|Service & Inventory revisada visualmente: layout de filtros, KPI, cumplimiento, Fill Rate, matriz de stockout, inventario, mix y metadata es correcto. Queda sólo un ajuste menor de tamaño para evitar el recorte del Dataset Build ID.| |2026-08-16|3.13|PBI-04|IN_PROGRESS|Profitability & Geography revisada visualmente: filtros, hero, barras, dispersión, mapa ArcGIS, tabla y metadata están presentes y sin error. Quedan ajustes de layout: ampliar tabla y mover metadata a una fila inferior de ancho completo para evitar recortes.| +|2026-08-16|3.14|RELEASE|IN_PROGRESS|Se sincroniza el estado real como RELEASE_CANDIDATE: PBI-03 queda PASSED con 39 medidas; PBI-04 conserva ajustes visuales menores y PBI-05 requiere refresh, recorrido interactivo, drill-through, exportación PBIP/PBIR y evidencia final.| +|2026-08-16|3.15|RELEASE|PASSED|El usuario acepta explícitamente el archivo PBIX construido manualmente en Power BI Desktop, cierra PBI-04/PBI-05 y autoriza declarar y publicar v1.0.0.| diff --git a/docs/chats_instruction/assets/customer-360.png b/docs/chats_instruction/assets/customer-360.png new file mode 100644 index 0000000..5a5a634 Binary files /dev/null and b/docs/chats_instruction/assets/customer-360.png differ diff --git a/docs/chats_instruction/assets/executive.png b/docs/chats_instruction/assets/executive.png new file mode 100644 index 0000000..f11efe9 Binary files /dev/null and b/docs/chats_instruction/assets/executive.png differ diff --git a/docs/chats_instruction/assets/profitability-geography.png b/docs/chats_instruction/assets/profitability-geography.png new file mode 100644 index 0000000..a3eaeca Binary files /dev/null and b/docs/chats_instruction/assets/profitability-geography.png differ diff --git a/docs/chats_instruction/assets/service-inventory.png b/docs/chats_instruction/assets/service-inventory.png new file mode 100644 index 0000000..00d03b2 Binary files /dev/null and b/docs/chats_instruction/assets/service-inventory.png differ diff --git a/powerbi/source/README.md b/powerbi/source/README.md new file mode 100644 index 0000000..b91f8cd --- /dev/null +++ b/powerbi/source/README.md @@ -0,0 +1,44 @@ +# Power BI versionable source + +This directory is the source-control companion of `../b2b_v1.pbix`. + +## Current status + +| Artifact | Status | Authority | +|---|---|---| +| `../b2b_v1.pbix` | accepted for V1.0 | authoritative executable Power BI artifact | +| `TMDLScripts/measures.tmdl` | present | reviewable mirror of the 39 DAX measures | +| `model-contract.json` | present | reviewable contract for tables, relationships, pages and gates | +| PBIP/TMDL semantic-model export | optional post-V1 | must be produced by Power BI Desktop | +| PBIR report export | optional post-V1 | must be produced and verified by Power BI Desktop | + +This folder intentionally does **not** contain a fabricated `.pbip` or PBIR +definition. Microsoft supports PBIX-to-PBIP conversion only through Power BI +Desktop's **File > Save As** operation. The generated files must therefore be +exported from the verified PBIX and reviewed before becoming authoritative. The +user accepted the manually authored PBIX as the V1.0 artifact on 2026-08-16. + +## Optional post-V1 migration checkpoint + +1. Open `powerbi/b2b_v1.pbix` in Power BI Desktop. +2. Preserve the accepted V1.0 PBIX before converting it. +3. Enable the Power BI Project, TMDL semantic model, and enhanced report format + preview features if the installed Desktop version still requires them. +4. Use **File > Save As** and select **Power BI Project (.pbip)**. +5. Save under `powerbi/source/export/b2b_v1/` using TMDL for the semantic model + and PBIR for the report. +6. Close and reopen the generated `.pbip`. +7. Refresh from `data/marts` and confirm: + - 14 loaded tables; + - 34 relationships: 24 active and 10 inactive; + - 39 measures; + - exactly four Core pages; + - QA Failed Gates = 0 and QA Status = passed; + - control totals reconcile with `data/manifest/kpi_controls.json`. +8. Compare the exported measures with `TMDLScripts/measures.tmdl` and resolve + only explained differences. +9. Commit the PBIP/TMDL/PBIR text sources. Do not commit local cache files under + `.pbi/`. + +The interactive checklist is maintained in +`../../docs/chats_instruction/POWER-BI-INTERACTIVE-GUIDE.md`. diff --git a/powerbi/source/TMDLScripts/measures.tmdl b/powerbi/source/TMDLScripts/measures.tmdl new file mode 100644 index 0000000..d0d5d80 --- /dev/null +++ b/powerbi/source/TMDLScripts/measures.tmdl @@ -0,0 +1,226 @@ +createOrReplace + + ref table '_Measures' + + measure 'Net Sales' = SUM ( 'fact_orders'[recognized_net_sales] ) + formatString: #,0.00 + displayFolder: Economic + + measure 'Variable Product Cost' = SUM ( 'fact_orders'[variable_product_cost] ) + formatString: #,0.00 + displayFolder: Economic + + measure 'Contribution Margin' = [Net Sales] - [Variable Product Cost] + formatString: #,0.00 + displayFolder: Economic + + measure CTS = SUM ( 'fact_orders'[cts_amount] ) + formatString: #,0.00 + displayFolder: Economic + + measure MC_CTS = + IF ( + ISCROSSFILTERED ( 'dim_product'[product_key] ), + BLANK (), + [Contribution Margin] - [CTS] + ) + formatString: #,0.00 + displayFolder: Economic + + measure 'Average Order Value' = DIVIDE ( [Net Sales], [Orders] ) + formatString: #,0.00 + displayFolder: Economic + + measure Orders = DISTINCTCOUNT ( 'fact_orders'[order_id] ) + formatString: #,0 + displayFolder: Commercial + + measure 'Customer Frequency' = DIVIDE ( [Orders], [Customers] ) + formatString: #,0.00 + displayFolder: Commercial + + measure Customers = DISTINCTCOUNT ( 'fact_orders'[customer_key] ) + formatString: #,0 + displayFolder: Commercial + + measure 'Eligible Orders' = + CALCULATE ( + COUNTROWS ( 'fact_orders' ), + 'fact_orders'[eligible_order_flag] = TRUE (), + USERELATIONSHIP ( 'dim_date'[date_key], 'fact_orders'[promised_date_key] ) + ) + formatString: #,0 + displayFolder: Service + + measure 'OTIF Orders' = + CALCULATE ( + COUNTROWS ( 'fact_orders' ), + 'fact_orders'[eligible_order_flag] = TRUE (), + 'fact_orders'[order_otif_flag] = TRUE (), + USERELATIONSHIP ( 'dim_date'[date_key], 'fact_orders'[promised_date_key] ) + ) + formatString: #,0 + displayFolder: Service + + measure OTIF = DIVIDE ( [OTIF Orders], [Eligible Orders] ) + formatString: 0.0% + displayFolder: Service + + measure 'Fill Rate' = + CALCULATE ( + DIVIDE ( + SUM ( 'fact_order_lines'[delivered_quantity] ), + SUM ( 'fact_order_lines'[ordered_quantity] ) + ), + USERELATIONSHIP ( + 'dim_date'[date_key], + 'fact_order_lines'[promised_date_key] + ) + ) + formatString: 0.0% + displayFolder: Service + + measure 'Order-to-Delivery Lead Time' = + CALCULATE ( + AVERAGE ( 'fact_orders'[lead_time_hours] ), + 'fact_orders'[in_full_flag] = TRUE (), + USERELATIONSHIP ( 'dim_date'[date_key], 'fact_orders'[promised_date_key] ) + ) + formatString: #,0.00 + displayFolder: Service + + measure 'Stockout Rate' = + DIVIDE ( + CALCULATE ( + COUNTROWS ( 'fact_inventory' ), + 'fact_inventory'[is_stockout] = TRUE () + ), + COUNTROWS ( 'fact_inventory' ) + ) + formatString: 0.0% + displayFolder: Inventory + + measure 'Available Inventory EOP' = + VAR LastVisibleDateKey = MAX ( 'dim_date'[date_key] ) + RETURN + CALCULATE ( + SUM ( 'fact_inventory'[available_quantity] ), + 'fact_inventory'[snapshot_date_key] = LastVisibleDateKey + ) + formatString: #,0.000 + displayFolder: Inventory + + measure 'Delivery Distance' = SUM ( 'fact_deliveries'[distance_km] ) + formatString: #,0.000 + displayFolder: Logistics + + measure 'Transport Cost' = + CALCULATE ( + SUM ( 'fact_cost_to_serve'[cost_amount] ), + 'fact_cost_to_serve'[cost_type] = "transport" + ) + formatString: #,0.00 + displayFolder: Logistics + + measure 'Vehicle Utilization' = AVERAGE ( 'fact_deliveries'[delivery_utilization] ) + formatString: 0.0% + displayFolder: Logistics + + measure 'Net Sales by SKU' = SUM ( 'fact_delivery_lines'[recognized_net_sales] ) + formatString: #,0.00 + displayFolder: Economic + + measure 'Contribution Margin by SKU' = SUM ( 'fact_delivery_lines'[contribution_margin] ) + formatString: #,0.00 + displayFolder: Economic + + measure 'Ordered Quantity' = SUM ( 'fact_order_lines'[ordered_quantity] ) + formatString: #,0.000 + displayFolder: Service + + measure 'Delivered Quantity' = SUM ( 'fact_order_lines'[delivered_quantity] ) + formatString: #,0.000 + displayFolder: Service + + measure 'Orders by Promised Date' = + CALCULATE ( + [Orders], + USERELATIONSHIP ( 'dim_date'[date_key], 'fact_orders'[promised_date_key] ) + ) + formatString: #,0 + displayFolder: Service + + measure 'Available Inventory' = SUM ( 'fact_inventory'[available_quantity] ) + formatString: #,0.000 + displayFolder: Inventory + + measure 'Safety Stock' = SUM ( 'fact_inventory'[safety_stock_quantity] ) + formatString: #,0.000 + displayFolder: Inventory + + measure 'Reorder Point' = SUM ( 'fact_inventory'[reorder_point_quantity] ) + formatString: #,0.000 + displayFolder: Inventory + + measure 'Weight Utilization' = AVERAGE ( 'fact_deliveries'[weight_utilization] ) + formatString: 0.0% + displayFolder: Logistics + + measure 'Volume Utilization' = AVERAGE ( 'fact_deliveries'[volume_utilization] ) + formatString: 0.0% + displayFolder: Logistics + + measure 'Order Fill Rate (Detail)' = + DIVIDE ( + SUM ( 'fact_orders'[delivered_quantity] ), + SUM ( 'fact_orders'[ordered_quantity] ) + ) + formatString: 0.0% + displayFolder: Service + + measure 'Promised Date (Detail)' = + VAR DateKey = SELECTEDVALUE ( 'fact_orders'[promised_date_key] ) + RETURN + LOOKUPVALUE ( 'dim_date'[date], 'dim_date'[date_key], DateKey ) + formatString: dd-MM-yyyy + displayFolder: Service + + measure 'Completion Date (Detail)' = + VAR DateKey = SELECTEDVALUE ( 'fact_orders'[completion_date_key] ) + RETURN + LOOKUPVALUE ( 'dim_date'[date], 'dim_date'[date_key], DateKey ) + formatString: dd-MM-yyyy + displayFolder: Service + + measure 'QA Failed Gates' = + CALCULATE ( + COUNTROWS ( 'qa_results' ), + 'qa_results'[status] <> "passed" + ) + formatString: #,0 + displayFolder: Quality + + measure 'QA Status' = IF ( [QA Failed Gates] = 0, "passed", "failed" ) + displayFolder: Quality + + measure 'Dataset Build ID' = SELECTEDVALUE ( 'meta_run'[dataset_build_id] ) + displayFolder: Quality + + measure 'Last Successful Execution' = SELECTEDVALUE ( 'meta_run'[validated_at] ) + displayFolder: Quality + + measure 'Max Available Date' = SELECTEDVALUE ( 'meta_run'[max_available_date] ) + formatString: dd-MM-yyyy + displayFolder: Quality + + measure 'Customer Selection Valid' = INT ( HASONEVALUE ( 'dim_customer'[customer_key] ) ) + formatString: 0 + displayFolder: Quality + + measure 'Customer 360 Message' = + IF ( + [Customer Selection Valid] = 1, + "Cliente: " & SELECTEDVALUE ( 'dim_customer'[customer_name] ), + "Seleccione exactamente un cliente para habilitar Customer 360" + ) + displayFolder: Quality diff --git a/powerbi/source/model-contract.json b/powerbi/source/model-contract.json new file mode 100644 index 0000000..a8ffa32 --- /dev/null +++ b/powerbi/source/model-contract.json @@ -0,0 +1,94 @@ +{ + "contract_version": "1.0.0", + "release_status": "V1.0", + "dataset_build_id": "BLD-9102B6E8BF1ABFFC5F32", + "data_source": "data/marts", + "data_class": "synthetic", + "model": { + "loaded_tables": 14, + "dimensions": [ + "dim_customer", + "dim_product", + "dim_location", + "dim_operational_site", + "dim_vehicle", + "dim_date" + ], + "facts": [ + "fact_orders", + "fact_order_lines", + "fact_deliveries", + "fact_delivery_lines", + "fact_inventory", + "fact_cost_to_serve" + ], + "technical_tables": ["meta_run", "qa_results"], + "measure_table": "_Measures", + "measures": { + "kpi": 17, + "support": 2, + "auxiliary": 20, + "total": 39 + } + }, + "relationships": [ + {"from": "dim_customer.customer_key", "to": "fact_orders.customer_key", "active": true}, + {"from": "dim_customer.customer_key", "to": "fact_order_lines.customer_key", "active": true}, + {"from": "dim_customer.customer_key", "to": "fact_deliveries.customer_key", "active": true}, + {"from": "dim_customer.customer_key", "to": "fact_delivery_lines.customer_key", "active": true}, + {"from": "dim_customer.customer_key", "to": "fact_cost_to_serve.customer_key", "active": true}, + {"from": "dim_product.product_key", "to": "fact_order_lines.product_key", "active": true}, + {"from": "dim_product.product_key", "to": "fact_delivery_lines.product_key", "active": true}, + {"from": "dim_product.product_key", "to": "fact_inventory.product_key", "active": true}, + {"from": "dim_location.location_key", "to": "fact_orders.location_key", "active": true}, + {"from": "dim_location.location_key", "to": "fact_deliveries.location_key", "active": true}, + {"from": "dim_location.location_key", "to": "fact_delivery_lines.location_key", "active": true}, + {"from": "dim_location.location_key", "to": "fact_cost_to_serve.location_key", "active": true}, + {"from": "dim_operational_site.site_key", "to": "fact_deliveries.site_key", "active": true}, + {"from": "dim_operational_site.site_key", "to": "fact_delivery_lines.site_key", "active": true}, + {"from": "dim_operational_site.site_key", "to": "fact_inventory.site_key", "active": true}, + {"from": "dim_vehicle.vehicle_key", "to": "fact_deliveries.vehicle_key", "active": true}, + {"from": "dim_vehicle.vehicle_key", "to": "fact_delivery_lines.vehicle_key", "active": true}, + {"from": "dim_vehicle.vehicle_key", "to": "fact_cost_to_serve.vehicle_key", "active": true}, + {"from": "dim_date.date_key", "to": "fact_orders.order_date_key", "active": true}, + {"from": "dim_date.date_key", "to": "fact_orders.promised_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_orders.completion_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_order_lines.order_date_key", "active": true}, + {"from": "dim_date.date_key", "to": "fact_order_lines.promised_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_deliveries.delivery_date_key", "active": true}, + {"from": "dim_date.date_key", "to": "fact_deliveries.order_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_deliveries.dispatch_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_deliveries.promised_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_delivery_lines.order_date_key", "active": true}, + {"from": "dim_date.date_key", "to": "fact_delivery_lines.delivery_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_delivery_lines.promised_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_inventory.snapshot_date_key", "active": true}, + {"from": "dim_date.date_key", "to": "fact_cost_to_serve.order_date_key", "active": true}, + {"from": "dim_date.date_key", "to": "fact_cost_to_serve.activity_date_key", "active": false}, + {"from": "dim_date.date_key", "to": "fact_cost_to_serve.delivery_date_key", "active": false} + ], + "relationship_rules": { + "cardinality": "one-to-many", + "filter_direction": "single", + "many_to_many": 0, + "bidirectional": 0, + "fact_to_fact": 0, + "active": 24, + "inactive": 10, + "total": 34 + }, + "pages": [ + "Executive", + "Customer 360", + "Service & Inventory", + "Profitability & Geography" + ], + "release_gates": { + "PBI-00": "PASSED", + "PBI-01": "PASSED", + "PBI-02": "PASSED", + "PBI-03": "PASSED", + "PBI-04": "PASSED", + "PBI-05": "PASSED" + } +} diff --git a/src/marts/builder.py b/src/marts/builder.py index 46976f2..b17e6e3 100644 --- a/src/marts/builder.py +++ b/src/marts/builder.py @@ -1083,6 +1083,14 @@ def build_and_publish_marts( updated_manifest["artifacts"] = dict(manifest["artifacts"]) updated_manifest["artifacts"]["marts_parquet_sha256"] = mart_hashes updated_manifest["artifacts"]["marts_combined_sha256"] = _combined_sha256(mart_hashes) + deterministic_mart_hashes = { + name: hash_value + for name, hash_value in mart_hashes.items() + if name != "meta_run" + } + updated_manifest["artifacts"][ + "marts_deterministic_combined_sha256" + ] = _combined_sha256(deterministic_mart_hashes) updated_manifest["artifacts"]["marts_quality_report_sha256"] = _sha256( staged_manifest / "marts_quality_report.json" ) diff --git a/src/pipeline.py b/src/pipeline.py index 3bca98f..dd1264a 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -3,10 +3,15 @@ from __future__ import annotations import argparse +from datetime import datetime, timezone import json +import os from pathlib import Path +import shutil import sys +from time import perf_counter, sleep from typing import Sequence +from uuid import uuid4 from src.config import ConfigurationError, load_scenario_config from src.marts.builder import build_and_publish_marts @@ -16,12 +21,12 @@ EXIT_OK = 0 EXIT_CONFIGURATION_ERROR = 2 -EXIT_PIPELINE_NOT_IMPLEMENTED = 3 EXIT_GENERATION_ERROR = 4 EXIT_VALIDATION_FAILED = 5 EXIT_VALIDATION_ERROR = 6 EXIT_MARTS_FAILED = 7 EXIT_MARTS_ERROR = 8 +EXIT_PIPELINE_ERROR = 9 def _sanitized_error(exc: Exception) -> str: @@ -70,7 +75,7 @@ def _build_parser() -> argparse.ArgumentParser: "--output-dir", type=Path, default=Path("data/synthetic"), - help="Synthetic CSV destination used with --generate-only.", + help="Synthetic CSV destination for the full pipeline or --generate-only.", ) parser.add_argument( "--input-dir", @@ -82,13 +87,13 @@ def _build_parser() -> argparse.ArgumentParser: "--validated-dir", type=Path, default=Path("data/validated"), - help="Validated Parquet destination used with --validate-only.", + help="Validated Parquet destination for validation and the full pipeline.", ) parser.add_argument( "--manifest-dir", type=Path, default=Path("data/manifest"), - help="QA evidence destination used with --validate-only.", + help="QA, KPI-control, and manifest destination.", ) parser.add_argument( "--schema", @@ -100,11 +105,106 @@ def _build_parser() -> argparse.ArgumentParser: "--marts-dir", type=Path, default=Path("data/marts"), - help="Analytical Parquet destination used with --build-marts-only.", + help="Analytical Parquet destination for marts and the full pipeline.", ) return parser +def _display_path(path: Path) -> str: + resolved = path.resolve() + try: + return resolved.relative_to(Path.cwd().resolve()).as_posix() + except ValueError: + return resolved.name + + +def _pipeline_staging_path(target: Path, token: str) -> Path: + resolved = target.resolve() + if resolved == resolved.parent: + raise ValueError("pipeline output cannot be a filesystem root") + resolved.parent.mkdir(parents=True, exist_ok=True) + return resolved.parent / f".{resolved.name}.pipeline-{token}" + + +def _replace_with_retry(source: Path, target: Path) -> None: + for attempt in range(20): + try: + os.replace(source, target) + return + except PermissionError: + if attempt == 19: + raise + sleep(min(0.1 * (attempt + 1), 0.5)) + + +def _promote_pipeline_outputs( + staged_targets: Sequence[tuple[Path, Path]], + token: str, +) -> None: + backups: dict[Path, Path] = {} + promoted: list[Path] = [] + try: + for _, target in staged_targets: + if target.exists(): + backup = target.parent / f".{target.name}.pipeline-backup-{token}" + _replace_with_retry(target, backup) + backups[target] = backup + for staging, target in staged_targets: + _replace_with_retry(staging, target) + promoted.append(target) + except Exception: + for target in reversed(promoted): + if target.exists(): + shutil.rmtree(target) + for target, backup in backups.items(): + if backup.exists() and not target.exists(): + _replace_with_retry(backup, target) + raise + finally: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + for backup in backups.values(): + if backup.exists(): + shutil.rmtree(backup) + + +def _rewrite_final_manifest( + staged_manifest_dir: Path, + *, + synthetic_dir: Path, + validated_dir: Path, + marts_dir: Path, + manifest_dir: Path, + started: float, +) -> None: + manifest_path = staged_manifest_dir / "run_manifest.json" + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + payload["paths"] = { + "synthetic_input": _display_path(synthetic_dir), + "validated_output": _display_path(validated_dir), + "marts_output": _display_path(marts_dir), + "manifest_output": _display_path(manifest_dir), + } + payload["pipeline"] = { + "completed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "duration_seconds": round(perf_counter() - started, 6), + "stages": [ + "generation", + "validation", + "marts", + "kpi_controls", + "manifest", + ], + "status": "passed", + } + manifest_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + + def main(argv: Sequence[str] | None = None) -> int: args = _build_parser().parse_args(argv) @@ -193,12 +293,175 @@ def main(argv: Sequence[str] | None = None) -> int: print(json.dumps(result, sort_keys=True), file=destination) return EXIT_OK if run.status == "passed" else EXIT_MARTS_FAILED - print( - "pipeline_not_implemented: generation, validation, and marts are available, " - "but Power BI is pending the next increment", - file=sys.stderr, + started = perf_counter() + token = uuid4().hex + final_synthetic = args.output_dir.resolve() + final_validated = args.validated_dir.resolve() + final_marts = args.marts_dir.resolve() + final_manifest = args.manifest_dir.resolve() + final_targets = ( + final_synthetic, + final_validated, + final_marts, + final_manifest, ) - return EXIT_PIPELINE_NOT_IMPLEMENTED + if len(set(final_targets)) != len(final_targets): + print( + "pipeline_error: output directories must be distinct", + file=sys.stderr, + ) + return EXIT_PIPELINE_ERROR + + try: + staged_synthetic = _pipeline_staging_path(final_synthetic, token) + staged_validated = _pipeline_staging_path(final_validated, token) + staged_marts = _pipeline_staging_path(final_marts, token) + staged_manifest = _pipeline_staging_path(final_manifest, token) + except Exception as exc: + print(f"pipeline_error: {_sanitized_error(exc)}", file=sys.stderr) + return EXIT_PIPELINE_ERROR + + staged_targets = ( + (staged_synthetic, final_synthetic), + (staged_validated, final_validated), + (staged_marts, final_marts), + (staged_manifest, final_manifest), + ) + + try: + generated = generate_synthetic_data(config) + synthetic_hashes = publish_synthetic_csvs(generated, staged_synthetic) + except Exception as exc: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + print(f"generation_error: {_sanitized_error(exc)}", file=sys.stderr) + return EXIT_GENERATION_ERROR + + try: + validation_run = validate_and_publish( + config, + input_dir=staged_synthetic, + validated_dir=staged_validated, + manifest_dir=staged_manifest, + schema_path=args.schema, + ) + except Exception as exc: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + print(f"validation_error: {_sanitized_error(exc)}", file=sys.stderr) + return EXIT_VALIDATION_ERROR + + if not validation_run.outcome.passed or not validation_run.published: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + result = { + "dataset_build_id": validation_run.outcome.dataset_build_id, + "execution_id": validation_run.execution_id, + "failed_gates": sum( + gate.status == "failed" for gate in validation_run.outcome.results + ), + "published": False, + "stage": "validation", + "status": validation_run.outcome.status, + } + print(json.dumps(result, sort_keys=True), file=sys.stderr) + return EXIT_VALIDATION_FAILED + + if validation_run.outcome.dataset_build_id != generated.dataset_build_id: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + print( + "validation_error: dataset_build_id differs from generated data", + file=sys.stderr, + ) + return EXIT_VALIDATION_ERROR + + try: + marts_run = build_and_publish_marts( + config, + validated_dir=staged_validated, + marts_dir=staged_marts, + manifest_dir=staged_manifest, + ) + except Exception as exc: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + print(f"marts_error: {_sanitized_error(exc)}", file=sys.stderr) + return EXIT_MARTS_ERROR + + if marts_run.status != "passed" or not marts_run.published: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + result = { + "dataset_build_id": marts_run.dataset_build_id, + "failed_gates": sum( + gate.status == "failed" for gate in marts_run.results + ), + "marts_execution_id": marts_run.marts_execution_id, + "published": False, + "stage": "marts", + "status": marts_run.status, + } + print(json.dumps(result, sort_keys=True), file=sys.stderr) + return EXIT_MARTS_FAILED + + if marts_run.dataset_build_id != generated.dataset_build_id: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + print( + "marts_error: dataset_build_id differs from generated data", + file=sys.stderr, + ) + return EXIT_MARTS_ERROR + + try: + _rewrite_final_manifest( + staged_manifest, + synthetic_dir=final_synthetic, + validated_dir=final_validated, + marts_dir=final_marts, + manifest_dir=final_manifest, + started=started, + ) + _promote_pipeline_outputs(staged_targets, token) + except Exception as exc: + for staging, _ in staged_targets: + if staging.exists(): + shutil.rmtree(staging) + print(f"pipeline_error: {_sanitized_error(exc)}", file=sys.stderr) + return EXIT_PIPELINE_ERROR + + result = { + "dataset_build_id": generated.dataset_build_id, + "generation": { + "files": len(synthetic_hashes), + "rows": { + name: len(table) for name, table in generated.tables.items() + }, + }, + "manifest": _display_path(final_manifest / "run_manifest.json"), + "marts": { + "execution_id": marts_run.marts_execution_id, + "failed_gates": 0, + "tables": len(marts_run.mart_hashes), + "total_gates": len(marts_run.results), + }, + "status": "passed", + "validation": { + "execution_id": validation_run.execution_id, + "failed_gates": 0, + "total_gates": len(validation_run.outcome.results), + }, + } + print(json.dumps(result, sort_keys=True)) + return EXIT_OK if __name__ == "__main__": diff --git a/src/reconcile.py b/src/reconcile.py new file mode 100644 index 0000000..9976993 --- /dev/null +++ b/src/reconcile.py @@ -0,0 +1,169 @@ +"""Reconcile a candidate pipeline build with the committed release baseline. + +Execution metadata is intentionally excluded: ``meta_run`` contains the current +execution identifier and timestamp. Business tables, source/validated hashes, +quality gates, and KPI controls must remain deterministic. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Any, Sequence + + +EXIT_OK = 0 +EXIT_RECONCILIATION_FAILED = 1 +EXIT_INPUT_ERROR = 2 + + +class ReconciliationError(ValueError): + """Raised when release artifacts do not reconcile.""" + + +def _read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ReconciliationError(f"Cannot read JSON artifact: {path}") from exc + if not isinstance(payload, dict): + raise ReconciliationError(f"JSON artifact must contain an object: {path}") + return payload + + +def _required(payload: dict[str, Any], path: str) -> Any: + value: Any = payload + for part in path.split("."): + if not isinstance(value, dict) or part not in value: + raise ReconciliationError(f"Missing required field: {path}") + value = value[part] + return value + + +def _assert_equal( + failures: list[dict[str, Any]], + name: str, + expected: Any, + candidate: Any, +) -> None: + if expected != candidate: + failures.append( + { + "check": name, + "expected": expected, + "candidate": candidate, + } + ) + + +def reconcile_artifacts( + expected_manifest: dict[str, Any], + candidate_manifest: dict[str, Any], + expected_controls: dict[str, Any], + candidate_controls: dict[str, Any], +) -> dict[str, Any]: + """Return a machine-readable reconciliation result.""" + failures: list[dict[str, Any]] = [] + + for manifest_name, manifest in ( + ("expected", expected_manifest), + ("candidate", candidate_manifest), + ): + if _required(manifest, "status") != "passed": + failures.append( + { + "check": f"{manifest_name}_manifest_status", + "expected": "passed", + "candidate": _required(manifest, "status"), + } + ) + for quality_path in ("quality_summary", "marts_quality_summary"): + failed = _required(manifest, f"{quality_path}.failed_gates") + if failed != 0: + failures.append( + { + "check": f"{manifest_name}_{quality_path}_failed_gates", + "expected": 0, + "candidate": failed, + } + ) + + comparable_fields = ( + "scenario_id", + "seed", + "generator_version", + "schema_version", + "config_sha256", + "dataset_build_id", + "datasets", + "control_metrics", + "quality_summary", + "marts_quality_summary", + "marts_build.tables", + "artifacts.synthetic_csv_sha256", + "artifacts.validated_parquet_sha256", + "artifacts.marts_deterministic_combined_sha256", + ) + for field in comparable_fields: + _assert_equal( + failures, + field, + _required(expected_manifest, field), + _required(candidate_manifest, field), + ) + + _assert_equal( + failures, + "kpi_controls.control_version", + _required(expected_controls, "control_version"), + _required(candidate_controls, "control_version"), + ) + _assert_equal( + failures, + "kpi_controls.controls", + _required(expected_controls, "controls"), + _required(candidate_controls, "controls"), + ) + + return { + "status": "passed" if not failures else "failed", + "dataset_build_id": candidate_manifest.get("dataset_build_id"), + "checks": len(comparable_fields) + 8, + "failed_checks": len(failures), + "failures": failures, + "excluded_execution_variant_artifacts": ["meta_run"], + } + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Reconcile a candidate pipeline build against a known baseline." + ) + parser.add_argument("--expected-manifest", type=Path, required=True) + parser.add_argument("--candidate-manifest", type=Path, required=True) + parser.add_argument("--expected-controls", type=Path, required=True) + parser.add_argument("--candidate-controls", type=Path, required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + try: + result = reconcile_artifacts( + _read_json(args.expected_manifest), + _read_json(args.candidate_manifest), + _read_json(args.expected_controls), + _read_json(args.candidate_controls), + ) + except ReconciliationError as exc: + print(json.dumps({"status": "error", "error": str(exc)}, indent=2)) + return EXIT_INPUT_ERROR + + print(json.dumps(result, indent=2, sort_keys=True)) + return EXIT_OK if result["status"] == "passed" else EXIT_RECONCILIATION_FAILED + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_marts.py b/tests/test_marts.py index fe05907..584216f 100644 --- a/tests/test_marts.py +++ b/tests/test_marts.py @@ -142,6 +142,13 @@ def test_publication_is_repeatable_and_emits_complete_evidence( assert first.published and second.published assert first.mart_hashes == second.mart_hashes assert set(first.mart_hashes) == set(MART_TABLES) + first_run_manifest = json.loads( + (first_manifest / "run_manifest.json").read_text(encoding="utf-8") + ) + expected_deterministic_hash = first_run_manifest["artifacts"][ + "marts_deterministic_combined_sha256" + ] + assert len(expected_deterministic_hash) == 64 report = json.loads( (first_manifest / "marts_quality_report.json").read_text(encoding="utf-8") ) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 4557952..7c3b6a0 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -2,11 +2,12 @@ from pathlib import Path from types import SimpleNamespace +import json from src.pipeline import ( EXIT_CONFIGURATION_ERROR, EXIT_OK, - EXIT_PIPELINE_NOT_IMPLEMENTED, + EXIT_VALIDATION_FAILED, main, ) from src.synthetic.generator import GeneratedSyntheticData @@ -37,12 +38,157 @@ def test_missing_config_fails_without_traceback(capsys) -> None: assert "Traceback" not in captured.err -def test_end_to_end_command_does_not_claim_false_success(capsys) -> None: - exit_code = main(["--config", str(BASE_CONFIG)]) +def test_end_to_end_command_orchestrates_and_promotes( + capsys, monkeypatch, tmp_path +) -> None: + calls: list[str] = [] + generated = GeneratedSyntheticData( + dataset_build_id="BLD-TEST", + tables={"customers": [1, 2, 3]}, + ) + + def publish_generated(data, output): + calls.append("generation") + output.mkdir(parents=True) + (output / "customers.csv").write_text("customer_id\n1\n", encoding="utf-8") + return {"customers": "ABC"} + + outcome = SimpleNamespace( + dataset_build_id="BLD-TEST", + results=[SimpleNamespace(status="passed")], + passed=True, + status="passed", + ) + + def validate(config, *, input_dir, validated_dir, manifest_dir, schema_path): + calls.append("validation") + assert (input_dir / "customers.csv").is_file() + validated_dir.mkdir(parents=True) + (validated_dir / "customers.parquet").write_bytes(b"PAR1") + manifest_dir.mkdir(parents=True) + (manifest_dir / "run_manifest.json").write_text( + json.dumps({"dataset_build_id": "BLD-TEST", "paths": {}}), + encoding="utf-8", + ) + return SimpleNamespace( + execution_id="EXE-TEST", + outcome=outcome, + published=True, + ) + + mart_gate = SimpleNamespace(status="passed") + + def build_marts(config, *, validated_dir, marts_dir, manifest_dir): + calls.append("marts") + assert (validated_dir / "customers.parquet").is_file() + marts_dir.mkdir(parents=True) + (marts_dir / "dim_customer.parquet").write_bytes(b"PAR1") + return SimpleNamespace( + dataset_build_id="BLD-TEST", + marts_execution_id="MRT-TEST", + mart_hashes={"dim_customer": "DEF"}, + published=True, + results=[mart_gate], + status="passed", + ) + + monkeypatch.setattr("src.pipeline.generate_synthetic_data", lambda config: generated) + monkeypatch.setattr("src.pipeline.publish_synthetic_csvs", publish_generated) + monkeypatch.setattr("src.pipeline.validate_and_publish", validate) + monkeypatch.setattr("src.pipeline.build_and_publish_marts", build_marts) + + synthetic = tmp_path / "synthetic" + validated = tmp_path / "validated" + marts = tmp_path / "marts" + manifest = tmp_path / "manifest" + exit_code = main( + [ + "--config", str(BASE_CONFIG), + "--output-dir", str(synthetic), + "--validated-dir", str(validated), + "--marts-dir", str(marts), + "--manifest-dir", str(manifest), + ] + ) + + captured = capsys.readouterr() + result = json.loads(captured.out) + final_manifest = json.loads( + (manifest / "run_manifest.json").read_text(encoding="utf-8") + ) + assert exit_code == EXIT_OK + assert calls == ["generation", "validation", "marts"] + assert result["status"] == "passed" + assert result["generation"]["files"] == 1 + assert final_manifest["pipeline"]["status"] == "passed" + assert final_manifest["pipeline"]["stages"][-2:] == [ + "kpi_controls", "manifest" + ] + assert (synthetic / "customers.csv").is_file() + assert (validated / "customers.parquet").is_file() + assert (marts / "dim_customer.parquet").is_file() + assert captured.err == "" + + +def test_end_to_end_failure_preserves_previous_valid_outputs( + capsys, monkeypatch, tmp_path +) -> None: + generated = GeneratedSyntheticData( + dataset_build_id="BLD-TEST", + tables={"customers": [1]}, + ) + final_dirs = [ + tmp_path / "synthetic", + tmp_path / "validated", + tmp_path / "marts", + tmp_path / "manifest", + ] + for directory in final_dirs: + directory.mkdir() + (directory / "sentinel.txt").write_text("previous-valid", encoding="utf-8") + + def publish_generated(data, output): + output.mkdir(parents=True) + return {"customers": "ABC"} + + failed_gate = SimpleNamespace(status="failed") + failed_outcome = SimpleNamespace( + dataset_build_id="BLD-TEST", + results=[failed_gate], + passed=False, + status="failed", + ) + + def validate(config, *, input_dir, validated_dir, manifest_dir, schema_path): + manifest_dir.mkdir(parents=True) + return SimpleNamespace( + execution_id="EXE-FAILED", + outcome=failed_outcome, + published=False, + ) + + monkeypatch.setattr("src.pipeline.generate_synthetic_data", lambda config: generated) + monkeypatch.setattr("src.pipeline.publish_synthetic_csvs", publish_generated) + monkeypatch.setattr("src.pipeline.validate_and_publish", validate) + + exit_code = main( + [ + "--config", str(BASE_CONFIG), + "--output-dir", str(final_dirs[0]), + "--validated-dir", str(final_dirs[1]), + "--marts-dir", str(final_dirs[2]), + "--manifest-dir", str(final_dirs[3]), + ] + ) captured = capsys.readouterr() - assert exit_code == EXIT_PIPELINE_NOT_IMPLEMENTED - assert "Power BI is pending" in captured.err + assert exit_code == EXIT_VALIDATION_FAILED + assert '"stage": "validation"' in captured.err + assert all( + (directory / "sentinel.txt").read_text(encoding="utf-8") + == "previous-valid" + for directory in final_dirs + ) def test_generate_only_publishes_via_pipeline(capsys, monkeypatch) -> None: diff --git a/tests/test_powerbi_source.py b/tests/test_powerbi_source.py new file mode 100644 index 0000000..43307e4 --- /dev/null +++ b/tests/test_powerbi_source.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import json +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "powerbi" / "source" + + +def test_powerbi_model_contract_counts_and_rules() -> None: + contract = json.loads( + (SOURCE / "model-contract.json").read_text(encoding="utf-8") + ) + relationships = contract["relationships"] + + assert contract["release_status"] == "V1.0" + assert len(relationships) == 34 + assert sum(item["active"] for item in relationships) == 24 + assert sum(not item["active"] for item in relationships) == 10 + assert contract["relationship_rules"] == { + "cardinality": "one-to-many", + "filter_direction": "single", + "many_to_many": 0, + "bidirectional": 0, + "fact_to_fact": 0, + "active": 24, + "inactive": 10, + "total": 34, + } + assert contract["pages"] == [ + "Executive", + "Customer 360", + "Service & Inventory", + "Profitability & Geography", + ] + assert set(contract["release_gates"].values()) == {"PASSED"} + + +def test_powerbi_tmdl_contains_39_unique_measures() -> None: + tmdl = (SOURCE / "TMDLScripts" / "measures.tmdl").read_text( + encoding="utf-8" + ) + measures = re.findall(r"^\s+measure\s+(?:'([^']+)'|([^\s=]+))\s*=", tmdl, re.M) + names = [quoted or unquoted for quoted, unquoted in measures] + + assert tmdl.startswith("createOrReplace\n") + assert len(names) == 39 + assert len(set(names)) == 39 + assert {"Net Sales", "MC_CTS", "OTIF", "Fill Rate", "QA Status"} <= set( + names + ) diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py new file mode 100644 index 0000000..945e59e --- /dev/null +++ b/tests/test_reconcile.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from copy import deepcopy + +from src.reconcile import reconcile_artifacts + + +def _manifest() -> dict: + return { + "status": "passed", + "scenario_id": "B2B-V1-BASE", + "seed": 20260813, + "generator_version": "0.1.0", + "schema_version": "1.0.0-rc.1", + "config_sha256": "CONFIG", + "dataset_build_id": "BLD-TEST", + "datasets": {"orders": {"rows_written": 1}}, + "control_metrics": {"eligible_orders": 1}, + "quality_summary": {"failed_gates": 0, "passed_gates": 2}, + "marts_quality_summary": {"failed_gates": 0, "passed_gates": 3}, + "marts_build": {"tables": {"fact_orders": 1, "meta_run": 1}}, + "artifacts": { + "synthetic_csv_sha256": {"orders": "CSV"}, + "validated_parquet_sha256": {"orders": "PARQUET"}, + "marts_deterministic_combined_sha256": "MARTS", + "marts_parquet_sha256": { + "fact_orders": "FACT", + "meta_run": "EXECUTION-VARIANT-A", + }, + }, + } + + +def _controls() -> dict: + return { + "control_version": "1.0", + "controls": [{"kpi_id": "KPI-01", "python_control_value": 1.0}], + } + + +def test_reconciliation_ignores_execution_variant_meta_run() -> None: + expected = _manifest() + candidate = deepcopy(expected) + candidate["artifacts"]["marts_parquet_sha256"]["meta_run"] = ( + "EXECUTION-VARIANT-B" + ) + + result = reconcile_artifacts(expected, candidate, _controls(), _controls()) + + assert result["status"] == "passed" + assert result["failed_checks"] == 0 + assert result["excluded_execution_variant_artifacts"] == ["meta_run"] + + +def test_reconciliation_detects_business_artifact_change() -> None: + expected = _manifest() + candidate = deepcopy(expected) + candidate["artifacts"]["marts_deterministic_combined_sha256"] = "CHANGED" + + result = reconcile_artifacts(expected, candidate, _controls(), _controls()) + + assert result["status"] == "failed" + assert result["failed_checks"] == 1 + assert result["failures"][0]["check"] == ( + "artifacts.marts_deterministic_combined_sha256" + ) + + +def test_reconciliation_detects_kpi_change() -> None: + candidate_controls = _controls() + candidate_controls["controls"][0]["python_control_value"] = 2.0 + + result = reconcile_artifacts( + _manifest(), _manifest(), _controls(), candidate_controls + ) + + assert result["status"] == "failed" + assert result["failures"][0]["check"] == "kpi_controls.controls"