import { expect } from "@odoo/hoot"; import { setupPosEnv } from "@point_of_sale/../tests/unit/utils"; import { onRpc } from "@web/../tests/web_test_helpers"; const { DateTime } = luxon; import { roundCurrency } from "@point_of_sale/app/models/utils/currency"; import { computeComboItems } from "@point_of_sale/app/models/utils/compute_combo_items"; export const expectTransactionLine = ( transaction, product, quantity, quantityType, price, codeVat, lineType, negativeQuantityReason = false ) => { const total = price * quantity; expect(transaction.lineType).toBe(lineType); expect(transaction.mainProduct.productId).toBe(`product_id_${product.id}`); expect(transaction.mainProduct.productName).toBe(product.name); expect(transaction.mainProduct.quantity).toBe(quantity); expect(transaction.mainProduct.quantityType).toBe(quantityType); expect(transaction.mainProduct.unitPrice).toBe(price); if (transaction.mainProduct.vats.length) { expect(transaction.mainProduct.vats[0].label).toBe(codeVat); expect(transaction.mainProduct.vats[0].price).toBe(total); } if (negativeQuantityReason) { expect(transaction.mainProduct.negQuantityReason).toBe(negativeQuantityReason); } }; export const expectTransactionTotals = (transaction, currency) => { // Compute sum of lineTotals and ensure it equals transactionTotal const sumLines = transaction.transactionLines.reduce((acc, line) => acc + line.lineTotal, 0); const roundedSumLines = roundCurrency(sumLines, currency); expect(transaction.transactionTotal).toBe(roundedSumLines); // For each transaction line, compute lineTotal (sum of vat prices + priceChanges) // and ensure it equals the lineTotal for (const transLine of transaction.transactionLines) { const sumVats = transLine.mainProduct.vats.length ? transLine.mainProduct.vats.reduce( (acc, vat) => acc + vat.price + vat.priceChanges.reduce((a, pc) => a + pc.amount, 0), 0 ) : transLine.subProducts.reduce( (acc, subProd) => acc + subProd.vats.reduce( (a, vat) => a + vat.price + vat.priceChanges.reduce((ac, pc) => ac + pc.amount, 0), 0 ), 0 ); const roundedSumVats = roundCurrency(sumVats, currency); expect(transLine.lineTotal).toBe(roundedSumVats); } }; export const expectGeneralProperties = ( input, { order = false, posId = "CPOS0031234567", bookingPeriodId = "0ca2ebb7-5f77-4d03-99a9-77b5673ab248", posSwVersion = "1.0", ticketMedium = "PAPER", employeeId = "1234567890", estNo = "8789456149", orderPrice = null, mustHavePriceChanges = false, } = {} ) => { expect(input.posId).toBe(posId); expect(input.bookingPeriodId).toBe(bookingPeriodId); expect(input.posSwVersion).toBe(posSwVersion); expect(input.ticketMedium).toBe(ticketMedium); expect(input.employeeId).toBe(employeeId); expect(input.estNo).toBe(estNo); expect(input.terminalId).not.toBeEmpty(); expect(isRFC3339DateTime(input.posDateTime)).toBe(true); expect(isValidISODate(input.bookingDate)).toBe(true); if (input.transaction && order) { // Validate priceChanges per vat line when not expected if (!mustHavePriceChanges) { for (const transLine of input.transaction.transactionLines) { if (transLine.lineType != "COMPOSITE_PRODUCT") { transLine.mainProduct.vats.forEach((vat) => { const filteredPriceChanges = vat.priceChanges.filter( (pc) => pc.type !== "INTERNAL" ); expect(filteredPriceChanges).toBeEmpty(); }); } } } // Validate COMPOSITE_PRODUCT vat structure for (const transLine of input.transaction.transactionLines) { if (transLine.lineType == "COMPOSITE_PRODUCT") { expect(transLine.mainProduct.vats.length).toBe(0); transLine.subProducts.forEach((subProd) => { expect(subProd.vats.length).toBeGreaterThan(0); }); } } // Validate transaction and line totals expectTransactionTotals(input.transaction, order.currency); if (input.financials?.length) { const sumFinancials = input.financials.reduce((acc, fin) => acc + fin.amount, 0); const orderPriceIncl = roundCurrency(orderPrice ?? order.priceIncl, order.currency); expect(orderPriceIncl).toBe(sumFinancials); } } if (input.fdmRef?.fdmDateTime) { expect(isRFC3339DateTimeUTC(input.fdmRef.fdmDateTime)).toBe(true); } }; export const expectCostCenter = (costCenter, id, type, reference) => { expect(costCenter.id).toBe(id); expect(costCenter.type).toBe(type); expect(costCenter.reference).toBe(reference); }; export const expectFinancial = ( financial, name, amountType, amount, type, inputMethod = "MANUAL" ) => { expect(financial.name).toBe(name); expect(financial.amountType).toBe(amountType); expect(financial.amount).toBe(amount); expect(financial.type).toBe(type); expect(financial.inputMethod).toBe(inputMethod); expect(financial.id).not.toBeEmpty(); }; export const generatePosOrder = ( models, rawLines = [], rawPayments = [], { orderId = 1, refundedOrderId = null } = {} ) => { let lineId = 1; let paymentId = 1; const lines = rawLines.map((line) => ({ ...line, id: lineId++, order_id: orderId, })); const payments = rawPayments.map((payment) => ({ ...payment, id: paymentId++, pos_order_id: orderId, })); const data = models.loadConnectedData({ "pos.order": [ { id: orderId, state: rawPayments.length ? "paid" : "draft", lines: lines.map((line) => line.id), payment_ids: payments.map((payment) => payment.id), refunded_order_id: refundedOrderId, is_refund: refundedOrderId ? true : false, }, ], "pos.order.line": lines, "pos.payment": payments, }); const order = data["pos.order"][0]; return order; }; export const getComboOrder = (store, paid = false, addSingleProductLine = false) => { const models = store.models; const menu = models["product.product"].find((m) => m.name === "Business Menu All-In"); const starter = models["product.product"].find((m) => m.name === "Carpaccio Beef"); const main = models["product.product"].find((m) => m.name === "Burger of the Chef"); const wine = models["product.product"].find((m) => m.name === "Matching Wines"); const order = store.addNewOrder(); if (addSingleProductLine) { const coca = models["product.product"].find((m) => m.name === "Coca Cola"); models["pos.order.line"].create({ order_id: order.id, product_id: coca.id, price_unit: coca.lst_price, qty: 2, }); } models["pos.order.line"].create({ product_id: menu, qty: 1, order_id: order.id, price_unit: 0, tax_ids: menu.taxes_id, combo_line_ids: [ [ "create", { price_unit: 16.11, product_id: starter, qty: 1, order_id: order.id, tax_ids: starter.taxes_id, }, ], [ "create", { price_unit: 22.56, product_id: main.id, qty: 1, order_id: order.id, tax_ids: main.taxes_id, }, ], [ "create", { price_unit: 19.330000000000002, product_id: wine.id, qty: 1, order_id: order.id, tax_ids: wine.taxes_id, }, ], ], }); if (paid) { payOrder(store, order); } return order; }; export const getMultiQtyComboOrder = (store, paid = false) => { const models = store.models; // Use the dedicated "Business Menu All-In Multi Qty" product (combo_ids=[4,5,6]) // where Multi Starters has qty_free=2, Multi Drink has qty_free=3, Single Main has qty_free=1. const menu = models["product.product"].find((m) => m.name === "Business Menu All-In Multi Qty"); const starter = models["product.product"].find((m) => m.name === "Carpaccio Beef"); const wine = models["product.product"].find((m) => m.name === "Matching Wines"); const main = models["product.product"].find((m) => m.name === "Burger of the Chef"); const starterItem = models["product.combo.item"].find( (i) => i.combo_id.name === "Multi Starters" ); const wineItem = models["product.combo.item"].find((i) => i.combo_id.name === "Multi Drink"); const mainItem = models["product.combo.item"].find((i) => i.combo_id.name === "Single Main"); const order = store.addNewOrder(); // Compute prices via computeComboItems. // Order: [starter(qty=2), wine(qty=3), main(qty=1)] — main is last with qty=1 // to prevent the split of the last item in the algorithm. // originalTotal = 20*2 + 24*3 + 28*1 = 140 // starter: round(20*58/140) = 8.29, wine: round(24*58/140) = 9.94, main: 11.6 (absorbs remainder) // Total: 8.29*2 + 9.94*3 + 11.6*1 = 58.0 const childLineConf = [ { combo_item_id: starterItem, qty: 2 }, { combo_item_id: wineItem, qty: 3 }, { combo_item_id: mainItem, qty: 1 }, ]; const comboPrices = computeComboItems( menu, childLineConf, order.pricelist_id, models["decimal.precision"].getAll(), models["product.template.attribute.value"].getAllBy("id"), [], order.config_id.currency_id ); // comboPrices: [{starter, price_unit:8.29, qty:2}, {wine, price_unit:9.94, qty:3}, {main, price_unit:11.6, qty:1}] models["pos.order.line"].create({ product_id: menu, qty: 1, order_id: order.id, price_unit: 0, tax_ids: menu.taxes_id, combo_line_ids: [ [ "create", { price_unit: comboPrices[0].price_unit, product_id: starter, qty: comboPrices[0].qty, order_id: order.id, tax_ids: starter.taxes_id, }, ], [ "create", { price_unit: comboPrices[1].price_unit, product_id: wine, qty: comboPrices[1].qty, order_id: order.id, tax_ids: wine.taxes_id, }, ], [ "create", { price_unit: comboPrices[2].price_unit, product_id: main, qty: comboPrices[2].qty, order_id: order.id, tax_ids: main.taxes_id, }, ], ], }); if (paid) { payOrder(store, order); } return order; }; export const payOrder = (store, order) => { const amountTotal = order.priceIncl; order.state = "paid"; store.models["pos.payment"].create({ amount: amountTotal, pos_order_id: order, payment_method_id: store.models["pos.payment.method"].find((m) => m.name === "Cash"), }); store.addPendingOrder([order.id]); }; export const setupPosBlackboxEnv = async ( blackboxRpcCallback = () => void 0, opts = { setupCashier: true } ) => { onRpc("/graphql", blackboxRpcCallback); const id = odoo.pos_config_id; const deviceKey = `l10n_be_pos_blackbox-device-id-${id}`; localStorage.setItem(deviceKey, "deviceId"); const store = await setupPosEnv(opts); return store; }; export const waitForUnawaitedCalls = async (method, timeout = 50) => new Promise((resolve) => { setTimeout(() => { method(); resolve(); }, timeout); }); export const waitForDelayedCalls = () => { let resolver; const promise = new Promise((resolve) => { resolver = resolve; }); return { promise, resolver }; }; export const setOrderFdmSignature = ( order, { l10n_be_fdm_id = "1234567890", l10n_be_fdm_date_time = "2023-10-01 12:00:00", eventLabel = "N", l10n_be_event_counter = 1, l10n_be_total_counter = 100, l10n_be_short_signature = "short-signature", } = {} ) => { order.update({ l10n_be_fdm_id: l10n_be_fdm_id, l10n_be_fdm_date_time: l10n_be_fdm_date_time, l10n_be_event_label: eventLabel, l10n_be_event_counter: l10n_be_event_counter, l10n_be_total_counter: l10n_be_total_counter, l10n_be_short_signature: l10n_be_short_signature, }); }; /** * Validates RFC3339 datetime in UTC (must end with 'Z') * Example: 2022-10-20T15:01:26Z * This format is required for FDM date time */ export const isRFC3339DateTimeUTC = (value) => { if (typeof value !== "string") { return false; } const rfc3339UtcRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}).(\d{3})Z$/; const match = value.match(rfc3339UtcRegex); if (!match) { return false; } const dt = DateTime.fromISO(value, { zone: "utc" }); return dt.isValid && dt.toUTC().toISO() === value; }; /** * Validates RFC3339 datetime with timezone offset (Z is NOT allowed) * Example: 2022-10-20T15:01:25:512+02:00 * This format is required for POS date time */ export const isRFC3339DateTime = (value) => { if (typeof value !== "string") { return false; } const rfc3339Regex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}).(\d{3})([+-]\d{2}:\d{2})$/; const match = value.match(rfc3339Regex); if (!match) { return false; } const dt = DateTime.fromISO(value, { setZone: true }); return dt.isValid; }; /** * Validates ISO date format YYYY-MM-DD */ export const isValidISODate = (value) => { if (typeof value !== "string") { return false; } const isoDateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; if (!isoDateRegex.test(value)) { return false; } const [year, month, day] = value.split("-").map(Number); const date = new Date(Date.UTC(year, month - 1, day)); return ( date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ); };