Rapportering mere detaljeret end normalt – Microsoft Access
Når vi rapporterer, gør vi det normalt med en højere granularitet. For eksempel ønsker kunder almindeligvis en månedlig rapport over salg. Databasen ville gemme det enkelte salg som en enkelt post, så det er ikke noget problem at opsummere tallene for hver måned. Ditto med år, eller endda gå fra en underkategori til kategori.
Men antag, at de skal ned ? Mere sandsynligt vil svaret være "databasedesignet er ikke godt. skrot og start forfra!" Når alt kommer til alt, er det vigtigt at have den rigtige granularitet til dine data for en solid database. Men dette var ikke et tilfælde, hvor normalisering ikke blev gennemført. Lad os overveje behovet for at lave en redegørelse for beholdningen og indtægterne og behandle dem på en FIFO-måde. Jeg vil hurtigt træde til side for at påpege, at jeg ikke er CBA, og alle regnskabskrav, jeg fremsætter, skal behandles med den største mistænksomhed. Ring til din revisor, hvis du er i tvivl.
Med ansvarsfraskrivelsen af vejen, lad os se på, hvordan vi i øjeblikket opbevarer dataene. I dette eksempel skal vi registrere indkøb af produkter, og så skal vi registrere salget af de indkøb, vi lige har købt.
Antag, at vi for et enkelt produkt har 3 køb:
Date | Qty | Per-Cost
9/03 | 3 | $45
9/08 | 6 | $40
9/09 | 8 | $50
Vi sælger så senere disse produkter ved forskellige lejligheder til en anden pris:
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
Bemærk, at granulariteten er på et transaktionsniveau - vi opretter en enkelt post for hvert køb og for hver ordre. Dette er meget almindeligt og giver logisk mening – vi behøver kun at indtaste mængden af produkter, vi har solgt, til en bestemt pris for en bestemt transaktion.
OK, hvor er de regnskabsmæssige ting, du fraskrev?
For rapporterne skal vi beregne den omsætning, vi har lavet på hver enhed af produktet. De fortæller mig, at de skal behandle produktet på en FIFO-måde... det vil sige, at den første produktenhed, der blev købt, skulle være den første produktenhed, der skal bestilles. For derefter at beregne den margin, vi lavede på den produktenhed, skal vi slå prisen op for den pågældende produktenhed og derefter trække fra den pris, den blev bestilt til.
Bruttomargin =omsætning af produkt – produktomkostninger
Intet verdensomspændende, men vent, se på indkøbene og ordrerne! Vi havde kun 3 køb med 3 forskellige omkostningspunkter, derefter havde vi 6 ordrer med 3 forskellige prispunkter. Hvilket omkostningspunkt går til hvilket prispunkt, så?
Denne enkle formel for beregning af bruttomargin på en FIFO-måde kræver nu, at vi går til granulariteten af den enkelte produktenhed. Vi har ingen steder i vores database. Jeg forestiller mig, at hvis jeg foreslog, at brugerne skulle indtaste én post pr. produktenhed, ville der være en ret højlydt protest og måske nogle navne. Så hvad skal man gøre?
At bryde det op
Lad os sige, at vi til regnskabsmæssige formål vil bruge købsdatoen til at sortere hver enkelt enhed af produktet. Sådan skulle det komme ud:
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
Hvis du studerer opdelingen, kan du se, at der er overlapninger, hvor vi forbruger nogle produkter fra et køb for sådan og sådan ordrer, mens vi andre gange har en ordre, der opfyldes af forskellige køb.
Som nævnt tidligere har vi faktisk ikke de 17 rækker nogen steder i databasen. Vi har kun de 3 rækker af indkøb og 6 rækker af ordrer. Hvordan får vi 17 rækker ud af begge tabeller?
Tilføjelse af mere mudder
Men vi er ikke færdige. Jeg gav dig lige et idealiseret eksempel, hvor vi tilfældigvis havde en perfekt balance på 17 købte enheder, som modsvares af 17 enheder af ordrer for samme produkt. I det virkelige liv er det ikke så kønt. Nogle gange står vi tilbage med overskydende produkter. Afhængigt af forretningsmodellen er det muligvis også muligt at holde flere ordrer, end hvad der er tilgængeligt på lageret. De, der spiller aktiemarkedet, genkender som short-salg.
Muligheden for en ubalance er også grunden til, at vi ikke kan tage en genvej til blot at summere alle omkostninger og priser og derefter trække fra for at få marginen. Hvis vi stod tilbage med X enheder, skal vi vide, hvilket omkostningspunkt de er for at beregne beholdningen. På samme måde kan vi ikke antage, at en ikke-opfyldt ordre vil blive pænt opfyldt ved et enkelt køb med ét omkostningspunkt. Så de beregninger, vi kommer, skal ikke kun fungere for det ideelle eksempel, men også for, hvor vi har overskydende beholdning eller uopfyldte ordrer.
Lad os først beskæftige os med spørgsmålet om at finde ud af, hvor mange produktstarter, vi skal overveje. Det er åbenlyst, at en simpel SUM() af mængderne af bestilte enheder eller mængderne af købte enheder ikke vil være tilstrækkelig. Nej, vi skal snarere SUM() både mængden af købte produkter og mængden af bestilte produkter. Vi vil derefter sammenligne SUM()'erne og vælge den højere. Vi kunne starte med denne forespørgsel:
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);
Det, vi gør her, er, at vi deler op i 3 logiske trin:
a) få SUM() for de mængder, der er købt af produkter
b) få SUM() for de mængder, der er bestilt af produkter
Fordi vi ikke ved, om vi måske har et produkt, der kan have nogle køb, men ingen ordrer, eller et produkt, der har ordrer placeret, men vi har ingen købt, kan vi ikke forlade deltage i enten 2 borde. Af den grund bruger vi produkttabellerne som den autoritative kilde til alle produkt-id'er, vi ønsker at vide om, hvilket bringer os til 3. trin:
c) matche beløbene til deres produkter, afgøre, om produktet har nogen transaktion (f.eks. enten køb eller ordrer, der nogensinde er foretaget), og hvis det er tilfældet, vælg det højeste nummer af parret. Det er vores optælling af samlede transaktioner, som et produkt har haft.
Men hvorfor tæller transaktionen?
Målet her er at finde ud af, hvor mange rækker vi skal generere pr. produkt for at repræsentere hver enkelt enhed af et produkt, der har deltaget i enten et køb eller en ordre. Husk i vores første ideelle eksempel, havde vi 3 køb og 6 ordrer, som begge udlignede til i alt 17 enheder af produktet købt og bestilt. For det pågældende produkt skal vi være i stand til at oprette 17 rækker for at generere de data, vi havde i figuren ovenfor.
Så hvordan transformerer vi den enkelte værdi af 17 i en række til 17 rækker? Det er her, magien ved optællingsbordet kommer ind.
Hvis du ikke har hørt om tally table, skal du nu. Jeg vil lade andre udfylde dig i emnet optællingstabel; her, her og her. Det er nok at sige, det er et formidabelt værktøj at have i dit SQL-værktøjssæt.
Hvis vi antager, at vi reviderer ovenstående forespørgsel, så den sidste del nu er en CTE ved navn ProductTransactionCount, kan vi skrive forespørgslen således:
<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;
Og pesto! Vi har nu lige så mange rækker, som vi skal bruge - præcist - for hvert produkt, som vi skal bruge til at lave regnskab. Bemærk udtrykket i ON-klausulen – vi laver en trekantet sammenføjning – vi bruger ikke den sædvanlige lighedsoperator, fordi vi ønsker at generere 17 rækker ud af den blå luft. Bemærk, at det samme kan opnås med en CROSS JOIN og en WHERE-klausul. Eksperimenter med begge for at finde, hvad der virker bedst.
Får vores transaktion til at tælle
Så vi har vores midlertidige tabel sat op det rigtige antal rækker. Nu skal vi udfylde tabellen med data om køb og ordrer. Som du så på figuren, skal vi kunne bestille indkøbene og ordrerne på den dato, de blev henholdsvis købt eller bestilt. Og det er her ROW_NUMBER() og taltabel kommer til undsætning.
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;
Du undrer dig måske over, hvorfor vi har brug for ROW_NUMBER(), når vi kunne bruge tallyens Num-kolonne. Svaret er, at hvis der er flere køb, vil antallet kun gå så højt som det pågældende købs antal, men vi skal op på 17 - i alt 3 separate køb af 3, 6 og 8 enheder. Således partitionerer vi efter ProductID, mens tally's Num kan siges at være opdelt af PurchaseID, hvilket ikke er det, vi ønsker.
Hvis du kørte SQL'en, vil du nu få et flot breakout, en række returneret for hver enhed af købt produkt, sorteret efter købsdato. Bemærk, at vi også sorterer efter PurchaseID, for at håndtere tilfældet, hvor der var flere køb af samme produkt samme dag, så vi er nødt til at bryde båndet på en eller anden måde for at sikre, at Per-Cost-tallene beregnes konsistent. Vi kan derefter opdatere den midlertidige tabel med købet:
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;
Ordredelen er dybest set den samme ting - bare udskift "Køb" med "Bestil", og du vil få bordet fyldt op, ligesom vi havde i den originale figur i starten af indlægget.
Og på dette tidspunkt er du klar til at udføre al anden form for regnskabsmæssig godhed, nu hvor du har opdelt produkterne fra et transaktionsniveau ned til et enhedsniveau, som du har brug for for nøjagtigt at kortlægge varens omkostninger til indtægten for den pågældende produktenhed ved at bruge FIFO eller LIFO som krævet af din revisor. Beregningerne er nu elementære.
Granularitet i en OLTP-verden
Begrebet granularitet er et begreb, der er mere almindeligt i datavarehus end i OLTP-applikationer, men jeg tror, at det diskuterede scenarie fremhæver behovet for at træde tilbage og klart identificere, hvad der er den nuværende granularitet af OLTP's skema. Som vi så, havde vi den forkerte granularitet i starten, og vi var nødt til at omarbejde, så vi kunne få den granularitet, der kræves for at opnå vores rapportering. Det var en lykkelig ulykke, at vi i dette tilfælde nøjagtigt kan sænke granulariteten, da vi allerede har alle komponentdataene til stede, så vi var simpelthen nødt til at transformere dataene. Det er ikke altid tilfældet, og det er mere sandsynligt, at hvis skemaet ikke er granulært nok, vil det berettige redesign af skemaet. Ikke desto mindre hjælper det at identificere den detaljerede karakter, der kræves for at opfylde kravene, med at definere de logiske trin, du skal tage for at nå dette mål.
Komplet SQL-script for at demonstrere pointen kan hentes DemoLowGranularity.sql.