Relatando de forma mais granular do que o normal – Microsoft Access
Normalmente, quando fazemos relatórios, geralmente fazemos isso em uma granularidade mais alta. Por exemplo, os clientes geralmente querem um relatório mensal de vendas. O banco de dados armazenaria as vendas individuais como um único registro, portanto, não há problema em resumir os números para cada mês. Ditto com o ano, ou mesmo passando de uma subcategoria para categoria.
Mas suponha que eles precisem descer ? Mais provavelmente, a resposta será “o design do banco de dados não é bom. sucatear e recomeçar!” Afinal, ter a granularidade certa para seus dados é essencial para um banco de dados sólido. Mas este não foi um caso em que a normalização não foi feita. Vamos considerar a necessidade de contabilizar o estoque e as receitas, e tratá-los de forma FIFO. Vou me afastar rapidamente para salientar que não sou CBA, e qualquer reclamação contábil que eu fizer deve ser tratada com a maior suspeita. Na dúvida, ligue para o seu contador.
Com o aviso fora do caminho, vamos ver como armazenamos os dados atualmente. Neste exemplo, precisamos registrar as compras de produtos e, em seguida, temos que registrar as vendas das compras que acabamos de comprar.
Suponha que para um único produto tenhamos 3 compras:
Date | Qty | Per-Cost
9/03 | 3 | $45
9/08 | 6 | $40
9/09 | 8 | $50
Posteriormente, vendemos esses produtos em diferentes ocasiões a um preço diferente:
Date | Qty | Per-Price
9/05 | 2 | $60
9/07 | 1 | $55
9/10 | 4 | $50
9/12 | 3 | $60
9/15 | 3 | $65
9/19 | 4 | $55
Observe que a granularidade está no nível da transação — criamos um único registro para cada compra e para cada pedido. Isso é muito comum e faz sentido lógico – precisamos apenas inserir a quantidade de produtos que vendemos, a um preço específico para uma determinada transação.
Ok, onde estão as coisas contábeis que você negou?
Para os relatórios, devemos calcular a receita que fizemos em cada unidade de produto. Eles me dizem que devem processar o produto de maneira FIFO… ou seja, a primeira unidade de produto que foi comprada deve ser a primeira unidade de produto a ser pedida. Para calcular a margem que fizemos nessa unidade de produto, devemos pesquisar o custo dessa unidade específica de produto e deduzir do preço pelo qual foi encomendado.
Margem bruta =receita do produto - custo do produto
Nada de quebrar a terra, mas espere, olhe as compras e pedidos! Tivemos apenas 3 compras, com 3 pontos de custo diferentes, depois tivemos 6 pedidos com 3 pontos de preço distintos. Qual ponto de custo vai para qual ponto de preço, então?
Esta fórmula simples de cálculo de margem bruta, de forma FIFO, agora nos obriga a ir para a granularidade de unidade individual de produto. Não temos em nenhum lugar em nosso banco de dados. Imagino que se eu sugerisse que os usuários digitassem um registro por unidade de produto, haveria um protesto bastante alto e talvez alguns xingamentos. Então o que fazer?
Separando
Digamos que, para fins contábeis, usaremos a data de compra para classificar cada unidade individual do produto. É assim que deve sair:
Line # | Purch Date | Order Date | Per-Cost | Per-Price
1 | 9/03 | 9/05 | $45 | $60
2 | 9/03 | 9/05 | $45 | $60
3 | 9/03 | 9/07 | $45 | $55
4 | 9/08 | 9/10 | $40 | $50
5 | 9/08 | 9/10 | $40 | $50
6 | 9/08 | 9/10 | $40 | $50
7 | 9/08 | 9/10 | $40 | $50
8 | 9/08 | 9/12 | $40 | $60
9 | 9/08 | 9/12 | $40 | $60
10 | 9/09 | 9/12 | $50 | $60
11 | 9/09 | 9/15 | $50 | $65
12 | 9/09 | 9/15 | $50 | $65
13 | 9/09 | 9/15 | $50 | $65
14 | 9/09 | 9/19 | $50 | $55
15 | 9/09 | 9/19 | $50 | $55
16 | 9/09 | 9/19 | $50 | $55
17 | 9/09 | 9/19 | $50 | $55
Se você estudar o detalhamento, verá que há sobreposições em que consumimos algum produto de uma compra para pedidos de fulano, enquanto outras vezes temos um pedido que é atendido por compras diferentes.
Conforme observado anteriormente, na verdade não temos essas 17 linhas em nenhum lugar do banco de dados. Temos apenas as 3 linhas de compras e 6 linhas de pedidos. Como obtemos 17 linhas de ambas as tabelas?
Adicionando mais lama
Mas não terminamos. Acabei de lhe dar um exemplo idealizado em que tivemos um saldo perfeito de 17 unidades compradas que é compensado por 17 unidades de pedidos para o mesmo produto. Na vida real, não é tão bonito. Às vezes ficamos com produtos em excesso. Dependendo do modelo de negócios, também pode ser possível reter mais pedidos do que o disponível no estoque. Aqueles que jogam no mercado de ações reconhecem como vendas a descoberto.
A possibilidade de um desequilíbrio também é a razão pela qual não podemos pegar um atalho de simplesmente somar todos os custos e preços e depois subtrair para obter a margem. Se ficarmos com X unidades, precisamos saber qual ponto de custo elas são para calcular o estoque. Da mesma forma, não podemos presumir que um pedido não atendido será perfeitamente atendido por uma única compra com um ponto de custo. Portanto, os cálculos que fazemos devem funcionar não apenas para o exemplo ideal, mas também para onde temos estoque em excesso ou pedidos não atendidos.
Vamos primeiro lidar com a questão de descobrir quantos inits de produto precisamos considerar. É óbvio que um simples SUM() das quantidades de unidades pedidas ou das quantidades de unidades compradas não será suficiente. Não, em vez disso, devemos SOMAR() tanto a quantidade de produtos comprados quanto a quantidade de produtos encomendados. Em seguida, compararemos os SUM()s e escolheremos o maior. Poderíamos começar com esta consulta:
WITH ProductPurchaseCount AS (
SELECT
p.ProductID,
SUM(p.QtyBought) AS TotalPurchases
FROM dbo.tblProductPurchase AS p
GROUP BY p.ProductID
), ProductOrderCount AS (
SELECT
o.ProductID,
SUM(o.QtySold) AS TotalOrders
FROM dbo.tblProductOrder AS o
GROUP BY o.ProductID
)
SELECT
p.ProductID,
IIF(ISNULL(pc.TotalPurchases, 0) > ISNULL(oc.TotalOrders, 0), pc.TotalPurchases, oc.TotalOrders) AS ProductTransactionCount
FROM dbo.tblProduct AS p
LEFT JOIN ProductPurchaseCount AS pc
ON p.ProductID = pc.ProductID
LEFT JOIN ProductOrderCount AS oc
ON p.ProductID = oc.ProductID
WHERE NOT (pc.TotalPurchases IS NULL AND oc.TotalOrders IS NULL);
O que estamos fazendo aqui é dividir em 3 passos lógicos:
a) obter o SUM() das quantidades compradas por produtos
b) obter o SUM() das quantidades encomendadas por produtos
Como não sabemos se podemos ter um produto que pode ter algumas compras, mas nenhum pedido ou um produto que tem pedidos feitos, mas não temos nenhum comprado, não podemos juntar 2 tabelas. Por esse motivo, usamos as tabelas de produtos como a fonte oficial de todos os ProductIDs que queremos conhecer, o que nos leva ao 3º passo:
c) combinar as somas com seus produtos, determinar se o produto possui alguma transação (por exemplo, compras ou pedidos já feitos) e, em caso afirmativo, escolher o número mais alto do par. Essa é a nossa contagem do total de transações que um produto teve.
Mas por que a transação conta?
O objetivo aqui é descobrir quantas linhas precisamos gerar por produto para representar adequadamente cada unidade individual de um produto que participou de uma compra ou de um pedido. Lembre-se de que em nosso primeiro exemplo ideal, tivemos 3 compras e 6 pedidos, ambos equilibrados em um total de 17 unidades de produto compradas e encomendadas. Para esse produto específico, precisaremos criar 17 linhas para gerar os dados que tínhamos na figura acima.
Então, como transformamos o valor único de 17 em uma linha em 17 linhas? É aí que entra a magia da mesa de contagem.
Se você ainda não ouviu falar da tabela de contagem, deveria agora. Vou deixar que outros preencham o assunto da tabela de contagem; aqui, aqui e aqui. Basta dizer que é uma ferramenta formidável para ter em seu kit de ferramentas SQL.
Supondo que revisamos a consulta acima para que a última parte seja agora um CTE chamado ProductTransactionCount, podemos escrever a consulta assim:
<the 3 CTEs from previous exampe>
INSERT INTO tblProductTransactionStaging (
ProductID,
TransactionNumber
)
SELECT
c.ProductID,
t.Num AS TransactionNumber
FROM ProductTransactionCount AS c
INNER JOIN dbo.tblTally AS t
ON c.TransactionCount >= t.Num;
E pesto! Agora temos quantas linhas precisarmos — exatamente — para cada produto que precisamos fazer contabilidade. Observe a expressão na cláusula ON – estamos fazendo uma junção triangular – não estamos usando o operador de igualdade usual porque queremos gerar 17 linhas do nada. Observe que a mesma coisa pode ser alcançada com uma cláusula CROSS JOIN e WHERE. Experimente os dois para descobrir qual funciona melhor.
Fazendo nossa transação valer a pena
Portanto, temos nossa tabela temporária configurada com o número correto de linhas. Agora, precisamos preencher a tabela com dados sobre compras e pedidos. Como você viu na figura, precisamos ser capazes de ordenar as compras e pedidos pela data em que foram comprados ou pedidos, respectivamente. E é aí que ROW_NUMBER() e tabela de registro vem em socorro.
SELECT
p.ProductID,
ROW_NUMBER() OVER (PARTITION BY p.ProductID ORDER BY p.PurchaseDate, p.PurchaseID) AS TransactionNumber,
p.PurchaseDate,
p.CostPer
FROM dbo.tblProductPurchase AS p
INNER JOIN dbo.tblTally AS t
ON p.QtyBought >= t.Num;
Você pode se perguntar por que precisamos de ROW_NUMBER() quando poderíamos usar a coluna Num da contagem. A resposta é que, se houver várias compras, o número será tão alto quanto a quantidade dessa compra, mas precisamos chegar a 17 - o total de 3 compras separadas de 3, 6 e 8 unidades. Assim, particionamos por ProductID, enquanto o Num de tally pode ser particionado por PurchaseID, o que não é o que queremos.
Se você executou o SQL, agora obterá um bom breakout, uma linha retornada para cada unidade de produto comprada, ordenada por data de compra. Observe que também classificamos por PurchaseID, para lidar com o caso em que houve várias compras do mesmo produto no mesmo dia, então temos que quebrar o empate de alguma forma para garantir que os valores por custo sejam calculados de forma consistente. Podemos então atualizar a tabela temporária com a compra:
WITH PurchaseData AS (
<previous query>
)
MERGE INTO dbo.tblProductTransactionStaging AS t
USING PurchaseData AS p
ON t.ProductID = p.ProductID
AND t.TransactionNumber = p.TransactionNumber
WHEN MATCHED THEN UPDATE SET
t.PurchaseID = p.PurchaseID,
t.PurchaseDate = p.PurchaseDate,
t.CostPer = p.CostPer;
A parte de pedidos é basicamente a mesma coisa – basta substituir “Compra” por “Pedido”, e você terá a tabela preenchida exatamente como tínhamos na figura original no início do post.
E neste ponto, você está pronto para fazer todos os outros tipos de bondade contábil agora que dividiu os produtos de um nível de transação para um nível de unidade que você precisa mapear com precisão o custo do bem para a receita para essa unidade específica de produto usando FIFO ou LIFO conforme exigido pelo seu contador. Os cálculos agora são elementares.
Granularidade em um mundo OLTP
O conceito de granularidade é um conceito mais comum em data warehouse do que em aplicativos OLTP, mas acho que o cenário discutido destaca a necessidade de retroceder e identificar claramente qual é a granularidade atual do esquema do OLTP. Como vimos, tínhamos a granularidade errada no início e precisávamos retrabalhar para que pudéssemos obter a granularidade necessária para alcançar nossos relatórios. Foi um feliz acidente que, neste caso, podemos diminuir com precisão a granularidade, pois já temos todos os dados do componente presentes, então simplesmente tivemos que transformar os dados. Isso nem sempre é o caso, e é mais provável que, se o esquema não for granular o suficiente, ele justifique o redesenho do esquema. No entanto, identificar a granularidade necessária para satisfazer os requisitos ajuda a definir claramente as etapas lógicas que você deve realizar para atingir esse objetivo.
Script SQL completo para demonstrar o ponto pode ser obtido DemoLowGranularity.sql.