Rapports plus détaillés que d'habitude – Microsoft Access
En règle générale, lorsque nous réalisons des rapports, nous le faisons généralement avec une granularité plus élevée. Par exemple, les clients veulent généralement un rapport mensuel des ventes. La base de données stockerait les ventes individuelles dans un seul enregistrement, il n'y a donc aucun problème à résumer les chiffres au mois chacun. Idem avec l'année, ou même en passant d'une sous-catégorie à une catégorie.
Mais supposons qu'ils doivent descendre vers le bas ? Plus probablement, la réponse sera « la conception de la base de données n'est pas bonne. débarrassez-vous et recommencez !" Après tout, avoir la bonne granularité pour vos données est essentiel pour une base de données solide. Mais ce n'était pas un cas où la normalisation n'a pas été faite. Considérons la nécessité de rendre compte de l'inventaire et des revenus, et de les traiter de manière FIFO. Je me retirerai rapidement pour souligner que je ne suis pas CBA et que toute réclamation comptable que je fais doit être traitée avec la plus grande méfiance. En cas de doute, appelez votre comptable.
Avec la clause de non-responsabilité à l'écart, regardons comment nous stockons actuellement les données. Dans cet exemple, nous devons enregistrer les achats de produits, puis nous devons enregistrer les ventes des achats que nous venons d'acheter.
Supposons que pour un même produit nous ayons 3 achats :
Date | Qty | Per-Cost
9/03 | 3 | $45
9/08 | 6 | $40
9/09 | 8 | $50
Nous vendons ensuite ces produits à différentes occasions à un prix différent :
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
Notez que la granularité se situe au niveau de la transaction :nous créons un seul enregistrement pour chaque achat et pour chaque commande. C'est très courant et logique - nous n'avons qu'à entrer la quantité de produits que nous avons vendus, à un prix spécifié pour une transaction particulière.
OK, où sont les informations comptables que vous avez rejetées ?
Pour les rapports, nous devons calculer les revenus que nous avons réalisés sur chaque unité de produit. Ils me disent qu'ils doivent traiter le produit de manière FIFO… c'est-à-dire que la première unité de produit achetée doit être la première unité de produit à commander. Pour ensuite calculer la marge que nous avons réalisée sur cette unité de produit, nous devons rechercher le coût de cette unité de produit particulière, puis la déduire du prix pour lequel elle a été commandée.
Marge brute =revenu du produit - coût du produit
Rien de bouleversant, mais attendez, regardez les achats et les commandes ! Nous n'avons eu que 3 achats, avec 3 niveaux de coûts différents, puis nous avons eu 6 commandes avec 3 niveaux de prix distincts. Quel niveau de coût va à quel niveau de prix, alors ?
Cette simple formule de calcul de la marge brute, de manière FIFO, nous oblige maintenant à passer à la granularité de l'unité individuelle de produit. Nous n'avons nulle part dans notre base de données. J'imagine que si je suggérais aux utilisateurs de saisir un enregistrement par unité de produit, il y aurait une protestation assez forte et peut-être des injures. Alors, que faire ?
Décomposer
Disons qu'à des fins comptables, nous utiliserons la date d'achat pour trier chaque unité individuelle du produit. Voici comment cela devrait apparaître :
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
Si vous étudiez la répartition, vous pouvez voir qu'il y a des chevauchements où nous consommons certains produits d'un achat pour telle ou telle commande tandis que d'autres fois nous avons une commande qui est exécutée par différents achats.
Comme indiqué précédemment, nous n'avons pas réellement ces 17 lignes dans la base de données. Nous n'avons que les 3 lignes d'achats et 6 lignes de commandes. Comment obtenir 17 lignes de l'une ou l'autre des tables ?
Ajouter plus de boue
Mais nous n'avons pas fini. Je viens de vous donner un exemple idéalisé où il se trouve que nous avons un équilibre parfait de 17 unités achetées qui est contré par 17 unités de commandes pour le même produit. Dans la vraie vie, ce n'est pas si beau. Parfois, nous nous retrouvons avec des produits excédentaires. Selon le modèle commercial, il peut également être possible de conserver plus de commandes que ce qui est disponible dans l'inventaire. Ceux qui jouent en bourse reconnaissent comme la vente à découvert.
La possibilité d'un déséquilibre est également la raison pour laquelle nous ne pouvons pas prendre un raccourci consistant simplement à additionner tous les coûts et prix, puis à soustraire pour obtenir la marge. S'il nous restait X unités, nous devons savoir à quel point de coût elles se situent pour calculer l'inventaire. De même, nous ne pouvons pas supposer qu'une commande non exécutée sera parfaitement exécutée par un seul achat avec un seul point de coût. Ainsi, les calculs que nous effectuons doivent non seulement fonctionner pour l'exemple idéal, mais également pour les endroits où nous avons des stocks excédentaires ou des commandes non exécutées.
Commençons par déterminer le nombre d'unités de produit à prendre en compte. Il est bien évident qu'un simple SOMME() des quantités d'unités commandées ou des quantités d'unités achetées ne suffira pas. Non, il faut plutôt SUM() à la fois la quantité de produits achetés et la quantité de produits commandés. Nous comparerons ensuite les SUM() et choisirons la plus élevée. Nous pourrions commencer par cette requête :
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);
Ce que nous faisons ici, c'est que nous nous décomposons en 3 étapes logiques :
a) obtenir le SUM() des quantités achetées par produits
b) obtenir le SUM() des quantités commandées par produits
Parce que nous ne savons pas si nous pourrions avoir un produit qui peut avoir des achats mais pas de commandes ou un produit qui a des commandes passées mais nous n'en avons pas acheté, nous ne pouvons pas rejoindre 2 tables. Pour cette raison, nous utilisons les tables de produits comme source faisant autorité pour tous les ProductID que nous voulons connaître, ce qui nous amène à la 3ème étape :
c) faire correspondre les sommes à leurs produits, déterminer si le produit a fait l'objet d'une transaction (par exemple, des achats ou des commandes déjà effectués) et, le cas échéant, choisir le nombre le plus élevé de la paire. C'est notre décompte du nombre total de transactions qu'un produit a eues.
Mais pourquoi la transaction compte ?
L'objectif ici est de déterminer le nombre de lignes que nous devons générer par produit pour représenter de manière adéquate chaque unité individuelle d'un produit ayant participé à un achat ou à une commande. Rappelez-vous que dans notre premier exemple idéal, nous avons eu 3 achats et 6 commandes, tous deux équilibrés pour un total de 17 unités de produits achetées puis commandées. Pour ce produit particulier, nous devrons être en mesure de créer 17 lignes pour générer les données que nous avions dans la figure ci-dessus.
Alors, comment transformer la valeur unique de 17 d'affilée en 17 lignes ? C'est là qu'intervient la magie de la table de pointage.
Si vous n'avez pas entendu parler de table de pointage, vous devriez maintenant. Je laisserai les autres vous renseigner sur le sujet de la table de pointage ; ici, ici et ici. Qu'il suffise de dire que c'est un outil formidable à avoir dans votre boîte à outils SQL.
En supposant que nous révisions la requête ci-dessus afin que la dernière partie soit maintenant un CTE nommé ProductTransactionCount, nous pouvons écrire la requête ainsi :
<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;
Et le pesto ! Nous avons maintenant autant de lignes que nous aurons besoin - exactement - pour chaque produit dont nous avons besoin pour faire la comptabilité. Notez l'expression dans la clause ON - nous faisons une jointure triangulaire - nous n'utilisons pas l'opérateur d'égalité habituel car nous voulons générer 17 lignes à partir de rien. Notez que la même chose peut être obtenue avec un CROSS JOIN et une clause WHERE. Expérimentez avec les deux pour trouver ce qui fonctionne le mieux.
Faire en sorte que notre transaction compte
Nous avons donc notre table temporaire configurée avec le bon nombre de lignes. Maintenant, nous devons remplir le tableau avec des données sur les achats et les commandes. Comme vous l'avez vu sur la figure, nous devons être en mesure de commander les achats et les commandes à la date à laquelle ils ont été achetés ou commandés, respectivement. Et c'est là que ROW_NUMBER() et la table de pointage viennent à la rescousse.
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;
Vous vous demandez peut-être pourquoi nous avons besoin de ROW_NUMBER() alors que nous pourrions utiliser la colonne Num du décompte. La réponse est que s'il y a plusieurs achats, le Num n'ira qu'à la quantité de cet achat, mais nous devons aller jusqu'à 17 - le total de 3 achats séparés de 3, 6 et 8 unités. Ainsi, nous partitionnons par ProductID alors que le Num de tally peut être considéré comme partitionné par PurchaseID, ce qui n'est pas ce que nous voulons.
Si vous avez exécuté le SQL, vous obtiendrez maintenant une belle répartition, une ligne renvoyée pour chaque unité de produit achetée, classée par date d'achat. Notez que nous trions également par PurchaseID, pour gérer le cas où il y avait plusieurs achats du même produit le même jour, nous devons donc rompre l'égalité d'une manière ou d'une autre pour nous assurer que les chiffres par coût sont calculés de manière cohérente. Nous pouvons ensuite mettre à jour la table temporaire avec l'achat :
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;
La partie des commandes est fondamentalement la même chose :remplacez simplement « Achat » par « Commande », et vous obtiendrez le tableau rempli comme nous l'avions dans la figure d'origine au début de l'article.
Et à ce stade, vous êtes prêt à faire toutes les autres sortes de bonté comptable maintenant que vous avez divisé les produits d'un niveau de transaction à un niveau d'unité dont vous avez besoin pour mapper avec précision le coût du bien au revenu pour cette unité particulière de produit en utilisant FIFO ou LIFO comme requis par votre comptable. Les calculs sont maintenant élémentaires.
Granularité dans un monde OLTP
Le concept de granularité est un concept plus courant dans les entrepôts de données que dans les applications OLTP, mais je pense que le scénario discuté souligne la nécessité de prendre du recul et d'identifier clairement quelle est la granularité actuelle du schéma OLTP. Comme nous l'avons vu, nous avions la mauvaise granularité au départ et nous devions retravailler afin d'obtenir la granularité nécessaire pour réaliser notre reporting. C'était un heureux hasard que dans ce cas, nous puissions réduire avec précision la granularité puisque nous avons déjà toutes les données des composants présentes, nous avons donc simplement dû transformer les données. Ce n'est pas toujours le cas, et il est plus probable que si le schéma n'est pas assez granulaire, cela justifiera une refonte du schéma. Néanmoins, l'identification de la granularité requise pour satisfaire aux exigences aide à définir clairement les étapes logiques que vous devez entreprendre pour atteindre cet objectif.
Le script SQL complet pour démontrer que le point peut être obtenu DemoLowGranularity.sql.