446 lines
12 KiB
TypeScript
446 lines
12 KiB
TypeScript
import type { FlightTicket } from "../data/entities";
|
|
import { Logger } from "@pkg/logger";
|
|
import { round } from "$lib/core/num.utils";
|
|
import { type Result } from "@pkg/result";
|
|
import { type LimitedOrderWithTicketInfoModel } from "$lib/domains/order/data/entities";
|
|
import { getJustDateString } from "@pkg/logic/core/date.utils";
|
|
|
|
type OrderWithUsageInfo = {
|
|
order: LimitedOrderWithTicketInfoModel;
|
|
usePartialAmount: boolean;
|
|
};
|
|
|
|
export class TicketWithOrderSkiddaddler {
|
|
THRESHOLD_DIFF_PERCENTAGE = 15;
|
|
ELEVATED_THRESHOLD_DIFF_PERCENTAGE = 50;
|
|
tickets: FlightTicket[];
|
|
|
|
private reservedOrders: number[];
|
|
private allActiveOrders: LimitedOrderWithTicketInfoModel[];
|
|
private urgentActiveOrders: LimitedOrderWithTicketInfoModel[];
|
|
// Stores oids that have their amount divided into smaller amounts for being reused for multiple tickets
|
|
// this record keeps track of the oid, with how much remainder is left to be divided
|
|
private reservedPartialOrders: Record<number, number>;
|
|
private minTicketPrice: number;
|
|
private maxTicketPrice: number;
|
|
|
|
constructor(
|
|
tickets: FlightTicket[],
|
|
allActiveOrders: LimitedOrderWithTicketInfoModel[],
|
|
) {
|
|
this.tickets = tickets;
|
|
this.reservedOrders = [];
|
|
this.reservedPartialOrders = {};
|
|
this.minTicketPrice = 0;
|
|
this.maxTicketPrice = 0;
|
|
this.allActiveOrders = [];
|
|
this.urgentActiveOrders = [];
|
|
|
|
this.loadupActiveOrders(allActiveOrders);
|
|
}
|
|
|
|
async magic(): Promise<Result<boolean>> {
|
|
// Sorting the orders by price in ascending order
|
|
this.allActiveOrders.sort((a, b) => b.basePrice - a.basePrice);
|
|
|
|
this.loadMinMaxPrices();
|
|
|
|
for (const ticket of this.tickets) {
|
|
if (this.areAllOrdersUsedUp()) {
|
|
break;
|
|
}
|
|
|
|
const suitableOrders = this.getSuitableOrders(ticket);
|
|
if (!suitableOrders) {
|
|
continue;
|
|
}
|
|
console.log("--------- suitable orders ---------");
|
|
console.log(suitableOrders);
|
|
console.log("-----------------------------------");
|
|
const [discountAmt, newDisplayPrice] = this.calculateNewAmounts(
|
|
ticket,
|
|
suitableOrders,
|
|
);
|
|
ticket.priceDetails.discountAmount = discountAmt;
|
|
ticket.priceDetails.displayPrice = newDisplayPrice;
|
|
|
|
const oids = Array.from(
|
|
new Set(suitableOrders.map((o) => o.order.id)),
|
|
).toSorted();
|
|
ticket.refOIds = oids;
|
|
this.reservedOrders.push(...oids);
|
|
}
|
|
|
|
Logger.debug(`Assigned ${this.reservedOrders.length} orders to tickets`);
|
|
return { data: true };
|
|
}
|
|
|
|
private loadupActiveOrders(data: LimitedOrderWithTicketInfoModel[]) {
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
// Removing all orders which have tickets with departure date of yesterday and older
|
|
this.allActiveOrders = data.filter((o) => {
|
|
return (
|
|
new Date(
|
|
getJustDateString(new Date(o.flightTicketInfo.departureDate)),
|
|
) >= today
|
|
);
|
|
});
|
|
this.urgentActiveOrders = [];
|
|
const threeDaysFromNowMs = 3 * 24 * 3600 * 1000;
|
|
for (const order of this.allActiveOrders) {
|
|
const depDate = new Date(order.flightTicketInfo.departureDate);
|
|
if (now.getTime() + threeDaysFromNowMs > depDate.getTime()) {
|
|
this.urgentActiveOrders.push(order);
|
|
}
|
|
}
|
|
|
|
Logger.info(`Found ${this.allActiveOrders.length} active orders`);
|
|
Logger.info(`We got ${this.urgentActiveOrders.length} urgent orders`);
|
|
}
|
|
|
|
private loadMinMaxPrices() {
|
|
this.minTicketPrice = 100000;
|
|
this.maxTicketPrice = 0;
|
|
|
|
for (const ticket of this.tickets) {
|
|
const _dPrice = ticket.priceDetails.displayPrice;
|
|
if (_dPrice < this.minTicketPrice) {
|
|
this.minTicketPrice = _dPrice;
|
|
}
|
|
if (_dPrice > this.maxTicketPrice) {
|
|
this.maxTicketPrice = _dPrice;
|
|
}
|
|
}
|
|
}
|
|
|
|
private areAllOrdersUsedUp() {
|
|
if (this.urgentActiveOrders.length === 0) {
|
|
return this.reservedOrders.length >= this.allActiveOrders.length;
|
|
}
|
|
const areUrgentOrdersUsedUp =
|
|
this.reservedOrders.length >= this.urgentActiveOrders.length;
|
|
return areUrgentOrdersUsedUp;
|
|
}
|
|
|
|
/**
|
|
* An order is used up in 2 cases
|
|
* 1. it's not being partially fulfulled and it's found in the used orders list
|
|
* 2. it's being partially fulfilled and it's found in both the used orders list and sub chunked orders list
|
|
*/
|
|
private isOrderUsedUp(oid: number, displayPrice: number) {
|
|
if (this.reservedPartialOrders.hasOwnProperty(oid)) {
|
|
return this.reservedPartialOrders[oid] < displayPrice;
|
|
}
|
|
return this.reservedOrders.includes(oid);
|
|
}
|
|
|
|
private calculateNewAmounts(
|
|
ticket: FlightTicket,
|
|
orders: OrderWithUsageInfo[],
|
|
) {
|
|
if (orders.length < 1) {
|
|
return [
|
|
ticket.priceDetails.discountAmount,
|
|
ticket.priceDetails.displayPrice,
|
|
];
|
|
}
|
|
|
|
const totalAmount = orders.reduce((sum, { order, usePartialAmount }) => {
|
|
return (
|
|
sum +
|
|
(usePartialAmount ? order.pricePerPassenger : order.displayPrice)
|
|
);
|
|
}, 0);
|
|
|
|
const discountAmt = round(ticket.priceDetails.displayPrice - totalAmount);
|
|
return [discountAmt, totalAmount];
|
|
}
|
|
|
|
/**
|
|
* Suitable orders are those orders that are ideally within the threshold and are not already reserved. So there's a handful of cases, which other sub methods are handling
|
|
*/
|
|
private getSuitableOrders(
|
|
ticket: FlightTicket,
|
|
): OrderWithUsageInfo[] | undefined {
|
|
const activeOrders =
|
|
this.urgentActiveOrders.length > 0
|
|
? this.urgentActiveOrders
|
|
: this.allActiveOrders;
|
|
const cases = [
|
|
(t: any, a: any) => {
|
|
return this.findFirstSuitableDirectOId(t, a);
|
|
},
|
|
(t: any, a: any) => {
|
|
return this.findFirstSuitablePartialOId(t, a);
|
|
},
|
|
(t: any, a: any) => {
|
|
return this.findManySuitableOIds(t, a);
|
|
},
|
|
(t: any, a: any) => {
|
|
return this.findFirstSuitableDirectOIdElevated(t, a);
|
|
},
|
|
];
|
|
let c = 1;
|
|
for (const each of cases) {
|
|
const out = each(ticket, activeOrders);
|
|
if (out) {
|
|
console.log(`Case ${c} worked, returning it's response`);
|
|
return out;
|
|
}
|
|
c++;
|
|
}
|
|
console.log("NO CASE WORKED, WUT, yee");
|
|
}
|
|
|
|
private findFirstSuitableDirectOId(
|
|
ticket: FlightTicket,
|
|
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
): OrderWithUsageInfo[] | undefined {
|
|
let ord: LimitedOrderWithTicketInfoModel | undefined;
|
|
for (const o of activeOrders) {
|
|
const [op, tp] = [o.displayPrice, ticket.priceDetails.displayPrice];
|
|
const diff = Math.abs(op - tp);
|
|
const diffPtage = (diff / tp) * 100;
|
|
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
|
|
ord = o;
|
|
break;
|
|
}
|
|
}
|
|
if (!ord) {
|
|
return;
|
|
}
|
|
return [{ order: ord, usePartialAmount: false }];
|
|
}
|
|
|
|
private findFirstSuitablePartialOId(
|
|
ticket: FlightTicket,
|
|
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
): OrderWithUsageInfo[] | undefined {
|
|
const tp = ticket.priceDetails.displayPrice;
|
|
|
|
let ord: LimitedOrderWithTicketInfoModel | undefined;
|
|
for (const o of activeOrders) {
|
|
const op = o.pricePerPassenger;
|
|
const diff = op - tp;
|
|
const diffPtage = (diff / tp) * 100;
|
|
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
|
|
ord = o;
|
|
break;
|
|
}
|
|
}
|
|
if (!ord) {
|
|
return;
|
|
}
|
|
this.upsertPartialOrderToCache(
|
|
ord.id,
|
|
ord.pricePerPassenger,
|
|
ord.fullfilledPrice,
|
|
);
|
|
return [{ order: ord, usePartialAmount: true }];
|
|
}
|
|
|
|
private findManySuitableOIds(
|
|
ticket: FlightTicket,
|
|
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
): OrderWithUsageInfo[] | undefined {
|
|
const targetPrice = ticket.priceDetails.displayPrice;
|
|
|
|
const validOrderOptions = activeOrders.flatMap((order) => {
|
|
const options: OrderWithUsageInfo[] = [];
|
|
|
|
// Add full price option if suitable
|
|
if (
|
|
!this.isOrderUsedUp(order.id, order.displayPrice) &&
|
|
order.displayPrice <= targetPrice
|
|
) {
|
|
options.push({ order, usePartialAmount: false });
|
|
}
|
|
|
|
// Add partial price option if suitable
|
|
if (
|
|
!this.isOrderUsedUp(order.id, order.pricePerPassenger) &&
|
|
order.pricePerPassenger <= targetPrice
|
|
) {
|
|
options.push({ order, usePartialAmount: true });
|
|
}
|
|
|
|
return options;
|
|
});
|
|
|
|
if (validOrderOptions.length === 0) return undefined;
|
|
// Helper function to get the effective price of an order
|
|
const getEffectivePrice = (ordInfo: {
|
|
order: LimitedOrderWithTicketInfoModel;
|
|
usePerPassenger: boolean;
|
|
}) => {
|
|
return ordInfo.usePerPassenger
|
|
? ordInfo.order.pricePerPassenger
|
|
: ordInfo.order.displayPrice;
|
|
};
|
|
|
|
const sumCombination = (combo: OrderWithUsageInfo[]): number => {
|
|
return combo.reduce(
|
|
(sum, { order, usePartialAmount }) =>
|
|
sum +
|
|
(usePartialAmount
|
|
? order.pricePerPassenger
|
|
: order.displayPrice),
|
|
0,
|
|
);
|
|
};
|
|
|
|
let bestCombination: OrderWithUsageInfo[] = [];
|
|
let bestDiff = targetPrice;
|
|
|
|
// Try combinations of different sizes
|
|
for (
|
|
let size = 1;
|
|
size <= Math.min(validOrderOptions.length, 3);
|
|
size++
|
|
) {
|
|
const combinations = this.getCombinations(validOrderOptions, size);
|
|
|
|
for (const combo of combinations) {
|
|
const sum = sumCombination(combo);
|
|
const diff = targetPrice - sum;
|
|
|
|
if (
|
|
diff >= 0 &&
|
|
(diff / targetPrice) * 100 <=
|
|
this.THRESHOLD_DIFF_PERCENTAGE &&
|
|
diff < bestDiff
|
|
) {
|
|
// Verify we're not using the same order twice
|
|
const orderIds = new Set(combo.map((c) => c.order.id));
|
|
if (orderIds.size === combo.length) {
|
|
bestDiff = diff;
|
|
bestCombination = combo;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestCombination.length === 0) return undefined;
|
|
|
|
// Update partial orders cache
|
|
bestCombination.forEach(({ order, usePartialAmount }) => {
|
|
if (usePartialAmount) {
|
|
this.upsertPartialOrderToCache(
|
|
order.id,
|
|
order.pricePerPassenger,
|
|
order.fullfilledPrice,
|
|
);
|
|
}
|
|
});
|
|
|
|
return bestCombination;
|
|
}
|
|
|
|
/**
|
|
* In case no other cases worked, but we have an order with far lower price amount,
|
|
* then we juss use it but bump up it's amount to match the order
|
|
*/
|
|
private findFirstSuitableDirectOIdElevated(
|
|
ticket: FlightTicket,
|
|
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
): OrderWithUsageInfo[] | undefined {
|
|
const targetPrice = ticket.priceDetails.displayPrice;
|
|
|
|
// Find the first unreserved order with the smallest price difference
|
|
let bestOrder: LimitedOrderWithTicketInfoModel | undefined;
|
|
let smallestPriceDiff = Number.MAX_VALUE;
|
|
|
|
for (const order of activeOrders) {
|
|
if (this.isOrderUsedUp(order.id, order.displayPrice)) {
|
|
continue;
|
|
}
|
|
|
|
// Check both regular price and per-passenger price
|
|
const prices = [order.displayPrice];
|
|
if (order.pricePerPassenger) {
|
|
prices.push(order.pricePerPassenger);
|
|
}
|
|
|
|
for (const price of prices) {
|
|
const diff = Math.abs(targetPrice - price);
|
|
if (diff < smallestPriceDiff) {
|
|
smallestPriceDiff = diff;
|
|
bestOrder = order;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!bestOrder) {
|
|
return undefined;
|
|
}
|
|
|
|
// Calculate if we should use partial amount
|
|
const usePartial =
|
|
bestOrder.pricePerPassenger &&
|
|
Math.abs(targetPrice - bestOrder.pricePerPassenger) <
|
|
Math.abs(targetPrice - bestOrder.displayPrice);
|
|
|
|
// The price will be elevated to match the ticket price
|
|
// We'll use the original price ratio to determine the new display price
|
|
const originalPrice = usePartial
|
|
? bestOrder.pricePerPassenger
|
|
: bestOrder.displayPrice;
|
|
|
|
// Only elevate if the price difference is reasonable
|
|
const priceDiffPercentage =
|
|
(Math.abs(targetPrice - originalPrice) / targetPrice) * 100;
|
|
if (priceDiffPercentage > this.ELEVATED_THRESHOLD_DIFF_PERCENTAGE) {
|
|
return undefined;
|
|
}
|
|
|
|
// if (usePartial) {
|
|
// bestOrder.pricePerPassenger =
|
|
// } else {
|
|
// }
|
|
|
|
return [{ order: bestOrder, usePartialAmount: !!usePartial }];
|
|
}
|
|
|
|
private getCombinations<T>(arr: T[], size: number): T[][] {
|
|
if (size === 0) return [[]];
|
|
if (arr.length === 0) return [];
|
|
|
|
const first = arr[0];
|
|
const rest = arr.slice(1);
|
|
|
|
const combosWithoutFirst = this.getCombinations(rest, size);
|
|
const combosWithFirst = this.getCombinations(rest, size - 1).map(
|
|
(combo) => [first, ...combo],
|
|
);
|
|
return [...combosWithoutFirst, ...combosWithFirst];
|
|
}
|
|
|
|
private isOrderSuitable(
|
|
orderId: number,
|
|
orderPrice: number,
|
|
ticketPrice: number,
|
|
diffPercentage: number,
|
|
) {
|
|
return (
|
|
diffPercentage > 0 &&
|
|
diffPercentage <= this.THRESHOLD_DIFF_PERCENTAGE &&
|
|
orderPrice <= ticketPrice &&
|
|
!this.isOrderUsedUp(orderId, orderPrice)
|
|
);
|
|
}
|
|
|
|
private upsertPartialOrderToCache(
|
|
oid: number,
|
|
price: number,
|
|
_defaultPrice: number = 0,
|
|
) {
|
|
if (!this.reservedPartialOrders[oid]) {
|
|
this.reservedPartialOrders[oid] = _defaultPrice;
|
|
return;
|
|
}
|
|
this.reservedPartialOrders[oid] += price;
|
|
}
|
|
}
|