@12-apps/discounts 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -5
- package/package.json +5 -5
- package/src/engine/allocate.ts +20 -5
- package/src/engine/combo-match.ts +370 -0
- package/src/engine/combo-offer.ts +135 -0
- package/src/engine/eligibility.ts +18 -1
- package/src/engine/evaluate-passes.ts +165 -25
- package/src/engine/evaluate.ts +16 -3
- package/src/engine/kinds.ts +66 -6
- package/src/engine/preview.ts +10 -1
- package/src/engine/pt-BR.ts +8 -0
- package/src/engine/types.ts +74 -0
- package/src/index.ts +37 -0
- package/src/server/copy.ts +24 -0
- package/src/server/index.ts +3 -0
- package/src/server/mcp.ts +40 -6
- package/src/server/pt-BR.ts +9 -0
- package/src/server/store.ts +16 -0
- package/src/server/validate-combo.ts +161 -0
- package/src/server/validate.ts +47 -5
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ No database, no framework, no clock, and **no baked user copy**.
|
|
|
11
11
|
|
|
12
12
|
| entry | what it is |
|
|
13
13
|
|---|---|
|
|
14
|
-
| `@12-apps/discounts` | the engine: `evaluateDiscounts`, `previewItemDiscount`, the vocabulary, the rejection copy port |
|
|
14
|
+
| `@12-apps/discounts` | the engine: `evaluateDiscounts`, `previewItemDiscount`, `comboOffersForItem`, `matchCombo`, the vocabulary, the rejection copy port |
|
|
15
15
|
| `@12-apps/discounts/pt-BR` | the pt-BR rejection pack, imported by hand |
|
|
16
16
|
| `@12-apps/discounts/server` | `createApiDiscounts`, the `DiscountStore` port, the permission contribution, the MCP tools |
|
|
17
17
|
| `@12-apps/discounts/server/pt-BR` | the pt-BR pack for the admin surface's sentences |
|
|
@@ -21,10 +21,11 @@ No database, no framework, no clock, and **no baked user copy**.
|
|
|
21
21
|
## The model
|
|
22
22
|
|
|
23
23
|
A discount is a **percentage** (basis points, 1..10000) or a **fixed amount**
|
|
24
|
-
(integer cents), scoped to the whole **order**, to **categories**,
|
|
25
|
-
|
|
26
|
-
by the buyer typing a **coupon code**. Money is integer cents
|
|
27
|
-
percentage is basis points, so "12,5%" needs no decimal column
|
|
24
|
+
(integer cents), scoped to the whole **order**, to **categories**, to specific
|
|
25
|
+
**items**, or to a **combo**, fired either **automatically** when its conditions
|
|
26
|
+
hold or by the buyer typing a **coupon code**. Money is integer cents
|
|
27
|
+
throughout; a percentage is basis points, so "12,5%" needs no decimal column
|
|
28
|
+
anywhere.
|
|
28
29
|
|
|
29
30
|
The evaluator is a pure function of `{ lines, rules, couponCode, now }`:
|
|
30
31
|
|
|
@@ -53,6 +54,81 @@ Three properties it holds to the cent, and the suite pins each:
|
|
|
53
54
|
strike through *before* there is a cart — from the same rules, so a card and a
|
|
54
55
|
cart cannot quote different numbers.
|
|
55
56
|
|
|
57
|
+
## Combos
|
|
58
|
+
|
|
59
|
+
A **combo** is a discount scoped to a group the merchant defines: a list of
|
|
60
|
+
**slots**, each naming products and/or categories and how many units it needs.
|
|
61
|
+
The evaluator matches those slots against the cart's **units**, repeatedly, and
|
|
62
|
+
prices each match.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// "1 pipoca grande + 2 refrigerantes por R$ 25,00"
|
|
66
|
+
{
|
|
67
|
+
scope: "COMBO",
|
|
68
|
+
type: "BUNDLE_PRICE",
|
|
69
|
+
bundlePriceCents: 2_500,
|
|
70
|
+
comboRequirements: [
|
|
71
|
+
{ menuItemIds: ["popcorn-lg"], categoryIds: [], quantity: 1 },
|
|
72
|
+
{ menuItemIds: [], categoryIds: ["drinks"], quantity: 2 },
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// "3 hambúrgueres pelo preço de 2" — one slot of three, the cheapest one free
|
|
77
|
+
{
|
|
78
|
+
scope: "COMBO",
|
|
79
|
+
type: "FREE_UNITS",
|
|
80
|
+
freeUnits: 1,
|
|
81
|
+
comboRequirements: [{ menuItemIds: ["burger"], categoryIds: [], quantity: 3 }],
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Four rewards, and the type picks which column is read:
|
|
86
|
+
|
|
87
|
+
| type | column | the group's discount |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `BUNDLE_PRICE` | `bundlePriceCents` | its value above the bundle price |
|
|
90
|
+
| `FREE_UNITS` | `freeUnits` | its N cheapest units |
|
|
91
|
+
| `PERCENTAGE` | `percentOffBp` | that rate off it |
|
|
92
|
+
| `FIXED_AMOUNT` | `amountOffCents` | that many cents off it |
|
|
93
|
+
|
|
94
|
+
The last two are the ordinary columns, doing the ordinary thing against a group
|
|
95
|
+
instead of against a line. The first two only mean something against a matched
|
|
96
|
+
group, so they are legal at `COMBO` scope and nowhere else — the write path
|
|
97
|
+
refuses the combination and a host's CHECK constraint should too.
|
|
98
|
+
|
|
99
|
+
Every reward is **per application**. A cart of seven burgers takes "3 for the
|
|
100
|
+
price of 2" twice and pays full price for the seventh; `maxComboApplications`
|
|
101
|
+
caps that when a merchant wants it capped.
|
|
102
|
+
|
|
103
|
+
Four decisions worth knowing before you wire it up:
|
|
104
|
+
|
|
105
|
+
- **Combos run first.** The pass order is `COMBO → ITEM → CATEGORY → ORDER`.
|
|
106
|
+
- **What a combo consumed is opaque to `ITEM` and `CATEGORY`.** A combo price is
|
|
107
|
+
a number the merchant set deliberately; a component-targeted promotion
|
|
108
|
+
stacking on top of it is a double discount. So a line of five burgers with
|
|
109
|
+
three inside a combo offers exactly two burgers, at full price, to an
|
|
110
|
+
item-level promotion. **`ORDER` is not blocked** and applies to the combo
|
|
111
|
+
price, because an order-wide promise is about the basket, not the components.
|
|
112
|
+
- **One pool.** Every combo draws from the same units, so two combos can never
|
|
113
|
+
both be paid for the same burger. The richer one claims them first.
|
|
114
|
+
- **A combo is not badged.** Its price does not exist until the other components
|
|
115
|
+
are in the cart, so `previewItemDiscount` skips it. `comboOffersForItem` is
|
|
116
|
+
what a card can honestly show instead — that the item takes part in a combo,
|
|
117
|
+
as a label rather than a price.
|
|
118
|
+
|
|
119
|
+
A combo the cart cannot assemble is rejected `COMBO_NOT_MATCHED`, which is the
|
|
120
|
+
one rejection reason this package refuses to coarsen: a buyer one soda short
|
|
121
|
+
can finish the combo, and telling them so is the difference between a dead end
|
|
122
|
+
and a sale.
|
|
123
|
+
|
|
124
|
+
### What a combo is NOT, here
|
|
125
|
+
|
|
126
|
+
It is a **pricing rule**, not a catalog entity. There is no `Combo` product with
|
|
127
|
+
its own menu card, its own single cart line and its own order snapshot — that is
|
|
128
|
+
a host table with foreign keys into the host's own catalog, which is exactly why
|
|
129
|
+
this package declares no `db` capability. What travels is the rule; the sellable
|
|
130
|
+
bundle, if a host wants one, stays the host's.
|
|
131
|
+
|
|
56
132
|
## The admin surface
|
|
57
133
|
|
|
58
134
|
`createApiDiscounts({ store, copy })` returns five `WireRoute` descriptors —
|
|
@@ -93,6 +169,16 @@ The host owns the schema, the tenant scoping, the transactions and the
|
|
|
93
169
|
uniqueness conflicts, and answers the `DiscountStore` port. What travels is the
|
|
94
170
|
RULE, not the storage.
|
|
95
171
|
|
|
172
|
+
Combos add three nullable columns to that table — `bundle_price_cents`,
|
|
173
|
+
`free_units`, `max_combo_applications` — and one child table for the slots, each
|
|
174
|
+
row carrying a quantity plus its own product and category id lists. `DiscountWrite`
|
|
175
|
+
hands a store exactly that: the columns on `scalars`, the slots on
|
|
176
|
+
`targets.comboRequirements`, already validated and narrowed to the scope. A host
|
|
177
|
+
that does not sell combos adds nothing: the fields are optional on everything
|
|
178
|
+
this package asks a host to BUILD (`DiscountRule`, `DiscountRecord`) and
|
|
179
|
+
complete on everything it PRODUCES, so adopting the version that introduced them
|
|
180
|
+
is a no-op until a `COMBO`-scoped row exists.
|
|
181
|
+
|
|
96
182
|
**Every sentence a human reads.** `DiscountRejectionCopy` (buyer-facing) and
|
|
97
183
|
`DiscountsServerCopy` (operator-facing) are required config with **no
|
|
98
184
|
defaults** — `createApiDiscounts` throws at construction naming every missing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
|
|
6
6
|
"exports": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"typecheck": "tsc --noEmit"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@12-apps/shared-helpers": "^2.0.
|
|
24
|
+
"@12-apps/shared-helpers": "^2.0.2",
|
|
25
25
|
"zod": "^4.3.5"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@12-apps/eslint-config": "^1.21.
|
|
37
|
-
"@12-apps/typescript-config": "^1.20.
|
|
38
|
-
"@12-apps/wiring": "^1.
|
|
36
|
+
"@12-apps/eslint-config": "^1.21.1",
|
|
37
|
+
"@12-apps/typescript-config": "^1.20.1",
|
|
38
|
+
"@12-apps/wiring": "^1.7.0",
|
|
39
39
|
"@types/node": "^22.10.6",
|
|
40
40
|
"eslint": "^9.39.1",
|
|
41
41
|
"eslint-plugin-test-flakiness": "^1.4.0",
|
package/src/engine/allocate.ts
CHANGED
|
@@ -22,14 +22,29 @@ import type { DiscountRule } from "./types";
|
|
|
22
22
|
* drifting negative. `FIXED_AMOUNT` is clamped to the base, so "R$ 20 off" on a
|
|
23
23
|
* base of R$ 12 removes R$ 12 and never issues a credit. A result of 0 means
|
|
24
24
|
* the discount must NOT be applied — an `R$ 0,00` line is not a promotion.
|
|
25
|
+
*
|
|
26
|
+
* The two COMBO reward types return 0 here, and that is deliberate rather than
|
|
27
|
+
* a gap: `BUNDLE_PRICE` and `FREE_UNITS` are not functions of a base at all.
|
|
28
|
+
* One needs to know which UNITS the combo matched (the cheapest of them are the
|
|
29
|
+
* free ones) and the other needs to know how many TIMES it matched, and a
|
|
30
|
+
* single `baseCents` has thrown both facts away. `applicationReward` in
|
|
31
|
+
* `./combo-match.ts` is where they are computed, per application, and it calls
|
|
32
|
+
* back into this function for the two types that ARE a function of a base.
|
|
33
|
+
*
|
|
34
|
+
* Returning 0 rather than throwing keeps the failure the safe one: a combo
|
|
35
|
+
* reward that somehow reached this path removes nothing, which the evaluator
|
|
36
|
+
* reports as `ZERO_VALUE`, instead of a wrong number reaching a charge.
|
|
25
37
|
*/
|
|
26
38
|
export function rawAmountCents(rule: DiscountRule, baseCents: number): number {
|
|
27
39
|
if (baseCents <= 0) return 0;
|
|
28
|
-
|
|
29
|
-
rule.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
if (rule.type === "PERCENTAGE") {
|
|
41
|
+
const raw = Math.floor((baseCents * (rule.percentOffBp ?? 0)) / MAX_PERCENT_OFF_BP);
|
|
42
|
+
return Math.max(0, Math.min(raw, baseCents));
|
|
43
|
+
}
|
|
44
|
+
if (rule.type === "FIXED_AMOUNT") {
|
|
45
|
+
return Math.max(0, Math.min(rule.amountOffCents ?? 0, baseCents));
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
/**
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { rawAmountCents } from "./allocate";
|
|
2
|
+
import type { ComboRequirement, DiscountCartLine, DiscountRule } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* COMBO matching (FUT-268): which UNITS of a cart satisfy a combo's slots, how
|
|
6
|
+
* many times, and how many cents that is worth.
|
|
7
|
+
*
|
|
8
|
+
* This is the one part of the engine that counts UNITS rather than lines. A
|
|
9
|
+
* discount scoped to an item takes a percentage off a whole line and never
|
|
10
|
+
* needs to know it holds five burgers; a combo does — "3 for the price of 2"
|
|
11
|
+
* on a line of five applies once and leaves two burgers at full price, and the
|
|
12
|
+
* cents that fall out of that are not a function of the line's total.
|
|
13
|
+
*
|
|
14
|
+
* Pure, like the rest of the engine: no database, no clock, no I/O. The same
|
|
15
|
+
* pool and the same rule always produce the same match, down to WHICH units
|
|
16
|
+
* were taken, because every ordering below has an explicit final tie-break.
|
|
17
|
+
*
|
|
18
|
+
* ## The matching rule, stated once
|
|
19
|
+
*
|
|
20
|
+
* One APPLICATION fills every slot. A slot is filled from the units its
|
|
21
|
+
* targets accept, MOST EXPENSIVE FIRST, and slots are filled MOST CONSTRAINED
|
|
22
|
+
* FIRST. The combo then applies again against whatever units are left, until a
|
|
23
|
+
* slot can no longer be filled or `maxComboApplications` is reached.
|
|
24
|
+
*
|
|
25
|
+
* Most-expensive-first is the buyer-favourable reading and it is the same
|
|
26
|
+
* instinct as R6's biggest-first ordering. On a `FREE_UNITS` combo it makes the
|
|
27
|
+
* free unit the dearest one the deal can reach; on a `BUNDLE_PRICE` combo the
|
|
28
|
+
* buyer pays the bundle price either way, so pulling the expensive units INTO
|
|
29
|
+
* the bundle leaves the cheap ones outside it at full price, which is again the
|
|
30
|
+
* cheaper cart.
|
|
31
|
+
*
|
|
32
|
+
* Most-constrained-first is a HEURISTIC, and the honest name for what it is: a
|
|
33
|
+
* greedy assignment, not a maximum bipartite matching. It is exact whenever the
|
|
34
|
+
* slots are disjoint — which every combo a merchant can describe in a sentence
|
|
35
|
+
* is — and it repairs the one overlap that occurs in practice, a specific item
|
|
36
|
+
* slot next to a broad category slot that also accepts it ("1 large popcorn + 2
|
|
37
|
+
* sodas", where the popcorn is itself filed under a targeted category). Filling
|
|
38
|
+
* the narrow slot first stops the broad one from eating its only candidate. A
|
|
39
|
+
* deliberately pathological spec — three slots overlapping three ways — can
|
|
40
|
+
* still leave an application unmatched that a full matching would have found.
|
|
41
|
+
*
|
|
42
|
+
* That residue is bounded and it is bounded in the safe direction: the cost is
|
|
43
|
+
* a combo firing fewer times than it could, never a wrong price, because
|
|
44
|
+
* whatever the assignment picks is a real set of units the buyer really has.
|
|
45
|
+
* The admin surface does NOT detect overlap — it caps the slot COUNT
|
|
46
|
+
* (`MAX_COMBO_SLOTS`), which is a limit on how pathological a spec can get and
|
|
47
|
+
* not a check that one is not. Trading an exact matching for that was
|
|
48
|
+
* deliberate: the exact algorithm is a maximum bipartite matching re-run per
|
|
49
|
+
* application, on a hot path a whole menu page walks, to buy correctness only
|
|
50
|
+
* on specs no merchant writes.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The units a combo may still consume, plus the lines they belong to.
|
|
55
|
+
*
|
|
56
|
+
* It is a POOL rather than a plain line list because combos share it: two
|
|
57
|
+
* combos that both want the same burgers must not both be paid for them, so
|
|
58
|
+
* the combo pass builds one pool, and each match deducts what it took. See
|
|
59
|
+
* `runPass` in `./evaluate-passes.ts`.
|
|
60
|
+
*/
|
|
61
|
+
export interface ComboPool {
|
|
62
|
+
/** lineId -> units still available. */
|
|
63
|
+
readonly unitsByLine: ReadonlyMap<string, number>;
|
|
64
|
+
/** Every line of the cart by id — the matcher needs prices and targeting. */
|
|
65
|
+
readonly linesById: ReadonlyMap<string, DiscountCartLine>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What one combo took from the cart, and what it is worth. */
|
|
69
|
+
export interface ComboMatch {
|
|
70
|
+
/** How many times the slots were filled. Always >= 1 on a real match. */
|
|
71
|
+
applications: number;
|
|
72
|
+
/** lineId -> units consumed across every application. */
|
|
73
|
+
consumedUnitsByLine: ReadonlyMap<string, number>;
|
|
74
|
+
/** lineId -> GROSS cents of those units. This is the allocation base. */
|
|
75
|
+
consumedCentsByLine: ReadonlyMap<string, number>;
|
|
76
|
+
/** Gross value of the whole matched group: the sum of the map above. */
|
|
77
|
+
groupCents: number;
|
|
78
|
+
/**
|
|
79
|
+
* Cents this combo removes, summed over its applications and before the
|
|
80
|
+
* cart-level clamps (R4's remainders and R9's payable floor) are applied.
|
|
81
|
+
*/
|
|
82
|
+
rewardCents: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** lineId -> a count of units. */
|
|
86
|
+
type UnitTally = Map<string, number>;
|
|
87
|
+
|
|
88
|
+
/** One slot with its target lists turned into sets, prepared once per match. */
|
|
89
|
+
interface PreparedSlot {
|
|
90
|
+
quantity: number;
|
|
91
|
+
menuItemIds: ReadonlySet<string>;
|
|
92
|
+
categoryIds: ReadonlySet<string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One candidate line for a slot, carried with the facts the sort needs. */
|
|
96
|
+
interface SlotCandidate {
|
|
97
|
+
lineId: string;
|
|
98
|
+
units: number;
|
|
99
|
+
line: DiscountCartLine;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A pristine pool: every line's full quantity available. */
|
|
103
|
+
export function freshComboPool(lines: readonly DiscountCartLine[]): ComboPool {
|
|
104
|
+
return {
|
|
105
|
+
unitsByLine: new Map(lines.map((line) => [line.lineId, Math.max(0, line.quantity)])),
|
|
106
|
+
linesById: new Map(lines.map((line) => [line.lineId, line])),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function prepareSlots(requirements: readonly ComboRequirement[]): PreparedSlot[] {
|
|
111
|
+
return requirements.map((requirement) => ({
|
|
112
|
+
quantity: Math.trunc(requirement.quantity),
|
|
113
|
+
menuItemIds: new Set(requirement.menuItemIds),
|
|
114
|
+
categoryIds: new Set(requirement.categoryIds),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether one slot accepts one line: its BASE item, its chosen VARIATION, or
|
|
120
|
+
* any category on its path. Exactly the reach `coveredLineIds` gives an
|
|
121
|
+
* `ITEM`- or `CATEGORY`-scoped discount, so a merchant does not have to learn
|
|
122
|
+
* a second targeting model for combos.
|
|
123
|
+
*/
|
|
124
|
+
function slotAccepts(slot: PreparedSlot, line: DiscountCartLine): boolean {
|
|
125
|
+
if (slot.menuItemIds.has(line.menuItemId)) return true;
|
|
126
|
+
if (line.variationMenuItemId !== null && slot.menuItemIds.has(line.variationMenuItemId)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return line.categoryPath.some((categoryId) => slot.categoryIds.has(categoryId));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every available unit a slot accepts, dearest first, then by line id. */
|
|
133
|
+
function candidatesFor(
|
|
134
|
+
slot: PreparedSlot,
|
|
135
|
+
available: ReadonlyMap<string, number>,
|
|
136
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
137
|
+
): SlotCandidate[] {
|
|
138
|
+
return [...available.entries()]
|
|
139
|
+
.map(([lineId, units]) => ({ lineId, units, line: linesById.get(lineId) }))
|
|
140
|
+
.filter(
|
|
141
|
+
(entry): entry is SlotCandidate =>
|
|
142
|
+
entry.units > 0 && entry.line !== undefined && slotAccepts(slot, entry.line),
|
|
143
|
+
)
|
|
144
|
+
.sort((a, b) => {
|
|
145
|
+
const byPrice = b.line.unitPriceCents - a.line.unitPriceCents;
|
|
146
|
+
if (byPrice !== 0) return byPrice;
|
|
147
|
+
return a.lineId < b.lineId ? -1 : 1;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Take one slot's units, dearest first. Null when the cart is short. */
|
|
152
|
+
function takeSlot(
|
|
153
|
+
slot: PreparedSlot,
|
|
154
|
+
available: ReadonlyMap<string, number>,
|
|
155
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
156
|
+
): UnitTally | null {
|
|
157
|
+
const taken: UnitTally = new Map();
|
|
158
|
+
let needed = slot.quantity;
|
|
159
|
+
for (const candidate of candidatesFor(slot, available, linesById)) {
|
|
160
|
+
if (needed <= 0) break;
|
|
161
|
+
const take = Math.min(needed, candidate.units);
|
|
162
|
+
taken.set(candidate.lineId, take);
|
|
163
|
+
needed -= take;
|
|
164
|
+
}
|
|
165
|
+
return needed > 0 ? null : taken;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** How many units are available to a slot right now — the constraint metric. */
|
|
169
|
+
function supplyFor(
|
|
170
|
+
slot: PreparedSlot,
|
|
171
|
+
available: ReadonlyMap<string, number>,
|
|
172
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
173
|
+
): number {
|
|
174
|
+
return candidatesFor(slot, available, linesById).reduce(
|
|
175
|
+
(total, candidate) => total + candidate.units,
|
|
176
|
+
0,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Most constrained first, ties broken by the merchant's declaration order. */
|
|
181
|
+
function orderSlots(
|
|
182
|
+
slots: readonly PreparedSlot[],
|
|
183
|
+
available: ReadonlyMap<string, number>,
|
|
184
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
185
|
+
): PreparedSlot[] {
|
|
186
|
+
return slots
|
|
187
|
+
.map((slot, index) => ({ slot, index, supply: supplyFor(slot, available, linesById) }))
|
|
188
|
+
.sort((a, b) => a.supply - b.supply || a.index - b.index)
|
|
189
|
+
.map((entry) => entry.slot);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function addUnits(target: UnitTally, source: ReadonlyMap<string, number>): void {
|
|
193
|
+
for (const [lineId, units] of source) {
|
|
194
|
+
target.set(lineId, (target.get(lineId) ?? 0) + units);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function subtractUnits(target: UnitTally, source: ReadonlyMap<string, number>): void {
|
|
199
|
+
for (const [lineId, units] of source) {
|
|
200
|
+
target.set(lineId, Math.max(0, (target.get(lineId) ?? 0) - units));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Fill every slot once, against a private copy of what is available.
|
|
206
|
+
*
|
|
207
|
+
* The copy is what makes an application ALL-OR-NOTHING: a combo that fills two
|
|
208
|
+
* of its three slots and then runs short must consume nothing at all, or the
|
|
209
|
+
* buyer would be charged for a bundle they were never given.
|
|
210
|
+
*/
|
|
211
|
+
function fillOneApplication(
|
|
212
|
+
slots: readonly PreparedSlot[],
|
|
213
|
+
available: ReadonlyMap<string, number>,
|
|
214
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
215
|
+
): UnitTally | null {
|
|
216
|
+
const left: UnitTally = new Map(available);
|
|
217
|
+
const taken: UnitTally = new Map();
|
|
218
|
+
for (const slot of orderSlots(slots, left, linesById)) {
|
|
219
|
+
const slotTake = takeSlot(slot, left, linesById);
|
|
220
|
+
if (slotTake === null) return null;
|
|
221
|
+
addUnits(taken, slotTake);
|
|
222
|
+
subtractUnits(left, slotTake);
|
|
223
|
+
}
|
|
224
|
+
return taken;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function tallyCents(
|
|
228
|
+
units: ReadonlyMap<string, number>,
|
|
229
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
230
|
+
): Map<string, number> {
|
|
231
|
+
const cents = new Map<string, number>();
|
|
232
|
+
for (const [lineId, count] of units) {
|
|
233
|
+
const line = linesById.get(lineId);
|
|
234
|
+
if (line !== undefined) cents.set(lineId, count * Math.max(0, line.unitPriceCents));
|
|
235
|
+
}
|
|
236
|
+
return cents;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sumValues(values: ReadonlyMap<string, number>): number {
|
|
240
|
+
let total = 0;
|
|
241
|
+
for (const cents of values.values()) total += cents;
|
|
242
|
+
return total;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The unit prices of one application, one entry per unit. */
|
|
246
|
+
function unitPricesOf(
|
|
247
|
+
units: ReadonlyMap<string, number>,
|
|
248
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
249
|
+
): number[] {
|
|
250
|
+
return [...units.entries()].flatMap(([lineId, count]) => {
|
|
251
|
+
const line = linesById.get(lineId);
|
|
252
|
+
if (line === undefined) return [];
|
|
253
|
+
return Array.from({ length: count }, () => Math.max(0, line.unitPriceCents));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The `FREE_UNITS` reward: the N CHEAPEST units of one application. */
|
|
258
|
+
function cheapestUnitsCents(
|
|
259
|
+
units: ReadonlyMap<string, number>,
|
|
260
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
261
|
+
freeUnits: number,
|
|
262
|
+
): number {
|
|
263
|
+
if (freeUnits <= 0) return 0;
|
|
264
|
+
return unitPricesOf(units, linesById)
|
|
265
|
+
.sort((a, b) => a - b)
|
|
266
|
+
.slice(0, freeUnits)
|
|
267
|
+
.reduce((total, cents) => total + cents, 0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* What ONE application removes.
|
|
272
|
+
*
|
|
273
|
+
* Per application rather than per match, because that is what each reward
|
|
274
|
+
* means: "R$ 5 off the combo" twice is R$ 10, and "one free" twice is two free
|
|
275
|
+
* units, one from each group. Only `PERCENTAGE` would read the same either
|
|
276
|
+
* way, and it goes through the same path so there is one rule to read.
|
|
277
|
+
*/
|
|
278
|
+
function applicationReward(
|
|
279
|
+
rule: DiscountRule,
|
|
280
|
+
units: ReadonlyMap<string, number>,
|
|
281
|
+
linesById: ReadonlyMap<string, DiscountCartLine>,
|
|
282
|
+
): number {
|
|
283
|
+
const groupCents = sumValues(tallyCents(units, linesById));
|
|
284
|
+
if (groupCents <= 0) return 0;
|
|
285
|
+
if (rule.type === "BUNDLE_PRICE") {
|
|
286
|
+
return Math.max(0, groupCents - Math.max(0, rule.bundlePriceCents ?? 0));
|
|
287
|
+
}
|
|
288
|
+
if (rule.type === "FREE_UNITS") {
|
|
289
|
+
return cheapestUnitsCents(units, linesById, rule.freeUnits ?? 0);
|
|
290
|
+
}
|
|
291
|
+
return rawAmountCents(rule, groupCents);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The application ceiling.
|
|
296
|
+
*
|
|
297
|
+
* Bounded by the pool's own size whatever the rule says, which is what makes
|
|
298
|
+
* the loop below provably terminate: every application consumes at least one
|
|
299
|
+
* unit (a zero-quantity slot is refused before we get here), so there can never
|
|
300
|
+
* be more applications than there are units in the cart.
|
|
301
|
+
*
|
|
302
|
+
* Only null/undefined mean UNCAPPED. A stored zero means "at most zero times",
|
|
303
|
+
* so the combo simply does not fire — the admin surface refuses that value, and
|
|
304
|
+
* a row that carries one anyway should cost the merchant a promotion nobody
|
|
305
|
+
* sees rather than an uncapped giveaway nobody authorised.
|
|
306
|
+
*/
|
|
307
|
+
function applicationCap(rule: DiscountRule, pool: ComboPool): number {
|
|
308
|
+
const totalUnits = sumValues(pool.unitsByLine);
|
|
309
|
+
const declared = rule.maxComboApplications;
|
|
310
|
+
if (declared === null || declared === undefined) return totalUnits;
|
|
311
|
+
return Math.min(Math.trunc(declared), totalUnits);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Match one combo rule against a pool. Null when the cart cannot fill its
|
|
316
|
+
* slots even once — which the screen reports as `COMBO_NOT_MATCHED`.
|
|
317
|
+
*/
|
|
318
|
+
export function matchCombo(rule: DiscountRule, pool: ComboPool): ComboMatch | null {
|
|
319
|
+
const slots = prepareSlots(rule.comboRequirements ?? []);
|
|
320
|
+
if (slots.length === 0 || slots.some((slot) => slot.quantity <= 0)) return null;
|
|
321
|
+
|
|
322
|
+
const left: UnitTally = new Map(pool.unitsByLine);
|
|
323
|
+
const consumedUnits: UnitTally = new Map();
|
|
324
|
+
const cap = applicationCap(rule, pool);
|
|
325
|
+
let rewardCents = 0;
|
|
326
|
+
let applications = 0;
|
|
327
|
+
while (applications < cap) {
|
|
328
|
+
const taken = fillOneApplication(slots, left, pool.linesById);
|
|
329
|
+
if (taken === null) break;
|
|
330
|
+
rewardCents += applicationReward(rule, taken, pool.linesById);
|
|
331
|
+
addUnits(consumedUnits, taken);
|
|
332
|
+
subtractUnits(left, taken);
|
|
333
|
+
applications += 1;
|
|
334
|
+
}
|
|
335
|
+
if (applications === 0) return null;
|
|
336
|
+
|
|
337
|
+
const consumedCentsByLine = tallyCents(consumedUnits, pool.linesById);
|
|
338
|
+
return {
|
|
339
|
+
applications,
|
|
340
|
+
consumedUnitsByLine: consumedUnits,
|
|
341
|
+
consumedCentsByLine,
|
|
342
|
+
groupCents: sumValues(consumedCentsByLine),
|
|
343
|
+
rewardCents,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* R3 for a `COMBO`-scoped rule: the lines a pristine cart would let it touch.
|
|
349
|
+
*
|
|
350
|
+
* Answered by MATCHING rather than by intersecting target lists, because a
|
|
351
|
+
* combo's coverage is not "every line it names" — a cart holding the popcorn
|
|
352
|
+
* and no soda names one of the slots and covers nothing, and reporting it as
|
|
353
|
+
* covered would let it through the screen only to remove zero.
|
|
354
|
+
*/
|
|
355
|
+
export function comboCoveredLineIds(
|
|
356
|
+
rule: DiscountRule,
|
|
357
|
+
lines: readonly DiscountCartLine[],
|
|
358
|
+
): ReadonlySet<string> {
|
|
359
|
+
const match = matchCombo(rule, freshComboPool(lines));
|
|
360
|
+
return match === null ? new Set<string>() : new Set(match.consumedUnitsByLine.keys());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Whether any of a combo's slots would accept this line — the question a menu
|
|
365
|
+
* card asks ("is this item part of a combo?"), which is strictly weaker than
|
|
366
|
+
* whether the combo MATCHES. Used by `./combo-offer.ts`.
|
|
367
|
+
*/
|
|
368
|
+
export function comboRuleAcceptsLine(rule: DiscountRule, line: DiscountCartLine): boolean {
|
|
369
|
+
return prepareSlots(rule.comboRequirements ?? []).some((slot) => slotAccepts(slot, line));
|
|
370
|
+
}
|