Skip to content

Commit 45b73c5

Browse files
committed
merged finalized suggestions
2 parents def2a69 + 011a35e commit 45b73c5

3 files changed

Lines changed: 212 additions & 7 deletions

File tree

‎package-lock.json‎

Lines changed: 6 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"argon2": "^0.41.1",
2929
"date-fns": "^4.1.0",
3030
"formidable": "^3.5.2",
31+
"highs": "^1.8.0",
3132
"next": "15.0.4",
3233
"next-auth": "^5.0.0-beta.25",
3334
"papaparse": "^5.5.2",
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { auth } from "@/auth";
2+
import { errorResponse, InternalError } from "@/util/errors";
3+
import { AuthenticationError, AuthorizationError, ArgumentError } from "@/util/errors";
4+
import { NextRequest, NextResponse } from "next/server";
5+
import { z } from "zod";
6+
import UserService from "@/services/userService";
7+
import highsLoader from "highs";
8+
import path from "path";
9+
10+
export const runtime = "nodejs";
11+
12+
const finalizedAutomatedSuggestionSchema = z.object({
13+
generalItems: z.array(
14+
z.object({
15+
totalQuantity: z.number(),
16+
lineItems: z.array(
17+
z.object({
18+
lineItemId: z.number(),
19+
quantity: z.number(),
20+
})
21+
),
22+
requests: z.array(
23+
z.object({
24+
partnerId: z.number(),
25+
quantity: z.number(),
26+
})
27+
),
28+
})
29+
),
30+
});
31+
32+
type Allocation = { lineItemId: number; quantity: number; partnerId: number };
33+
34+
export async function POST(request: NextRequest) {
35+
try {
36+
const session = await auth();
37+
if (!session?.user) {
38+
throw new AuthenticationError("Session required");
39+
}
40+
41+
if (!UserService.isAdmin(session.user.type)) {
42+
throw new AuthorizationError("Must be ADMIN, or SUPER_ADMIN");
43+
}
44+
45+
const reqBody = await request.json();
46+
const parsed = finalizedAutomatedSuggestionSchema.safeParse(reqBody);
47+
48+
if (!parsed.success) {
49+
throw new ArgumentError(parsed.error.message);
50+
}
51+
52+
const allAllocations: Allocation[] = [];
53+
54+
for (const generalItem of parsed.data.generalItems) {
55+
const { totalQuantity, lineItems, requests } = generalItem;
56+
57+
// Normalize partner requests to match totalQuantity
58+
// Creates a per-partner target (floating point) that sums to totalQuantity
59+
60+
const totalRequested = requests.reduce((sum, r) => sum + r.quantity, 0);
61+
const targets = requests.map((r) => ({
62+
partnerId: r.partnerId,
63+
target: totalRequested > 0 ? (r.quantity / totalRequested) * totalQuantity : 0,
64+
}));
65+
66+
// Build an ILP (Integer Linear Programming) in CPLEX .lp format (IBM Standard) for HiGHS:
67+
// Variables:
68+
// - x_i_p ∈ {0,1}: assign each line item, i, to exactly one partner, p
69+
// - dpos_p, dneg_p ≥ 0: linearize absolute deviation from partner p's target
70+
// Objective:
71+
// Minimize Σ_p (dpos_p + dneg_p) => L1 deviation from desired totals
72+
// Constraints:
73+
// - For each item i: Σ_p x_i_p = 1 (assign to exactly one partner)
74+
// - For each partner p: Σ_i (q_i * x_i_p) - dpos_p + dneg_p = target_p where q_i is the line item’s quantity
75+
76+
const itemCount = lineItems.length;
77+
const partnerCount = targets.length;
78+
79+
// Map indices for stable variable names (use small integers instead of large ids)
80+
const itemIndex = new Map<number, number>();
81+
lineItems.forEach((li, idx) => itemIndex.set(li.lineItemId, idx));
82+
const partnerIndex = new Map<number, number>();
83+
targets.forEach((t, idx) => partnerIndex.set(t.partnerId, idx));
84+
85+
// Helpers to build variable names in the LP text
86+
const variableX = (iIdx: number, pIdx: number) => `x_i${iIdx}_p${pIdx}`;
87+
const variableDevPos = (pIdx: number) => `dpos_p${pIdx}`;
88+
const variableDevNeg = (pIdx: number) => `dneg_p${pIdx}`;
89+
90+
// LP text builder (CPLEX .lp IBM format)
91+
const lines: string[] = [];
92+
93+
// Objective: minimize sum_p (dpos_p + dneg_p) (L1 deviation from desired totals)
94+
lines.push("Minimize");
95+
lines.push(" obj:");
96+
if (partnerCount === 0) {
97+
// No partners to allocate to; skip this general item
98+
continue;
99+
}
100+
const objectiveTerms: string[] = [];
101+
for (let p = 0; p < partnerCount; p++) {
102+
objectiveTerms.push(variableDevPos(p));
103+
objectiveTerms.push(variableDevNeg(p));
104+
}
105+
lines.push(" " + objectiveTerms.join(" + "));
106+
107+
// Constraints: each line item assigned to exactly one partner
108+
lines.push("Subject To");
109+
for (let itemIdx = 0; itemIdx < itemCount; itemIdx++) {
110+
const terms: string[] = [];
111+
for (let partnerIdx = 0; partnerIdx < partnerCount; partnerIdx++) {
112+
terms.push(variableX(itemIdx, partnerIdx));
113+
}
114+
lines.push(` assign_i${itemIdx}: ` + terms.join(" + ") + " = 1");
115+
}
116+
117+
// Balance constraints per partner: Σ_i (q_i * x_i_p) - dpos_p + dneg_p = target_p
118+
for (let partnerIdx = 0; partnerIdx < partnerCount; partnerIdx++) {
119+
const t = targets[partnerIdx];
120+
const terms: string[] = [];
121+
for (let itemIdx = 0; itemIdx < itemCount; itemIdx++) {
122+
const q = lineItems[itemIdx].quantity;
123+
terms.push(`${q} ${variableX(itemIdx, partnerIdx)}`);
124+
}
125+
terms.push(`- ${variableDevPos(partnerIdx)}`);
126+
terms.push(`+ ${variableDevNeg(partnerIdx)}`);
127+
lines.push(` balance_p${partnerIdx}: ` + terms.join(" ") + ` = ${t.target}`);
128+
}
129+
130+
// Bounds section: dpos_p, dneg_p ≥ 0 (x variables will be declared binary below)
131+
lines.push("Bounds");
132+
for (let partnerIdx = 0; partnerIdx < partnerCount; partnerIdx++) {
133+
lines.push(` 0 <= ${variableDevPos(partnerIdx)}`);
134+
lines.push(` 0 <= ${variableDevNeg(partnerIdx)}`);
135+
}
136+
137+
// Declare binary variables x_i_p
138+
lines.push("Binaries");
139+
const binaries: string[] = [];
140+
for (let itemIdx = 0; itemIdx < itemCount; itemIdx++) {
141+
for (let partnerIdx = 0; partnerIdx < partnerCount; partnerIdx++) {
142+
binaries.push(variableX(itemIdx, partnerIdx));
143+
}
144+
}
145+
146+
// End of LP
147+
lines.push("End");
148+
149+
// Final LP model text
150+
const lp = lines.join("\n");
151+
152+
const highs = await highsLoader({ locateFile: (f) => path.join(process.cwd(), "node_modules/highs/build", f) });
153+
const solution = highs.solve(lp, {
154+
presolve: "on",
155+
threads: 0, // auto
156+
time_limit: 10, // seconds safeguard
157+
});
158+
159+
if (!solution || solution.Status !== "Optimal") {
160+
throw new InternalError("Unable to solve problem optimally");
161+
}
162+
163+
// Extract assignment variables x_i_p from the solution and build allocations
164+
165+
// For each item, pick the partner with x_i_p ≈ 1
166+
// If none exceeds 0.5 due to tolerance, choose the partner with the highest value.
167+
for (let itemIdx = 0; itemIdx < itemCount; itemIdx++) {
168+
let chosenPartnerIdx: number | null = null;
169+
for (let p = 0; p < partnerCount; p++) {
170+
const varName = variableX(itemIdx, p);
171+
const col = solution.Columns[varName];
172+
const val = col?.Primal ?? 0;
173+
if (val > 0.5) {
174+
chosenPartnerIdx = p;
175+
break;
176+
}
177+
}
178+
179+
// If no partner strictly selected (due to tolerance), choose the one with max value
180+
if (chosenPartnerIdx === null) {
181+
let maxVal = -Infinity;
182+
let maxIdx = 0;
183+
for (let partnerIdx = 0; partnerIdx < partnerCount; partnerIdx++) {
184+
const varName = variableX(itemIdx, partnerIdx);
185+
const col = solution.Columns[varName];
186+
const val = col?.Primal ?? 0;
187+
if (val > maxVal) {
188+
maxVal = val;
189+
maxIdx = partnerIdx;
190+
}
191+
}
192+
chosenPartnerIdx = maxIdx;
193+
}
194+
195+
const lineItem = lineItems[itemIdx];
196+
const partnerId = targets[chosenPartnerIdx].partnerId;
197+
allAllocations.push({ lineItemId: lineItem.lineItemId, quantity: lineItem.quantity, partnerId });
198+
}
199+
}
200+
201+
return NextResponse.json({ allocations: allAllocations });
202+
} catch (error) {
203+
return errorResponse(error);
204+
}
205+
}

0 commit comments

Comments
 (0)