@zolve/offers 0.3.1 → 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 +44 -8
- package/dist/assess.d.ts +7 -1
- package/dist/assess.js +81 -39
- package/dist/cart.d.ts +15 -9
- package/dist/cart.js +35 -10
- package/dist/conditions.d.ts +3 -1
- package/dist/conditions.js +4 -1
- package/dist/decide.js +27 -21
- package/dist/io.d.ts +24 -7
- package/dist/kind.d.ts +2 -1
- package/dist/kind.js +12 -4
- package/dist/money.d.ts +6 -1
- package/dist/money.js +12 -3
- package/dist/price.d.ts +3 -2
- package/dist/price.js +3 -2
- package/dist/schema-version.d.ts +2 -1
- package/dist/schema-version.js +2 -1
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,12 +16,11 @@ 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, compareAtPrice } | { 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
|
|
23
|
-
preço da
|
|
24
|
-
loja no documento, quando maior que o da oferta (o mesmo do `decide`).
|
|
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`).
|
|
25
24
|
|
|
26
25
|
`offerKind(doc, offer)` (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de
|
|
27
26
|
`cart_has_any` no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.
|
|
@@ -60,9 +59,8 @@ o `decide` deu ao visitante.
|
|
|
60
59
|
`visitorId`, `holdout` é sempre `false`.
|
|
61
60
|
- **Só o que a cliente escolheu** é gatilho, subtotal, vaga extra e combo, no `decide` e na reconferência do `price`:
|
|
62
61
|
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)
|
|
64
|
-
|
|
65
|
-
com o próprio preço). O `replacedItem` fora de `doc.items` é reconhecido pelo `storeProductId` da linha (a troca é no
|
|
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
|
|
66
64
|
mesmo produto da loja). Só conta como escolhida a troca cuja oferta `replace` está ligada (com a variante do
|
|
67
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
|
|
68
66
|
sustenta outra oferta.
|
|
@@ -75,13 +73,50 @@ o `decide` deu ao visitante.
|
|
|
75
73
|
ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga no
|
|
76
74
|
`decide` (a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga.
|
|
77
75
|
- **Vitrine e cobrança concordam**: para o mesmo documento e carrinho, toda oferta que o `decide` mostra e a cliente
|
|
78
|
-
aceita
|
|
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
|
|
79
78
|
`placement` da `DecidedOffer`: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra
|
|
80
79
|
posição.
|
|
81
80
|
- **`visitorId` no `price`**: a ponte manda sempre o mesmo `visitorId` (e as mesmas `assignments`) do `decide`. Sem
|
|
82
81
|
ele o `price` não sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no
|
|
83
82
|
grupo de fora leva a oferta.
|
|
84
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
|
+
|
|
85
120
|
Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_started`, `expired`, `trigger_missing`,
|
|
86
121
|
`subtotal_out_of_range`, `unsupported_schema` e, desde a 0.3.1:
|
|
87
122
|
|
|
@@ -92,6 +127,7 @@ Motivos de `ineligible`: `unknown_offer`, `inactive`, `item_mismatch`, `not_star
|
|
|
92
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 |
|
|
93
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) |
|
|
94
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 |
|
|
95
131
|
|
|
96
132
|
Documento com `schemaVersion` maior que `SUPPORTED_SCHEMA_VERSION` não é decidido nem precificado: a resposta vem com
|
|
97
133
|
`status: 'unsupported_schema'` (e toda linha do `price` inelegível com esse motivo). Atualize o pacote.
|
package/dist/assess.d.ts
CHANGED
|
@@ -4,13 +4,15 @@ import type { SlotClaim } from './slots.js';
|
|
|
4
4
|
import type { Offer, OfferDocument } from './types.js';
|
|
5
5
|
/**
|
|
6
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.
|
|
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.
|
|
8
9
|
*/
|
|
9
10
|
export interface Assessor {
|
|
10
11
|
doc: OfferDocument;
|
|
11
12
|
items: ItemIndex;
|
|
12
13
|
input: PriceInput;
|
|
13
14
|
isSwap: SwapCheck;
|
|
15
|
+
placed: Map<number, Offer>;
|
|
14
16
|
}
|
|
15
17
|
/** Uma linha de oferta avaliada: a posição a que ela foi atribuída e, se passou, a oferta que disputa a vaga. */
|
|
16
18
|
export interface Assessed {
|
|
@@ -22,6 +24,10 @@ export interface Assessed {
|
|
|
22
24
|
export declare const refused: (index: number, offerId: string, reason: IneligibleReason) => PricedLine;
|
|
23
25
|
/** O portão da oferta em si, sem carrinho: tipo que o pacote conhece, ligada e dentro da validade. */
|
|
24
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
|
+
*/
|
|
25
31
|
export declare function assessor(doc: OfferDocument, items: ItemIndex, input: PriceInput): Assessor;
|
|
26
32
|
/** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
|
|
27
33
|
export declare function assessOfferLines(ctx: Assessor): Assessed[];
|
package/dist/assess.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { isSameProductSwap,
|
|
2
|
-
import { conditionsFailure, isSupportedOffer, validityFailure } from './conditions.js';
|
|
1
|
+
import { isSameProductSwap, isValidLine, itemKey, viewCart } from './cart.js';
|
|
2
|
+
import { conditionsFailure, isSellable, isSupportedOffer, validityFailure } from './conditions.js';
|
|
3
3
|
import { applyVariant } from './effective.js';
|
|
4
4
|
import { assignExperiment } from './experiments.js';
|
|
5
|
-
import { compareAtPrice, offerUnitPrice } from './money.js';
|
|
5
|
+
import { compareAtPrice, discountedQuantity, offerUnitPrice } from './money.js';
|
|
6
6
|
export const refused = (index, offerId, reason) => ({ index, offerId, status: 'ineligible', reason });
|
|
7
7
|
/** A oferta do documento como o visitante a vê: com a variante do teste da posição dela. */
|
|
8
8
|
function liveOffer(doc, found, input) {
|
|
@@ -17,48 +17,60 @@ export function offerGate(offer, at) {
|
|
|
17
17
|
return validityFailure(offer, at);
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* a troca desligada, vencida
|
|
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
23
|
*/
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
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;
|
|
27
30
|
}
|
|
28
|
-
|
|
29
|
-
|
|
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);
|
|
30
44
|
}
|
|
31
45
|
/**
|
|
32
|
-
* O carrinho como no instante em que a oferta apareceu: só o que a cliente escolheu (
|
|
33
|
-
* própria linha e, no `replace`, com
|
|
34
|
-
* ou diz um item que não é o mesmo produto.
|
|
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.
|
|
35
49
|
*/
|
|
36
50
|
function recheckView(ctx, offer, index) {
|
|
37
51
|
const line = ctx.input.cart[index];
|
|
38
|
-
const others = ctx.input.cart.filter((_, position) => position !== index);
|
|
39
52
|
if (offer.action === 'add')
|
|
40
|
-
return viewCart(ctx.items,
|
|
41
|
-
|
|
42
|
-
if (!replaced || !isSameProductSwap(ctx.items, line))
|
|
53
|
+
return viewCart(ctx.items, ctx.input.cart, ctx.isSwap, index);
|
|
54
|
+
if (!isSameProductSwap(ctx.items, line))
|
|
43
55
|
return null;
|
|
44
|
-
return viewCart(ctx.items,
|
|
56
|
+
return viewCart(ctx.items, ctx.input.cart, (position) => position === index || ctx.isSwap(position));
|
|
45
57
|
}
|
|
46
58
|
function lineFailure(ctx, offer, index) {
|
|
47
59
|
const line = ctx.input.cart[index];
|
|
48
|
-
if (!
|
|
49
|
-
return '
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
52
65
|
const item = ctx.items.get(itemKey(offer.item));
|
|
53
66
|
if (!item)
|
|
54
67
|
return 'unknown_offer';
|
|
55
68
|
if (itemKey(line.item) !== itemKey(offer.item))
|
|
56
69
|
return 'item_mismatch';
|
|
57
|
-
if (!item
|
|
70
|
+
if (!isSellable(item))
|
|
58
71
|
return 'unavailable';
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return validity;
|
|
72
|
+
if (gate)
|
|
73
|
+
return gate;
|
|
62
74
|
const view = recheckView(ctx, offer, index);
|
|
63
75
|
if (!view)
|
|
64
76
|
return 'trigger_missing';
|
|
@@ -74,26 +86,56 @@ function assessOffer(ctx, found, index) {
|
|
|
74
86
|
if (reason)
|
|
75
87
|
return { priced: refused(index, offerId, reason), claim: null, placement: found.placement };
|
|
76
88
|
const storePrice = ctx.items.get(itemKey(offer.item)).price;
|
|
77
|
-
const
|
|
78
|
-
const
|
|
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
|
+
};
|
|
79
99
|
return { priced, claim: { index, offer, order: ctx.doc.offers.indexOf(found) }, placement: found.placement };
|
|
80
100
|
}
|
|
81
101
|
/**
|
|
82
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
|
|
83
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
|
|
84
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`).
|
|
85
106
|
*/
|
|
86
|
-
function
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 };
|
|
95
132
|
}
|
|
96
133
|
/** Toda linha com `offerId`, brinde ou não, na ordem do carrinho: é linha de oferta, e disputa a vaga da posição dela. */
|
|
97
134
|
export function assessOfferLines(ctx) {
|
|
98
|
-
return ctx.input.cart.flatMap((line, index) =>
|
|
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
|
+
});
|
|
99
141
|
}
|
package/dist/cart.d.ts
CHANGED
|
@@ -8,13 +8,16 @@ 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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
18
21
|
*/
|
|
19
22
|
export interface CartView {
|
|
20
23
|
chosen: ResolvedLine[];
|
|
@@ -24,14 +27,17 @@ export interface CartView {
|
|
|
24
27
|
}
|
|
25
28
|
export type ItemIndex = Map<string, OfferItem>;
|
|
26
29
|
export declare function indexItems(doc: OfferDocument): ItemIndex;
|
|
27
|
-
export declare function resolveLine(items: ItemIndex, line: CartLine): ResolvedLine;
|
|
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;
|
|
28
33
|
/** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
|
|
29
34
|
export declare function replacedLine(line: CartLine): CartLine | null;
|
|
30
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. */
|
|
31
36
|
export declare function isSameProductSwap(items: ItemIndex, line: CartLine): boolean;
|
|
32
|
-
/** A linha é
|
|
33
|
-
export type SwapCheck = (
|
|
34
|
-
|
|
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;
|
|
35
41
|
export declare function lineMatches(match: ProductMatch, line: ResolvedLine): boolean;
|
|
36
42
|
export declare function anyLineMatches(match: ProductMatch, lines: ResolvedLine[]): boolean;
|
|
37
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,10 +11,21 @@ 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
31
|
/** O item que a troca tirou, como linha; fora do documento, ele é do produto da loja da linha (a troca é no mesmo produto). */
|
|
@@ -29,14 +40,28 @@ export function isSameProductSwap(items, line) {
|
|
|
29
40
|
const replaced = replacedLine(line);
|
|
30
41
|
return replaced !== null && sameProduct(resolveLine(items, replaced), resolveLine(items, line));
|
|
31
42
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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) };
|
|
40
65
|
}
|
|
41
66
|
export function lineMatches(match, line) {
|
|
42
67
|
if (match.items?.some((ref) => itemKey(ref) === line.key))
|
package/dist/conditions.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
/**
|
|
@@ -8,6 +8,8 @@ export type GateFailure = IneligibleReason | 'excluded';
|
|
|
8
8
|
* pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
|
|
9
9
|
*/
|
|
10
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;
|
|
11
13
|
/** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
|
|
12
14
|
export declare function validityFailure(offer: Offer, at: string): 'not_started' | 'expired' | 'invalid_instant' | null;
|
|
13
15
|
/**
|
package/dist/conditions.js
CHANGED
|
@@ -7,8 +7,11 @@ const ACTIONS = new Set(['add', 'replace']);
|
|
|
7
7
|
* pode trazer um tipo que este não sabe avaliar: a oferta não vale, em vez de passar ou cobrar `NaN`.
|
|
8
8
|
*/
|
|
9
9
|
export function isSupportedOffer(offer) {
|
|
10
|
-
|
|
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));
|
|
11
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;
|
|
12
15
|
/** Validade: início inclusivo, fim exclusivo. Com validade, um instante ilegível (o `at` ou a própria validade) não vale. */
|
|
13
16
|
export function validityFailure(offer, at) {
|
|
14
17
|
if (offer.validFrom === undefined && offer.validUntil === undefined)
|
package/dist/decide.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assessOfferLines, assessor, offerGate } from './assess.js';
|
|
2
2
|
import { anyLineMatches, indexItems, itemKey, resolveLine, viewCart } from './cart.js';
|
|
3
|
-
import { conditionsFailure } from './conditions.js';
|
|
3
|
+
import { conditionsFailure, isSellable } from './conditions.js';
|
|
4
4
|
import { placementOffers } from './effective.js';
|
|
5
5
|
import { assignExperiment, inHoldout } from './experiments.js';
|
|
6
6
|
import { classify } from './kind.js';
|
|
@@ -17,17 +17,23 @@ function openSlots(ctx) {
|
|
|
17
17
|
const taken = ctx.accepted.filter((assessed) => assessed.placement === ctx.placement.key).length;
|
|
18
18
|
return Math.max(0, slotsFor(ctx.placement, ctx.view) - taken);
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
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);
|
|
27
|
+
}
|
|
22
28
|
function candidate(ctx, offer) {
|
|
23
29
|
if (offerGate(offer, ctx.at))
|
|
24
30
|
return null;
|
|
25
31
|
const item = ctx.items.get(itemKey(offer.item));
|
|
26
|
-
if (!
|
|
32
|
+
if (!isSellable(item) || ctx.view.keys.has(itemKey(offer.item)))
|
|
27
33
|
return null;
|
|
28
34
|
if (conditionsFailure(offer.conditions, ctx.view, 'decide'))
|
|
29
35
|
return null;
|
|
30
|
-
const classification =
|
|
36
|
+
const classification = classifyOffer(ctx, offer);
|
|
31
37
|
if (offer.action === 'replace' && !classification.target)
|
|
32
38
|
return null;
|
|
33
39
|
return { offer, item, classification };
|
|
@@ -54,23 +60,26 @@ function groupingFor(ctx) {
|
|
|
54
60
|
const groupings = ctx.variant?.groupings ?? ctx.placement.groupings ?? [];
|
|
55
61
|
return groupings.find((grouping) => anyLineMatches(grouping.when, ctx.view.chosen))?.presentation ?? null;
|
|
56
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
|
+
}
|
|
57
68
|
/**
|
|
58
|
-
*
|
|
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
|
|
59
71
|
* perguntas iguais seguidas são a mesma decisão. A ordem das linhas também não.
|
|
60
72
|
*/
|
|
61
|
-
function
|
|
62
|
-
const cart = input.cart
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const canonical = JSON.stringify([version, input.placement, input.visitorId ?? '', cart, offerIds, experiments]);
|
|
66
|
-
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 })) };
|
|
67
77
|
}
|
|
68
|
-
function toDecided(ctx, pick, slot
|
|
78
|
+
function toDecided(ctx, pick, slot) {
|
|
69
79
|
const { offer, item, classification } = pick;
|
|
70
80
|
const price = offerUnitPrice(offer.price, item.price);
|
|
71
81
|
return {
|
|
72
82
|
offerId: offer.id,
|
|
73
|
-
decisionId,
|
|
74
83
|
placement: ctx.placement.key,
|
|
75
84
|
slot,
|
|
76
85
|
kind: classification.kind,
|
|
@@ -95,8 +104,7 @@ function placementPresentation(ctx) {
|
|
|
95
104
|
* grupo de fora geral responde vazio com `holdout: true`.
|
|
96
105
|
*/
|
|
97
106
|
function emptyDecision(doc, input, status, holdout = false) {
|
|
98
|
-
|
|
99
|
-
return { status, version: doc.version, placement: input.placement, decisionId, offers: [], presentation: null, grouping: null, experiments: [], holdout };
|
|
107
|
+
return withDecisionId(input, doc.version, { status, version: doc.version, placement: input.placement, offers: [], presentation: null, grouping: null, experiments: [], holdout });
|
|
100
108
|
}
|
|
101
109
|
/**
|
|
102
110
|
* As ofertas que valem para esta posição e este carrinho, com preço, tipo calculado e o teste sorteado. Respeita
|
|
@@ -118,16 +126,14 @@ export function decide(doc, input) {
|
|
|
118
126
|
const ctx = { doc, items, view, accepted: assessOfferLines(assess), placement, variant: assignment?.variant ?? null, at: input.at };
|
|
119
127
|
const picks = pickOffers(ctx);
|
|
120
128
|
const experiments = assignment ? [{ key: assignment.experiment.key, variant: assignment.variant.key }] : [];
|
|
121
|
-
|
|
122
|
-
return {
|
|
129
|
+
return withDecisionId(input, doc.version, {
|
|
123
130
|
status: 'ok',
|
|
124
131
|
version: doc.version,
|
|
125
132
|
placement: placement.key,
|
|
126
|
-
|
|
127
|
-
offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1, decisionId)),
|
|
133
|
+
offers: picks.map((pick, index) => toDecided(ctx, pick, index + 1)),
|
|
128
134
|
presentation: placementPresentation(ctx),
|
|
129
135
|
grouping: groupingFor(ctx),
|
|
130
136
|
experiments,
|
|
131
137
|
holdout: false,
|
|
132
|
-
};
|
|
138
|
+
});
|
|
133
139
|
}
|
package/dist/io.d.ts
CHANGED
|
@@ -2,8 +2,13 @@ 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
|
/**
|
|
9
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,9 +20,10 @@ export interface CartLine {
|
|
|
15
20
|
/** A posição de onde a oferta aceita veio (o `placement` da `DecidedOffer`); desempata o id repetido em duas posições. */
|
|
16
21
|
placement?: string;
|
|
17
22
|
/**
|
|
18
|
-
* Na ação `replace`: o item que a oferta trocou.
|
|
19
|
-
*
|
|
20
|
-
* vaga extra, combo).
|
|
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).
|
|
21
27
|
*/
|
|
22
28
|
replacedItem?: ItemRef;
|
|
23
29
|
/** Brinde: não é escolha da cliente nem entra no subtotal. Com `offerId`, segue linha de oferta e ocupa a vaga. */
|
|
@@ -57,7 +63,10 @@ export interface DecidedOffer {
|
|
|
57
63
|
pricePerDay: Cents | null;
|
|
58
64
|
currency: string;
|
|
59
65
|
action: OfferAction;
|
|
60
|
-
/**
|
|
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
|
+
*/
|
|
61
70
|
replaces: ItemRef | null;
|
|
62
71
|
content: OfferContent;
|
|
63
72
|
presentation: string | null;
|
|
@@ -68,7 +77,11 @@ export interface Decision {
|
|
|
68
77
|
status: ResultStatus;
|
|
69
78
|
version: number;
|
|
70
79
|
placement: string;
|
|
71
|
-
/**
|
|
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
|
+
*/
|
|
72
85
|
decisionId: string;
|
|
73
86
|
offers: DecidedOffer[];
|
|
74
87
|
/** Apresentação padrão da posição (já com a variante do teste). */
|
|
@@ -105,13 +118,17 @@ export type IneligibleReason = 'unknown_offer' | 'inactive' | 'item_mismatch' |
|
|
|
105
118
|
/** O `at` (ou a validade da oferta) não é um instante legível, e a oferta tem validade. */
|
|
106
119
|
| 'invalid_instant'
|
|
107
120
|
/** O documento é de um formato mais novo que o pacote: toda linha volta com este motivo. */
|
|
108
|
-
| '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';
|
|
109
124
|
export type PricedLine = {
|
|
110
125
|
index: number;
|
|
111
126
|
offerId: string;
|
|
112
127
|
status: 'ok';
|
|
113
128
|
unitPrice: Cents;
|
|
114
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;
|
|
115
132
|
} | {
|
|
116
133
|
index: number;
|
|
117
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,6 +1,11 @@
|
|
|
1
1
|
import type { Cents, OfferPrice } from './types.js';
|
|
2
|
-
/** O preço é de um tipo que o pacote sabe calcular, com
|
|
2
|
+
/** O preço é de um tipo que o pacote sabe calcular, com inteiro no campo do valor (centavos; pontos-base até 100%). */
|
|
3
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;
|
|
4
9
|
/**
|
|
5
10
|
* Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
|
|
6
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
|
package/dist/money.js
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
function divideHalfUp(numerator, denominator) {
|
|
3
3
|
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
|
4
4
|
}
|
|
5
|
-
|
|
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%). */
|
|
6
7
|
export function isKnownPrice(price) {
|
|
7
8
|
if (price.type === 'percent_off')
|
|
8
|
-
return
|
|
9
|
+
return isWhole(price.basisPoints, 10_000);
|
|
9
10
|
if (price.type === 'fixed' || price.type === 'amount_off')
|
|
10
|
-
return
|
|
11
|
+
return isWhole(price.amount);
|
|
11
12
|
return false;
|
|
12
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
|
+
}
|
|
13
22
|
/**
|
|
14
23
|
* Preço da oferta por unidade, a partir do preço da loja. O desconto percentual é arredondado meio para cima (15% de
|
|
15
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
|
package/dist/price.d.ts
CHANGED
|
@@ -3,8 +3,9 @@ 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. A reconferência usa
|
|
7
|
-
*
|
|
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
|
|
8
9
|
* pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
|
|
9
10
|
* variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
|
|
10
11
|
* "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
|
package/dist/price.js
CHANGED
|
@@ -16,8 +16,9 @@ function refuseAll(cart, reason) {
|
|
|
16
16
|
/**
|
|
17
17
|
* O preço de cada linha de oferta aceita, **conferindo as condições de novo** com o carrinho real (ADR-0083 §1,
|
|
18
18
|
* princípio 2, e §6). Linha que perdeu a condição volta `ineligible` com o motivo, e a ponte escolhe remover ou cobrar
|
|
19
|
-
* o preço cheio. A reconferência usa
|
|
20
|
-
*
|
|
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
|
|
21
22
|
* pacote não é precificado: toda linha volta `unsupported_schema`; visitante do grupo de fora geral, `holdout`. A
|
|
22
23
|
* variante do teste é sorteada de novo pelo visitante (com as mesmas `assignments`), a mesma que o `decide` deu. O
|
|
23
24
|
* "produto que esconde" (`cart_has_none`) não é conferido aqui: esconder não desfaz a oferta já aceita (app ADR 0025).
|
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/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,
|