Rateio de Materiais de Consumo Compartilhado
Problema
Toda peça ou insumo solicitado em uma ordem de serviço (OS) é apropriado diretamente ao custo da frota daquela OS. Isso funciona bem para peças específicas — um filtro trocado no PN-0007 é custo do PN-0007.
Mas existem materiais de uso compartilhado que não servem a um único ativo:
| Material | Como é consumido | Por que não é de um ativo só |
|---|---|---|
| Graxa | Aplicada em vários veículos ao longo do mês | Um balde lubrifica a frota inteira |
| Disco de corte | Gasto em múltiplos reparos | Serve à oficina, não a uma OS |
| Arame MIG (solda) | Consumido em qualquer solda | Não há como medir por veículo |
| Estopa, lixa, produtos de limpeza | Consumo difuso | Uso geral da oficina |
Hoje esse consumo cai 100% na frota da OS onde o material foi solicitado. Um balde de graxa pedido numa OS do Veículo A é debitado integralmente no Veículo A, mesmo tendo lubrificado outros dez veículos.
Resultado: o custo por ativo e o CPK (custo por km) ficam distorcidos. Um veículo aparece artificialmente caro, outros aparecem baratos demais, e decisões gerenciais — renovação de frota, priorização de manutenção, negociação com transportadora — partem de um número errado.
Fluxo Atual
Como o custo de peça deveria fluir
O modelo de dados já rastreia a cadeia completa peça -> OS -> frota/veículo:
Part.costPrice
└─ PartRequest (quantity / approvedQuantity) ← baixa de estoque na aprovação
└─ WorkOrder.fleetId ← OS amarrada a uma frota
└─ WorkOrderAsset.vehicleId ← e a um veículo específico
Os dashboards de custo por frota, custo por veículo, CPK e centro de custo leem os campos denormalizados da OS: partsCost, labourCost, tireCost, externalCost, totalCost.
Como o consumível é tratado hoje
Consumíveis já têm um comportamento distinto no sistema, controlado pela flag isConsumable na categoria da peça:
- A
PartRequestde um consumível fica semserviceExecutionId— ou seja, sem localização de veículo/eixo. Ela é auto-atribuída a um serviço de sistema chamado "Consumíveis Diversos". - Peças não-consumíveis, ao contrário, exigem um
serviceExecutionIde herdam dele o veículo/eixo.
Ou seja, o consumível já não aponta para um veículo específico. Mas ele continua preso a uma única OS, que por sua vez está amarrada a uma única frota. O custo, portanto, ainda cai integralmente naquela frota.
:::warning Descoberta importante durante a análise
A materialização do custo de peça na OS ainda não existe em produção. Hoje os campos partsCost / totalCost da WorkOrder são preenchidos por um seed com valores aleatórios (prisma/seed-work-order-costs.ts) — nenhum use case recalcula partsCost a partir das PartRequest reais. O custo real das peças (approvedQuantity × costPrice) só é computado em relatórios de consumo/curva ABC, que não são segmentados por frota nem por veículo.
Consequência para esta proposta: o rateio não deve ser construído sobre um mecanismo que ainda não existe. A decisão de rateio precisa ser tomada agora e embutida no momento em que a apropriação real de custo (PartRequest -> WorkOrder.partsCost) for implementada — que é um passo natural e próximo. São duas faces do mesmo trabalho.
:::
Proposta
Conceito em uma frase
Classificar cada material como Direto ou Compartilhado. Material direto continua sendo apropriado ao ativo/frota da OS. Material compartilhado não vai para uma frota única: seu custo entra num pool de manutenção e é rateado entre os ativos por um critério justo.
As três peças da solução
| Peça | O que é | Decisão envolvida |
|---|---|---|
| 1. Classificação | Marcar o material como Direto ou Compartilhado | Reusar isConsumable ou criar campo próprio? |
| 2. Acúmulo | Onde o custo compartilhado se junta antes de distribuir | Pool por período vs. rateio dentro da própria OS |
| 3. Distribuição | Por qual critério o pool é dividido entre os ativos | Base de rateio (custo direto, nº de OS, km, igualitário) |
Fluxo proposto
Consumível compartilhado solicitado
│
▼
Custo NÃO debita a frota da OS
│
▼
Entra no POOL de material compartilhado (por período + centro de custo de oficina)
│
▼
No fechamento do período / geração de relatório
│
▼
RATEIO entre os ativos ativos no período (base configurável)
│
▼
Custo por ativo = custo DIRETO + parcela RATEADA (as duas parcelas visíveis)
O ponto inegociável: o custo por ativo passa a ter duas parcelas explícitas e auditáveis — "direto" e "rateio de compartilhados". Nunca um número opaco que o gestor não consegue rastrear.
Protótipo Interativo
O cenário abaixo simula uma oficina que consumiu R$ 1.100 em materiais compartilhados no mês (graxa, disco de corte, arame MIG). Troque as abas para comparar como o mesmo pool muda o custo por ativo — e veja a distorção do modelo atual.
Todo o custo dos materiais compartilhados (R$ 1.100) e apropriado ao ativo da OS onde a graxa foi solicitada. O Veiculo A absorve sozinho o consumo que serviu a oficina inteira.
Decisões
1. O que define "compartilhado"? — Campo próprio na categoria (Opção B) · APROVADO
Duas opções:
| Opção | Como funciona | Trade-off |
|---|---|---|
A. Reusar isConsumable | Todo consumível é tratado como compartilhado (rateio); o resto é direto | Zero campo novo. Mas acopla dois conceitos: isConsumable hoje governa "pula seleção de serviço", não "como o custo é apropriado" |
B. Campo próprio costAllocation (DIRETO | RATEIO) na categoria | Classificação explícita de custo, independente do isConsumable | Um campo novo, mas desacopla as responsabilidades e permite exceções (um consumível direto, um material caro compartilhado) |
Recomendação: Opção B, com a migração definindo o default de forma inteligente — categorias com isConsumable = true nascem como RATEIO, as demais como DIRETO. Assim ninguém precisa reclassificar o catálogo na mão, e os casos de exceção ficam possíveis.
Um interruptor, não dois campos para o usuário
As duas flags parecem redundantes, mas respondem perguntas diferentes:
| Flag | Pergunta que responde | Momento |
|---|---|---|
isConsumable | O mecânico precisa dizer em qual serviço/veículo ao PEDIR? | Solicitação |
costAllocation | O custo vai para um ativo ou para o pool ao CUSTEAR? | Cálculo de custo |
Hoje elas coincidem (todo material compartilhado é consumível), mas são conceitos distintos — por isso ficam separadas no banco. Se o isConsumable fosse removido, pedir graxa passaria a exigir a escolha de um serviço/veículo específico, que é justamente o atrito que não queremos.
Na tela, o gestor vê um único interruptor — "Material compartilhado" — que aciona as duas flags por baixo: pede sem exigir veículo (isConsumable = true) e manda o custo para o pool (costAllocation = RATEIO). A separação interna existe apenas para permitir a exceção rara (um material que precisa de serviço, mas cujo custo se rateia).
A configuração é por categoria, não por peça
O interruptor vive na PartCategory (uma dúzia de categorias, no máximo), não na peça. Cada Part apenas aponta para uma categoria e herda o comportamento.
| Nível | Campos novos para o usuário |
|---|---|
| Cada peça (graxa, filtro, parafuso) | Zero — só escolhe a categoria, como hoje |
| Categoria (Consumíveis, Filtros, Lonas...) | Um interruptor "Material compartilhado" |
Marca-se "Consumíveis" como compartilhada uma vez, e toda graxa/disco/arame daquela categoria já entra no rateio — sem tocar em peça nenhuma. O mecânico que cadastra ou solicita peça nunca vê essas flags.
Nota de implementação: hoje nenhum formulário grava isConsumable — ele só existe no backend/seed. A tela de categoria (create-category-form, update-category-form) passa a expor o interruptor único, que grava as duas flags de uma vez.
2. Onde acumula e quando distribui — Pool por período · APROVADO (configurável)
| Opção | Como funciona | Trade-off |
|---|---|---|
| A. Rateio dentro da OS | Se a OS toca vários ativos, divide o consumível só entre eles | Simples e imediato. Mas não resolve o caso real: um balde de graxa pedido numa OS de um único veículo continua 100% naquele veículo |
| B. Pool por período | Todo consumo compartilhado do mês vai a um pool e é rateado entre os ativos ativos no período | Remove a distorção de verdade. Exige um "fechamento" de período (mensal) |
Recomendação: Opção B. O problema descrito é cross-OS por natureza — a graxa serve à frota inteira ao longo do mês, não a uma OS. Rateio intra-OS só mascara o sintoma.
Decisão (produto, 26/07/2026): adotado o pool por período como padrão, porém configurável por empresa/centro de custo — quem preferir pode acionar também o rateio intra-OS quando fizer sentido. O modelo nasce flexível para os dois modos.
3. Base de rateio — Custo direto de manutenção · APROVADO (configurável)
O pool precisa de um critério para ser dividido. Opções, da mais justa à mais simples:
| Base | Lógica | Dado disponível hoje? |
|---|---|---|
| Custo direto (peças + MO) | Quem gerou mais manutenção consome mais insumo. Melhor proxy | Depende da materialização de custo (ver aviso acima) |
| Nº de OS / serviços | Quantas OS tocaram o ativo | Sim — rastreável direto por WorkOrderAsset |
| Km rodado | Proporcional a Vehicle.km | Sim — mas km é proxy fraco para consumível de oficina |
| Igualitário | Partes iguais entre ativos ativos | Sempre disponível, porém ignora o esforço real |
Recomendação: custo direto como default, com a base selecionável por empresa/centro de custo. Enquanto a materialização de custo não estiver pronta, nº de OS é o fallback mais honesto (dado já existe e não depende de custo). A base escolhida deve ficar registrada no relatório — rateio nunca pode ser uma caixa-preta.
4. Visibilidade do rateio — Sempre desdobrado
O custo por ativo nas telas de analytics e centro de custo passa a mostrar as duas parcelas:
Veículo PN-0007
Custo direto ............. R$ 800
Rateio compartilhado ..... R$ 220 (base: custo direto)
─────────────────────────────────
Total .................... R$ 1.020
O gestor sempre consegue responder "de onde veio esse número?". Sem isso, o rateio vira desconfiança.
5. Fechamento de período — como o mês "fecha"? (Aprovado · Opção C)
O rateio divide o pool do mês (ex: R$ 1.100 de graxa, disco e arame) entre os veículos, olhando os dados daquele mês. A pergunta:
Depois que o mês passa, o rateio vira uma foto fixa (oficial) ou é sempre recalculado com o dado mais recente?
Isso importa porque hoje o conceito de "fechar o mês" não existe — "período" é só filtro de data.
As opções
| Opção | Como funciona | Trade-off |
|---|---|---|
| A. Só congela | O mês vira foto oficial e nunca mais muda | Rígido — o mês corrente também ficaria travado |
| B. Só on-the-fly | Recalcula sempre com o dado atual | Flexível, mas sem foto oficial; número de mês passado pode mudar |
| C. Híbrido — RECOMENDADO | Mês aberto recalcula ao vivo; mês fechado vira snapshot oficial; reprocessar gera nova foto com auditoria | O melhor dos dois. Padrão de ERP (competência aberta vs. fechada) |
Como o híbrido (C) funciona
- Mês aberto (o corrente) → rateio calculado ao vivo, valores provisórios, refletem o dado mais recente. Flexível.
- Fechar mês (ação do admin) → o sistema tira uma foto dos valores e congela. A partir daí o mês mostra a foto oficial, com carimbo de quem e quando fechou. Auditável.
- Reprocessar → se entrar lançamento atrasado num mês já fechado, o admin refecha, gerando nova foto com registro de auditoria. Rastro preservado.
Ou seja: flexível no presente, oficial no passado.
Como mostrar ao usuário
┌─────────────────────────────────────────────┐
│ Rateio · Julho 2026 ● Fechado │
│ Fechado em 02/08 por Tamires · base: custo │
│ Veículo A ....... R$ 220 (oficial) │
│ Veículo B ....... R$ 180 │
│ ⚠ Há 1 lançamento posterior ao fechamento │ ← fase 2
│ [ Reprocessar mês ] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Rateio · Agosto 2026 ○ Aberto │
│ Valores provisórios · recalculados ao vivo │
│ Veículo A ....... R$ 190 (provisório) │
│ [ Fechar mês ] (admin) │
└─────────────────────────────────────────────┘
Três sinais garantem a confiança do gestor:
- Selo de status — Aberto/provisório (○) vs. Fechado/oficial (●). Nunca se confunde um número provisório com um oficial.
- Carimbo de fechamento — "Fechado em DD/MM por [usuário], base: X".
- Alerta de divergência (fase 2) — se entrou lançamento depois do fechamento, aparece "há lançamentos posteriores → Reprocessar?". O sistema é honesto sobre a foto estar desatualizada.
Esforço
C reaproveita todo o motor de cálculo do rateio (que é construído de qualquer forma — é a base). O incremental é moderado: 1 tabela de período/snapshot + os use cases de fechar/reprocessar + a UI de status. O detector de divergência (sinal 3) é opcional e fica para fase 2 — o admin pode reprocessar manualmente sem ele.
Decisão (produto, 28/07/2026): adotado o modelo híbrido (Opção C). Escopo distribuído nas tasks #17 (motor), #21 (fechamento de período + snapshot + reprocessar), #18 (exibição com selo de status) e #22 (detector de divergência — fase 2).
Cenários e Garantias
| Cenário | Comportamento |
|---|---|
| Peça direta (filtro, lona) | Apropriada ao ativo da OS, como hoje. Zero mudança |
| Consumível compartilhado (graxa) | Vai ao pool, rateado no fechamento. Não cai numa frota só |
| Ativo sem nenhuma OS no período | Não entra no rateio (base zero) |
| Período sem consumo compartilhado | Pool vazio, rateio não roda, custo = só direto |
| Empresa quer manter tudo direto | Base "nenhuma" / classificar tudo como DIRETO — comportamento atual preservado |
| Material caro usado em vários ativos (ex: fluido especial) | Pode ser marcado RATEIO mesmo não sendo consumível (exceção da Opção B) |
| Reprocessar um mês já fechado | Rateio é determinístico a partir dos dados do período — recalcula igual |
Migração
- Classificação: categorias com
isConsumable = true->costAllocation = RATEIO; demais ->DIRETO. Sem reclassificação manual. - Comportamento preservado: enquanto ninguém marcar materiais como RATEIO (ou enquanto a base for "nenhuma"), o custo continua sendo apropriado como hoje. Adoção opcional e incremental.
- Histórico: o rateio é calculado a partir dos dados do período. Meses passados podem ser recalculados sob demanda, mas não há reescrita retroativa automática.
Impacto Técnico
Dependência crítica (sequenciamento)
Esta proposta depende da materialização do custo real de peça na OS (o "elo faltante" identificado na análise). Recomendação: tratar como um único trabalho em duas camadas:
- Camada base — listener de
PART_APPROVED/PART_DELIVERED/PART_RETURNEDque recalcula e persisteWorkOrder.partsCosta partir dePartRequest.approvedQuantity × Part.costPrice. A infraestrutura de eventos já existe. - Camada de rateio — no mesmo listener, decidir se o custo vai para o ativo/frota (direto) ou para o pool (compartilhado), e materializar a distribuição.
Construir o rateio antes da camada base seria construir sobre o seed aleatório.
Resumo das mudanças
| Área | Mudanças |
|---|---|
| Database | Campo costAllocation em PartCategory; estrutura de pool/rateio por período + centro de custo; (pré-requisito) materialização de partsCost |
| Backend | Listener de custo (base + rateio); use case de fechamento/cálculo de rateio; queries de analytics desdobrando direto vs. rateado |
| Frontend | Form de categoria expondo classificação (+ isConsumable); analytics e centro de custo mostrando as duas parcelas; seleção da base de rateio |
| Migração | Default de costAllocation derivado de isConsumable, zero reclassificação manual |
O que NÃO muda
- Fluxo de solicitação, aprovação, entrega e devolução de peças
- Baixa de estoque (continua na aprovação)
- Conversão de unidade de medida (proposta Unidade de Medida)
- Peças diretas — apropriação idêntica à de hoje
Relação com outras propostas
| Proposta | Relação |
|---|---|
| Unidade de Medida e Conversão (aprovada) | Define quanto de um consumível foi realmente consumido (stockImpact). O rateio distribui o custo desse consumo. Complementares |
| Devolução de Peças | Devolução de compartilhado retorna ao pool, não a um ativo. A ser tratado quando o rateio for detalhado |
ADRs (Architecture Decision Records)
ADR-1: Classificação de custo em campo próprio, não reuso de isConsumable
Contexto: precisamos distinguir material direto de compartilhado. A flag isConsumable na categoria já separa consumíveis (que pulam a seleção de serviço) do resto, e na prática os materiais compartilhados são justamente consumíveis.
Decisão: criar costAllocation (DIRETO | RATEIO) na PartCategory, com default derivado de isConsumable na migração.
Justificativa:
isConsumablegoverna fluxo de solicitação ("precisa de serviço/localização?"), não apropriação de custo. São conceitos distintos que hoje coincidem por acaso.- Reusar acoplaria os dois — mudar um quebraria o outro no futuro.
- O campo próprio permite exceções reais: um consumível barato apropriado direto, ou um material caro compartilhado que não é consumível.
- Custo de implementar: um campo + migração com default inteligente. Baixo.
Consequências:
- A tela de categoria passa a expor classificação de custo (e, junto, o
isConsumableque hoje não tem UI). - Toda query de custo por ativo precisa ramificar direto vs. rateio.
ADR-2: Pool por período, não rateio intra-OS
Contexto: o custo compartilhado precisa se acumular em algum lugar antes de ser distribuído. Pode ser dentro da própria OS (entre os ativos que ela toca) ou num pool por período.
Decisão: pool por período (mensal), por centro de custo de oficina.
Justificativa:
- O consumo compartilhado é cross-OS: um balde de graxa serve várias OS e vários veículos ao longo do mês.
- Rateio intra-OS não resolve o caso mais comum (consumível pedido numa OS de um único veículo).
- O pool por período espelha a prática contábil de rateio de custos indiretos.
Consequências:
- Introduz o conceito de "fechamento de período" para consolidar o rateio.
- O rateio é recalculável de forma determinística a partir dos dados do período.
ADR-3: Base de rateio configurável, default por custo direto
Contexto: o pool precisa de um critério de divisão. Nenhum critério único é perfeito para toda empresa.
Decisão: base selecionável (custo direto, nº de OS, km, igualitário), default = custo direto, fallback = nº de OS enquanto a materialização de custo não existir.
Justificativa:
- Custo direto é o melhor proxy de "quanto de manutenção cada ativo gerou".
- Mas ele depende da materialização de custo (ainda inexistente), então nº de OS é um fallback honesto e sempre disponível.
- A base escolhida fica registrada no relatório — rateio auditável, nunca caixa-preta.
Consequências:
- A configuração vive por empresa/centro de custo.
- Trocar a base muda números históricos se o período for reprocessado — precisa ser explícito na UI.
ADR-4: Rateio depende da materialização de custo real
Contexto: hoje WorkOrder.partsCost vem de seed aleatório; nenhum código de produção calcula custo de peça a partir das requisições reais.
Decisão: implementar rateio como segunda camada do mesmo trabalho que materializa o custo real via listener de eventos de peça.
Justificativa:
- Construir rateio sobre custo de seed produziria números sem sentido.
- A infraestrutura de eventos (
PART_APPROVED/PART_DELIVERED/PART_RETURNED) já existe e é o ponto natural para recalcular custo. - Unir os dois evita retrabalho e garante que a distinção direto/rateio nasça junto com o custo real.
Consequências:
- O escopo desta proposta engloba a materialização de custo, que hoje é uma lacuna independente.
- Benefício colateral: os dashboards de custo por frota deixam de depender de seed e passam a refletir dados reais.
Decisão
| Item | Decisão | Status |
|---|---|---|
| Classificação de material | Campo próprio costAllocation na categoria (Opção B, ADR-1) | Aprovado (26/07/2026) |
| Acúmulo | Pool por período, configurável para rateio intra-OS (ADR-2) | Aprovado (26/07/2026) |
| Base de rateio | Custo direto de manutenção, configurável (ADR-3) | Aprovado (26/07/2026) |
| Visibilidade | Sempre desdobrado (direto + rateio) | Aprovado (28/07/2026) |
| Sequenciamento | Materializar custo real + rateio no mesmo trabalho (ADR-4) | Aprovado (28/07/2026) |
| Período/fechamento | Modelo híbrido (Opção C): mês aberto recalcula ao vivo, mês fechado vira snapshot oficial, com reprocessar. Detector de divergência = fase 2. Ver Seção 5 (Decisões) | Aprovado (28/07/2026) |
Proposta aprovada (28/07/2026). Implementação sequenciada: Custo real (#8 → #9/#10 → #15), depois Rateio (#16 → #17 → #21 → #18/#19 → #22).