从日期范围返回按比例计算的金额

时间:2016-03-03 15:12:27

标签: sql sql-server tsql

我能够找到一个给出一系列开始和结束日期的脚本,它将根据日期范围创建新行。我遇到的问题是,对于每条记录,我都有 AMOUNT 字段,我需要在整个日期范围内正确按比例分配。

CREATE TABLE #TempData (Company VARCHAR(6), InvoiceDate DATE, StartPeriod DATE, EndPeriod DATE, SchoolDistrict VARCHAR(100), Amount NUMERIC(10,2))
INSERT INTO #TempData (Company,InvoiceDate,StartPeriod,EndPeriod,SchoolDistrict,Amount)

SELECT '000123','1/1/2016','12/1/2015','12/31/2015','School District 123',140 UNION ALL
SELECT '000123','12/1/2016','6/15/2015','11/30/2015','School District 123',500 

;WITH Recurse AS (
SELECT Company,InvoiceDate, StartPeriod
,CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0)) AS DATE) EOM,EndPeriod
,SchoolDistrict,Amount
FROM #TempData

UNION ALL

SELECT Company,InvoiceDate
,CAST(DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0) AS DATE) StartPeriod
,CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+2,0)) AS DATE)
,EndPeriod
,SchoolDistrict,Amount
FROM Recurse
WHERE EOM<EndPeriod
)

SELECT Company,InvoiceDate,StartPeriod
,CASE WHEN EndPeriod<EOM THEN EndPeriod ELSE EOM END EndPeriod
,SchoolDistrict,Amount
FROM Recurse

DROP TABLE TempData

我的输出如下:

Company InvoiceDate StartPeriod EndPeriod  SchoolDistrict       Amount
000123  2016-01-01  2015-12-01  2015-12-31 School District 123  140.00
000123  2016-12-01  2015-06-15  2015-06-30 School District 123  500.00
000123  2016-12-01  2015-07-01  2015-07-31 School District 123  500.00
000123  2016-12-01  2015-08-01  2015-08-31 School District 123  500.00
000123  2016-12-01  2015-09-01  2015-09-30 School District 123  500.00
000123  2016-12-01  2015-10-01  2015-10-31 School District 123  500.00
000123  2016-12-01  2015-11-01  2015-11-30 School District 123  500.00

至于第一次记录返回,不需要做任何按比例分配,因为它只有1个月,但其他记录,我需要帮助我如何正确按比例分配 AMOUNT 500 正确地返回了6条记录。

注意更新:在完整月份是平均分配,然后任何不是完整期间的StartPeriod和/或EndPeriod月份,将获得部分按比例分配。

2 个答案:

答案 0 :(得分:2)

这是从原始输入日期和金额派生的表达链。你可以很容易地将它提供给Recurse方法,尽管我建议使用其他一种方法来生成月份,例如使用数字表,特别是如果日期可以超过多年。

对于部分月份,它会根据当月所涵盖的天数计算分数。除数是该月的总天数。有时会计师将一个月视为有30天,因此您必须确定这是否合适。

全部金额在整个月内分配,无论长度如何均等加权,加上两个部分按各自月份的个别比例加权。首先计算全月金额,并将结果四舍五入;部分月份取决于该计算,并在最后注意我关于四舍五入的后果的评论。最后的结果需要注意正确分配最后一分钱,以便总和正确。

with Expr1 as (
select *,
    StartPeriod as RangeStart, EndPeriod as RangeEnd,

    case when datediff(month, StartPeriod, EndPeriod) < 1 then null else
        datediff(month, StartPeriod, EndPeriod) + 1
            - case when datepart(day, StartPeriod) <> 1
                   then 1 else 0 end
            - case when month(EndPeriod) = month(dateadd(day, 1, EndPeriod))
                   then 1 else 0 end
    end as WholeMonths,

    case when datepart(day, StartPeriod) <> 1
         then 1 else 0 end as IsPartialStart,
    case when month(EndPeriod) = month(dateadd(day, 1, EndPeriod))
         then 1 else 0 end as IsPartialEnd,

    datepart(day, StartPeriod) as StartPartialComplement,
    datepart(day, EndPeriod) as EndPartialOffset,

    datepart(day,
        dateadd(day, -1, dateadd(month, datediff(month, 0, StartPeriod) + 1, 0))
    ) as StartPartialDaysInMonth,

    datepart(day,
        dateadd(day, -1, dateadd(month, datediff(month, 0, EndPeriod) + 1, 0))
    ) as EndPartialDaysInMonth
from #TempData
),

Expr2 as (
select *,
    case when IsPartialStart = 1
         then StartPartialDaysInMonth - StartPartialComplement + 1
         else 0 end as StartPartialDays,
    case when IsPartialEnd = 1
         then EndPartialOffset else 0 end as EndPartialDays
from Expr1
),

Expr3 as (
select *,
    cast(round(Amount / (
        WholeMonths
            + StartPartialDays / cast(StartPartialDaysInMonth as float)
            + EndPartialDays / cast(EndPartialDaysInMonth as float)
    ), 2) as numeric(10, 2)) as WholeMonthAllocation,
    StartPartialDays / cast(StartPartialDaysInMonth as float) as StartPartialFraction,
    EndPartialDays / cast(EndPartialDaysInMonth as float) as EndPartialFraction
from Expr2
),

Expr4 as (
select *,
    cast(case when IsPartialEnd = 0
         then Amount - WholeMonthAllocation * WholeMonths
         else StartPartialFraction * WholeMonthAllocation
         end as numeric(10, 2)) as StartPartialAmount,
    cast(case when IsPartialEnd = 0 then 0
              else Amount 
                   - WholeMonthAllocation * WholeMonths
                   - StartPartialFraction * WholeMonthAllocation
         end as numeric(10, 2)) as EndPartialAmount
from Expr3
),
...

从这些值中,您可以确定在创建了所有额外行后最终结果中应该包含哪个金额。这个表达式将通过合并您的原始查询来实现。 (由于SQL Fiddle已经失效,我无法测试任何一个:)

... /* all of the above */
Recurse AS (
SELECT
    RangeStart, RangeEnd, IsPartialStart, IsPartialEnd,
    StartPartialAmount, EndPartialAmount, WholeMonthAllocation,
    Company, InvoiceDate, StartPeriod,
    CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0)) AS DATE) EOM,
    EndPeriod, SchoolDistrict,
    case
        when datediff(month, RangeStart, RangeEnd) = 0 then Amount
        when IsPartialStart = 1 then StartPartialAmount
        else WholeMonthAllocation
    end as Amount
FROM Expr4
UNION ALL
SELECT
    RangeStart, RangeEnd, IsPartialStart, IsPartialEnd,
    StartPartialAmount, EndPartialAmount, WholeMonthAllocation,
    Company, InvoiceDate,
    CAST(DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0) AS DATE) AS StartPeriod,
    CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+2,0)) AS DATE) EOM,
    EndPeriod, SchoolDistrict,
    case
        -- final month is when StartPeriod is one month before RangeEnd.
        -- remember this is recursive!
        when IsPartialEnd = 1 and datediff(month, StartPeriod, RangeEnd) = 1
        then EndPartialAmount
        else WholeMonthAllocation
    end as Amount
FROM Recurse
WHERE EOM < EndPeriod
)
SELECT
    Company, InvoiceDate, StartPeriod,
    CASE WHEN EndPeriod < EOM THEN EndPeriod ELSE EOM END EndPeriod,
    SchoolDistrict, Amount
FROM Recurse  

我已添加/别名RangeStartRangeEnd值,以避免与您在临时表中使用的StartPeriodEndPeriod混淆和输出查询。 范围 - 值表示完整范围的开始和结束,期间 - 值是分解各个期间的计算值。根据需要进行调整。

编辑#1:我意识到我没有处理同一个月开始和结束的情况:或许有一个更干净的方式来完成这一切。我刚刚将WholeMonths表达式归零以避免可能除以零。最后的case表达式捕获此条件并返回原始Amount值。虽然您可能不必担心处理开始和结束日期会发生逆转,但我继续将它们与相同的< 1测试结合在一起。

编辑#2:一旦我有一个地方尝试这个,你的测试用例显示四舍五入失去了一分钱,并且在最后的部分月份计算中被提升,即使它实际上是整整几个月中的一个。所以我不得不调整以寻找没有最后部分月的情况。这是Expr4中的内容。我还发现了你注意到的一些次要语法错误。

递归查询允许按顺序查看月份并简化逻辑。锚点始终是开始月份,因此最终月份逻辑都不适用,对于查询的另一半也是如此。如果您最终通过针对数字表的常规联接来解决此问题,则您希望使用这样的表达式:

case
    when datediff(month, RangeStart, RangeEnd) = 0
    then Amount
    when IsPartialStart = 1 and is first month...
    then StartPartialAmount
    when IsPartialEnd = 1 and is final month...
    then EndPartialAmount
    else WholeMonthAllocation
end as Amount

编辑#3 :还要注意,在处理非常小的数量时,此方法不适用,其中舍入将使结果偏斜。例子:

$ 0.13分为1月02日至12月01日给出[.01,.01,.01,.01,.01,.01,.01,.01,.01,.01,.01,.02] 1月02日至12月01日的$ 0.08分为[.01,.01,.01,.01,.01,.01,.01,.01,.01,.01,.01,-.03] $ 0.08除以1月31日至12月31日给出[-.03,.01,.01,.01,.01,.01,.01,.01,.01,.01,.01,.01] 1月31日至11月30日的0.05美元分为[.05,.00,.00,.00,.00,.00,.00,.00,.00,.00,.00] 1月31日至12月01日的0.05美元分为[.00,.00,.00,.00,.00,.00,.00,.00,.00,.00,.00,.05] 1月02日至3月1日划分的0.30美元给出[.15,.15,.00]

答案 1 :(得分:1)

这是一个有趣的问题,因为它既需要扩展行数,又需要在查询中检测和纠正的舍入问题。

首先需要一些精确的日期计算来确定每个月中有多少天属于StartPeriod和EndPeriod。

然后,将每个月的估算值计算为一个简单的比例,但舍入误差将意味着这些估算值的总和不等于总发票金额。然后使用窗口函数计算总舍入误差,以便可以调整最后一次付款。

另外,我建议使用带有简单“数字”视图的连接,而不是使用递归CTE为每个月生成一行。有关详细信息,请参阅此question about number tables

-- I use the #tempdata table mentioned in the question

; WITH numbers AS ( -- A fast way to get a sequence of integers starting at 0
    SELECT TOP(10000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 as n
    FROM sys.all_columns a CROSS JOIN sys.all_columns b
),
data_with_pk AS ( -- Add a primary key so that we know how to sort output
    SELECT ROW_NUMBER() OVER (ORDER BY company, invoicedate) AS InvoiceId, *
    FROM #tempdata
),
step1 AS ( -- Calc first and last day of each month in which payment is due
    SELECT data_with_pk.*,
        CAST(DATEADD(MONTH, DATEDIFF(MONTH, 0, StartPeriod) + numbers.n, 0) 
             AS DATE) AS StartOfMonth,
        CAST(DATEADD(DAY, -1,  
                     DATEADD(MONTH, DATEDIFF(MONTH,0,StartPeriod) + numbers.n + 1, 0)) 
             AS DATE) AS EndOfMonth
    FROM data_with_pk
    -- This join is a simpler way to generate multiple rows than using a recursive CTE
    JOIN numbers ON numbers.n <= DATEDIFF(MONTH, StartPeriod, EndPeriod)
),
step2 AS ( -- Calc block of days in each month which fall within whole period 
    SELECT *,
        CASE WHEN StartPeriod > StartOfMonth THEN StartPeriod ELSE StartOfMonth END 
            AS StartOfBlock,
        CASE WHEN EndPeriod < EndOfMonth THEN EndPeriod ELSE EndOfMonth END 
            AS EndOfBlock
    FROM step1
), 
step3 AS ( -- Whole months count as 30 days for purposes of calculated proportions 
    SELECT *, 
        CASE WHEN StartOfBlock = StartOfMonth AND EndOfBlock = EndOfMonth 
             THEN 30
             ELSE DATEDIFF(DAY, StartOfBlock, EndOfBlock) + 1 END AS DaysInBlock
    FROM step2
), 
step3b AS (
    SELECT *,
        SUM(DaysInBlock) OVER (PARTITION BY InvoiceId) AS DaysInPeriod
    FROM step3
),
step4 AS ( -- Calc proportion of whole amount due in this block
    SELECT *, 
        CAST(Amount * DaysInBlock / DaysInPeriod AS NUMERIC(10,2)) AS Estimate
    FROM step3b
), 
step5 AS ( -- Calc running total of estimates
    SELECT *, 
      SUM(Estimate) OVER (PARTITION BY InvoiceId ORDER BY EndOfBlock) AS RunningEstimate
    FROM step4
), 
step6 AS ( -- Adjust last estimate to ensure final Prorata total is equal to Amount
    SELECT *, 
        CASE WHEN EndOfBlock = EndPeriod 
             THEN Estimate + amount - RunningEstimate 
             ELSE Estimate end AS Prorata
    FROM step5
),
step7 AS ( -- Just for illustration to prove that payments sum to the Invoice Amount
    SELECT *, 
        SUM(Prorata) OVER (PARTITION BY InvoiceId ORDER BY EndOfBlock) AS RunningProrata
    FROM step6
)
SELECT InvoiceId, InvoiceDate, StartPeriod, EndPeriod, Amount, DaysInBlock, EndOfBlock, 
    Estimate, RunningEstimate, Prorata, RunningProrata
FROM step7
ORDER BY InvoiceId, EndOfBlock

您可以在下面的结果集中看到“Estimate”和“RunningEstimate”列最终为$ 0.01 out,但在“Prorata”列中进行了更正。

+-----------+-------------+-------------+------------+--------+-------------+------------+----------+-----------------+---------+----------------+
| InvoiceId | InvoiceDate | StartPeriod | EndPeriod  | Amount | DaysInBlock | EndOfBlock | Estimate | RunningEstimate | Prorata | RunningProrata |
+-----------+-------------+-------------+------------+--------+-------------+------------+----------+-----------------+---------+----------------+
|         1 | 2016-01-01  | 2015-12-01  | 2015-12-31 | 140.00 |          30 | 2015-12-31 | 140.00   | 140.00          | 140.00  | 140.00         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          16 | 2015-06-30 | 48.19    | 48.19           | 48.19   | 48.19          |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-07-31 | 90.36    | 138.55          | 90.36   | 138.55         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-08-31 | 90.36    | 228.91          | 90.36   | 228.91         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-09-30 | 90.36    | 319.27          | 90.36   | 319.27         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-10-31 | 90.36    | 409.63          | 90.36   | 409.63         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-11-30 | 90.36    | 499.99          | 90.37   | 500.00         |
+-----------+-------------+-------------+------------+--------+-------------+------------+----------+-----------------+---------+----------------+