@zolve/offers 0.3.0 → 0.3.2
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 +81 -1
- package/dist/assess.d.ts +33 -0
- package/dist/assess.js +141 -0
- package/dist/cart.d.ts +20 -7
- package/dist/cart.js +47 -10
- package/dist/conditions.d.ts +10 -2
- package/dist/conditions.js +24 -4
- package/dist/decide.d.ts +2 -2
- package/dist/decide.js +47 -39
- package/dist/io.d.ts +51 -9
- package/dist/kind.d.ts +2 -1
- package/dist/kind.js +12 -4
- package/dist/money.d.ts +9 -1
- package/dist/money.js +21 -1
- package/dist/price.d.ts +6 -3
- package/dist/price.js +24 -67
- package/dist/schema-version.d.ts +2 -1
- package/dist/schema-version.js +2 -1
- package/dist/slots.d.ts +15 -0
- package/dist/slots.js +29 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,9 +16,12 @@ 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, discountedQuantity } | { status: 'ineligible', reason }
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
`unitPrice` é por unidade e vale para `discountedQuantity` unidades da linha (0.3.2); o resto da linha vai a preço
|
|
23
|
+
cheio. `compareAtPrice` é o preço da loja no documento, quando maior que o da oferta (o mesmo do `decide`).
|
|
24
|
+
|
|
22
25
|
`offerKind(doc, offer)` (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
|
|
23
26
|
`cart_has_any` no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
|
|
24
27
|
|
|
@@ -49,6 +52,83 @@ price(doc, { cart, visitorId, assignments, at });
|
|
|
49
52
|
por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. `price` devolve a mesma variante de teste que
|
|
50
53
|
o `decide` deu ao visitante.
|
|
51
54
|
|
|
55
|
+
## Regras de cobrança (0.3.1)
|
|
56
|
+
|
|
57
|
+
- **Grupo de fora geral** (ADR-0047): ligado, quem cai nele não vê oferta. O `decide` devolve `offers: []` e
|
|
58
|
+
`holdout: true` (a ponte manda o `exposed` do controle geral); o `price` recusa as linhas dele com `holdout`. Sem
|
|
59
|
+
`visitorId`, `holdout` é sempre `false`.
|
|
60
|
+
- **Só o que a cliente escolheu** é gatilho, subtotal, vaga extra e combo, no `decide` e na reconferência do `price`:
|
|
61
|
+
linha de oferta aceita não sustenta outra. A exceção é a **linha de troca** (`offerId` + `replacedItem` de uma oferta
|
|
62
|
+
`replace` do documento, item trocado do mesmo produto), que desde a 0.3.2 conta como o **item trocado** para todas as
|
|
63
|
+
ofertas (ver abaixo). O `replacedItem` fora de `doc.items` é reconhecido pelo `storeProductId` da linha (a troca é no
|
|
64
|
+
mesmo produto da loja). Só conta como escolhida a troca cuja oferta `replace` está ligada (com a variante do
|
|
65
|
+
teste do visitante), dentro da validade no `at` e de tipo que o pacote conhece; senão é linha de oferta comum e não
|
|
66
|
+
sustenta outra oferta.
|
|
67
|
+
- **Vagas na cobrança**: o `price` não cobra mais ofertas por posição do que as vagas (com a extra), na ordem de
|
|
68
|
+
prioridade; o excesso, e a mesma oferta ou o mesmo item numa segunda linha, volta `no_slot`.
|
|
69
|
+
- **Linha de oferta é toda linha com `offerId`, brinde (`gift: true`) ou não**: ocupa a vaga da posição dela no
|
|
70
|
+
`decide` e disputa a vaga no `price`. O `gift` só a tira do gatilho e do subtotal.
|
|
71
|
+
- **Posição de cada linha de oferta**, uma regra só para o `decide` (vagas já tomadas) e o `price` (cobrança): vale o
|
|
72
|
+
`placement` da linha quando a oferta tem aquele id naquela posição; sem ele (ou com outro), a primeira posição, na
|
|
73
|
+
ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga no
|
|
74
|
+
`decide` (a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga.
|
|
75
|
+
- **Vitrine e cobrança concordam**: para o mesmo documento e carrinho, toda oferta que o `decide` mostra e a cliente
|
|
76
|
+
aceita (e, desde a 0.3.2, toda combinação delas, com troca, vaga extra e combo) sai `ok` no `price`, ao preço
|
|
77
|
+
mostrado (é um teste de propriedade do pacote). Para isso a linha acrescentada leva o
|
|
78
|
+
`placement` da `DecidedOffer`: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra
|
|
79
|
+
posição.
|
|
80
|
+
- **`visitorId` no `price`**: a ponte manda sempre o mesmo `visitorId` (e as mesmas `assignments`) do `decide`. Sem
|
|
81
|
+
ele o `price` não sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no
|
|
82
|
+
grupo de fora leva a oferta.
|
|
83
|
+
|
|
84
|
+
## Um carrinho só e quantidade com teto (0.3.2)
|
|
85
|
+
|
|
86
|
+
- **O carrinho de antes da troca**, no `decide` e no `price`: a linha de troca válida volta a ser o item trocado
|
|
87
|
+
(`replacedItem`), com a quantidade da linha, como linha escolhida, para **tudo**: gatilho, `cart_has_none`, subtotal,
|
|
88
|
+
vaga extra, combo e contagem de vagas, da própria troca e das outras ofertas. A linha de troca em si ocupa a vaga da
|
|
89
|
+
posição dela. Assim o `price` vê exatamente o carrinho que o `decide` viu: a troca LP → 90 não fecha a vaga extra
|
|
90
|
+
presa à LP, o cross-sell com gatilho na Intense 30 segue valendo depois da troca 30 → 90, e a troca de R$ 199 não
|
|
91
|
+
destrava o brinde de R$ 250 (no subtotal entra o item trocado, não o novo). Substitui a regra da 0.3.1 ("a linha de
|
|
92
|
+
troca conta como o produto que a cliente leva").
|
|
93
|
+
- **Subtotal pelo preço do documento**: toda linha escolhida de item listado em `doc.items` entra no subtotal pelo
|
|
94
|
+
preço do documento; o `unitPrice` da linha só vale para item fora dele (0 sem nenhum dos dois). O item trocado já
|
|
95
|
+
saiu do carrinho e o pacote não sabe o preço da linha que sumiu: com o preço do documento, as duas chamadas somam
|
|
96
|
+
igual. Item trocado **fora** do documento conta 0 no subtotal depois da troca (a compilação deve listar os itens que
|
|
97
|
+
uma troca pode tirar). O `unitPrice` segue sendo teto do preço da linha de oferta.
|
|
98
|
+
- **Troca só na posição dela**: a linha é troca quando a oferta `replace` com aquele `offerId` é a da posição da linha
|
|
99
|
+
(o `placement` dela, ou a atribuída pela regra de posição abaixo), ligada, na validade, de tipo conhecido, do mesmo
|
|
100
|
+
item, e o `replacedItem` é do mesmo produto. O mesmo id como `add` em `cart` e `replace` em `product_page`: a linha
|
|
101
|
+
de `cart` com `replacedItem` forjado é `add` e não sustenta outra oferta.
|
|
102
|
+
- **Alvo da troca sem depender da ordem**: com mais de uma linha escolhida do mesmo produto, o `replaces` é a de menor
|
|
103
|
+
duração no catálogo e, no empate (ou sem duração), a de menor chave de item (`source:variantId`). Linha que já é
|
|
104
|
+
troca não é alvo de outra troca.
|
|
105
|
+
- **`maxQuantity`** (campo opcional da oferta, inteiro ≥ 1): quantas unidades da linha levam o preço da oferta.
|
|
106
|
+
Ausente: **1** quando o preço da oferta dá 0 (brinde), a linha toda nos outros casos. O `price` devolve
|
|
107
|
+
`discountedQuantity` por linha `ok`; a ponte aplica o desconto só nessas unidades (na Shopify, o desconto de linha
|
|
108
|
+
com quantidade limitada) e o resto vai a preço cheio. `maxQuantity` que não é inteiro ≥ 1 torna a oferta
|
|
109
|
+
`unsupported_offer`. Como a 0.3.2 cobra diferente da 0.3.1 com o mesmo documento (o brinde passa a valer uma
|
|
110
|
+
unidade mesmo sem o campo), o formato subiu para **`schemaVersion` 2**: uma ponte ainda na 0.3.1 recebe
|
|
111
|
+
`unsupported_schema` e não cobra, em vez de dar o desconto na linha toda.
|
|
112
|
+
- **Linha validada**: `quantity` inteiro ≥ 1 e `unitPrice` (se vier) centavos inteiros ≥ 0. Linha fora disso não é
|
|
113
|
+
gatilho nem entra no subtotal; a linha de oferta volta `invalid_line`. O pacote nunca devolve `NaN`, negativo ou
|
|
114
|
+
fração; preço de oferta com valor que não é inteiro também é `unsupported_offer`.
|
|
115
|
+
- **`decisionId`** = sha1 da versão, posição, visitante, carrinho (cada linha com item, quantidade, `unitPrice`,
|
|
116
|
+
`offerId`, `placement`, `replacedItem`, `storeProductId`, `gift`, sem a ordem) e da **resposta inteira** (cada oferta
|
|
117
|
+
com preço, alvo da troca, conteúdo e vaga; apresentação, combo, teste, `holdout`). Mesma pergunta e mesma resposta =
|
|
118
|
+
mesmo id; resposta diferente, outro id. Os ids mudam em relação à 0.3.1.
|
|
119
|
+
|
|
120
|
+
Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_started`, `expired`, `trigger_missing`,
|
|
121
|
+
`subtotal_out_of_range`, `unsupported_schema` e, desde a 0.3.1:
|
|
122
|
+
|
|
123
|
+
| Motivo | Quando |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `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) |
|
|
126
|
+
| `holdout` | Visitante no grupo de fora geral |
|
|
127
|
+
| `no_slot` | A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a oferta/item já vale em outra linha |
|
|
128
|
+
| `unsupported_offer` | Condição, preço ou ação de tipo que o pacote não conhece (o `decide` também não mostra) |
|
|
129
|
+
| `invalid_instant` | Oferta com validade e `at` (ou a validade) ilegível |
|
|
130
|
+
| `invalid_line` (0.3.2) | Linha com `quantity` que não é inteiro ≥ 1 ou `unitPrice` que não é centavo inteiro ≥ 0 |
|
|
131
|
+
|
|
52
132
|
Documento com `schemaVersion` maior que `SUPPORTED_SCHEMA_VERSION` não é decidido nem precificado: a resposta vem com
|
|
53
133
|
`status: 'unsupported_schema'` (e toda linha do `price` inelegível com esse motivo). Atualize o pacote.
|
|
54
134
|
|
package/dist/assess.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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. `placed` é a oferta
|
|
8
|
+
* do documento a que cada linha de oferta foi atribuída (a regra de posição), e `isSwap` sai dela.
|
|
9
|
+
*/
|
|
10
|
+
export interface Assessor {
|
|
11
|
+
doc: OfferDocument;
|
|
12
|
+
items: ItemIndex;
|
|
13
|
+
input: PriceInput;
|
|
14
|
+
isSwap: SwapCheck;
|
|
15
|
+
placed: Map<number, Offer>;
|
|
16
|
+
}
|
|
17
|
+
/** Uma linha de oferta avaliada: a posição a que ela foi atribuída e, se passou, a oferta que disputa a vaga. */
|
|
18
|
+
export interface Assessed {
|
|
19
|
+
priced: PricedLine;
|
|
20
|
+
claim: SlotClaim | null;
|
|
21
|
+
/** Nula só para o id que o documento não tem. */
|
|
22
|
+
placement: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare const refused: (index: number, offerId: string, reason: IneligibleReason) => PricedLine;
|
|
25
|
+
/** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
|
|
26
|
+
export declare function offerGate(offer: Offer, at: string): IneligibleReason | null;
|
|
27
|
+
/**
|
|
28
|
+
* Duas passadas: primeiro as trocas que a própria linha define (o id numa posição só, ou a posição da linha); com elas,
|
|
29
|
+
* a posição de cada linha de oferta; daí as trocas de fato (`replace` na posição atribuída), que valem para tudo.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assessor(doc: OfferDocument, items: ItemIndex, input: PriceInput): Assessor;
|
|
32
|
+
/** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
|
|
33
|
+
export declare function assessOfferLines(ctx: Assessor): Assessed[];
|
package/dist/assess.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { isSameProductSwap, isValidLine, itemKey, viewCart } from './cart.js';
|
|
2
|
+
import { conditionsFailure, isSellable, isSupportedOffer, validityFailure } from './conditions.js';
|
|
3
|
+
import { applyVariant } from './effective.js';
|
|
4
|
+
import { assignExperiment } from './experiments.js';
|
|
5
|
+
import { compareAtPrice, discountedQuantity, 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
|
+
* Troca viva: a oferta (a da posição da linha) é `replace` daquele item, passa no portão com a variante do visitante e
|
|
21
|
+
* o `at`, e a linha é válida e diz que trocou um item do mesmo produto. Forjar os campos só rende o que o caminho
|
|
22
|
+
* legítimo daria; a troca desligada, vencida, de tipo desconhecido ou de outra posição é linha de oferta comum.
|
|
23
|
+
*/
|
|
24
|
+
function isLiveSwap(ctx, offer, line) {
|
|
25
|
+
if (offer.action !== 'replace' || itemKey(offer.item) !== itemKey(line.item))
|
|
26
|
+
return false;
|
|
27
|
+
if (!isValidLine(line) || !isSameProductSwap(ctx.items, line))
|
|
28
|
+
return false;
|
|
29
|
+
return offerGate(liveOffer(ctx.doc, offer, ctx.input), ctx.input.at) === null;
|
|
30
|
+
}
|
|
31
|
+
/** As ofertas que podem valer para a linha: a da posição dela, quando o id está lá; senão todas com o id. */
|
|
32
|
+
function lineOptions(doc, line) {
|
|
33
|
+
const same = doc.offers.filter((offer) => offer.id === line.offerId);
|
|
34
|
+
const declared = same.filter((offer) => offer.placement === line.placement);
|
|
35
|
+
return declared.length > 0 ? declared : same;
|
|
36
|
+
}
|
|
37
|
+
/** As posições do carrinho que são troca, calculadas uma vez (o sorteio do teste não roda a cada linha). */
|
|
38
|
+
function swapSet(cart, offerOf, isSwap) {
|
|
39
|
+
const swaps = new Set(cart.flatMap((line, index) => {
|
|
40
|
+
const offer = line.offerId ? offerOf(line, index) : undefined;
|
|
41
|
+
return offer && isSwap(offer, line) ? [index] : [];
|
|
42
|
+
}));
|
|
43
|
+
return (index) => swaps.has(index);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* O carrinho como no instante em que a oferta apareceu, o de antes da troca: só o que a cliente escolheu (as trocas
|
|
47
|
+
* de volta ao item trocado), sem a própria linha no `add` e, no `replace`, com ela de volta ao item trocado. Nulo
|
|
48
|
+
* quando o `replace` não diz o que trocou, ou diz um item que não é o mesmo produto.
|
|
49
|
+
*/
|
|
50
|
+
function recheckView(ctx, offer, index) {
|
|
51
|
+
const line = ctx.input.cart[index];
|
|
52
|
+
if (offer.action === 'add')
|
|
53
|
+
return viewCart(ctx.items, ctx.input.cart, ctx.isSwap, index);
|
|
54
|
+
if (!isSameProductSwap(ctx.items, line))
|
|
55
|
+
return null;
|
|
56
|
+
return viewCart(ctx.items, ctx.input.cart, (position) => position === index || ctx.isSwap(position));
|
|
57
|
+
}
|
|
58
|
+
function lineFailure(ctx, offer, index) {
|
|
59
|
+
const line = ctx.input.cart[index];
|
|
60
|
+
if (!isValidLine(line))
|
|
61
|
+
return 'invalid_line';
|
|
62
|
+
const gate = offerGate(offer, ctx.input.at);
|
|
63
|
+
if (gate === 'unsupported_offer' || gate === 'inactive')
|
|
64
|
+
return gate;
|
|
65
|
+
const item = ctx.items.get(itemKey(offer.item));
|
|
66
|
+
if (!item)
|
|
67
|
+
return 'unknown_offer';
|
|
68
|
+
if (itemKey(line.item) !== itemKey(offer.item))
|
|
69
|
+
return 'item_mismatch';
|
|
70
|
+
if (!isSellable(item))
|
|
71
|
+
return 'unavailable';
|
|
72
|
+
if (gate)
|
|
73
|
+
return gate;
|
|
74
|
+
const view = recheckView(ctx, offer, index);
|
|
75
|
+
if (!view)
|
|
76
|
+
return 'trigger_missing';
|
|
77
|
+
const failure = conditionsFailure(offer.conditions, view, 'price');
|
|
78
|
+
return failure === 'excluded' ? null : failure;
|
|
79
|
+
}
|
|
80
|
+
/** A oferta do documento nesta posição, com a variante do teste do visitante, contra esta linha. */
|
|
81
|
+
function assessOffer(ctx, found, index) {
|
|
82
|
+
const line = ctx.input.cart[index];
|
|
83
|
+
const offerId = found.id;
|
|
84
|
+
const offer = liveOffer(ctx.doc, found, ctx.input);
|
|
85
|
+
const reason = lineFailure(ctx, offer, index);
|
|
86
|
+
if (reason)
|
|
87
|
+
return { priced: refused(index, offerId, reason), claim: null, placement: found.placement };
|
|
88
|
+
const storePrice = ctx.items.get(itemKey(offer.item)).price;
|
|
89
|
+
const offerPrice = offerUnitPrice(offer.price, storePrice);
|
|
90
|
+
const unitPrice = Math.min(offerPrice, line.unitPrice ?? storePrice);
|
|
91
|
+
const priced = {
|
|
92
|
+
index,
|
|
93
|
+
offerId,
|
|
94
|
+
status: 'ok',
|
|
95
|
+
unitPrice,
|
|
96
|
+
compareAtPrice: compareAtPrice(storePrice, unitPrice),
|
|
97
|
+
discountedQuantity: discountedQuantity(line.quantity, offer.maxQuantity, offerPrice),
|
|
98
|
+
};
|
|
99
|
+
return { priced, claim: { index, offer, order: ctx.doc.offers.indexOf(found) }, placement: found.placement };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 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
|
|
103
|
+
* 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
|
|
104
|
+
* 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.
|
|
105
|
+
* A elegibilidade desse desempate é medida com as trocas cuja posição a linha já define (`declared`).
|
|
106
|
+
*/
|
|
107
|
+
function placeLines(declared) {
|
|
108
|
+
const placed = new Map();
|
|
109
|
+
declared.input.cart.forEach((line, index) => {
|
|
110
|
+
const options = line.offerId ? lineOptions(declared.doc, line) : [];
|
|
111
|
+
const pick = options.length > 1 ? options.find((offer) => assessOffer(declared, offer, index).claim !== null) : undefined;
|
|
112
|
+
const offer = pick ?? options[0];
|
|
113
|
+
if (offer)
|
|
114
|
+
placed.set(index, offer);
|
|
115
|
+
});
|
|
116
|
+
return placed;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Duas passadas: primeiro as trocas que a própria linha define (o id numa posição só, ou a posição da linha); com elas,
|
|
120
|
+
* a posição de cada linha de oferta; daí as trocas de fato (`replace` na posição atribuída), que valem para tudo.
|
|
121
|
+
*/
|
|
122
|
+
export function assessor(doc, items, input) {
|
|
123
|
+
const base = { doc, items, input };
|
|
124
|
+
const declaredOffer = (line) => {
|
|
125
|
+
const options = lineOptions(doc, line);
|
|
126
|
+
return options.length === 1 ? options[0] : undefined;
|
|
127
|
+
};
|
|
128
|
+
const declaredSwap = swapSet(input.cart, declaredOffer, (offer, line) => isLiveSwap(base, offer, line));
|
|
129
|
+
const placed = placeLines({ ...base, isSwap: declaredSwap, placed: new Map() });
|
|
130
|
+
const isSwap = swapSet(input.cart, (_, index) => placed.get(index), (offer, line) => isLiveSwap(base, offer, line));
|
|
131
|
+
return { ...base, isSwap, placed };
|
|
132
|
+
}
|
|
133
|
+
/** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
|
|
134
|
+
export function assessOfferLines(ctx) {
|
|
135
|
+
return ctx.input.cart.flatMap((line, index) => {
|
|
136
|
+
if (!line.offerId)
|
|
137
|
+
return [];
|
|
138
|
+
const offer = ctx.placed.get(index);
|
|
139
|
+
return [offer ? assessOffer(ctx, offer, index) : { priced: refused(index, line.offerId, 'unknown_offer'), claim: null, placement: null }];
|
|
140
|
+
});
|
|
141
|
+
}
|
package/dist/cart.d.ts
CHANGED
|
@@ -8,23 +8,36 @@ export interface ResolvedLine {
|
|
|
8
8
|
storeProductId: string | null;
|
|
9
9
|
catalogProductIds: string[];
|
|
10
10
|
durationDays: number | null;
|
|
11
|
+
/** Linha de troca vista como o item que ela trocou (o carrinho de antes da troca): não é alvo de outra troca. */
|
|
12
|
+
swapped: boolean;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
|
-
* O carrinho do ponto de vista do motor
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* O carrinho do ponto de vista do motor, **o de antes da troca**, o mesmo no `decide` e no `price` (pacote 0.3.2).
|
|
16
|
+
* O gatilho, o subtotal, a vaga extra e o combo são só o que a cliente escolheu: brinde e linha de oferta aceita ficam
|
|
17
|
+
* fora, senão a oferta aceita vira gatilho da próxima ("a fila que não acaba", app ADR 0006) ou sustenta o subtotal da
|
|
18
|
+
* outra. A **linha de troca** (`replace`) volta a ser o item trocado, com a quantidade dela: a vitrine decidiu com ele
|
|
19
|
+
* no carrinho, e a cobrança confere o mesmo carrinho. Linha com quantidade ou preço inválido não conta. `keys` é o
|
|
20
|
+
* carrinho inteiro, com o item trocado: não se oferece o que ela tem (ou acabou de trocar), venha de onde vier.
|
|
16
21
|
*/
|
|
17
22
|
export interface CartView {
|
|
18
23
|
chosen: ResolvedLine[];
|
|
19
|
-
accepted: ResolvedLine[];
|
|
20
24
|
keys: Set<string>;
|
|
21
|
-
/** Mercadoria sem brinde
|
|
25
|
+
/** Mercadoria escolhida (as linhas de `chosen`), sem brinde nem linha de oferta. */
|
|
22
26
|
subtotal: Cents;
|
|
23
27
|
}
|
|
24
28
|
export type ItemIndex = Map<string, OfferItem>;
|
|
25
29
|
export declare function indexItems(doc: OfferDocument): ItemIndex;
|
|
26
|
-
export declare function resolveLine(items: ItemIndex, line: CartLine): ResolvedLine;
|
|
27
|
-
|
|
30
|
+
export declare function resolveLine(items: ItemIndex, line: CartLine, swapped?: boolean): ResolvedLine;
|
|
31
|
+
/** Quantidade inteira ≥ 1 e, se vier, preço em centavos inteiros ≥ 0. A linha fora disso não é gatilho nem é cobrada. */
|
|
32
|
+
export declare function isValidLine(line: CartLine): boolean;
|
|
33
|
+
/** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
|
|
34
|
+
export declare function replacedLine(line: CartLine): CartLine | null;
|
|
35
|
+
/** A linha diz que trocou um item do mesmo produto pelo ofertado. Só a forma: se a troca ainda vale é o `price` que diz. */
|
|
36
|
+
export declare function isSameProductSwap(items: ItemIndex, line: CartLine): boolean;
|
|
37
|
+
/** A linha nesta posição do carrinho é uma troca válida (`assess.ts`): no carrinho de antes, ela é o item trocado. */
|
|
38
|
+
export type SwapCheck = (index: number) => boolean;
|
|
39
|
+
/** `omit`: a posição da linha que fica de fora (a própria linha de oferta, na reconferência dela). */
|
|
40
|
+
export declare function viewCart(items: ItemIndex, cart: CartLine[], isSwap: SwapCheck, omit?: number): CartView;
|
|
28
41
|
export declare function lineMatches(match: ProductMatch, line: ResolvedLine): boolean;
|
|
29
42
|
export declare function anyLineMatches(match: ProductMatch, lines: ResolvedLine[]): boolean;
|
|
30
43
|
/**
|
package/dist/cart.js
CHANGED
|
@@ -2,7 +2,7 @@ export const itemKey = (ref) => `${ref.source}:${ref.variantId}`;
|
|
|
2
2
|
export function indexItems(doc) {
|
|
3
3
|
return new Map(doc.items.map((item) => [itemKey(item), item]));
|
|
4
4
|
}
|
|
5
|
-
export function resolveLine(items, line) {
|
|
5
|
+
export function resolveLine(items, line, swapped = false) {
|
|
6
6
|
const key = itemKey(line.item);
|
|
7
7
|
const item = items.get(key);
|
|
8
8
|
return {
|
|
@@ -11,20 +11,57 @@ export function resolveLine(items, line) {
|
|
|
11
11
|
storeProductId: item?.storeProductId ?? line.storeProductId ?? null,
|
|
12
12
|
catalogProductIds: item?.catalogProductIds ?? [],
|
|
13
13
|
durationDays: item?.durationDays ?? null,
|
|
14
|
+
swapped,
|
|
14
15
|
};
|
|
15
16
|
}
|
|
17
|
+
const isCents = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
18
|
+
/** Quantidade inteira ≥ 1 e, se vier, preço em centavos inteiros ≥ 0. A linha fora disso não é gatilho nem é cobrada. */
|
|
19
|
+
export function isValidLine(line) {
|
|
20
|
+
return Number.isSafeInteger(line.quantity) && line.quantity >= 1 && (line.unitPrice === undefined || isCents(line.unitPrice));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Valor da linha no subtotal: o preço do **documento** quando o item está nele, o `unitPrice` da linha só quando não
|
|
24
|
+
* está (0 sem nenhum dos dois). O item trocado não tem linha no carrinho de depois; com o preço do documento para todo
|
|
25
|
+
* item listado, o `decide` (antes) e o `price` (depois) somam o mesmo.
|
|
26
|
+
*/
|
|
16
27
|
function lineTotal(items, line) {
|
|
17
|
-
const unit =
|
|
28
|
+
const unit = items.get(itemKey(line.item))?.price ?? line.unitPrice ?? 0;
|
|
18
29
|
return unit * line.quantity;
|
|
19
30
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
/** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
|
|
32
|
+
export function replacedLine(line) {
|
|
33
|
+
if (!line.replacedItem)
|
|
34
|
+
return null;
|
|
35
|
+
const product = line.storeProductId === undefined ? {} : { storeProductId: line.storeProductId };
|
|
36
|
+
return { item: line.replacedItem, quantity: line.quantity, ...product };
|
|
37
|
+
}
|
|
38
|
+
/** A linha diz que trocou um item do mesmo produto pelo ofertado. Só a forma: se a troca ainda vale é o `price` que diz. */
|
|
39
|
+
export function isSameProductSwap(items, line) {
|
|
40
|
+
const replaced = replacedLine(line);
|
|
41
|
+
return replaced !== null && sameProduct(resolveLine(items, replaced), resolveLine(items, line));
|
|
42
|
+
}
|
|
43
|
+
/** A linha como o carrinho de antes da troca a vê: escolhida, trocada (o item que saiu) ou fora (oferta, brinde, inválida). */
|
|
44
|
+
function chosenAs(items, line, swap) {
|
|
45
|
+
if (line.gift || !isValidLine(line))
|
|
46
|
+
return null;
|
|
47
|
+
const replaced = swap ? replacedLine(line) : null;
|
|
48
|
+
if (replaced)
|
|
49
|
+
return resolveLine(items, replaced, true);
|
|
50
|
+
return line.offerId ? null : resolveLine(items, line);
|
|
51
|
+
}
|
|
52
|
+
/** `omit`: a posição da linha que fica de fora (a própria linha de oferta, na reconferência dela). */
|
|
53
|
+
export function viewCart(items, cart, isSwap, omit = -1) {
|
|
54
|
+
const chosen = [];
|
|
55
|
+
const keys = new Set();
|
|
56
|
+
cart.forEach((line, index) => {
|
|
57
|
+
keys.add(itemKey(line.item));
|
|
58
|
+
const seen = index === omit ? null : chosenAs(items, line, isSwap(index));
|
|
59
|
+
if (seen)
|
|
60
|
+
chosen.push(seen);
|
|
61
|
+
if (seen?.swapped)
|
|
62
|
+
keys.add(seen.key);
|
|
63
|
+
});
|
|
64
|
+
return { chosen, keys, subtotal: chosen.reduce((sum, { line }) => sum + lineTotal(items, line), 0) };
|
|
28
65
|
}
|
|
29
66
|
export function lineMatches(match, line) {
|
|
30
67
|
if (match.items?.some((ref) => itemKey(ref) === line.key))
|
package/dist/conditions.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { type CartView } from './cart.js';
|
|
2
2
|
import type { IneligibleReason } from './io.js';
|
|
3
|
-
import type { Offer, OfferCondition } from './types.js';
|
|
3
|
+
import type { Offer, OfferCondition, OfferItem } 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
|
+
/** Item vendável pela oferta: disponível e com preço da loja em centavos > 0 (sem ele a compilação grava 0, e a oferta sairia de graça). */
|
|
12
|
+
export declare const isSellable: (item: OfferItem | undefined) => item is OfferItem;
|
|
13
|
+
/** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
|
|
14
|
+
export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | 'invalid_instant' | null;
|
|
7
15
|
/**
|
|
8
16
|
* Toda oferta tem uma condição implícita: a cliente escolheu alguma coisa. A oferta "sempre" só aparece porque há
|
|
9
17
|
* 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,29 @@
|
|
|
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
|
+
const quantity = offer.maxQuantity === undefined || (Number.isSafeInteger(offer.maxQuantity) && offer.maxQuantity >= 1);
|
|
11
|
+
return quantity && isKnownPrice(offer.price) && ACTIONS.has(offer.action) && offer.conditions.every((condition) => CONDITION_TYPES.has(condition.type));
|
|
12
|
+
}
|
|
13
|
+
/** Item vendável pela oferta: disponível e com preço da loja em centavos > 0 (sem ele a compilação grava 0, e a oferta sairia de graça). */
|
|
14
|
+
export const isSellable = (item) => item !== undefined && item.available && Number.isSafeInteger(item.price) && item.price > 0;
|
|
15
|
+
/** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
|
|
2
16
|
export function validityFailure(offer, at) {
|
|
17
|
+
if (offer.validFrom === undefined && offer.validUntil === undefined)
|
|
18
|
+
return null;
|
|
3
19
|
const now = Date.parse(at);
|
|
4
|
-
|
|
20
|
+
const from = offer.validFrom === undefined ? -Infinity : Date.parse(offer.validFrom);
|
|
21
|
+
const until = offer.validUntil === undefined ? Infinity : Date.parse(offer.validUntil);
|
|
22
|
+
if ([now, from, until].some(Number.isNaN))
|
|
23
|
+
return 'invalid_instant';
|
|
24
|
+
if (now < from)
|
|
5
25
|
return 'not_started';
|
|
6
|
-
|
|
7
|
-
return 'expired';
|
|
8
|
-
return null;
|
|
26
|
+
return now >= until ? 'expired' : null;
|
|
9
27
|
}
|
|
10
28
|
function conditionFailure(condition, view, mode) {
|
|
11
29
|
if (condition.type === 'cart_has_any')
|
|
@@ -13,6 +31,8 @@ function conditionFailure(condition, view, mode) {
|
|
|
13
31
|
if (condition.type === 'cart_has_none') {
|
|
14
32
|
return mode === 'price' || !anyLineMatches(condition.match, view.chosen) ? null : 'excluded';
|
|
15
33
|
}
|
|
34
|
+
if (condition.type !== 'subtotal_between')
|
|
35
|
+
return 'unsupported_offer';
|
|
16
36
|
const { min, max } = condition;
|
|
17
37
|
const inRange = (min === undefined || view.subtotal >= min) && (max === undefined || view.subtotal <= max);
|
|
18
38
|
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,37 +1,39 @@
|
|
|
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, isSellable } 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);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* O tipo pela linha escolhida do mesmo produto. A troca só mira linha escolhida de fato: a que já é troca (vista como o
|
|
22
|
+
* item que saiu) não tem linha no carrinho para a ponte trocar de novo.
|
|
23
|
+
*/
|
|
24
|
+
function classifyOffer(ctx, offer) {
|
|
25
|
+
const pool = offer.action === 'replace' ? ctx.view.chosen.filter((line) => !line.swapped) : ctx.view.chosen;
|
|
26
|
+
return classify(resolveLine(ctx.items, { item: offer.item, quantity: 1 }), pool);
|
|
25
27
|
}
|
|
26
28
|
function candidate(ctx, offer) {
|
|
27
|
-
if (
|
|
29
|
+
if (offerGate(offer, ctx.at))
|
|
28
30
|
return null;
|
|
29
31
|
const item = ctx.items.get(itemKey(offer.item));
|
|
30
|
-
if (!item
|
|
32
|
+
if (!isSellable(item) || ctx.view.keys.has(itemKey(offer.item)))
|
|
31
33
|
return null;
|
|
32
34
|
if (conditionsFailure(offer.conditions, ctx.view, 'decide'))
|
|
33
35
|
return null;
|
|
34
|
-
const classification =
|
|
36
|
+
const classification = classifyOffer(ctx, offer);
|
|
35
37
|
if (offer.action === 'replace' && !classification.target)
|
|
36
38
|
return null;
|
|
37
39
|
return { offer, item, classification };
|
|
@@ -58,23 +60,26 @@ function groupingFor(ctx) {
|
|
|
58
60
|
const groupings = ctx.variant?.groupings ?? ctx.placement.groupings ?? [];
|
|
59
61
|
return groupings.find((grouping) => anyLineMatches(grouping.when, ctx.view.chosen))?.presentation ?? null;
|
|
60
62
|
}
|
|
63
|
+
/** A linha com tudo o que muda a decisão (troca, posição, produto, preço), sem depender da ordem do carrinho. */
|
|
64
|
+
function lineKey(line) {
|
|
65
|
+
const replaced = line.replacedItem ? itemKey(line.replacedItem) : '';
|
|
66
|
+
return JSON.stringify([itemKey(line.item), line.quantity, line.unitPrice ?? null, line.offerId ?? '', line.placement ?? '', replaced, line.storeProductId ?? '', line.gift ? 1 : 0]);
|
|
67
|
+
}
|
|
61
68
|
/**
|
|
62
|
-
*
|
|
69
|
+
* Mesma pergunta e mesma resposta = mesmo id (ADR-0083 §1, princípio 3); resposta diferente, outro (pacote 0.3.2: o
|
|
70
|
+
* hash leva a resposta inteira, com preço, alvo da troca e conteúdo de cada oferta). O instante não entra: duas
|
|
63
71
|
* perguntas iguais seguidas são a mesma decisão. A ordem das linhas também não.
|
|
64
72
|
*/
|
|
65
|
-
function
|
|
66
|
-
const cart = input.cart
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const canonical = JSON.stringify([version, input.placement, input.visitorId ?? '', cart, offerIds, experiments]);
|
|
70
|
-
return `dec_${sha1Hex(canonical).slice(0, 24)}`;
|
|
73
|
+
function withDecisionId(input, version, answer) {
|
|
74
|
+
const cart = input.cart.map(lineKey).sort();
|
|
75
|
+
const decisionId = `dec_${sha1Hex(JSON.stringify([version, input.placement, input.visitorId ?? '', cart, answer])).slice(0, 24)}`;
|
|
76
|
+
return { ...answer, decisionId, offers: answer.offers.map((offer) => ({ ...offer, decisionId })) };
|
|
71
77
|
}
|
|
72
|
-
function toDecided(ctx, pick, slot
|
|
78
|
+
function toDecided(ctx, pick, slot) {
|
|
73
79
|
const { offer, item, classification } = pick;
|
|
74
80
|
const price = offerUnitPrice(offer.price, item.price);
|
|
75
81
|
return {
|
|
76
82
|
offerId: offer.id,
|
|
77
|
-
decisionId,
|
|
78
83
|
placement: ctx.placement.key,
|
|
79
84
|
slot,
|
|
80
85
|
kind: classification.kind,
|
|
@@ -95,37 +100,40 @@ function placementPresentation(ctx) {
|
|
|
95
100
|
}
|
|
96
101
|
/**
|
|
97
102
|
* 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.
|
|
103
|
+
* também responde vazio, com `status: 'unsupported_schema'`, para a ponte saber que precisa atualizar o pacote. O
|
|
104
|
+
* grupo de fora geral responde vazio com `holdout: true`.
|
|
99
105
|
*/
|
|
100
|
-
function emptyDecision(doc, input, status) {
|
|
101
|
-
|
|
102
|
-
return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [] };
|
|
106
|
+
function emptyDecision(doc, input, status, holdout = false) {
|
|
107
|
+
return withDecisionId(input, doc.version, { status, version: doc.version, placement: input.placement, offers: [], presentation: null, grouping: null, experiments: [], holdout });
|
|
103
108
|
}
|
|
104
109
|
/**
|
|
105
110
|
* 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.
|
|
111
|
+
* condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade e um teste por posição.
|
|
112
|
+
* 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
113
|
*/
|
|
109
114
|
export function decide(doc, input) {
|
|
110
115
|
if (!isSupportedSchema(doc.schemaVersion))
|
|
111
116
|
return emptyDecision(doc, input, 'unsupported_schema');
|
|
117
|
+
if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
|
|
118
|
+
return emptyDecision(doc, input, 'ok', true);
|
|
112
119
|
const placement = doc.placements.find((candidatePlacement) => candidatePlacement.key === input.placement);
|
|
113
120
|
if (!placement)
|
|
114
121
|
return emptyDecision(doc, input, 'ok');
|
|
115
122
|
const assignment = assignExperiment(doc, placement.key, input);
|
|
116
123
|
const items = indexItems(doc);
|
|
117
|
-
const
|
|
124
|
+
const assess = assessor(doc, items, input);
|
|
125
|
+
const view = viewCart(items, input.cart, assess.isSwap);
|
|
126
|
+
const ctx = { doc, items, view, accepted: assessOfferLines(assess), placement, variant: assignment?.variant ?? null, at: input.at };
|
|
118
127
|
const picks = pickOffers(ctx);
|
|
119
128
|
const experiments = assignment ? [{ key: assignment.experiment.key, variant: assignment.variant.key }] : [];
|
|
120
|
-
|
|
121
|
-
return {
|
|
129
|
+
return withDecisionId(input, doc.version, {
|
|
122
130
|
status: 'ok',
|
|
123
131
|
version: doc.version,
|
|
124
132
|
placement: placement.key,
|
|
125
|
-
|
|
126
|
-
offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1, decisionId)),
|
|
133
|
+
offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1)),
|
|
127
134
|
presentation: placementPresentation(ctx),
|
|
128
135
|
grouping: groupingFor(ctx),
|
|
129
136
|
experiments,
|
|
130
|
-
|
|
137
|
+
holdout: false,
|
|
138
|
+
});
|
|
131
139
|
}
|
package/dist/io.d.ts
CHANGED
|
@@ -2,18 +2,31 @@ import type { Cents, ItemRef, OfferAction, OfferContent } from './types.js';
|
|
|
2
2
|
/** Uma linha do carrinho como a ponte a vê. */
|
|
3
3
|
export interface CartLine {
|
|
4
4
|
item: ItemRef;
|
|
5
|
+
/** Inteiro ≥ 1; fora disso a linha não é gatilho nem entra no subtotal, e a linha de oferta volta `invalid_line`. */
|
|
5
6
|
quantity: number;
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* O que a loja cobra por unidade agora (centavos inteiros ≥ 0; fora disso, como `quantity`). Teto do preço da linha
|
|
9
|
+
* de oferta. No subtotal só vale para item **fora** do documento: o item listado conta pelo preço do documento, o
|
|
10
|
+
* mesmo que o item trocado (que já saiu do carrinho) tem, para o `decide` e o `price` somarem igual.
|
|
11
|
+
*/
|
|
7
12
|
unitPrice?: Cents;
|
|
8
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Produto da loja da linha, para o item que o documento não lista (o produto que a cliente escolheu). Na linha de
|
|
15
|
+
* troca, vale também para o `replacedItem` fora do documento: a troca é dentro do mesmo produto.
|
|
16
|
+
*/
|
|
9
17
|
storeProductId?: string;
|
|
10
18
|
/** Linha de oferta aceita: leva o `offerId` até o pedido (ADR-0083 §1, princípio 4). Não é gatilho de outra oferta. */
|
|
11
19
|
offerId?: string;
|
|
12
|
-
/** A posição de onde a oferta aceita veio; desempata o id repetido em duas posições. */
|
|
20
|
+
/** A posição de onde a oferta aceita veio (o `placement` da `DecidedOffer`); desempata o id repetido em duas posições. */
|
|
13
21
|
placement?: string;
|
|
14
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Na ação `replace`: o item que a oferta trocou. O motor avalia o **carrinho de antes da troca** (pacote 0.3.2): para
|
|
24
|
+
* todas as ofertas, a própria e as outras, a linha de troca volta a ser este item, com a quantidade dela (gatilho,
|
|
25
|
+
* `cart_has_none`, subtotal pelo preço do documento, vaga extra, combo). A linha em si ocupa a vaga da posição dela.
|
|
26
|
+
* Só é troca quando a oferta `replace` com o `offerId` está na posição da linha (a dela, ou a da regra de posição).
|
|
27
|
+
*/
|
|
15
28
|
replacedItem?: ItemRef;
|
|
16
|
-
/** Brinde: não é escolha da cliente nem entra no subtotal. */
|
|
29
|
+
/** Brinde: não é escolha da cliente nem entra no subtotal. Com `offerId`, segue linha de oferta e ocupa a vaga. */
|
|
17
30
|
gift?: boolean;
|
|
18
31
|
}
|
|
19
32
|
export interface DecideInput {
|
|
@@ -50,7 +63,10 @@ export interface DecidedOffer {
|
|
|
50
63
|
pricePerDay: Cents | null;
|
|
51
64
|
currency: string;
|
|
52
65
|
action: OfferAction;
|
|
53
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* O item que a cliente leva e a oferta troca, na ação `replace`: a linha escolhida do mesmo produto (nunca uma que já
|
|
68
|
+
* é troca) de menor duração, e no empate a de menor chave de item, sem depender da ordem das linhas.
|
|
69
|
+
*/
|
|
54
70
|
replaces: ItemRef | null;
|
|
55
71
|
content: OfferContent;
|
|
56
72
|
presentation: string | null;
|
|
@@ -61,7 +77,11 @@ export interface Decision {
|
|
|
61
77
|
status: ResultStatus;
|
|
62
78
|
version: number;
|
|
63
79
|
placement: string;
|
|
64
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* sha1 da versão, posição, visitante, carrinho (sem ordem) e da **resposta inteira** (ofertas com preço, alvo da
|
|
82
|
+
* troca, conteúdo; apresentação, combo, teste): mesma pergunta e mesma resposta = mesmo id; resposta diferente, outro.
|
|
83
|
+
* Sai também sem oferta (para o `exposed`).
|
|
84
|
+
*/
|
|
65
85
|
decisionId: string;
|
|
66
86
|
offers: DecidedOffer[];
|
|
67
87
|
/** Apresentação padrão da posição (já com a variante do teste). */
|
|
@@ -69,24 +89,46 @@ export interface Decision {
|
|
|
69
89
|
/** Apresentação de grupo (combo) quando vale para este carrinho. */
|
|
70
90
|
grouping: string | null;
|
|
71
91
|
experiments: ExperimentAssignment[];
|
|
92
|
+
/**
|
|
93
|
+
* O visitante caiu no grupo de fora geral (ADR-0047): nenhuma oferta, e a ponte manda o `exposed` do controle geral.
|
|
94
|
+
* Sem `visitorId` é sempre `false`.
|
|
95
|
+
*/
|
|
96
|
+
holdout: boolean;
|
|
72
97
|
}
|
|
73
98
|
export interface PriceInput {
|
|
74
99
|
cart: CartLine[];
|
|
75
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Sempre o mesmo do `decide`: a variante do teste é sorteada de novo por ele, e o grupo de fora geral conferido. Sem
|
|
102
|
+
* ele, nem teste nem grupo de fora valem (cobra o documento sem variante).
|
|
103
|
+
*/
|
|
76
104
|
visitorId?: string;
|
|
77
105
|
/** As mesmas do `decide`: sem elas, o teste congelado cobra o documento sem mudança. */
|
|
78
106
|
assignments?: ExperimentAssignment[];
|
|
79
107
|
at: string;
|
|
80
108
|
}
|
|
81
109
|
export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' | 'not_started' | 'expired' | 'trigger_missing' | 'subtotal_out_of_range'
|
|
110
|
+
/** 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. */
|
|
111
|
+
| 'unavailable'
|
|
112
|
+
/** O visitante está no grupo de fora geral: não leva oferta nenhuma. */
|
|
113
|
+
| 'holdout'
|
|
114
|
+
/** A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a mesma oferta (ou item) em outra linha. */
|
|
115
|
+
| 'no_slot'
|
|
116
|
+
/** Condição, preço ou ação de um tipo que este pacote não conhece: atualize o pacote. */
|
|
117
|
+
| 'unsupported_offer'
|
|
118
|
+
/** O `at` (ou a validade da oferta) não é um instante legível, e a oferta tem validade. */
|
|
119
|
+
| 'invalid_instant'
|
|
82
120
|
/** O documento é de um formato mais novo que o pacote: toda linha volta com este motivo. */
|
|
83
|
-
| 'unsupported_schema'
|
|
121
|
+
| 'unsupported_schema'
|
|
122
|
+
/** A linha tem `quantity` que não é inteiro ≥ 1, ou `unitPrice` que não é centavo inteiro ≥ 0 (pacote 0.3.2). */
|
|
123
|
+
| 'invalid_line';
|
|
84
124
|
export type PricedLine = {
|
|
85
125
|
index: number;
|
|
86
126
|
offerId: string;
|
|
87
127
|
status: 'ok';
|
|
88
128
|
unitPrice: Cents;
|
|
89
129
|
compareAtPrice: Cents | null;
|
|
130
|
+
/** Quantas unidades da linha levam o `unitPrice` (o `maxQuantity` da oferta); o resto vai a preço cheio. */
|
|
131
|
+
discountedQuantity: number;
|
|
90
132
|
} | {
|
|
91
133
|
index: number;
|
|
92
134
|
offerId: string;
|
package/dist/kind.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export interface Classification {
|
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
11
|
* Tipo calculado, nunca cadastrado (ADR-0083 §3; a mesma regra do `offerKindExpr` da análise): upsell quando o carrinho
|
|
12
|
-
* escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo.
|
|
12
|
+
* escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo. Com mais de uma
|
|
13
|
+
* linha do mesmo produto, o alvo (o que a `replace` troca) é a de menor duração, e no empate a de menor chave de item.
|
|
13
14
|
*/
|
|
14
15
|
export declare function classify(offered: ResolvedLine, chosen: ResolvedLine[]): Classification;
|
|
15
16
|
/**
|
package/dist/kind.js
CHANGED
|
@@ -6,12 +6,20 @@ function directionOf(offered, target) {
|
|
|
6
6
|
return null;
|
|
7
7
|
return offered.durationDays > target.durationDays ? 'up' : 'down';
|
|
8
8
|
}
|
|
9
|
+
/** Menor duração primeiro (sem duração por último), depois a chave do item: o alvo não depende da ordem das linhas. */
|
|
10
|
+
function byTarget(a, b) {
|
|
11
|
+
const byDuration = (a.durationDays ?? Number.MAX_SAFE_INTEGER) - (b.durationDays ?? Number.MAX_SAFE_INTEGER);
|
|
12
|
+
if (byDuration !== 0)
|
|
13
|
+
return byDuration;
|
|
14
|
+
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
15
|
+
}
|
|
9
16
|
/**
|
|
10
17
|
* Tipo calculado, nunca cadastrado (ADR-0083 §3; a mesma regra do `offerKindExpr` da análise): upsell quando o carrinho
|
|
11
|
-
* escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo.
|
|
18
|
+
* escolhido já tem o mesmo produto, cross-sell quando é outro. A direção sai da duração no catálogo. Com mais de uma
|
|
19
|
+
* linha do mesmo produto, o alvo (o que a `replace` troca) é a de menor duração, e no empate a de menor chave de item.
|
|
12
20
|
*/
|
|
13
21
|
export function classify(offered, chosen) {
|
|
14
|
-
const target = chosen.
|
|
22
|
+
const target = chosen.filter((line) => sameProduct(offered, line)).sort(byTarget)[0] ?? null;
|
|
15
23
|
if (!target)
|
|
16
24
|
return { kind: 'cross_sell', direction: null, target: null };
|
|
17
25
|
return { kind: 'upsell', direction: directionOf(offered, target), target };
|
|
@@ -22,8 +30,8 @@ function triggerLines(items, offer) {
|
|
|
22
30
|
const matches = offer.conditions.flatMap((condition) => (condition.type === 'cart_has_any' ? [condition.match] : []));
|
|
23
31
|
return matches.flatMap((match) => [
|
|
24
32
|
...(match.items ?? []).map((item) => resolveLine(items, { item, quantity: 1 })),
|
|
25
|
-
...(match.storeProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: id, catalogProductIds: [], durationDays: null })),
|
|
26
|
-
...(match.catalogProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: null, catalogProductIds: [id], durationDays: null })),
|
|
33
|
+
...(match.storeProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: id, catalogProductIds: [], durationDays: null, swapped: false })),
|
|
34
|
+
...(match.catalogProductIds ?? []).map((id) => ({ line: BLANK_LINE, key: '', storeProductId: null, catalogProductIds: [id], durationDays: null, swapped: false })),
|
|
27
35
|
]);
|
|
28
36
|
}
|
|
29
37
|
/**
|
package/dist/money.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { Cents, OfferPrice } from './types.js';
|
|
2
|
+
/** O preço é de um tipo que o pacote sabe calcular, com inteiro no campo do valor (centavos; pontos-base até 100%). */
|
|
3
|
+
export declare function isKnownPrice(price: OfferPrice): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Quantas unidades da linha levam o preço da oferta: o `maxQuantity` da oferta; sem ele, **1** quando a oferta sai de
|
|
6
|
+
* graça (brinde) e a linha toda nos outros casos. O resto da linha vai a preço cheio.
|
|
7
|
+
*/
|
|
8
|
+
export declare function discountedQuantity(quantity: number, maxQuantity: number | undefined, offerPrice: Cents): number;
|
|
2
9
|
/**
|
|
3
10
|
* Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
|
|
4
11
|
* 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).
|
|
12
|
+
* 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
|
|
13
|
+
* conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
|
|
6
14
|
*/
|
|
7
15
|
export declare function offerUnitPrice(price: OfferPrice, storePrice: Cents): Cents;
|
|
8
16
|
/** O preço "de": o da loja, só quando há o que riscar. */
|
package/dist/money.js
CHANGED
|
@@ -2,12 +2,32 @@
|
|
|
2
2
|
function divideHalfUp(numerator, denominator) {
|
|
3
3
|
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
|
4
4
|
}
|
|
5
|
+
const isWhole = (value, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
6
|
+
/** O preço é de um tipo que o pacote sabe calcular, com inteiro no campo do valor (centavos; pontos-base até 100%). */
|
|
7
|
+
export function isKnownPrice(price) {
|
|
8
|
+
if (price.type === 'percent_off')
|
|
9
|
+
return isWhole(price.basisPoints, 10_000);
|
|
10
|
+
if (price.type === 'fixed' || price.type === 'amount_off')
|
|
11
|
+
return isWhole(price.amount);
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Quantas unidades da linha levam o preço da oferta: o `maxQuantity` da oferta; sem ele, **1** quando a oferta sai de
|
|
16
|
+
* graça (brinde) e a linha toda nos outros casos. O resto da linha vai a preço cheio.
|
|
17
|
+
*/
|
|
18
|
+
export function discountedQuantity(quantity, maxQuantity, offerPrice) {
|
|
19
|
+
const cap = maxQuantity ?? (offerPrice === 0 ? 1 : quantity);
|
|
20
|
+
return Math.min(quantity, cap);
|
|
21
|
+
}
|
|
5
22
|
/**
|
|
6
23
|
* Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
|
|
7
24
|
* 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).
|
|
25
|
+
* 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
|
|
26
|
+
* conhece não desconta: devolve o preço da loja, nunca `NaN` (o `decide`/`price` nem chegam a usar essa oferta).
|
|
9
27
|
*/
|
|
10
28
|
export function offerUnitPrice(price, storePrice) {
|
|
29
|
+
if (!isKnownPrice(price))
|
|
30
|
+
return storePrice;
|
|
11
31
|
const raw = price.type === 'fixed'
|
|
12
32
|
? price.amount
|
|
13
33
|
: price.type === 'amount_off'
|
package/dist/price.d.ts
CHANGED
|
@@ -3,8 +3,11 @@ 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 o carrinho de antes da troca, o mesmo do `decide` (linha de oferta não sustenta
|
|
7
|
+
* outra; a de troca volta a ser o item trocado) e as vagas da posição, na ordem de prioridade. Cada linha `ok` diz em
|
|
8
|
+
* `discountedQuantity` quantas unidades levam o preço da oferta (`maxQuantity`); o resto vai a preço cheio. Documento de formato mais novo que o
|
|
9
|
+
* pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
|
|
10
|
+
* variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
|
|
11
|
+
* "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
|
|
9
12
|
*/
|
|
10
13
|
export declare function price(doc: OfferDocument, input: PriceInput): PriceResult;
|
package/dist/price.js
CHANGED
|
@@ -1,77 +1,34 @@
|
|
|
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 o carrinho de antes da troca, o mesmo do `decide` (linha de oferta não sustenta
|
|
20
|
+
* outra; a de troca volta a ser o item trocado) e as vagas da posição, na ordem de prioridade. Cada linha `ok` diz em
|
|
21
|
+
* `discountedQuantity` quantas unidades levam o preço da oferta (`maxQuantity`); o resto vai a preço cheio. Documento de formato mais novo que o
|
|
22
|
+
* pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
|
|
23
|
+
* variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
|
|
24
|
+
* "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
|
|
67
25
|
*/
|
|
68
26
|
export function price(doc, input) {
|
|
69
27
|
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 };
|
|
28
|
+
if (!isSupportedSchema(doc.schemaVersion))
|
|
29
|
+
return { status: 'unsupported_schema', ...head, lines: refuseAll(input.cart, 'unsupported_schema') };
|
|
30
|
+
if (input.visitorId && inHoldout(doc.holdout, input.visitorId))
|
|
31
|
+
return { status: 'ok', ...head, lines: refuseAll(input.cart, 'holdout') };
|
|
32
|
+
const ctx = assessor(doc, indexItems(doc), input);
|
|
33
|
+
return { status: 'ok', ...head, lines: withSlots(ctx, assessOfferLines(ctx)) };
|
|
77
34
|
}
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
* precificado: a resposta diz `unsupported_schema` e a ponte sabe que precisa atualizar o pacote, em vez de cobrar
|
|
5
5
|
* com uma regra que não conhece.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
/** 2 = pacote 0.3.2: brinde vale 1 unidade por padrão e `maxQuantity` limita as unidades com desconto. */
|
|
8
|
+
export declare const SUPPORTED_SCHEMA_VERSION = 2;
|
|
8
9
|
export declare function isSupportedSchema(schemaVersion: number): boolean;
|
package/dist/schema-version.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* precificado: a resposta diz `unsupported_schema` e a ponte sabe que precisa atualizar o pacote, em vez de cobrar
|
|
5
5
|
* com uma regra que não conhece.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
/** 2 = pacote 0.3.2: brinde vale 1 unidade por padrão e `maxQuantity` limita as unidades com desconto. */
|
|
8
|
+
export const SUPPORTED_SCHEMA_VERSION = 2;
|
|
8
9
|
export function isSupportedSchema(schemaVersion) {
|
|
9
10
|
return Number.isInteger(schemaVersion) && schemaVersion >= 1 && schemaVersion <= SUPPORTED_SCHEMA_VERSION;
|
|
10
11
|
}
|
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/dist/types.d.ts
CHANGED
|
@@ -82,6 +82,12 @@ export interface Offer {
|
|
|
82
82
|
/** Nome do visual que a ponte implementa; sem ele vale o da posição. */
|
|
83
83
|
presentation?: string;
|
|
84
84
|
conditions: OfferCondition[];
|
|
85
|
+
/**
|
|
86
|
+
* Quantas unidades da linha levam o preço da oferta (inteiro ≥ 1, pacote 0.3.2); o resto vai a preço cheio. Ausente:
|
|
87
|
+
* **1** quando o preço da oferta dá 0 (brinde), a linha toda nos outros casos. O `price` devolve a conta em
|
|
88
|
+
* `discountedQuantity`.
|
|
89
|
+
*/
|
|
90
|
+
maxQuantity?: number;
|
|
85
91
|
/** Instantes ISO com offset; início inclusivo, fim exclusivo. */
|
|
86
92
|
validFrom?: string;
|
|
87
93
|
validUntil?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zolve/offers",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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,
|