Rust discount function (one automatic discount evaluating all offers in priority order, parity with the Odoo engine), React Router admin with Polaris, theme app extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
12 KiB
Python
203 lines
12 KiB
Python
"""Generate spec/test-vectors.json by running the ORIGINAL Odoo bundle engine.
|
|
|
|
The Shopify port of the engine must produce exactly these results (see index.html, "Parity tests").
|
|
Run: python spec/generate_vectors.py (no Odoo needed: the engine is plain Python)
|
|
|
|
Cart format used by the vectors (Shopify-shaped):
|
|
lines: [{id, variant_id, product_id, collection_ids: [...], unit_price, quantity}]
|
|
Offer format: see spec/offer-config.schema.json ("engine offer").
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ENGINE = os.path.join(HERE, '..', 'reference', 'odoo-c2c_bundle_offers', 'models', 'bundle_engine.py')
|
|
spec = importlib.util.spec_from_file_location('bundle_engine', ENGINE)
|
|
engine = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(engine)
|
|
|
|
|
|
def to_units(lines):
|
|
"""One unit per item, in cart order (the Odoo engine's pool order)."""
|
|
units = []
|
|
for line in lines:
|
|
for _i in range(int(line['quantity'])):
|
|
units.append(engine.Unit(line['id'], line['variant_id'], line.get('collection_ids', []), line['unit_price']))
|
|
return units
|
|
|
|
|
|
def to_engine_offer(offer):
|
|
"""Shopify-shaped offer -> the dict the Odoo engine expects (variant ids play Odoo's product ids)."""
|
|
def scope(s):
|
|
return (set(s.get('variant_ids', [])), set(s.get('collection_ids', [])))
|
|
out = {'id': offer['id'], 'type': offer['type'], 'max_uses': offer.get('max_uses', 0),
|
|
'discount_type': offer.get('discount_type'), 'value': offer.get('value')}
|
|
if offer['type'] == 'fixed':
|
|
out['lines'] = [(l['variant_id'], l['quantity']) for l in offer['lines']]
|
|
elif offer['type'] == 'mix':
|
|
out.update(scope=scope(offer['scope']), qty=offer['qty'])
|
|
else:
|
|
out.update(buy_scope=scope(offer['buy_scope']), buy_qty=offer['buy_qty'],
|
|
get_scope=scope(offer['get_scope']), get_qty=offer['get_qty'], get_percent=offer['get_percent'])
|
|
return out
|
|
|
|
|
|
def largest_remainder_cents(amounts):
|
|
"""Round each amount to cents so the rounded total equals round(total). Ties: larger remainder first,
|
|
then input order. This is the rounding rule the Shopify app must use per offer."""
|
|
cents_exact = [a * 100.0 for a in amounts]
|
|
target = int(round(sum(amounts) * 100.0 + 1e-9))
|
|
floors = [int(c + 1e-9) for c in cents_exact]
|
|
remainder = target - sum(floors)
|
|
order = sorted(range(len(amounts)), key=lambda i: (-(cents_exact[i] - floors[i]), i))
|
|
for i in order[:max(remainder, 0)]:
|
|
floors[i] += 1
|
|
return [f / 100.0 for f in floors]
|
|
|
|
|
|
def run(name, lines, offers, note=''):
|
|
apps = engine.apply_offers(to_units(lines), [to_engine_offer(o) for o in offers])
|
|
per_line = engine.split_by(apps, key=lambda u: u.ref) # {(offer_id, line_id): amount}
|
|
allocations = []
|
|
for offer_id in [o['id'] for o in offers]:
|
|
items = [(line_id, amt) for (oid, line_id), amt in per_line.items() if oid == offer_id]
|
|
items.sort(key=lambda x: [l['id'] for l in lines].index(x[0]))
|
|
rounded = largest_remainder_cents([a for _l, a in items])
|
|
for (line_id, amt), r in zip(items, rounded):
|
|
allocations.append({'offer_id': offer_id, 'line_id': line_id, 'amount_exact': round(amt, 6),
|
|
'amount': r})
|
|
return {
|
|
'name': name, 'note': note, 'cart': {'lines': lines}, 'offers': offers,
|
|
'expected': {
|
|
'applications': [{'offer_id': a['offer_id'], 'discount': round(a['discount'], 6),
|
|
'line_ids_used': [u.ref for u in a['units']]} for a in apps],
|
|
'allocations': allocations,
|
|
'total_discount': round(sum(x['amount'] for x in allocations), 2),
|
|
},
|
|
}
|
|
|
|
|
|
def L(i, variant, price, qty=1, cols=(), product=None):
|
|
return {'id': 'gid://shopify/CartLine/%s' % i, 'variant_id': variant, 'product_id': product or 'P-' + variant,
|
|
'collection_ids': list(cols), 'unit_price': price, 'quantity': qty}
|
|
|
|
|
|
def S(variants=(), cols=()):
|
|
return {'variant_ids': list(variants), 'collection_ids': list(cols)}
|
|
|
|
|
|
V = []
|
|
# --- fixed bundles ---------------------------------------------------------------------------
|
|
V.append(run('fixed_percent_complete',
|
|
[L(1, 'diya', 10.0), L(2, 'camphor', 5.0, 2)],
|
|
[{'id': 'kit', 'type': 'fixed', 'discount_type': 'percent', 'value': 20,
|
|
'lines': [{'variant_id': 'diya', 'quantity': 1}, {'variant_id': 'camphor', 'quantity': 2}]}],
|
|
'Odoo test: 20% of (10 + 2 x 5) = 4.00, spread 2.00 / 2.00 by price.'))
|
|
V.append(run('fixed_incomplete_no_discount',
|
|
[L(1, 'diya', 10.0), L(2, 'camphor', 5.0, 1)],
|
|
[{'id': 'kit', 'type': 'fixed', 'discount_type': 'percent', 'value': 20,
|
|
'lines': [{'variant_id': 'diya', 'quantity': 1}, {'variant_id': 'camphor', 'quantity': 2}]}],
|
|
'Bundle needs 2 camphor; only 1 in cart -> nothing.'))
|
|
V.append(run('fixed_three_tax_groups_split',
|
|
[L(1, 'a', 10.0), L(2, 'b', 5.0, 2), L(3, 'c', 4.0)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'percent', 'value': 20,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 2},
|
|
{'variant_id': 'c', 'quantity': 1}]}],
|
|
'Odoo split per tax; on Shopify the discount is allocated per line (tax follows the line).'))
|
|
V.append(run('fixed_repeats_twice',
|
|
[L(1, 'a', 10.0, 2), L(2, 'b', 5.0, 4)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'amount', 'value': 3,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 2}]}],
|
|
'Enough items for 2 bundles and no limit -> applied twice ($6).'))
|
|
V.append(run('fixed_max_uses_1',
|
|
[L(1, 'a', 10.0, 2), L(2, 'b', 5.0, 4)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'amount', 'value': 3, 'max_uses': 1,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 2}]}],
|
|
'"Max uses per order" = 1.'))
|
|
V.append(run('fixed_amount_capped_at_regular',
|
|
[L(1, 'a', 2.0), L(2, 'b', 1.0)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'amount', 'value': 10,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 1}]}],
|
|
'$ off never exceeds the items\' price (discount 3.00, not 10.00).'))
|
|
V.append(run('fixed_price_bundle',
|
|
[L(1, 'a', 12.0), L(2, 'b', 9.0), L(3, 'c', 8.0)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'fixed_price', 'value': 20,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 1},
|
|
{'variant_id': 'c', 'quantity': 1}]}],
|
|
'Bundle price 20 for items worth 29 -> 9.00 off.'))
|
|
V.append(run('fixed_price_above_regular_gives_nothing',
|
|
[L(1, 'a', 5.0), L(2, 'b', 5.0)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'fixed_price', 'value': 15,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 1}]}],
|
|
'Bundle price higher than the items -> no discount, items stay free for other offers.'))
|
|
# --- mix & match -----------------------------------------------------------------------------
|
|
V.append(run('mix_fixed_price_uses_cheapest_no_gain',
|
|
[L(1, 'p1', 12.0, 1, ['sweets']), L(2, 'p2', 9.0, 1, ['sweets']), L(3, 'p3', 8.0, 1, ['sweets']),
|
|
L(4, 'p4', 3.0, 1, ['sweets'])],
|
|
[{'id': 'M', 'type': 'mix', 'discount_type': 'fixed_price', 'value': 20, 'qty': 3,
|
|
'scope': S(cols=['sweets'])}],
|
|
'Cheapest 3 (3+8+9=20) already cost 20 -> no discount (Odoo test).'))
|
|
V.append(run('mix_fixed_price_any_3_for_20',
|
|
[L(1, 'p1', 12.0, 1, ['sweets']), L(2, 'p2', 9.0, 1, ['sweets']), L(3, 'p3', 8.0, 1, ['sweets'])],
|
|
[{'id': 'M', 'type': 'mix', 'discount_type': 'fixed_price', 'value': 20, 'qty': 3,
|
|
'scope': S(cols=['sweets'])}],
|
|
'29 -> 20: 9.00 off, spread by price.'))
|
|
V.append(run('mix_percent_twice_cheapest_first',
|
|
[L(1, 'p1', 10.0, 3, ['g']), L(2, 'p2', 4.0, 3, ['g'])],
|
|
[{'id': 'M', 'type': 'mix', 'discount_type': 'percent', 'value': 10, 'qty': 3, 'scope': S(cols=['g'])}],
|
|
'Two uses: first takes the three 4.00 items, second the three 10.00 items.'))
|
|
V.append(run('mix_by_variants_and_collections',
|
|
[L(1, 'x', 6.0), L(2, 'y', 7.0, 1, ['flowers']), L(3, 'z', 50.0)],
|
|
[{'id': 'M', 'type': 'mix', 'discount_type': 'amount', 'value': 5, 'qty': 2,
|
|
'scope': S(variants=['x'], cols=['flowers'])}],
|
|
'A unit matches if its variant OR any of its collections is in the scope.'))
|
|
# --- buy X get Y -----------------------------------------------------------------------------
|
|
V.append(run('bxgy_same_scope_cheapest_free',
|
|
[L(1, 'a', 10.0), L(2, 'b', 8.0), L(3, 'c', 6.0)],
|
|
[{'id': 'B', 'type': 'bxgy', 'buy_qty': 2, 'get_qty': 1, 'get_percent': 100,
|
|
'buy_scope': S(variants=['a', 'b', 'c']), 'get_scope': S(variants=['a', 'b', 'c'])}],
|
|
'Most expensive count as "buy", cheapest remaining is free -> 6.00 (Odoo test).'))
|
|
V.append(run('bxgy_get_item_missing',
|
|
[L(1, 'a', 10.0, 2)],
|
|
[{'id': 'B', 'type': 'bxgy', 'buy_qty': 2, 'get_qty': 1, 'get_percent': 50,
|
|
'buy_scope': S(variants=['a']), 'get_scope': S(variants=['gift'])}],
|
|
'Buy part complete but no "get" item -> nothing, buy items released.'))
|
|
V.append(run('bxgy_half_price',
|
|
[L(1, 'a', 10.0, 2), L(2, 'gift', 4.0)],
|
|
[{'id': 'B', 'type': 'bxgy', 'buy_qty': 2, 'get_qty': 1, 'get_percent': 50,
|
|
'buy_scope': S(variants=['a']), 'get_scope': S(variants=['gift'])}],
|
|
'Get item at 50% -> 2.00. Discount is spread over ALL used units (buy + get) by price, as Odoo does.'))
|
|
V.append(run('bxgy_repeats',
|
|
[L(1, 'a', 10.0, 6)],
|
|
[{'id': 'B', 'type': 'bxgy', 'buy_qty': 2, 'get_qty': 1, 'get_percent': 100,
|
|
'buy_scope': S(variants=['a']), 'get_scope': S(variants=['a'])}],
|
|
'6 items: buy 2 get 1 twice -> 20.00.'))
|
|
# --- priority / exclusivity -----------------------------------------------------------------
|
|
V.append(run('priority_incomplete_first_offer_frees_items',
|
|
[L(1, 'a', 10.0)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'percent', 'value': 50,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 1}]},
|
|
{'id': 'M', 'type': 'mix', 'discount_type': 'percent', 'value': 10, 'qty': 1, 'scope': S(variants=['a'])}],
|
|
'Odoo test: the incomplete fixed bundle leaves the item for the next offer.'))
|
|
V.append(run('priority_item_used_once',
|
|
[L(1, 'a', 10.0), L(2, 'b', 10.0)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'percent', 'value': 10,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 1}]},
|
|
{'id': 'M', 'type': 'mix', 'discount_type': 'percent', 'value': 50, 'qty': 2,
|
|
'scope': S(variants=['a', 'b'])}],
|
|
'Offers in priority order: the first takes both items, the better second offer gets nothing.'))
|
|
V.append(run('rounding_three_way_split',
|
|
[L(1, 'a', 3.33), L(2, 'b', 3.33), L(3, 'c', 3.34)],
|
|
[{'id': 'F', 'type': 'fixed', 'discount_type': 'amount', 'value': 1,
|
|
'lines': [{'variant_id': 'a', 'quantity': 1}, {'variant_id': 'b', 'quantity': 1},
|
|
{'variant_id': 'c', 'quantity': 1}]}],
|
|
'Per-line cents use largest remainder so they add up to exactly 1.00.'))
|
|
|
|
out = os.path.join(HERE, 'test-vectors.json')
|
|
with open(out, 'w', encoding='utf-8') as f:
|
|
json.dump({'generated_from': 'reference/odoo-c2c_bundle_offers/models/bundle_engine.py',
|
|
'rounding': 'per offer: largest remainder to cents (see largest_remainder_cents)',
|
|
'vectors': V}, f, indent=2)
|
|
print('wrote %d vectors to %s' % (len(V), out))
|