"""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))