Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/controllers/api/v1/reports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module Api
module V1
class ReportsController < ApplicationController
rescue_from Reports::Window::UnknownPeriodError, with: :render_bad_request

# GET /api/v1/reports/overview?period=7d|30d|90d
#
# Un recurso y no una colección: viaja pelado, sin `data` (ADR-015).
def overview
authorize :report, :overview?

window = Reports::Window.new(period: scalar_param(:period))
render json: Reports::BuildOverview.new(window: window).call
end
end
end
end
8 changes: 8 additions & 0 deletions app/policies/report_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

# Política sin registro (headless): un reporte no es un recurso con dueño, es una
# vista sobre los datos del tenant. El aislamiento lo da `CompanyScoped` en cada
# modelo que el PORO consulta; acá sólo se exige una sesión.
class ReportPolicy < ApplicationPolicy
def overview? = user.present?
end
101 changes: 101 additions & 0 deletions app/poros/reports/build_overview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# frozen_string_literal: true

module Reports
# Agregados de la pantalla de Reportes (S14) sobre una ventana de tiempo.
#
# Calcula sólo lo que el modelo permite calcular. El diseño tiene además el
# cumplimiento de plazo y las anomalías regionales, que no tienen dominio
# detrás (no hay fecha comprometida en `shipments`, ni una entidad de
# anomalía): esos van como `nil` explícito y no como un número inventado.
#
# Todo pasa por los modelos con `CompanyScoped`, así que el reporte es del
# tenant del request sin filtrar a mano.
class BuildOverview < ApplicationPoro
DISPATCH_STATUS = Shipments::ConfirmDispatch::DISPATCHED_STATUS

def initialize(window:)
super()
@window = window
end

def call
{
period: @window.period,
from: @window.from.iso8601,
to: @window.to.iso8601,
granularity: @window.granularity,
kpis: kpis,
curve: curve,
carriers: carriers
}
end

private

def kpis
{
orders: trended { |range| sales(range).count },
revenue: trended { |range| sales(range).sum(:total_amount).to_f },
dispatched_units: trended { |range| dispatched_units(range) },
on_time_delivery_rate: nil,
active_anomalies: nil
}
end

# El valor de la ventana y su variación porcentual contra la anterior. Sin
# nada en la anterior, la variación es `nil`: un "+100 %" o un "0 %" ahí se
# leería como un dato.
def trended
current = yield(@window.range)
previous = yield(@window.previous_range)
trend = previous.zero? ? nil : ((current - previous) * 100.0 / previous).round(1)

{ value: current, trend: trend }
end

# Las ventas que cuentan: las canceladas no facturaron.
def sales(range)
Order.where(created_at: range).where.not(status: 'cancelled')
end

# `shipments` no guarda cuándo se despachó: lo dice el primer evento de la
# bitácora, el `ready_to_ship` que escribe `ConfirmDispatch`.
def dispatched_shipments(range)
Shipment.where(id: ShipmentEvent.where(internal_status: DISPATCH_STATUS, occurred_at: range)
.select(:shipment_id))
end

def dispatched_units(range)
OrderItem.where(order_id: dispatched_shipments(range).select(:order_id)).sum(:quantity)
end

def curve
totals = sales(@window.range).pluck(:created_at, :total_amount)
.group_by { |created_at, _| @window.bucket_for(created_at) }

@window.buckets.map do |date|
rows = totals.fetch(date, [])
{ date: date.iso8601, orders: rows.size, revenue: rows.sum { |_, amount| amount.to_f } }
end
end

# Volumen por operador: cuántos envíos se despacharon con cada integración de
# courier en la ventana, y cuántos de esos ya se entregaron. No es el
# cumplimiento de plazo del diseño: es lo que el modelo puede decir.
def carriers
shipments = dispatched_shipments(@window.range).where.not(company_integration_id: nil)
dispatched = shipments.group(:company_integration_id).count
delivered = shipments.where(status: 'delivered').group(:company_integration_id).count

CompanyIntegration.includes(:service).where(id: dispatched.keys)
.map { |integration| carrier_row(integration, dispatched, delivered) }
.sort_by { |row| [-row[:dispatched], row[:name]] }
end

def carrier_row(integration, dispatched, delivered)
{ company_integration_id: integration.id, name: integration.service_name,
dispatched: dispatched.fetch(integration.id, 0),
delivered: delivered.fetch(integration.id, 0) }
end
end
end
59 changes: 59 additions & 0 deletions app/poros/reports/window.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module Reports
# La ventana de tiempo de un reporte: los últimos N días calendario, en hora de
# Argentina, desde el inicio del primer día hasta ahora.
#
# Hora de Argentina y no la del servidor (UTC): el MVP opera sólo en Argentina
# (E4b, exclusión 9), y un corte de día en UTC partiría las ventas de las
# 21 a las 24 en el día siguiente.
#
# El período anterior es la ventana del mismo largo inmediatamente antes, y es
# contra lo que se calcula la tendencia.
class Window
class UnknownPeriodError < StandardError; end

PERIODS = { '7d' => 7, '30d' => 30, '90d' => 90 }.freeze
DEFAULT_PERIOD = '7d'
# A partir de este largo la curva va por semana: 90 puntos diarios no se
# leen en una tarjeta.
WEEKLY_FROM = 90
TIME_ZONE = 'America/Argentina/Buenos_Aires'

attr_reader :period, :days, :from, :to

def initialize(period:, now: Time.current)
@period = period.presence || DEFAULT_PERIOD
@days = PERIODS.fetch(@period) do
raise UnknownPeriodError, "period must be one of #{PERIODS.keys.join(', ')}"
end
@to = now.in_time_zone(zone)
@from = (@to.to_date - (@days - 1)).in_time_zone(zone).beginning_of_day
end

def range = from..to

# Mismo largo, inmediatamente antes. Excluye el borde para no contar dos veces
# lo que pasó justo en el inicio de la ventana actual.
def previous_range = (from - days.days)...from

def granularity = days >= WEEKLY_FROM ? 'week' : 'day'

# El bucket al que pertenece un instante: su día, o el lunes de su semana.
def bucket_for(time)
date = time.in_time_zone(zone).to_date
granularity == 'week' ? date.beginning_of_week : date
end

# Todos los buckets de la ventana, en orden, incluidos los que quedan vacíos:
# una curva sin los días en cero dibujaría una línea entre dos ventas como si
# hubiera habido ventas en el medio.
def buckets
(bucket_for(from)..to.to_date).select { |date| bucket_for(date) == date }
end

private

def zone = ActiveSupport::TimeZone[TIME_ZONE]
end
end
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
# del token.
get 'me', to: 'me#show'

# Agregados de la pantalla de Reportes (S14) sobre una ventana de tiempo.
get 'reports/overview', to: 'reports#overview'

# Sólo lectura: las credenciales las carga el equipo de OneStock desde el
# backoffice (ADR-018).
resources :integrations, only: :index
Expand Down
10 changes: 7 additions & 3 deletions docs/guidelines/feature-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ app/poros/
│ ├── create_order.rb
│ ├── confirm_order.rb # Confirma orden + descuenta stock
│ └── process_webhook_order.rb # Ingesta orden de un canal externo
└── shipments/
├── quote_shipment.rb # Cotización concurrente con múltiples couriers
└── confirm_dispatch.rb # Confirma despacho + obtiene tracking
├── shipments/
│ ├── quote_shipment.rb # Cotización concurrente con múltiples couriers
│ └── confirm_dispatch.rb # Confirma despacho + obtiene tracking
└── reports/
├── window.rb # Ventana de N días en hora de Argentina + período anterior
└── build_overview.rb # Agregados de S14: ventas, unidades despachadas, couriers
```

Un PORO = un caso de uso. El nombre del archivo es el verbo + sustantivo del caso de uso.
Expand Down Expand Up @@ -93,6 +96,7 @@ orders → catalog ✅ (confirmar orden descuenta stock)
shipments → orders ✅ (un envío pertenece a una orden)
webhooks → orders ✅ (los webhooks disparan procesamiento de órdenes)
webhooks → shipments ✅ (los webhooks de couriers actualizan tracking)
reports → orders, shipments ✅ (sólo lee: agrega ventas y despachos, nunca escribe)
```

Los dominios nunca importan hacia arriba en la jerarquía de dependencia. Si dos dominios necesitan lógica compartida que no pertenece a ninguno, esa lógica va en `app/poros/shared/` o en el model directamente.
Expand Down
114 changes: 114 additions & 0 deletions spec/poros/reports/build_overview_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Reports::BuildOverview do
let(:company) { Company.create!(name: 'Norte', tax_id: '30-11111111-1') }
let(:now) { Time.zone.parse('2026-10-02 15:00:00 UTC') }
let(:window) { Reports::Window.new(period: '7d', now: now) }
let(:product) { Product.create!(company: company, sku: 'R-1', name: 'Reportado') }

around { |example| Current.set(company_id: company.id) { example.run } }

def overview = described_class.new(window: window).call

def order(at:, amount:, status: 'paid', units: 1)
Order.create!(company: company, customer_name: 'Cliente', total_amount: amount,
status: status, created_at: at).tap do |created|
OrderItem.create!(order: created, product: product, quantity: units, unit_price: amount)
end
end

def dispatch(order, at:, with:, delivered: false)
shipment = Shipment.create!(company: company, order: order, company_integration: with,
status: delivered ? 'delivered' : 'ready_to_ship')
ShipmentEvent.create!(shipment: shipment, internal_status: 'ready_to_ship',
external_status: 'Etiqueta generada', occurred_at: at)
end

describe 'the sales of the window' do
before do
order(at: now - 1.day, amount: 1_000)
order(at: now - 2.days, amount: 500)
order(at: now - 1.day, amount: 9_999, status: 'cancelled')
order(at: now - 10.days, amount: 750)
end

it 'adds up the revenue of the orders that were not cancelled' do
expect(overview[:kpis][:revenue][:value]).to eq(1_500.0)
end

it 'counts those orders' do
expect(overview[:kpis][:orders][:value]).to eq(2)
end

# 750 en la semana anterior contra 1.500 en esta.
it 'compares them with the previous window of the same length' do
expect(overview[:kpis][:revenue][:trend]).to eq(100.0)
end
end

it 'answers no trend when the previous window had nothing' do
order(at: now - 1.day, amount: 1_000)

expect(overview[:kpis][:revenue]).to eq(value: 1_000.0, trend: nil)
end

it 'draws one point per day, the empty ones in zero', :aggregate_failures do
order(at: now - 1.day, amount: 1_000)

expect(overview[:curve].size).to eq(7)
expect(overview[:curve].sum { |point| point[:orders] }).to eq(1)
expect(overview[:curve].count { |point| point[:orders].zero? }).to eq(6)
end

describe 'the dispatched units and the carriers' do
# Métodos y no `let`: el grupo ya hereda cuatro helpers y
# RSpec/MultipleMemoizedHelpers corta en cinco.
def andreani = CompanyIntegration.find_by(service: Service.find_by(service_name: 'Andreani'))
def oca = CompanyIntegration.find_by(service: Service.find_by(service_name: 'OCA'))

before do
courier_integration(company: company, name: 'Andreani')
courier_integration(company: company, name: 'OCA')
dispatch(order(at: now - 3.days, amount: 10, units: 4), at: now - 1.day, with: andreani)
dispatch(order(at: now - 3.days, amount: 10, units: 2), at: now - 2.days, with: andreani,
delivered: true)
dispatch(order(at: now - 3.days, amount: 10, units: 7), at: now - 1.day, with: oca)
# Despachada la semana anterior: no cuenta en esta ventana.
dispatch(order(at: now - 9.days, amount: 10, units: 50), at: now - 8.days, with: oca)
end

# Por la fecha del despacho, no por la de la orden.
it 'counts the units of the shipments dispatched in the window' do
expect(overview[:kpis][:dispatched_units][:value]).to eq(13)
end

it 'counts the shipments of each carrier and how many were delivered' do
expect(overview[:carriers]).to eq(
[{ company_integration_id: andreani.id, name: 'Andreani', dispatched: 2, delivered: 1 },
{ company_integration_id: oca.id, name: 'OCA', dispatched: 1, delivered: 0 }]
)
end
end

# El modelo no tiene con qué calcularlos: `nil` y no un número inventado.
it 'leaves out what the model cannot answer', :aggregate_failures do
expect(overview[:kpis][:on_time_delivery_rate]).to be_nil
expect(overview[:kpis][:active_anomalies]).to be_nil
end

it 'only sees the orders of the current tenant' do
order_of_another_company

expect(overview[:kpis][:revenue][:value]).to eq(0.0)
end

def order_of_another_company
other = Company.create!(name: 'Sur', tax_id: '30-22222222-2')
Current.set(company_id: other.id) do
Order.create!(company: other, customer_name: 'Otro', total_amount: 5_000,
status: 'paid', created_at: now - 1.day)
end
end
end
51 changes: 51 additions & 0 deletions spec/poros/reports/window_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Reports::Window do
# Jueves 02/10/2026 a las 01:30 en Argentina, que en UTC ya es 04:30.
let(:now) { Time.zone.parse('2026-10-02 04:30:00 UTC') }

def window(period = '7d') = described_class.new(period: period, now: now)

it 'starts at the beginning of the first of the last N days, in Argentina' do
expect(window.from.iso8601).to eq('2026-09-26T00:00:00-03:00')
end

it 'ends now' do
expect(window.to).to eq(now)
end

it 'defaults to the last seven days' do
expect(described_class.new(period: nil, now: now).days).to eq(7)
end

it 'refuses a period it does not know' do
expect { window('1y') }.to raise_error(described_class::UnknownPeriodError, /7d, 30d, 90d/)
end

it 'puts the previous window right before, with the same length' do
expect(window.previous_range).to eq((window.from - 7.days)...window.from)
end

it 'lists every day of the window, empty ones included' do
expect(window.buckets.map(&:iso8601)).to eq(
%w[2026-09-26 2026-09-27 2026-09-28 2026-09-29 2026-09-30 2026-10-01 2026-10-02]
)
end

# 21:30 en Argentina del 01/10 ya es 02/10 en UTC: el corte va por la hora local.
it 'buckets an instant by its day in Argentina, not in UTC' do
expect(window.bucket_for(Time.zone.parse('2026-10-02 00:30:00 UTC')).iso8601).to eq('2026-10-01')
end

context 'with ninety days' do
it 'groups the curve by week, starting on Monday', :aggregate_failures do
buckets = window('90d').buckets

expect(window('90d').granularity).to eq('week')
expect(buckets).to all(satisfy(&:monday?))
expect(buckets.last.iso8601).to eq('2026-09-28')
end
end
end
Loading
Loading