Came across an interesting little problem at work yesterday. This is a question about arithmetic as much as SQL. Let’s say you have a bunch of orders, and there is a limit to the volume that the orders can have (all 20 in this case):
if object_id('tempdb..#OMAX') is not null drop table #OMAX
create table #OMAX
(
OrderId int primary key,
MaxVol decimal(15,3)
)
insert into #OMAX(OrderId, MaxVol) values (1, 20), (2, 20), (3, 20)
And here are your order line items with their current, proposed, volumes:
if object_id('tempdb..#OLI') is not null drop table #OLI
create table #OLI
(
OrderId int,
ProposedVolume decimal(15,3)
)
insert into #OLI(OrderId, ProposedVolume)
values
(1, 11.6),
(1, 5.4),
(2, 9.744),
(2, 16.254),
(2, 9.556),
(3, 7.1),
(3, 7.23),
(3, 7.45)
You also want to round the results to a particular degree of accuracy, let’s say that is 1.0 (whole numbers) for the moment:
declare @nOrderRoundAmt decimal(15,3) = 1.0;
Question: For orders whose current total is greater than the OMAX.MaxVol, can you write a SQL statement that will scale the ProposedVolumes down so that the new total of the order lines is equal to MaxVol? It has to be equal, not less than (reason: the business case here is that order 2 has a total proposed volume of 35.554, but we are saying that the max allowed is 20, so when we reduce the order we need to reduce it to 20, not any less as that would be unreasonable).
Complications: An order can have 1..N line items. Do not consider this an exhaustive set of test data, I suspect there are other tricky cases.
In this case order 1 should be untouched except for rounding, orders 2 and 3 should be reduced and rounded to 20.
Here is my best effort so far:
; with OrderTotals as
(
select OrderId, sum(ProposedVolume) as TotalVolume
from #OLI
group by OrderId
)
select
OLI.*,
Ratio.Ratio,
Scaled.Vol as SVol,
ScaledAndRounded.Vol as SRVol
from
#OLI OLI
join OrderTotals OT on OLI.OrderId = OT.OrderId
join #OMAX OMAX on OLI.OrderId = OMAX.OrderId
cross apply
(
-- Don't reduce orders that are already below the max.
select
case when OMAX.MaxVol / OT.TotalVolume > 1 then 1
else OMAX.MaxVol / OT.TotalVolume
end as Ratio
) Ratio
cross apply (select OLI.ProposedVolume * Ratio.Ratio as Vol) Scaled
-- Rounds to nearest.
cross apply (select round(Scaled.Vol / @nOrderRoundAmt, 0) * @nOrderRoundAmt as Vol) ScaledAndRounded
-- Rounds down.
-- cast(Scaled.Vol / @nOrderRoundAmt as bigint) * @nOrderRoundAmt as ScaledAndRoundedDown,
This demonstrates two problems: order 2 comes out with a total of 19, and order 3 with a total of 21. You can stop order 3 from being more than 20 by always rounding down, but you can then get cases where the order total comes out at 18.
So is it possible in a single statement? My best solution so far is to apply the above logic (using round down) then apply a second step of processing in a cursor to add on differences until we get back to the total of 20.
Can you prove your solution works for all cases?
The following code for generating random orders for testing may be useful:
declare @OrderId int = 0, @NumLineItems int;
while @OrderId < 1000 begin
set @NumLineItems = cast(rand() * 5 as int) + 1
insert into #OLI(OrderId, ProposedVolume)
select top (@NumLineItems) @OrderId, rand(cast(newId() as varbinary)) * 15
from sys.objects
set @OrderId = @OrderId + 1
end
SOLUTION
In case anyone is interested in the final solution I made based on Gordon’s answer, here it is. It is a little verbose, returning far more columns than is actually required, but that aids debugging/understanding. Try setting the degree of rounding to 0.1 or 0.01. The solution is vulnerable to division-by-zero errors if any of the line items have a proposed volume of 0, but they are easily filtered out beforehand. It can also generate some line items that are rounded to zero, which need excluding after the fact.
declare @nOrderRoundAmt decimal(15,3) = 0.1; -- Degree of rounding required.
if object_id('tempdb..#Results') is not null drop table #Results
select
T.*,
row_number() over (partition by OrderId order by Remainder desc) as seqnum,
case
when NeedsAdjustment = 0 then ProposedVolumeRounded
else
(case when row_number() over (partition by OrderId order by Remainder desc) <= LeftOver
then AppliedVolInt + 1
else AppliedVolInt
end)
end * @nOrderRoundAmt as NewVolume
--into #Results
from
(
select
T.*,
floor(T.AppliedVol) as AppliedVolInt,
(T.AppliedVol - 1.000 * floor(T.AppliedVol)) as Remainder,
T.MaxVol * 1.0 - sum(floor(T.AppliedVol)) over (partition by T.OrderId) as LeftOver
from
(
select
OLI.OrderId,
OMAX.MaxVol as OrigMaxVol,
MaxVol.Vol as MaxVol,
OLI.ProposedVolume as OrigProposedVolume,
ProposedVolume.Vol as ProposedVolume,
ProposedVolumeRounded.Vol as ProposedVolumeRounded,
sum(ProposedVolume.Vol) over (partition by OLI.OrderId) as SumProposedVolume,
sum(ProposedVolumeRounded.Vol) over (partition by OLI.OrderId) as SumProposedVolumeRounded, -- Round, THEN sum.
case
-- when SumProposedVolumeRounded > MaxVol, i.e. the sum of the rounded line items would be
-- greater than the order limit, then scale, else take the original.
when sum(ProposedVolumeRounded.Vol) over (partition by OLI.OrderId) > MaxVol.Vol then 1
else 0
end as NeedsAdjustment,
case
-- when SumProposedVolumeRounded > MaxVol, i.e. the sum of the rounded line items would be
-- greater than the order limit, then scale, else take the original.
when sum(ProposedVolumeRounded.Vol) over (partition by OLI.OrderId) > MaxVol.Vol then MaxVol.Vol * (ProposedVolume.Vol / sum(ProposedVolume.Vol) over (partition by OLI.OrderId))
else ProposedVolume.Vol
end as AppliedVol
from
##OLI OLI
join ##OMax OMAX on OLI.OrderId = OMAX.OrderId
cross apply (select OLI.ProposedVolume / @nOrderRoundAmt as Vol) ProposedVolume
cross apply (select OMAX.MaxVol / @nOrderRoundAmt as Vol) MaxVol
cross apply (select round(ProposedVolume.Vol, 0) as Vol) ProposedVolumeRounded
) T
) T
This is a partitioning problem, where you are trying to have the results be integers (or equivalently, some fixed multiple of integers). The strategy is to calculate everything as integers, find the remainder, and then apportion the remainder among the items.
Here is an overview of the calculation:
The following SQL does this:
If you don’t have integers the arithmetic is slightly more complicated (because of the use of the enumeration from (4) to (5). My recommendation is to just multiply all numbers by a constant and turn it into the integer problem or multiply the enumeration in (4) by the factor.
And, yes, I have tested this on your test data. It not only works logically but in practice.