@zolve/offers 0.3.0 → 0.3.1
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 +45 -1
- package/dist/assess.d.ts +27 -0
- package/dist/assess.js +99 -0
- package/dist/cart.d.ts +13 -6
- package/dist/cart.js +16 -4
- package/dist/conditions.d.ts +7 -1
- package/dist/conditions.js +21 -4
- package/dist/decide.d.ts +2 -2
- package/dist/decide.js +25 -23
- package/dist/io.d.ts +30 -5
- package/dist/money.d.ts +4 -1
- package/dist/money.js +12 -1
- package/dist/price.d.ts +5 -3
- package/dist/price.js +23 -67
- package/dist/slots.d.ts +15 -0
- package/dist/slots.js +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,9 +16,13 @@ const decision = decide(doc, { placement: 'cart', cart, visitorId, at: new Date(
|
|
|
16
16
|
|
|
17
17
|
// Cobrança: o preço de cada linha aceita (com offerId), conferindo as condições de novo com o carrinho real.
|
|
18
18
|
const priced = price(doc, { cart, visitorId, at });
|
|
19
|
-
// priced.lines[i]: { status: 'ok', unitPrice } | { status: 'ineligible', reason }
|
|
19
|
+
// priced.lines[i]: { status: 'ok', unitPrice, compareAtPrice } | { status: 'ineligible', reason }
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
`unitPrice` é por unidade e vale para a **quantidade toda** da linha (duas unidades da oferta custam duas vezes o
|
|
23
|
+
preço da oferta), como a function de desconto do app sempre cobrou (`appliesToEachItem`). `compareAtPrice` é o preço da
|
|
24
|
+
loja no documento, quando maior que o da oferta (o mesmo do `decide`).
|
|
25
|
+
|
|
22
26
|
`offerKind(doc, offer)` (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
|
|
23
27
|
`cart_has_any` no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
|
|
24
28
|
|
|
@@ -49,6 +53,46 @@ price(doc, { cart, visitorId, assignments, at });
|
|
|
49
53
|
por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. `price` devolve a mesma variante de teste que
|
|
50
54
|
o `decide` deu ao visitante.
|
|
51
55
|
|
|
56
|
+
## Regras de cobrança (0.3.1)
|
|
57
|
+
|
|
58
|
+
- **Grupo de fora geral** (ADR-0047): ligado, quem cai nele não vê oferta. O `decide` devolve `offers: []` e
|
|
59
|
+
`holdout: true` (a ponte manda o `exposed` do controle geral); o `price` recusa as linhas dele com `holdout`. Sem
|
|
60
|
+
`visitorId`, `holdout` é sempre `false`.
|
|
61
|
+
- **Só o que a cliente escolheu** é gatilho, subtotal, vaga extra e combo, no `decide` e na reconferência do `price`:
|
|
62
|
+
linha de oferta aceita não sustenta outra. A exceção é a **linha de troca** (`offerId` + `replacedItem` de uma oferta
|
|
63
|
+
`replace` do documento, item trocado do mesmo produto): é o que a cliente leva no lugar do que escolheu e conta como
|
|
64
|
+
escolhida para as **outras** ofertas. Para a própria troca vale o item trocado, nunca a linha dela (não se sustenta
|
|
65
|
+
com o próprio preço). O `replacedItem` fora de `doc.items` é reconhecido pelo `storeProductId` da linha (a troca é no
|
|
66
|
+
mesmo produto da loja). Só conta como escolhida a troca cuja oferta `replace` está ligada (com a variante do
|
|
67
|
+
teste do visitante), dentro da validade no `at` e de tipo que o pacote conhece; senão é linha de oferta comum e não
|
|
68
|
+
sustenta outra oferta.
|
|
69
|
+
- **Vagas na cobrança**: o `price` não cobra mais ofertas por posição do que as vagas (com a extra), na ordem de
|
|
70
|
+
prioridade; o excesso, e a mesma oferta ou o mesmo item numa segunda linha, volta `no_slot`.
|
|
71
|
+
- **Linha de oferta é toda linha com `offerId`, brinde (`gift: true`) ou não**: ocupa a vaga da posição dela no
|
|
72
|
+
`decide` e disputa a vaga no `price`. O `gift` só a tira do gatilho e do subtotal.
|
|
73
|
+
- **Posição de cada linha de oferta**, uma regra só para o `decide` (vagas já tomadas) e o `price` (cobrança): vale o
|
|
74
|
+
`placement` da linha quando a oferta tem aquele id naquela posição; sem ele (ou com outro), a primeira posição, na
|
|
75
|
+
ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga no
|
|
76
|
+
`decide` (a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga.
|
|
77
|
+
- **Vitrine e cobrança concordam**: para o mesmo documento e carrinho, toda oferta que o `decide` mostra e a cliente
|
|
78
|
+
aceita sai `ok` no `price`, ao preço mostrado (é um teste de propriedade do pacote). Para isso a linha acrescentada leva o
|
|
79
|
+
`placement` da `DecidedOffer`: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra
|
|
80
|
+
posição.
|
|
81
|
+
- **`visitorId` no `price`**: a ponte manda sempre o mesmo `visitorId` (e as mesmas `assignments`) do `decide`. Sem
|
|
82
|
+
ele o `price` não sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no
|
|
83
|
+
grupo de fora leva a oferta.
|
|
84
|
+
|
|
85
|
+
Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_started`, `expired`, `trigger_missing`,
|
|
86
|
+
`subtotal_out_of_range`, `unsupported_schema` e, desde a 0.3.1:
|
|
87
|
+
|
|
88
|
+
| Motivo | Quando |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `unavailable` | Item oferecido indisponível ou com preço da loja 0 no documento (nunca sai de graça por falta de preço; brinde a R$ 0 é `fixed` 0 com preço da loja) |
|
|
91
|
+
| `holdout` | Visitante no grupo de fora geral |
|
|
92
|
+
| `no_slot` | A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a oferta/item já vale em outra linha |
|
|
93
|
+
| `unsupported_offer` | Condição, preço ou ação de tipo que o pacote não conhece (o `decide` também não mostra) |
|
|
94
|
+
| `invalid_instant` | Oferta com validade e `at` (ou a validade) ilegível |
|
|
95
|
+
|
|
52
96
|
Documento com `schemaVersion` maior que `SUPPORTED_SCHEMA_VERSION` não é decidido nem precificado: a resposta vem com
|
|
53
97
|
`status: 'unsupported_schema'` (e toda linha do `price` inelegível com esse motivo). Atualize o pacote.
|
|
54
98
|
|
package/dist/assess.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ItemIndex, type SwapCheck } from './cart.js';
|
|
2
|
+
import type { IneligibleReason, PricedLine, PriceInput } from './io.js';
|
|
3
|
+
import type { SlotClaim } from './slots.js';
|
|
4
|
+
import type { Offer, OfferDocument } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* A avaliação das linhas de oferta do carrinho, a mesma no `decide` (as vagas já tomadas) e no `price` (a cobrança).
|
|
7
|
+
* O `input` é só o que as duas chamadas têm em comum: carrinho, instante, visitante e atribuições.
|
|
8
|
+
*/
|
|
9
|
+
export interface Assessor {
|
|
10
|
+
doc: OfferDocument;
|
|
11
|
+
items: ItemIndex;
|
|
12
|
+
input: PriceInput;
|
|
13
|
+
isSwap: SwapCheck;
|
|
14
|
+
}
|
|
15
|
+
/** Uma linha de oferta avaliada: a posição a que ela foi atribuída e, se passou, a oferta que disputa a vaga. */
|
|
16
|
+
export interface Assessed {
|
|
17
|
+
priced: PricedLine;
|
|
18
|
+
claim: SlotClaim | null;
|
|
19
|
+
/** Nula só para o id que o documento não tem. */
|
|
20
|
+
placement: string | null;
|
|
21
|
+
}
|
|
22
|
+
export declare const refused: (index: number, offerId: string, reason: IneligibleReason) => PricedLine;
|
|
23
|
+
/** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
|
|
24
|
+
export declare function offerGate(offer: Offer, at: string): IneligibleReason | null;
|
|
25
|
+
export declare function assessor(doc: OfferDocument, items: ItemIndex, input: PriceInput): Assessor;
|
|
26
|
+
/** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
|
|
27
|
+
export declare function assessOfferLines(ctx: Assessor): Assessed[];
|
package/dist/assess.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isSameProductSwap, itemKey, replacedLine, viewCart } from './cart.js';
|
|
2
|
+
import { conditionsFailure, isSupportedOffer, validityFailure } from './conditions.js';
|
|
3
|
+
import { applyVariant } from './effective.js';
|
|
4
|
+
import { assignExperiment } from './experiments.js';
|
|
5
|
+
import { compareAtPrice, offerUnitPrice } from './money.js';
|
|
6
|
+
export const refused = (index, offerId, reason) => ({ index, offerId, status: 'ineligible', reason });
|
|
7
|
+
/** A oferta do documento como o visitante a vê: com a variante do teste da posição dela. */
|
|
8
|
+
function liveOffer(doc, found, input) {
|
|
9
|
+
return applyVariant(found, assignExperiment(doc, found.placement, input)?.variant ?? null);
|
|
10
|
+
}
|
|
11
|
+
/** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
|
|
12
|
+
export function offerGate(offer, at) {
|
|
13
|
+
if (!isSupportedOffer(offer))
|
|
14
|
+
return 'unsupported_offer';
|
|
15
|
+
if (!offer.active)
|
|
16
|
+
return 'inactive';
|
|
17
|
+
return validityFailure(offer, at);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Linha de troca: uma oferta `replace` do documento com aquele id e aquele item, que passa no portão (com a variante
|
|
21
|
+
* do visitante e o `at`), e o item trocado do mesmo produto. Forjar os campos só rende o que o caminho legítimo daria;
|
|
22
|
+
* a troca desligada, vencida ou de tipo desconhecido é linha de oferta comum, e não sustenta outra oferta.
|
|
23
|
+
*/
|
|
24
|
+
function swapCheck(doc, items, input) {
|
|
25
|
+
const isLiveReplace = (offer, line) => offer.id === line.offerId && offer.action === 'replace' && itemKey(offer.item) === itemKey(line.item) && offerGate(liveOffer(doc, offer, input), input.at) === null;
|
|
26
|
+
return (line) => line.offerId !== undefined && isSameProductSwap(items, line) && doc.offers.some((offer) => isLiveReplace(offer, line));
|
|
27
|
+
}
|
|
28
|
+
export function assessor(doc, items, input) {
|
|
29
|
+
return { doc, items, input, isSwap: swapCheck(doc, items, input) };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* O carrinho como no instante em que a oferta apareceu: só o que a cliente escolheu (com as linhas de troca), sem a
|
|
33
|
+
* própria linha e, no `replace`, com o item trocado de volta no lugar. Nulo quando o `replace` não diz o que trocou,
|
|
34
|
+
* ou diz um item que não é o mesmo produto.
|
|
35
|
+
*/
|
|
36
|
+
function recheckView(ctx, offer, index) {
|
|
37
|
+
const line = ctx.input.cart[index];
|
|
38
|
+
const others = ctx.input.cart.filter((_, position) => position !== index);
|
|
39
|
+
if (offer.action === 'add')
|
|
40
|
+
return viewCart(ctx.items, others, ctx.isSwap);
|
|
41
|
+
const replaced = replacedLine(line);
|
|
42
|
+
if (!replaced || !isSameProductSwap(ctx.items, line))
|
|
43
|
+
return null;
|
|
44
|
+
return viewCart(ctx.items, [...others, replaced], ctx.isSwap);
|
|
45
|
+
}
|
|
46
|
+
function lineFailure(ctx, offer, index) {
|
|
47
|
+
const line = ctx.input.cart[index];
|
|
48
|
+
if (!isSupportedOffer(offer))
|
|
49
|
+
return 'unsupported_offer';
|
|
50
|
+
if (!offer.active)
|
|
51
|
+
return 'inactive';
|
|
52
|
+
const item = ctx.items.get(itemKey(offer.item));
|
|
53
|
+
if (!item)
|
|
54
|
+
return 'unknown_offer';
|
|
55
|
+
if (itemKey(line.item) !== itemKey(offer.item))
|
|
56
|
+
return 'item_mismatch';
|
|
57
|
+
if (!item.available || item.price <= 0)
|
|
58
|
+
return 'unavailable';
|
|
59
|
+
const validity = validityFailure(offer, ctx.input.at);
|
|
60
|
+
if (validity)
|
|
61
|
+
return validity;
|
|
62
|
+
const view = recheckView(ctx, offer, index);
|
|
63
|
+
if (!view)
|
|
64
|
+
return 'trigger_missing';
|
|
65
|
+
const failure = conditionsFailure(offer.conditions, view, 'price');
|
|
66
|
+
return failure === 'excluded' ? null : failure;
|
|
67
|
+
}
|
|
68
|
+
/** A oferta do documento nesta posição, com a variante do teste do visitante, contra esta linha. */
|
|
69
|
+
function assessOffer(ctx, found, index) {
|
|
70
|
+
const line = ctx.input.cart[index];
|
|
71
|
+
const offerId = found.id;
|
|
72
|
+
const offer = liveOffer(ctx.doc, found, ctx.input);
|
|
73
|
+
const reason = lineFailure(ctx, offer, index);
|
|
74
|
+
if (reason)
|
|
75
|
+
return { priced: refused(index, offerId, reason), claim: null, placement: found.placement };
|
|
76
|
+
const storePrice = ctx.items.get(itemKey(offer.item)).price;
|
|
77
|
+
const unitPrice = Math.min(offerUnitPrice(offer.price, storePrice), line.unitPrice ?? storePrice);
|
|
78
|
+
const priced = { index, offerId, status: 'ok', unitPrice, compareAtPrice: compareAtPrice(storePrice, unitPrice) };
|
|
79
|
+
return { priced, claim: { index, offer, order: ctx.doc.offers.indexOf(found) }, placement: found.placement };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A atribuição de posição de uma linha de oferta, a única do pacote. O id repetido em duas posições (oferta migrada do
|
|
83
|
+
* app) desempata pela posição da linha. Sem ela (ou com uma que não tem o id), vale a primeira posição, na ordem do
|
|
84
|
+
* documento, em que a oferta é elegível; nenhuma elegível, a primeira. Id que o documento não tem não fica em posição.
|
|
85
|
+
*/
|
|
86
|
+
function assessLine(ctx, line, index) {
|
|
87
|
+
const offerId = line.offerId;
|
|
88
|
+
const same = ctx.doc.offers.filter((offer) => offer.id === offerId);
|
|
89
|
+
const declared = same.filter((offer) => offer.placement === line.placement);
|
|
90
|
+
const options = declared.length > 0 ? declared : same;
|
|
91
|
+
if (options.length === 0)
|
|
92
|
+
return { priced: refused(index, offerId, 'unknown_offer'), claim: null, placement: null };
|
|
93
|
+
const results = options.map((offer) => assessOffer(ctx, offer, index));
|
|
94
|
+
return results.find((result) => result.claim !== null) ?? results[0];
|
|
95
|
+
}
|
|
96
|
+
/** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
|
|
97
|
+
export function assessOfferLines(ctx) {
|
|
98
|
+
return ctx.input.cart.flatMap((line, index) => (line.offerId ? [assessLine(ctx, line, index)] : []));
|
|
99
|
+
}
|
package/dist/cart.d.ts
CHANGED
|
@@ -10,21 +10,28 @@ export interface ResolvedLine {
|
|
|
10
10
|
durationDays: number | null;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* O carrinho do ponto de vista do motor. O gatilho
|
|
14
|
-
* fora, senão a oferta aceita vira gatilho da próxima ("a fila que não acaba", app ADR 0006)
|
|
15
|
-
*
|
|
13
|
+
* O carrinho do ponto de vista do motor. O gatilho e o subtotal são só o que a cliente escolheu: brinde e linha de
|
|
14
|
+
* oferta aceita ficam fora, senão a oferta aceita vira gatilho da próxima ("a fila que não acaba", app ADR 0006) ou
|
|
15
|
+
* sustenta o subtotal da outra. A exceção é a **linha de troca** (`replace`): ela é o produto que a cliente leva no
|
|
16
|
+
* lugar do que escolheu, e conta como escolhida para as outras ofertas. `keys` é o carrinho inteiro: não se oferece o
|
|
17
|
+
* que ela já tem, venha de onde vier.
|
|
16
18
|
*/
|
|
17
19
|
export interface CartView {
|
|
18
20
|
chosen: ResolvedLine[];
|
|
19
|
-
accepted: ResolvedLine[];
|
|
20
21
|
keys: Set<string>;
|
|
21
|
-
/** Mercadoria sem brinde
|
|
22
|
+
/** Mercadoria escolhida (as linhas de `chosen`), sem brinde nem linha de oferta. */
|
|
22
23
|
subtotal: Cents;
|
|
23
24
|
}
|
|
24
25
|
export type ItemIndex = Map<string, OfferItem>;
|
|
25
26
|
export declare function indexItems(doc: OfferDocument): ItemIndex;
|
|
26
27
|
export declare function resolveLine(items: ItemIndex, line: CartLine): ResolvedLine;
|
|
27
|
-
|
|
28
|
+
/** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
|
|
29
|
+
export declare function replacedLine(line: CartLine): CartLine | null;
|
|
30
|
+
/** A linha diz que trocou um item do mesmo produto pelo ofertado. Só a forma: se a troca ainda vale é o `price` que diz. */
|
|
31
|
+
export declare function isSameProductSwap(items: ItemIndex, line: CartLine): boolean;
|
|
32
|
+
/** A linha é de troca (`assess.ts`): conta como escolhida. */
|
|
33
|
+
export type SwapCheck = (line: CartLine) => boolean;
|
|
34
|
+
export declare function viewCart(items: ItemIndex, lines: CartLine[], isSwap: SwapCheck): CartView;
|
|
28
35
|
export declare function lineMatches(match: ProductMatch, line: ResolvedLine): boolean;
|
|
29
36
|
export declare function anyLineMatches(match: ProductMatch, lines: ResolvedLine[]): boolean;
|
|
30
37
|
/**
|
package/dist/cart.js
CHANGED
|
@@ -17,13 +17,25 @@ function lineTotal(items, line) {
|
|
|
17
17
|
const unit = line.unitPrice ?? items.get(itemKey(line.item))?.price ?? 0;
|
|
18
18
|
return unit * line.quantity;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
/** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
|
|
21
|
+
export function replacedLine(line) {
|
|
22
|
+
if (!line.replacedItem)
|
|
23
|
+
return null;
|
|
24
|
+
const product = line.storeProductId === undefined ? {} : { storeProductId: line.storeProductId };
|
|
25
|
+
return { item: line.replacedItem, quantity: line.quantity, ...product };
|
|
26
|
+
}
|
|
27
|
+
/** A linha diz que trocou um item do mesmo produto pelo ofertado. Só a forma: se a troca ainda vale é o `price` que diz. */
|
|
28
|
+
export function isSameProductSwap(items, line) {
|
|
29
|
+
const replaced = replacedLine(line);
|
|
30
|
+
return replaced !== null && sameProduct(resolveLine(items, replaced), resolveLine(items, line));
|
|
31
|
+
}
|
|
32
|
+
export function viewCart(items, lines, isSwap) {
|
|
21
33
|
const resolved = lines.map((line) => resolveLine(items, line));
|
|
34
|
+
const chosen = resolved.filter(({ line }) => !line.gift && (!line.offerId || isSwap(line)));
|
|
22
35
|
return {
|
|
23
|
-
chosen
|
|
24
|
-
accepted: resolved.filter(({ line }) => !line.gift && line.offerId),
|
|
36
|
+
chosen,
|
|
25
37
|
keys: new Set(resolved.map(({ key }) => key)),
|
|
26
|
-
subtotal:
|
|
38
|
+
subtotal: chosen.reduce((sum, { line }) => sum + lineTotal(items, line), 0),
|
|
27
39
|
};
|
|
28
40
|
}
|
|
29
41
|
export function lineMatches(match, line) {
|
package/dist/conditions.d.ts
CHANGED
|
@@ -3,7 +3,13 @@ import type { IneligibleReason } from './io.js';
|
|
|
3
3
|
import type { Offer, OfferCondition } from './types.js';
|
|
4
4
|
/** `excluded` só existe no `decide`: o "produto que esconde" esconde a oferta, mas não desfaz a já aceita (app ADR 0025). */
|
|
5
5
|
export type GateFailure = IneligibleReason | 'excluded';
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* O pacote conhece todos os tipos da oferta (condições, preço, ação). Documento publicado para um pacote mais novo
|
|
8
|
+
* pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isSupportedOffer(offer: Offer): boolean;
|
|
11
|
+
/** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
|
|
12
|
+
export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | 'invalid_instant' | null;
|
|
7
13
|
/**
|
|
8
14
|
* Toda oferta tem uma condição implícita: a cliente escolheu alguma coisa. A oferta "sempre" só aparece porque há
|
|
9
15
|
* carrinho; sozinha (ou só com brinde) ela é o promocional se sustentando em nada (app, `cart-bump-orphan.test.js`).
|
package/dist/conditions.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { anyLineMatches } from './cart.js';
|
|
2
|
+
import { isKnownPrice } from './money.js';
|
|
3
|
+
const CONDITION_TYPES = new Set(['cart_has_any', 'cart_has_none', 'subtotal_between']);
|
|
4
|
+
const ACTIONS = new Set(['add', 'replace']);
|
|
5
|
+
/**
|
|
6
|
+
* O pacote conhece todos os tipos da oferta (condições, preço, ação). Documento publicado para um pacote mais novo
|
|
7
|
+
* pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
|
|
8
|
+
*/
|
|
9
|
+
export function isSupportedOffer(offer) {
|
|
10
|
+
return isKnownPrice(offer.price) && ACTIONS.has(offer.action) && offer.conditions.every((condition) => CONDITION_TYPES.has(condition.type));
|
|
11
|
+
}
|
|
12
|
+
/** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
|
|
2
13
|
export function validityFailure(offer, at) {
|
|
14
|
+
if (offer.validFrom === undefined && offer.validUntil === undefined)
|
|
15
|
+
return null;
|
|
3
16
|
const now = Date.parse(at);
|
|
4
|
-
|
|
17
|
+
const from = offer.validFrom === undefined ? -Infinity : Date.parse(offer.validFrom);
|
|
18
|
+
const until = offer.validUntil === undefined ? Infinity : Date.parse(offer.validUntil);
|
|
19
|
+
if ([now, from, until].some(Number.isNaN))
|
|
20
|
+
return 'invalid_instant';
|
|
21
|
+
if (now < from)
|
|
5
22
|
return 'not_started';
|
|
6
|
-
|
|
7
|
-
return 'expired';
|
|
8
|
-
return null;
|
|
23
|
+
return now >= until ? 'expired' : null;
|
|
9
24
|
}
|
|
10
25
|
function conditionFailure(condition, view, mode) {
|
|
11
26
|
if (condition.type === 'cart_has_any')
|
|
@@ -13,6 +28,8 @@ function conditionFailure(condition, view, mode) {
|
|
|
13
28
|
if (condition.type === 'cart_has_none') {
|
|
14
29
|
return mode === 'price' || !anyLineMatches(condition.match, view.chosen) ? null : 'excluded';
|
|
15
30
|
}
|
|
31
|
+
if (condition.type !== 'subtotal_between')
|
|
32
|
+
return 'unsupported_offer';
|
|
16
33
|
const { min, max } = condition;
|
|
17
34
|
const inRange = (min === undefined || view.subtotal >= min) && (max === undefined || view.subtotal <= max);
|
|
18
35
|
return inRange ? null : 'subtotal_out_of_range';
|
package/dist/decide.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Decision, DecideInput } from './io.js';
|
|
|
2
2
|
import type { OfferDocument } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* As ofertas que valem para esta posição e este carrinho, com preço, tipo calculado e o teste sorteado. Respeita
|
|
5
|
-
* condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade
|
|
6
|
-
* grupo de fora geral. Determinística: não lê relógio nem sorteia ao acaso.
|
|
5
|
+
* condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade e um teste por posição.
|
|
6
|
+
* Quem cai no grupo de fora geral (ADR-0047) não vê oferta nenhuma. Determinística: não lê relógio nem sorteia ao acaso.
|
|
7
7
|
*/
|
|
8
8
|
export declare function decide(doc: OfferDocument, input: DecideInput): Decision;
|
package/dist/decide.js
CHANGED
|
@@ -1,33 +1,29 @@
|
|
|
1
|
+
import { assessOfferLines, assessor, offerGate } from './assess.js';
|
|
1
2
|
import { anyLineMatches, indexItems, itemKey, resolveLine, viewCart } from './cart.js';
|
|
2
|
-
import { conditionsFailure
|
|
3
|
+
import { conditionsFailure } from './conditions.js';
|
|
3
4
|
import { placementOffers } from './effective.js';
|
|
4
|
-
import { assignExperiment } from './experiments.js';
|
|
5
|
+
import { assignExperiment, inHoldout } from './experiments.js';
|
|
5
6
|
import { classify } from './kind.js';
|
|
6
7
|
import { compareAtPrice, offerUnitPrice, pricePerDay } from './money.js';
|
|
7
8
|
import { isSupportedSchema } from './schema-version.js';
|
|
8
9
|
import { sha1Hex } from './sha1.js';
|
|
9
|
-
|
|
10
|
-
function slotsFor(placement, view) {
|
|
11
|
-
const extra = placement.extraSlot && anyLineMatches(placement.extraSlot.when, view.chosen) ? 1 : 0;
|
|
12
|
-
return placement.slots + extra;
|
|
13
|
-
}
|
|
14
|
-
/** A posição de onde veio uma linha aceita: a que ela declara, ou a da oferta com aquele id. */
|
|
15
|
-
function acceptedPlacement(doc, line) {
|
|
16
|
-
return line.placement ?? doc.offers.find((offer) => offer.id === line.offerId)?.placement;
|
|
17
|
-
}
|
|
10
|
+
import { slotsFor } from './slots.js';
|
|
18
11
|
/**
|
|
19
|
-
* Vagas abertas: as do carrinho menos as
|
|
20
|
-
* nascer no lugar é a fila que não acaba; com duas vagas e uma aceita,
|
|
12
|
+
* Vagas abertas: as do carrinho menos as linhas de oferta atribuídas a esta posição (app ADR 0017), pela mesma regra
|
|
13
|
+
* do `price` (`assess.ts`). Aceitar uma e ver outra nascer no lugar é a fila que não acaba; com duas vagas e uma aceita,
|
|
14
|
+
* fica a que já estava ao lado. A linha que o `price` recusa ainda ocupa a vaga: a ponte pode cobrá-la cheia.
|
|
21
15
|
*/
|
|
22
16
|
function openSlots(ctx) {
|
|
23
|
-
const
|
|
24
|
-
return Math.max(0, slotsFor(ctx.placement, ctx.view) -
|
|
17
|
+
const taken = ctx.accepted.filter((assessed) => assessed.placement === ctx.placement.key).length;
|
|
18
|
+
return Math.max(0, slotsFor(ctx.placement, ctx.view) - taken);
|
|
25
19
|
}
|
|
20
|
+
/** Item vendável pela oferta: disponível e com preço da loja (sem ele a compilação grava 0, e a oferta sairia de graça). */
|
|
21
|
+
const sellable = (item) => item !== undefined && item.available && item.price > 0;
|
|
26
22
|
function candidate(ctx, offer) {
|
|
27
|
-
if (
|
|
23
|
+
if (offerGate(offer, ctx.at))
|
|
28
24
|
return null;
|
|
29
25
|
const item = ctx.items.get(itemKey(offer.item));
|
|
30
|
-
if (!item
|
|
26
|
+
if (!sellable(item) || ctx.view.keys.has(itemKey(offer.item)))
|
|
31
27
|
return null;
|
|
32
28
|
if (conditionsFailure(offer.conditions, ctx.view, 'decide'))
|
|
33
29
|
return null;
|
|
@@ -95,26 +91,31 @@ function placementPresentation(ctx) {
|
|
|
95
91
|
}
|
|
96
92
|
/**
|
|
97
93
|
* Posição desconhecida responde vazio (a ponte ainda manda o `exposed`), nunca erro. Documento de formato mais novo
|
|
98
|
-
* também responde vazio, com `status: 'unsupported_schema'`, para a ponte saber que precisa atualizar o pacote.
|
|
94
|
+
* também responde vazio, com `status: 'unsupported_schema'`, para a ponte saber que precisa atualizar o pacote. O
|
|
95
|
+
* grupo de fora geral responde vazio com `holdout: true`.
|
|
99
96
|
*/
|
|
100
|
-
function emptyDecision(doc, input, status) {
|
|
97
|
+
function emptyDecision(doc, input, status, holdout = false) {
|
|
101
98
|
const decisionId = decisionIdOf(input, doc.version, [], []);
|
|
102
|
-
return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [] };
|
|
99
|
+
return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [], holdout };
|
|
103
100
|
}
|
|
104
101
|
/**
|
|
105
102
|
* As ofertas que valem para esta posição e este carrinho, com preço, tipo calculado e o teste sorteado. Respeita
|
|
106
|
-
* condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade
|
|
107
|
-
* grupo de fora geral. Determinística: não lê relógio nem sorteia ao acaso.
|
|
103
|
+
* condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade e um teste por posição.
|
|
104
|
+
* Quem cai no grupo de fora geral (ADR-0047) não vê oferta nenhuma. Determinística: não lê relógio nem sorteia ao acaso.
|
|
108
105
|
*/
|
|
109
106
|
export function decide(doc, input) {
|
|
110
107
|
if (!isSupportedSchema(doc.schemaVersion))
|
|
111
108
|
return emptyDecision(doc, input, 'unsupported_schema');
|
|
109
|
+
if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
|
|
110
|
+
return emptyDecision(doc, input, 'ok', true);
|
|
112
111
|
const placement = doc.placements.find((candidatePlacement) => candidatePlacement.key === input.placement);
|
|
113
112
|
if (!placement)
|
|
114
113
|
return emptyDecision(doc, input, 'ok');
|
|
115
114
|
const assignment = assignExperiment(doc, placement.key, input);
|
|
116
115
|
const items = indexItems(doc);
|
|
117
|
-
const
|
|
116
|
+
const assess = assessor(doc, items, input);
|
|
117
|
+
const view = viewCart(items, input.cart, assess.isSwap);
|
|
118
|
+
const ctx = { doc, items, view, accepted: assessOfferLines(assess), placement, variant: assignment?.variant ?? null, at: input.at };
|
|
118
119
|
const picks = pickOffers(ctx);
|
|
119
120
|
const experiments = assignment ? [{ key: assignment.experiment.key, variant: assignment.variant.key }] : [];
|
|
120
121
|
const decisionId = decisionIdOf(input, doc.version, picks.map((pick) => pick.offer.id), experiments);
|
|
@@ -127,5 +128,6 @@ export function decide(doc, input) {
|
|
|
127
128
|
presentation: placementPresentation(ctx),
|
|
128
129
|
grouping: groupingFor(ctx),
|
|
129
130
|
experiments,
|
|
131
|
+
holdout: false,
|
|
130
132
|
};
|
|
131
133
|
}
|
package/dist/io.d.ts
CHANGED
|
@@ -5,15 +5,22 @@ export interface CartLine {
|
|
|
5
5
|
quantity: number;
|
|
6
6
|
/** O que a loja cobra por unidade agora; sem ele vale o preço do item no documento (0 se ele não estiver lá). */
|
|
7
7
|
unitPrice?: Cents;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Produto da loja da linha, para o item que o documento não lista (o produto que a cliente escolheu). Na linha de
|
|
10
|
+
* troca, vale também para o `replacedItem` fora do documento: a troca é dentro do mesmo produto.
|
|
11
|
+
*/
|
|
9
12
|
storeProductId?: string;
|
|
10
13
|
/** Linha de oferta aceita: leva o `offerId` até o pedido (ADR-0083 §1, princípio 4). Não é gatilho de outra oferta. */
|
|
11
14
|
offerId?: string;
|
|
12
|
-
/** A posição de onde a oferta aceita veio; desempata o id repetido em duas posições. */
|
|
15
|
+
/** A posição de onde a oferta aceita veio (o `placement` da `DecidedOffer`); desempata o id repetido em duas posições. */
|
|
13
16
|
placement?: string;
|
|
14
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Na ação `replace`: o item que a oferta trocou. `price()` confere a condição da própria troca como se ele ainda
|
|
19
|
+
* estivesse lá; para as outras ofertas, a linha de troca conta como o produto que a cliente leva (gatilho, subtotal,
|
|
20
|
+
* vaga extra, combo).
|
|
21
|
+
*/
|
|
15
22
|
replacedItem?: ItemRef;
|
|
16
|
-
/** Brinde: não é escolha da cliente nem entra no subtotal. */
|
|
23
|
+
/** Brinde: não é escolha da cliente nem entra no subtotal. Com `offerId`, segue linha de oferta e ocupa a vaga. */
|
|
17
24
|
gift?: boolean;
|
|
18
25
|
}
|
|
19
26
|
export interface DecideInput {
|
|
@@ -69,16 +76,34 @@ export interface Decision {
|
|
|
69
76
|
/** Apresentação de grupo (combo) quando vale para este carrinho. */
|
|
70
77
|
grouping: string | null;
|
|
71
78
|
experiments: ExperimentAssignment[];
|
|
79
|
+
/**
|
|
80
|
+
* O visitante caiu no grupo de fora geral (ADR-0047): nenhuma oferta, e a ponte manda o `exposed` do controle geral.
|
|
81
|
+
* Sem `visitorId` é sempre `false`.
|
|
82
|
+
*/
|
|
83
|
+
holdout: boolean;
|
|
72
84
|
}
|
|
73
85
|
export interface PriceInput {
|
|
74
86
|
cart: CartLine[];
|
|
75
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Sempre o mesmo do `decide`: a variante do teste é sorteada de novo por ele, e o grupo de fora geral conferido. Sem
|
|
89
|
+
* ele, nem teste nem grupo de fora valem (cobra o documento sem variante).
|
|
90
|
+
*/
|
|
76
91
|
visitorId?: string;
|
|
77
92
|
/** As mesmas do `decide`: sem elas, o teste congelado cobra o documento sem mudança. */
|
|
78
93
|
assignments?: ExperimentAssignment[];
|
|
79
94
|
at: string;
|
|
80
95
|
}
|
|
81
96
|
export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' | 'not_started' | 'expired' | 'trigger_missing' | 'subtotal_out_of_range'
|
|
97
|
+
/** O item oferecido está indisponível ou sem preço da loja (0) no documento: nunca sai de graça por falta de preço. */
|
|
98
|
+
| 'unavailable'
|
|
99
|
+
/** O visitante está no grupo de fora geral: não leva oferta nenhuma. */
|
|
100
|
+
| 'holdout'
|
|
101
|
+
/** A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a mesma oferta (ou item) em outra linha. */
|
|
102
|
+
| 'no_slot'
|
|
103
|
+
/** Condição, preço ou ação de um tipo que este pacote não conhece: atualize o pacote. */
|
|
104
|
+
| 'unsupported_offer'
|
|
105
|
+
/** O `at` (ou a validade da oferta) não é um instante legível, e a oferta tem validade. */
|
|
106
|
+
| 'invalid_instant'
|
|
82
107
|
/** O documento é de um formato mais novo que o pacote: toda linha volta com este motivo. */
|
|
83
108
|
| 'unsupported_schema';
|
|
84
109
|
export type PricedLine = {
|
package/dist/money.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { Cents, OfferPrice } from './types.js';
|
|
2
|
+
/** O preço é de um tipo que o pacote sabe calcular, com número no campo do valor. */
|
|
3
|
+
export declare function isKnownPrice(price: OfferPrice): boolean;
|
|
2
4
|
/**
|
|
3
5
|
* Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
|
|
4
6
|
* 79,90 = 11,985 → 11,99, cobra 67,91). A oferta é **teto**, nunca acréscimo: o resultado fica entre 0 e o preço da
|
|
5
|
-
* loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis).
|
|
7
|
+
* loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis). Tipo que o pacote não
|
|
8
|
+
* conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
|
|
6
9
|
*/
|
|
7
10
|
export declare function offerUnitPrice(price: OfferPrice, storePrice: Cents): Cents;
|
|
8
11
|
/** O preço "de": o da loja, só quando há o que riscar. */
|
package/dist/money.js
CHANGED
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
function divideHalfUp(numerator, denominator) {
|
|
3
3
|
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
|
4
4
|
}
|
|
5
|
+
/** O preço é de um tipo que o pacote sabe calcular, com número no campo do valor. */
|
|
6
|
+
export function isKnownPrice(price) {
|
|
7
|
+
if (price.type === 'percent_off')
|
|
8
|
+
return Number.isFinite(price.basisPoints);
|
|
9
|
+
if (price.type === 'fixed' || price.type === 'amount_off')
|
|
10
|
+
return Number.isFinite(price.amount);
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
5
13
|
/**
|
|
6
14
|
* Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
|
|
7
15
|
* 79,90 = 11,985 → 11,99, cobra 67,91). A oferta é **teto**, nunca acréscimo: o resultado fica entre 0 e o preço da
|
|
8
|
-
* loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis).
|
|
16
|
+
* loja (preço fixo acima da loja cobra o da loja, desconto maior que o preço vira grátis). Tipo que o pacote não
|
|
17
|
+
* conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
|
|
9
18
|
*/
|
|
10
19
|
export function offerUnitPrice(price, storePrice) {
|
|
20
|
+
if (!isKnownPrice(price))
|
|
21
|
+
return storePrice;
|
|
11
22
|
const raw = price.type === 'fixed'
|
|
12
23
|
? price.amount
|
|
13
24
|
: price.type === 'amount_off'
|
package/dist/price.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ import type { OfferDocument } from './types.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
|
|
5
5
|
* princípio 2, e §6). Linha que perdeu a condição volta `ineligible` com o motivo, e a ponte escolhe remover ou cobrar
|
|
6
|
-
* o preço cheio.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* o preço cheio. A reconferência usa só o que a cliente escolheu (linha de oferta não sustenta outra; a de troca conta
|
|
7
|
+
* como o produto que ela leva) e as vagas da posição, na ordem de prioridade. Documento de formato mais novo que o
|
|
8
|
+
* pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
|
|
9
|
+
* variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
|
|
10
|
+
* "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
|
|
9
11
|
*/
|
|
10
12
|
export declare function price(doc: OfferDocument, input: PriceInput): PriceResult;
|
package/dist/price.js
CHANGED
|
@@ -1,77 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { assignExperiment } from './experiments.js';
|
|
5
|
-
import { compareAtPrice, offerUnitPrice } from './money.js';
|
|
1
|
+
import { assessOfferLines, assessor, refused } from './assess.js';
|
|
2
|
+
import { indexItems, viewCart } from './cart.js';
|
|
3
|
+
import { inHoldout } from './experiments.js';
|
|
6
4
|
import { isSupportedSchema } from './schema-version.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import { slotLosers } from './slots.js';
|
|
6
|
+
/** As linhas que passaram disputam as vagas da posição; quem fica de fora volta `no_slot`. */
|
|
7
|
+
function withSlots(ctx, assessed) {
|
|
8
|
+
const view = viewCart(ctx.items, ctx.input.cart, ctx.isSwap);
|
|
9
|
+
const claims = assessed.flatMap(({ claim }) => (claim ? [claim] : []));
|
|
10
|
+
const losers = slotLosers(ctx.doc, view, claims);
|
|
11
|
+
return assessed.map(({ priced }) => (losers.has(priced.index) ? refused(priced.index, priced.offerId, 'no_slot') : priced));
|
|
11
12
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* sustenta sozinha, o preço dela empurra o subtotal que ela exige) e, no `replace`, com o item trocado de volta no
|
|
15
|
-
* lugar. Nulo quando o `replace` não diz o que trocou, ou diz um item que não é o mesmo produto.
|
|
16
|
-
*/
|
|
17
|
-
function recheckView(ctx, offer, index) {
|
|
18
|
-
const line = ctx.input.cart[index];
|
|
19
|
-
const others = ctx.input.cart.filter((_, position) => position !== index);
|
|
20
|
-
if (offer.action === 'add')
|
|
21
|
-
return viewCart(ctx.items, others);
|
|
22
|
-
if (!line.replacedItem)
|
|
23
|
-
return null;
|
|
24
|
-
const replaced = { item: line.replacedItem, quantity: line.quantity };
|
|
25
|
-
if (!sameProduct(resolveLine(ctx.items, replaced), resolveLine(ctx.items, line)))
|
|
26
|
-
return null;
|
|
27
|
-
return viewCart(ctx.items, [...others, replaced]);
|
|
28
|
-
}
|
|
29
|
-
function lineFailure(ctx, offer, index) {
|
|
30
|
-
const line = ctx.input.cart[index];
|
|
31
|
-
if (!offer.active)
|
|
32
|
-
return 'inactive';
|
|
33
|
-
if (!ctx.items.has(itemKey(offer.item)))
|
|
34
|
-
return 'unknown_offer';
|
|
35
|
-
if (itemKey(line.item) !== itemKey(offer.item))
|
|
36
|
-
return 'item_mismatch';
|
|
37
|
-
const validity = validityFailure(offer, ctx.input.at);
|
|
38
|
-
if (validity)
|
|
39
|
-
return validity;
|
|
40
|
-
const view = recheckView(ctx, offer, index);
|
|
41
|
-
if (!view)
|
|
42
|
-
return 'trigger_missing';
|
|
43
|
-
const failure = conditionsFailure(offer.conditions, view, 'price');
|
|
44
|
-
return failure === 'excluded' ? null : failure;
|
|
45
|
-
}
|
|
46
|
-
function priceLine(ctx, line, index) {
|
|
47
|
-
const offerId = line.offerId;
|
|
48
|
-
const found = findOffer(ctx.doc, line);
|
|
49
|
-
if (!found)
|
|
50
|
-
return { index, offerId, status: 'ineligible', reason: 'unknown_offer' };
|
|
51
|
-
const assignment = assignExperiment(ctx.doc, found.placement, ctx.input);
|
|
52
|
-
const offer = applyVariant(found, assignment?.variant ?? null);
|
|
53
|
-
const reason = lineFailure(ctx, offer, index);
|
|
54
|
-
if (reason)
|
|
55
|
-
return { index, offerId, status: 'ineligible', reason };
|
|
56
|
-
const storePrice = ctx.items.get(itemKey(offer.item)).price;
|
|
57
|
-
const charged = line.unitPrice ?? storePrice;
|
|
58
|
-
const unitPrice = Math.min(offerUnitPrice(offer.price, storePrice), charged);
|
|
59
|
-
return { index, offerId, status: 'ok', unitPrice, compareAtPrice: compareAtPrice(charged, unitPrice) };
|
|
13
|
+
function refuseAll(cart, reason) {
|
|
14
|
+
return cart.flatMap((line, index) => (line.offerId ? [refused(index, line.offerId, reason)] : []));
|
|
60
15
|
}
|
|
61
16
|
/**
|
|
62
17
|
* O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
|
|
63
18
|
* princípio 2, e §6). Linha que perdeu a condição volta `ineligible` com o motivo, e a ponte escolhe remover ou cobrar
|
|
64
|
-
* o preço cheio.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
19
|
+
* o preço cheio. A reconferência usa só o que a cliente escolheu (linha de oferta não sustenta outra; a de troca conta
|
|
20
|
+
* como o produto que ela leva) e as vagas da posição, na ordem de prioridade. Documento de formato mais novo que o
|
|
21
|
+
* pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
|
|
22
|
+
* variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
|
|
23
|
+
* "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
|
|
67
24
|
*/
|
|
68
25
|
export function price(doc, input) {
|
|
69
26
|
const head = { version: doc.version, currency: doc.currency };
|
|
70
|
-
if (!isSupportedSchema(doc.schemaVersion))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const ctx =
|
|
75
|
-
|
|
76
|
-
return { status: 'ok', ...head, lines };
|
|
27
|
+
if (!isSupportedSchema(doc.schemaVersion))
|
|
28
|
+
return { status: 'unsupported_schema', ...head, lines: refuseAll(input.cart, 'unsupported_schema') };
|
|
29
|
+
if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
|
|
30
|
+
return { status: 'ok', ...head, lines: refuseAll(input.cart, 'holdout') };
|
|
31
|
+
const ctx = assessor(doc, indexItems(doc), input);
|
|
32
|
+
return { status: 'ok', ...head, lines: withSlots(ctx, assessOfferLines(ctx)) };
|
|
77
33
|
}
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type CartView } from './cart.js';
|
|
2
|
+
import type { Offer, OfferDocument, Placement } from './types.js';
|
|
3
|
+
/** Vagas deste carrinho: as da posição, mais a extra quando o carrinho escolhido casa (a 2ª vaga presa a produtos). */
|
|
4
|
+
export declare function slotsFor(placement: Placement, view: CartView): number;
|
|
5
|
+
/** Uma linha de oferta que passou em tudo e pede vaga: a posição no carrinho, a oferta e a ordem dela no documento. */
|
|
6
|
+
export interface SlotClaim {
|
|
7
|
+
index: number;
|
|
8
|
+
offer: Offer;
|
|
9
|
+
order: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* As linhas que ficam sem vaga, a regra do `decide` na cobrança: por posição, na ordem de prioridade, até as vagas do
|
|
13
|
+
* carrinho (com a extra); a mesma oferta ou o mesmo item numa segunda linha não ganha outra vaga.
|
|
14
|
+
*/
|
|
15
|
+
export declare function slotLosers(doc: OfferDocument, view: CartView, claims: SlotClaim[]): Set<number>;
|
package/dist/slots.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { anyLineMatches, itemKey } from './cart.js';
|
|
2
|
+
/** Vagas deste carrinho: as da posição, mais a extra quando o carrinho escolhido casa (a 2ª vaga presa a produtos). */
|
|
3
|
+
export function slotsFor(placement, view) {
|
|
4
|
+
const extra = placement.extraSlot && anyLineMatches(placement.extraSlot.when, view.chosen) ? 1 : 0;
|
|
5
|
+
return placement.slots + extra;
|
|
6
|
+
}
|
|
7
|
+
const byPriority = (a, b) => a.offer.priority - b.offer.priority || a.order - b.order || a.index - b.index;
|
|
8
|
+
/**
|
|
9
|
+
* As linhas que ficam sem vaga, a regra do `decide` na cobrança: por posição, na ordem de prioridade, até as vagas do
|
|
10
|
+
* carrinho (com a extra); a mesma oferta ou o mesmo item numa segunda linha não ganha outra vaga.
|
|
11
|
+
*/
|
|
12
|
+
export function slotLosers(doc, view, claims) {
|
|
13
|
+
const losers = new Set();
|
|
14
|
+
const used = new Map();
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const claim of [...claims].sort(byPriority)) {
|
|
17
|
+
const key = claim.offer.placement;
|
|
18
|
+
const placement = doc.placements.find((candidate) => candidate.key === key);
|
|
19
|
+
const marks = [`${key}|offer|${claim.offer.id}`, `${key}|item|${itemKey(claim.offer.item)}`];
|
|
20
|
+
const taken = used.get(key) ?? 0;
|
|
21
|
+
if (!placement || taken >= slotsFor(placement, view) || marks.some((mark) => seen.has(mark))) {
|
|
22
|
+
losers.add(claim.index);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
used.set(key, taken + 1);
|
|
26
|
+
marks.forEach((mark) => seen.add(mark));
|
|
27
|
+
}
|
|
28
|
+
return losers;
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zolve/offers",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Motor de ofertas headless (ADR-0083): tipos do documento publicado, decide() e price(), sorteio A/B do PostHog. Sem dependências de runtime, para caber numa Shopify Function",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|