Socket
Socket
Sign inDemoInstall

@entur/add-customers-to-offer-configurations

Package Overview
Dependencies
2
Maintainers
11
Versions
13
Alerts
File Explorer

Advanced tools

Install Socket

Detect and block malicious and high-risk dependencies

Install

Comparing version 1.2.2 to 1.3.0

dist/utilities.d.ts

26

dist/index.d.ts
import type { Offer } from './types/offersTypes';
import { OfferConfiguration, Customer } from './types/reserveOfferTypes';
/** This is a stripped down version of the Offer type, containing only the root keys that we need. The hope is that this will lead to fewer updates because the type has changed. */
import { Customer, OfferConfiguration } from './types/reserveOfferTypes';
/**
* This is a stripped down version of the Offer type,
* containing only the root keys that we need.
* The hope is that this will lead to fewer updates because
* the type has changed.
*/
declare type StrippedOffer = Pick<Offer, 'id' | 'travelerMapping' | 'salesPackageConfig'>;

@@ -13,2 +18,19 @@ /**

export default function addCustomersToOfferConfigurations(customers: Customer[], offerConfigurations: OfferConfiguration[], offers: StrippedOffer[]): OfferConfiguration[];
declare type SafeBetReturnType = {
solvedOfferConfigurations: OfferConfiguration[];
remainingOfferConfigurations: OfferConfiguration[];
remainingCustomers: Customer[];
};
/**
* If an offer has a minimum amount of travelers equal to the travellers it is
* meant for, assigning these customers to the offer's corresponding offer
* configuration is considered a safe bet.
*
* This algorithm was invented to cope with group tickets, in a single ticket
* and a single fare product covers multiple travellers. We could not come up
* with an elegant modification to assignCustomersUsingMinimumCostFlow,
* but this algorithm seems to remove all offer configurations that would
* warrant a modification from its input.
*/
export declare function assignUsingSafeBets(customers: Customer[], offerConfigurations: OfferConfiguration[], offers: StrippedOffer[]): SafeBetReturnType;
export {};

273

dist/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.assignUsingSafeBets = void 0;
const min_cost_flow_1 = require("min-cost-flow");
const utilities_1 = require("./utilities");
const NETEX_ID_SEPARATOR = '|';

@@ -13,8 +15,8 @@ /**

function addCustomersToOfferConfigurations(customers, offerConfigurations, offers) {
const customersById = new Map(customers.map((customer) => [
customer.customerId,
customer
]));
/*
* Having N identical offerConfigurations with count 1 is the same thing as having 1 offerConfiguration with count N:
* In order for our algorithms to work, offers cannot have a count other than
* one.
*
* Having N identical offerConfigurations with count 1
* is the same thing as having 1 offerConfiguration with count N:
* https://entur.slack.com/archives/C01BNGQ8LM6/p1601995996121000

@@ -27,2 +29,84 @@ */

});
const configurationsGroupedByServiceJourney = getConfigurationsGroupedByServiceJourney(offerConfigurationsWithOnly1Counts, offers);
return configurationsGroupedByServiceJourney
.flatMap((offerConfigurationsForServiceJourney) => assignCustomersForOneServiceJourney(customers, offerConfigurationsForServiceJourney, offers))
.map((offerConfiguration) => {
var _a;
const offersUsed = offers.filter((offer) => offer.id === offerConfiguration.offerId);
return Object.assign(Object.assign({}, offerConfiguration), { customers: (_a = offerConfiguration.customers) === null || _a === void 0 ? void 0 : _a.map((customer) => {
var _a;
return getCustomerWithOnlyTheEntitlementsThatAreRequiredForPurchase(offersUsed, (_a = offerConfiguration.selectableProductIds) !== null && _a !== void 0 ? _a : [], customer);
}) });
})
.map((offerConfiguration) => makeEachEntitlementTypeOccurOnceInEachOfferConfiguration(offerConfiguration));
}
exports.default = addCustomersToOfferConfigurations;
/**
* Assigns customers optimally to the offer configurations given the supplied
* offers. For the algorithms to work, the offers all have to be on the same
* service journey.
*
* @param customers The customers to be added
* @param offerConfigurations offerConfigurations belonging to the same service
* journey
* @param offers The offers used to generate the offerConfigurations
*/
function assignCustomersForOneServiceJourney(customers, offerConfigurations, offers) {
const { solvedOfferConfigurations, remainingOfferConfigurations, remainingCustomers } = assignUsingSafeBets(customers, offerConfigurations, offers);
return [
...solvedOfferConfigurations,
...assignCustomersUsingMinimumCostFlow(remainingCustomers, remainingOfferConfigurations, offers)
];
}
/**
* If an offer has a minimum amount of travelers equal to the travellers it is
* meant for, assigning these customers to the offer's corresponding offer
* configuration is considered a safe bet.
*
* This algorithm was invented to cope with group tickets, in a single ticket
* and a single fare product covers multiple travellers. We could not come up
* with an elegant modification to assignCustomersUsingMinimumCostFlow,
* but this algorithm seems to remove all offer configurations that would
* warrant a modification from its input.
*/
function assignUsingSafeBets(customers, offerConfigurations, offers) {
return offerConfigurations.reduce((accumulator, nextOfferConfiguration) => {
const valueIfNoSafeBetCanBeMade = Object.assign(Object.assign({}, accumulator), { remainingOfferConfigurations: [
...accumulator.remainingOfferConfigurations,
nextOfferConfiguration
] });
const theOfferInQuestion = offers.find(({ id }) => nextOfferConfiguration.offerId === id);
if (theOfferInQuestion === undefined) {
return valueIfNoSafeBetCanBeMade;
}
const moreThanOneTravelerMapping = theOfferInQuestion.travelerMapping.length > 1;
if (moreThanOneTravelerMapping) {
return valueIfNoSafeBetCanBeMade;
}
const travelerValidityGroup = theOfferInQuestion.travelerMapping[0];
if (travelerValidityGroup === undefined) {
return valueIfNoSafeBetCanBeMade;
}
const { travelerIds, minNumberOfTravelers } = travelerValidityGroup;
const allSpecifiedTravelersMustUseThisOffer = travelerIds.length === minNumberOfTravelers;
if (allSpecifiedTravelersMustUseThisOffer) {
const [customersForThisOfferConfiguration, remainingCustomers] = (0, utilities_1.partition)(accumulator.remainingCustomers, (customer) => travelerIds.includes(customer.customerId));
return Object.assign(Object.assign({}, accumulator), { remainingCustomers, solvedOfferConfigurations: [
...accumulator.solvedOfferConfigurations,
Object.assign(Object.assign({}, nextOfferConfiguration), { customers: customersForThisOfferConfiguration })
] });
}
return valueIfNoSafeBetCanBeMade;
}, {
remainingCustomers: customers,
remainingOfferConfigurations: [],
solvedOfferConfigurations: []
});
}
exports.assignUsingSafeBets = assignUsingSafeBets;
function assignCustomersUsingMinimumCostFlow(customers, offerConfigurations, offers) {
const customersById = new Map(customers.map((customer) => [
customer.customerId,
customer
]));
const offerWithId = new Map(offers.map((offer) => [offer.id, offer]));

@@ -33,3 +117,3 @@ const travellersForOfferId = new Map(offers.map((offer) => [

]));
const configurationsGroupedByServiceJourney = groupBy(offerConfigurationsWithOnly1Counts, (configuration) => {
const configurationsGroupedByServiceJourney = (0, utilities_1.groupBy)(offerConfigurations, (configuration) => {
var _a;

@@ -40,3 +124,3 @@ const offer = offerWithId.get(configuration.offerId);

return [...configurationsGroupedByServiceJourney.values()].flatMap((configurationsOnThisServiceJourney) => {
const allTravellerIds = uniq(compact(configurationsOnThisServiceJourney
const allTravellerIds = (0, utilities_1.uniq)((0, utilities_1.compact)(configurationsOnThisServiceJourney
.map((configuration) => configuration.offerId)

@@ -54,3 +138,3 @@ .flatMap((offerId) => travellersForOfferId.get(offerId))));

var _a;
return compact((_a = travellersForOfferId
return (0, utilities_1.compact)((_a = travellersForOfferId
.get(configuration.offerId)) === null || _a === void 0 ? void 0 : _a.map((travellerId) => {

@@ -62,9 +146,21 @@ const nodeNumber = travellerIdToNodeNumber.get(travellerId);

/*
* Every offer received from the backend contains the IDs of the (possibly anonymous) travellers it is meant for, and
* anonymous travellers are identified by a generated ID.
* We have not seen examples of it yet, but it is technically possible for this set of generated IDs
* to exceed the number of requested anonymous travellers. If the cost of assigning an anonymous and a named traveller
* to an order was equal, there are many valid solutions to such a min-cost-max-flow problem that end up leaving out a named customer.
* But by assigning a higher cost to picking an anonymous traveller for an offerConfiguration,
* we can make sure that the named customers are preferred in such a case.
* Every offer received from the backend contains
* the IDs of the (possibly anonymous) travellers
* it is meant for, and anonymous travellers
* are identified by a generated ID.
* We have not seen examples of it yet,
* but it is technically possible
* for this set of generated IDs
* to exceed the number of requested anonymous travellers.
*
* If the cost of assigning an anonymous
* and a named traveller to an order was equal,
* there are many valid solutions to such
* a min-cost-max-flow problem that end up leaving out a
* named customer.
*
* By assigning a higher cost to picking an anonymous
* traveller for an offerConfiguration,
* we can make sure that the named customers
* are preferred in such a case.
*/

@@ -81,6 +177,15 @@ const cost = customersById.has(travellerId) ? 0 : 1;

/*
* For the base case, allTravellerIds.length=1 and configurationsOnThisServiceJourney.length=1,
* 0 is the start node, 1 is the only traveller node, 2 is the only offer configuration node and 3 is the end node.
* It follows that endNode = allTravellerIds.length + configurationsOnThisServiceJourney.length + 1 will hold
* for any larger number of travellers or offerConfigurations.
* For the base case, allTravellerIds.length=1 and
* configurationsOnThisServiceJourney.length=1,
* 0 is the start node, 1 is the only traveller node,
* 2 is the only offer configuration node and 3 is the end node.
*
* It follows that
*
* endNode =
* allTravellerIds.length
* + configurationsOnThisServiceJourney.length
* + 1
*
* will hold for any larger number of travellers or offerConfigurations
*/

@@ -90,25 +195,25 @@ const endNode = allTravellerIds.length + configurationsOnThisServiceJourney.length + 1;

...[...nodeNumberToTravellerId.keys()].map((n) => ({
capacity: 1,
cost: 0,
from: startNode,
to: n,
capacity: 1,
cost: 0
to: n
})),
...travellerToOfferEdgesAsTuples.map(([from, to, cost]) => ({
capacity: 1,
cost,
from,
to,
capacity: 1,
cost
to
})),
...compact(configurationsOnThisServiceJourney.map((configuration, configurationIndex) => {
...(0, utilities_1.compact)(configurationsOnThisServiceJourney.map((configuration, configurationIndex) => {
const offer = offerWithId.get(configuration.offerId);
if (offer) {
const kroneAmount = findPriceObjectByCurrency(offer.salesPackageConfig.prices);
const kroneAmount = (0, utilities_1.findPriceObjectByCurrency)(offer.salesPackageConfig.prices);
const cost = kroneAmount
? convertFromTwoDecimalKroneStringToOreInteger(kroneAmount.amount)
? (0, utilities_1.convertFromTwoDecimalKroneStringToOreInteger)(kroneAmount.amount)
: 0;
return {
capacity: 1,
cost,
from: configurationIndex + allTravellerIds.length + 1,
to: endNode,
capacity: 1,
cost
to: endNode
};

@@ -119,3 +224,3 @@ }

];
const optimalAssignmentOfCustomersToConfigurationsAsFlowNetwork = min_cost_flow_1.minCostFlowForNumberNodes(edges, Number.POSITIVE_INFINITY);
const optimalAssignmentOfCustomersToConfigurationsAsFlowNetwork = (0, min_cost_flow_1.minCostFlowForNumberNodes)(edges, Number.POSITIVE_INFINITY);
const edgesFromTravellersToOffer = optimalAssignmentOfCustomersToConfigurationsAsFlowNetwork

@@ -127,32 +232,29 @@ .filter((edge) => edge.flow > 0)

const configurationNode = allTravellerIds.length + index + 1;
const customersAssignedToThisOffer = compact(edgesFromTravellersToOffer
const customersAssignedToThisOffer = (0, utilities_1.compact)(edgesFromTravellersToOffer
.filter(({ to }) => to === configurationNode)
.map(({ from }) => nodeNumberToTravellerId.get(from)));
return Object.assign(Object.assign({}, configuration), { customers: compact(customersAssignedToThisOffer.map((travellerId) => {
var _a;
const customer = customersById.get(travellerId);
if (!customer) {
return;
}
const offer = offerWithId.get(configuration.offerId);
if (!offer) {
// This should really never be the case, but if no matching offer is found, it seems more sensible to return the customer as-is rather than remove him/her
return customer;
}
return getCustomerWithOnlyTheEntitlementsThatAreRequiredForPurchase(offer, (_a = configuration.selectableProductIds) !== null && _a !== void 0 ? _a : [], customer);
})) });
return Object.assign(Object.assign({}, configuration), { customers: (0, utilities_1.compact)(customersAssignedToThisOffer.map((travellerId) => customersById.get(travellerId))) });
});
});
}
exports.default = addCustomersToOfferConfigurations;
function getCustomerWithOnlyTheEntitlementsThatAreRequiredForPurchase(offer, selectableProductIds, customer) {
/**
* Removes any entitlements from the customer that are not required to purchase
* the offer.
*
* reserve-offers has been known to crash if entitlements other than the ones
* required to purchase the specified offer are found in the offer
* configuration.
*/
function getCustomerWithOnlyTheEntitlementsThatAreRequiredForPurchase(offers, selectableProductIds, customer) {
var _a;
const idsOfEntitlementProductsRequiredForPurchase = extractIdsOfEntitlementProductsRequiredToPurchaseOffer(offer, selectableProductIds);
const idsOfEntitlementProductsRequiredForPurchase = extractIdsOfEntitlementProductsRequiredToPurchaseOffer(offers, selectableProductIds);
const entitlementsThatReserveOffersWillAccept = (_a = customer.entitlements) === null || _a === void 0 ? void 0 : _a.filter(({ entitlementProductRef }) => idsOfEntitlementProductsRequiredForPurchase.has(entitlementProductRef.id));
return Object.assign(Object.assign({}, customer), { entitlements: entitlementsThatReserveOffersWillAccept });
}
function extractIdsOfEntitlementProductsRequiredToPurchaseOffer(offer, selectableProductIds) {
function extractIdsOfEntitlementProductsRequiredToPurchaseOffer(offers, selectableProductIds) {
const selectableProductIdsAsSet = new Set(selectableProductIds);
const selectedFareProducts = offer.salesPackageConfig.fareProducts.filter((fareProduct) => isFareProductToBePurchased(selectableProductIdsAsSet, fareProduct));
return new Set(compact(selectedFareProducts.map((fareProduct) => { var _a; return (_a = fareProduct.discountRight) === null || _a === void 0 ? void 0 : _a.originatingFromProductId; })));
const selectedFareProducts = offers
.flatMap((offer) => offer.salesPackageConfig.fareProducts)
.filter((fareProduct) => isFareProductToBePurchased(selectableProductIdsAsSet, fareProduct));
return new Set((0, utilities_1.compact)(selectedFareProducts.map((fareProduct) => { var _a; return (_a = fareProduct.discountRight) === null || _a === void 0 ? void 0 : _a.originatingFromProductId; })));
}

@@ -164,34 +266,43 @@ function isFareProductToBePurchased(selectableProductIds, fareProduct) {

}
function compact(array) {
if (!array)
return [];
return array.filter((item) => Boolean(item));
}
function uniq(array) {
if (!array)
return [];
return [...new Set(array)];
}
function groupBy(array, iteratee) {
return array.reduce((map, item, index, array_) => {
const key = iteratee(item, index, array_);
const existingGroup = map.get(key);
if (existingGroup) {
existingGroup.push(item);
}
else {
map.set(key, [item]);
}
return map;
}, new Map());
}
/**
* Takes a list of prices with different currencies and returns the first that matches the provided currency
* Defaults to 'NOK'
* Prevents two customers from having entitlements with the same IDs within the
* same offer configuration. If two or more entitlements with the same ID are
* found, only the first occurrence is kept.
*
* <h2>Background</h2>
* If two customers have supply same entitlement, they will <em>both</em> be
* taxed the same amount as if only one of them had supplied the entitlement.
* In effect, they will be doubly taxed.
*
* There is some hope that the benefits service may, at some point in the
* future, share the burden between the travellers.
*
* @see <a href="https://entur.slack.com/archives/C01BNGQ8LM6/p1632298229281100?thread_ts=1632293883.270800&cid=C01BNGQ8LM6">
* The Slack message in which we were told that customers would be taxed doubly
* </a>
* @param offerConfiguration
*/
function findPriceObjectByCurrency(prices, currency = 'NOK') {
return prices.find((price) => price.currency === currency);
function makeEachEntitlementTypeOccurOnceInEachOfferConfiguration(offerConfiguration) {
var _a;
const updatedCustomers = (_a = offerConfiguration.customers) === null || _a === void 0 ? void 0 : _a.reduce((accumulator, nextCustomer) => {
var _a;
const alreadyUsedEntitlementIds = new Set(accumulator.flatMap((customer) => {
var _a, _b;
return (_b = (_a = customer.entitlements) === null || _a === void 0 ? void 0 : _a.map((otherCustomerEntitlement) => otherCustomerEntitlement.entitlementProductRef.id)) !== null && _b !== void 0 ? _b : [];
}));
const entitlements = (_a = nextCustomer.entitlements) === null || _a === void 0 ? void 0 : _a.filter((entitlement) => {
const somePreviousCustomerHasSameEntitlement = alreadyUsedEntitlementIds.has(entitlement.entitlementProductRef.id);
return !somePreviousCustomerHasSameEntitlement;
});
return [...accumulator, Object.assign(Object.assign({}, nextCustomer), { entitlements })];
}, []);
return Object.assign(Object.assign({}, offerConfiguration), { customers: updatedCustomers });
}
function convertFromTwoDecimalKroneStringToOreInteger(kroneAmount) {
return Math.floor(Number.parseFloat(kroneAmount) * 100);
function getConfigurationsGroupedByServiceJourney(offerConfigurations, offers) {
return [
...(0, utilities_1.groupBy)(offerConfigurations, (configuration) => offers
.filter((offer) => offer.id === configuration.offerId)
.flatMap((offer) => offer.salesPackageConfig.serviceJourneyIds)
.join(NETEX_ID_SEPARATOR)).values()
];
}
{
"name": "@entur/add-customers-to-offer-configurations",
"version": "1.2.2",
"version": "1.3.0",
"description": "Adds customers to a group of offer configurations, given the offers used to generate them",

@@ -14,3 +14,3 @@ "main": "dist/index.js",

"scripts": {
"build": "rm -rf dist && tsc",
"build": "rm -rf dist && npx tsc",
"update-types": "npx swagger-typescript-api --path https://api.staging.entur.io/sales/v1/offers/api-docs/swagger.json --output source/types -n offersTypes.ts --no-client && npx swagger-typescript-api --path https://api.staging.entur.io/api-docs/reserve-offer?group=public --output source/types -n reserveOfferTypes.ts --no-client",

@@ -20,3 +20,3 @@ "test": "jest",

"lint:fix": "npx prettier --write *.{md,json} .*.js; xo --fix",
"prepublishOnly": "tsc && npm run test",
"prepublishOnly": "npx tsc && npm run test",
"prepare": "husky install"

@@ -39,2 +39,4 @@ },

"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-sort-keys": "^2.3.5",
"husky": "^7.0.1",

@@ -45,2 +47,3 @@ "jest": "^27.0.6",

"prettier": "^2.3.2",
"typescript": "^4.4.3",
"xo": "^0.42.0"

@@ -47,0 +50,0 @@ },

@@ -103,2 +103,10 @@ # @entur/add-customers-to-offer-configurations

### Known shortcomings
Due to
[the way the assignment problem algorithm works](#transforming-the-problem-to-a-graph),
the module's support for offer configurations that require multiple customers is
limited. The algorithm is only guaranteed to work if such offers can be
[pruned as safe bets](#pruning-safe-bets).
### You can only add customers that were present in the offers request

@@ -136,9 +144,2 @@

Optimally assigning customers to offer configurations is an
[assignment problem](https://en.wikipedia.org/wiki/Assignment_problem)
(specifically, a _balanced_ assignment problem), which is solvable as a
[minimum-cost flow problem](https://en.wikipedia.org/wiki/Minimum-cost_flow_problem).
### Transforming the problem to a graph
To solve the problem, we first assume that **each stretch in the journey (each

@@ -150,5 +151,27 @@ service journey) is independent of the others.** That is to say, there is no

For each stretch (or _service journey_, as they're called in the Entur domain),
we can then construct a problem graph in the following way:
After making that assumption, the problem is solved by two algorithms in
succession.
### Pruning safe bets
If an offer has a minimum amount of travelers equal to the travellers it is
meant for, assigning these customers to the offer's corresponding offer
configuration is considered a safe bet. The function assignUsingSafeBets handles
this.
This algorithm was invented to cope with group tickets, in a single ticket and a
single fare product covers multiple travellers. We could not come up with an
elegant modification to assignCustomersUsingMinimumCostFlow, but this algorithm
seems to remove all offer configurations that would warrant a modification from
its input.
### Transforming the problem to a graph
Optimally assigning customers to offer configurations is an
[assignment problem](https://en.wikipedia.org/wiki/Assignment_problem)
(specifically, a _balanced_ assignment problem), which is solvable as a
[minimum-cost flow problem](https://en.wikipedia.org/wiki/Minimum-cost_flow_problem).
We construct a flow network representing the problem in the following way:
1. Every traveler and offer configuration on this service journey is considered

@@ -155,0 +178,0 @@ a node.

Sorry, the diff of this file is too big to display

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc