diff --git a/commera/api/admin/analytics.py b/commera/api/admin/analytics.py index 8f1738b8..6cbe9d48 100644 --- a/commera/api/admin/analytics.py +++ b/commera/api/admin/analytics.py @@ -32,6 +32,7 @@ in_window, is_provider_configured, ) +from commera.discounts.allocations import get_allocations ANALYTICS_SETTINGS_DOCTYPE = "Analytics Settings" @@ -49,6 +50,8 @@ SCRIPT_FIELDS = ("title", "enabled", "script") +TOP_DISCOUNTS_LIMIT = 5 + def get_analytics_settings_doc(): frappe.only_for("System Manager") @@ -247,6 +250,8 @@ def get_revenue_report(months: int = REPORT_MONTHS_DEFAULT): sales_order.transaction_date, sales_order.base_grand_total, sales_order.base_discount_amount, + sales_order.conversion_rate, + sales_order.custom_discount_allocations, ) .where(is_webshop_order(sales_order)) .where(sales_order.transaction_date >= start) @@ -264,7 +269,7 @@ def get_revenue_report(months: int = REPORT_MONTHS_DEFAULT): ) bucket["revenue"] += flt(row.base_grand_total) bucket["orders"] += 1 - bucket["discounts"] += flt(row.base_discount_amount) + bucket["discounts"] += get_base_discount_given(row) bucket["refunds"] += flt(refunds_by_order.get(row.name, 0)) series = [] @@ -303,9 +308,51 @@ def get_revenue_report(months: int = REPORT_MONTHS_DEFAULT): }, "refunds": {"value": total_refunds, "previous": previous["refunds"]}, }, + "discounts": get_discount_summary(order_rows), + } + + +def get_discount_summary(order_rows) -> dict: + given, orders, by_label = 0.0, 0, {} + for row in order_rows: + allocations = get_allocations(row) + if not allocations: + continue + orders += 1 + labels_on_order = set() + for allocation in allocations: + amount = get_base_allocation_amount(row, allocation) + label = allocation.get("code") or allocation.get("title") + given += amount + entry = by_label.setdefault(label, {"label": label, "given": 0.0, "orders": 0}) + entry["given"] += amount + if label not in labels_on_order: + entry["orders"] += 1 + labels_on_order.add(label) + top = sorted(by_label.values(), key=lambda entry: entry["given"], reverse=True)[:TOP_DISCOUNTS_LIMIT] + return { + "given": flt(given, 2), + "orders": orders, + "top": [{**entry, "given": flt(entry["given"], 2)} for entry in top], } +def get_base_discount_given(row) -> float: + # Codes, plus what ERPNext gave on its own (a Pricing Rule, staff's Additional Discount) beyond the order code. + allocations = get_allocations(row) + codes = sum(get_base_allocation_amount(row, allocation) for allocation in allocations) + order_codes = sum( + get_base_allocation_amount(row, allocation) + for allocation in allocations + if allocation.get("discount_class") == "Order" + ) + return codes + max(flt(row.base_discount_amount) - order_codes, 0) + + +def get_base_allocation_amount(row, allocation: dict) -> float: + return flt(allocation.get("amount")) * (flt(row.conversion_rate) or 1) + + def get_item_sales(from_date, to_date, item_codes): """units/revenue/last-sold per sellable size, one aggregate query regardless of scope. from_date=None scans the size's whole history ("last sold, ever"); drafts count - see is_webshop_order.""" diff --git a/commera/api/admin/customers.py b/commera/api/admin/customers.py index a857ff96..f77b504b 100644 --- a/commera/api/admin/customers.py +++ b/commera/api/admin/customers.py @@ -5,6 +5,7 @@ import frappe from frappe import _ +from frappe.query_builder import Order from frappe.query_builder.functions import Count, Sum from frappe.utils.data import cint, cstr, date_diff, flt, formatdate @@ -189,6 +190,7 @@ def get_customer(customer: str): payment_modes = Counter( row.custom_ecommerce_payment_mode for row in lifetime_orders if row.custom_ecommerce_payment_mode ) + discount_uses = read_customer_discount_uses(customer) return { "id": doc.name, @@ -210,6 +212,8 @@ def get_customer(customer: str): "top_products": get_top_products(items), "spend_by_month": get_spend_by_month(lifetime_orders), "note": cstr(doc.customer_details), + "discount_uses": discount_uses, + "discount_saved": sum(flt(use.amount) for use in discount_uses), "recent_orders": [ {key: value for key, value in order.items() if key != "base_total"} for order in read_customer_orders(customer) @@ -333,6 +337,25 @@ def get_acquisition(first_order: str | None) -> dict | None: return {"source": event[0].utm_source, "campaign": event[0].utm_campaign or None} +def read_customer_discount_uses(customer: str) -> list: + use = frappe.qb.DocType("Commera Discount Use") + discount = frappe.qb.DocType("Commera Discount") + return ( + frappe.qb.from_(use) + .left_join(discount) + .on(discount.name == use.discount) + .select( + use.sales_order, + use.code, + use.base_amount.as_("amount"), + use.creation.as_("used_on"), + discount.title, + ) + .where(use.customer == customer) + .orderby(use.creation, order=Order.desc) + ).run(as_dict=True) + + def read_customer_orders(customer: str) -> list: """This one customer's own order history, in the same shape orders.get_orders uses for its rows. Counts drafts, for the reason read_customer_order_stats gives.""" diff --git a/commera/api/admin/discounts.py b/commera/api/admin/discounts.py new file mode 100644 index 00000000..90807f47 --- /dev/null +++ b/commera/api/admin/discounts.py @@ -0,0 +1,425 @@ +import frappe +from frappe import _ +from frappe.query_builder.functions import Count +from frappe.utils.data import cint, cstr, flt, getdate, today + +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import add_code, enqueue_codes, normalize_code +from commera.discounts.preview import get_preview_quotation +from commera.discounts.selection import get_unmet_reason + +DOCTYPE = "Commera Discount" +PAGE_LENGTH = 20 +MAX_PAGE_LENGTH = 100 +MAX_TEST_LINES = 20 + +EDITABLE_FIELDS = ( + "title", + "method", + "discount_type", + "enabled", + "starts_on", + "ends_on", + "value_type", + "value", + "maximum_discount", + "fixed_applies_to", + "currency", + "tiered", + "applies_to", + "exclude_sale_items", + "buy_requirement", + "buy_value", + "uses_per_order", + "get_quantity", + "get_value_type", + "get_value", + "delivery_options", + "shipping_percentage", + "maximum_shipping_rate", + "minimum_requirement", + "minimum_subtotal", + "minimum_quantity", + "payment_condition", + "eligibility", + "combines_with_product", + "combines_with_order", + "combines_with_shipping", + "usage_limit", + "once_per_customer", + "budget", + "campaign", + "discount_account", +) +TABLES = { + "tiers": ("minimum_subtotal", "value"), + "targets": ("role", "target_type", "target"), + "customer_groups": ("customer_group",), + "customers": ("customer", "customer_name"), +} +LIST_FIELDS = ( + "name", + "title", + "currency", + "method", + "discount_type", + "enabled", + "starts_on", + "ends_on", + "value_type", + "value", + "maximum_discount", + "tiered", + "payment_condition", + "eligibility", + "once_per_customer", + "usage_limit", + "budget", + "times_used", + "discount_given", + "exclude_sale_items", + "minimum_requirement", + "minimum_subtotal", + "minimum_quantity", + "buy_requirement", + "buy_value", + "get_quantity", + "get_value_type", + "get_value", + "shipping_percentage", + "combines_with_product", + "combines_with_order", + "combines_with_shipping", +) + + +@frappe.whitelist() +def get_discounts( + tab: str = "all", search: str | None = None, start: int = 0, page_length: int = PAGE_LENGTH +): + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + + discount = frappe.qb.DocType(DOCTYPE) + condition = get_tab_condition(discount, tab) + if search: + matching = frappe.get_all( + "Commera Discount Code", filters={"code": ("like", f"%{cstr(search)}%")}, pluck="discount" + ) + by_text = discount.title.like(f"%{cstr(search)}%") + by_code = discount.name.isin(matching) if matching else None + search_condition = by_text | by_code if by_code is not None else by_text + condition = search_condition if condition is None else condition & search_condition + + query = frappe.qb.from_(discount).select(*(discount[field] for field in LIST_FIELDS)) + count = frappe.qb.from_(discount).select(Count(discount.name)) + if condition is not None: + query, count = query.where(condition), count.where(condition) + rows = ( + query.orderby(discount.creation, order=frappe.qb.desc) + .limit(min(cint(page_length) or PAGE_LENGTH, MAX_PAGE_LENGTH)) + .offset(cint(start)) + ).run(as_dict=True) + + names = [row.name for row in rows] + codes = get_code_summaries(names) + tiers = get_tiers(names) + return { + "discounts": [ + {**row, **codes.get(row.name, {}), "tiers": tiers.get(row.name, []), "status": get_status(row)} + for row in rows + ], + "total": count.run()[0][0], + } + + +def get_tab_condition(discount, tab: str): + day = getdate(today()) + if tab in ("active", "used_up"): + started = discount.starts_on.isnull() | (discount.starts_on <= day) + running = discount.ends_on.isnull() | (discount.ends_on >= day) + used_up = ((discount.usage_limit > 0) & (discount.times_used >= discount.usage_limit)) | ( + (discount.budget > 0) & (discount.discount_given >= discount.budget) + ) + live = (discount.enabled == 1) & started & running + return live & used_up if tab == "used_up" else live & used_up.negate() + if tab == "scheduled": + return (discount.enabled == 1) & (discount.starts_on > day) + if tab == "expired": + return discount.ends_on < day + if tab == "disabled": + return discount.enabled == 0 + return None + + +def get_status(row) -> str: + return frappe.get_doc({"doctype": DOCTYPE, **row}).status + + +def get_tiers(discounts: list[str]) -> dict[str, list[dict]]: + tiers = {} + if not discounts: + return tiers + for row in frappe.get_all( + "Commera Discount Tier", + filters={"parent": ("in", discounts), "parenttype": DOCTYPE}, + fields=["parent", "minimum_subtotal", "value"], + order_by="idx asc", + ): + tiers.setdefault(row.parent, []).append( + {"minimum_subtotal": row.minimum_subtotal, "value": row.value} + ) + return tiers + + +def get_code_summaries(discounts: list[str]) -> dict[str, dict]: + if not discounts: + return {} + rows = frappe.get_all( + "Commera Discount Code", + filters={"discount": ("in", discounts)}, + fields=["discount", "code", "is_generated", "times_used"], + order_by="creation asc", + ) + summaries = {} + for row in rows: + summary = summaries.setdefault( + row.discount, {"code": None, "generated_codes": 0, "generated_codes_used": 0} + ) + if row.is_generated: + summary["generated_codes"] += 1 + summary["generated_codes_used"] += 1 if row.times_used else 0 + elif not summary["code"]: + summary["code"] = row.code + return summaries + + +@frappe.whitelist() +def get_discount(name: str) -> dict: + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + discount = frappe.get_doc(DOCTYPE, name) + record = {field: discount.get(field) for field in EDITABLE_FIELDS} + for table, fields in TABLES.items(): + record[table] = [{field: row.get(field) for field in fields} for row in discount.get(table)] + return { + "name": discount.name, + **record, + **get_code_summaries([discount.name]).get( + discount.name, {"code": None, "generated_codes": 0, "generated_codes_used": 0} + ), + "status": discount.status, + "discount_class": discount.discount_class, + "performance": { + "orders": cint(discount.times_used), + "discount_given": flt(discount.discount_given), + "sales": flt(discount.sales), + "budget_left": discount.budget_left, + }, + } + + +@frappe.whitelist(methods=["POST"]) +def save_discount(discount: dict | str) -> dict: + values = frappe.parse_json(discount) + name = values.get("name") + frappe.has_permission(DOCTYPE, ptype="write" if name else "create", throw=True) + + doc = frappe.get_doc(DOCTYPE, name) if name else frappe.new_doc(DOCTYPE) + doc.update({field: values[field] for field in EDITABLE_FIELDS if field in values}) + for table, fields in TABLES.items(): + if table in values: + doc.set(table, [{field: row.get(field) for field in fields} for row in values[table]]) + is_new = doc.is_new() + doc.flags.code = values.get("code") + doc.save() + + if not is_new and doc.method == "Code" and values.get("code"): + set_single_code(doc.name, values["code"]) + return get_discount(doc.name) + + +def set_single_code(discount: str, code: str): + # A used code is kept, not renamed, so its orders still link. + code = normalize_code(code) + current = frappe.get_all( + "Commera Discount Code", + filters={"discount": discount, "is_generated": 0}, + fields=["name", "times_used"], + ) + if any(row.name == code for row in current): + return + add_code(discount, code) + for row in current: + if not row.times_used: + frappe.delete_doc("Commera Discount Code", row.name, ignore_permissions=True) + + +@frappe.whitelist(methods=["POST"]) +def delete_discount(name: str): + frappe.delete_doc(DOCTYPE, name) + + +@frappe.whitelist(methods=["POST"]) +def create_codes(name: str, count: int, prefix: str | None = None) -> dict: + frappe.has_permission(DOCTYPE, ptype="write", throw=True) + created = enqueue_codes(name, count, prefix or "") + return {"created": created, "queued": cint(count) - created} + + +@frappe.whitelist() +def download_codes(name: str): + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + rows = frappe.get_all( + "Commera Discount Code", + filters={"discount": name, "is_generated": 1}, + fields=["code", "times_used"], + order_by="creation asc", + ) + frappe.response["filename"] = f"{name}-codes.csv" + frappe.response["filecontent"] = "code,used\n" + "".join( + f"{row.code},{cint(row.times_used)}\n" for row in rows + ) + frappe.response["type"] = "download" + + +@frappe.whitelist(methods=["POST"]) +def test_discount( + discount: dict | str, + lines: list | str, + payment_kind: str = "prepaid", + first_order: int = 0, + customer: str | None = None, +) -> dict: + # With a customer, first_order is ignored: their group and history decide. + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + if customer: + frappe.has_permission("Customer", doc=customer, ptype="read", throw=True) + values = frappe.parse_json(discount) + lines = [ + {"item_code": cstr(line["item_code"]), "qty": max(cint(line.get("qty")), 1)} + for line in (frappe.parse_json(lines) or [])[:MAX_TEST_LINES] + if line.get("item_code") + ] + if not lines: + return {"lines": [], "subtotal": 0, "discount": 0, "total": 0, "reasons": []} + + draft = frappe.get_doc({"doctype": DOCTYPE, **{field: values.get(field) for field in EDITABLE_FIELDS}}) + for table, fields in TABLES.items(): + draft.set(table, [{field: row.get(field) for field in fields} for row in values.get(table) or []]) + # The test shows the rules' effect today, whatever the switch and dates say. + draft.update( + {"enabled": 1, "starts_on": None, "ends_on": None, "title": draft.title or _("This discount")} + ) + flags = { + "commera_discounts": [draft], + "commera_payment_kind": "cod" if payment_kind == "cod" else "prepaid", + "commera_first_order": None if customer else bool(cint(first_order)), + } + quotation = get_preview_quotation( + lines, flags=flags, fields={"party_name": customer} if customer else None + ) + subtotal = sum(flt(item.price_list_rate) * flt(item.qty) for item in quotation.items) + return { + "lines": [ + { + "item_code": item.item_code, + "title": item.item_name, + "qty": flt(item.qty), + "price": flt(item.price_list_rate), + "discount": flt(item.custom_code_discount) * flt(item.qty), + "amount": flt(item.amount), + } + for item in quotation.items + ], + "currency": quotation.currency, + "subtotal": subtotal, + "discount": subtotal - flt(quotation.net_total), + "total": flt(quotation.net_total), + "reasons": [reason] if (reason := get_unmet_reason(draft, get_cart_context(quotation))) else [], + } + + +@frappe.whitelist() +def get_editor_options() -> dict: + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + company = frappe.defaults.get_global_default("company") + return { + "currency": frappe.get_cached_value("Company", company, "default_currency") if company else None, + "customer_groups": as_picker_options( + frappe.get_all("Customer Group", filters={"is_group": 0}, pluck="name", order_by="name") + ), + "currencies": [ + {"label": currency.name, "value": currency.name, "symbol": currency.symbol} + for currency in frappe.get_all( + "Currency", filters={"enabled": 1}, fields=["name", "symbol"], order_by="name" + ) + ], + "campaigns": frappe.get_all("UTM Campaign", pluck="name", order_by="name"), + "discount_accounts": frappe.get_all( + "Account", + filters={"company": company, "is_group": 0, "disabled": 0}, + pluck="name", + order_by="name", + limit=500, + ), + "delivery_options": as_picker_options(get_delivery_option_titles()), + } + + +def as_picker_options(names: list[str]) -> list[dict]: + return [{"label": name, "value": name} for name in names] + + +def get_delivery_option_titles() -> list[str]: + if not frappe.db.exists("DocType", "Shipping Service"): + return [] + return frappe.get_all("Shipping Service", filters={"enabled": 1}, pluck="title", order_by="title") + + +@frappe.whitelist() +def search_targets(target_type: str, search: str | None = None, sellable: int = 0) -> list[dict]: + # sellable returns variants (each size), not the template products. + search = f"%{cstr(search)}%" + if target_type == "Item": + frappe.has_permission("Item", ptype="read", throw=True) + rows = frappe.get_all( + "Item", + filters={"disabled": 0, "has_variants": 0} + if cint(sellable) + else {"disabled": 0, "variant_of": ("is", "not set")}, + or_filters={"name": ("like", search), "item_name": ("like", search)}, + fields=["name", "item_name"], + order_by="item_name", + limit=20, + ) + return [{"label": row.item_name, "value": row.name} for row in rows] + if target_type == "Item Group": + frappe.has_permission("Item Group", ptype="read", throw=True) + item_group = frappe.qb.DocType("Item Group") + names = ( + frappe.qb.from_(item_group) + .select(item_group.name) + .where((item_group.rgt == item_group.lft + 1) & item_group.name.like(search)) + .orderby(item_group.name) + .limit(20) + ).run(pluck=True) + return [{"label": name, "value": name} for name in names] + if target_type == "Customer": + frappe.has_permission("Customer", ptype="read", throw=True) + rows = frappe.get_list( + "Customer", + filters={"disabled": 0}, + or_filters={"name": ("like", search), "customer_name": ("like", search)}, + fields=["name", "customer_name", "customer_group"], + order_by="customer_name", + limit=20, + ) + return [ + {"label": row.customer_name, "value": row.name, "description": row.customer_group} for row in rows + ] + if target_type == "Brand": + frappe.has_permission("Brand", ptype="read", throw=True) + names = frappe.get_all( + "Brand", filters={"name": ("like", search)}, pluck="name", order_by="name", limit=20 + ) + return [{"label": name, "value": name} for name in names] + frappe.throw(_("Unknown target type {0}.").format(target_type)) diff --git a/commera/api/admin/orders.py b/commera/api/admin/orders.py index c135bf14..ad376db9 100644 --- a/commera/api/admin/orders.py +++ b/commera/api/admin/orders.py @@ -11,6 +11,7 @@ from commera.api.admin.inventory import get_inventory from commera.api.admin.plugins import query_deliveries from commera.api.shipping import get_order_charge_lines +from commera.discounts.allocations import get_allocations, get_discount_rows, get_order_discount_rows from commera.utils import get_address_lines, get_item_images PAGE_LENGTH = 20 @@ -34,6 +35,7 @@ "currency", "total", "net_total", + "discount_amount", "total_taxes_and_charges", "grand_total", "rounded_total", @@ -44,6 +46,8 @@ "custom_ecommerce_payment_mode", "custom_ecommerce_status", "custom_delivery_option", + "custom_discount_allocations", + "custom_cart_attributes", "shipping_address", "address_display", "modified", @@ -617,6 +621,7 @@ def get_order(sales_order: str): state = describe_state(order, lifecycle) charges = get_order_charges(order) payment_state = describe_payment(order) + allocations = get_allocations(order) return { "name": order.name, @@ -632,6 +637,8 @@ def get_order(sales_order: str): "total": flt(order.total), "net_total": flt(order.net_total), "shipping": charges["shipping"], + "order_discounts": get_order_discount_rows(order), + "shipping_discounts": get_discount_rows(allocations, ("Shipping",)), # The delivery option the shopper actually chose and paid for. Empty on an order placed # before the shipping connector was installed, or one that took the flat Shipping Rule. "delivery_option": order.custom_delivery_option, @@ -656,6 +663,7 @@ def get_order(sales_order: str): "rate": flt(row.rate), "amount": flt(row.amount), "image": row.image, + "discounts": get_discount_rows(allocations, line=row.idx), } for row in order.lines ], @@ -712,6 +720,7 @@ def read_order_lines(order_names: list[str]) -> dict[str, list]: fields=[ "name", "parent", + "idx", "item_code", "item_name", "qty", @@ -720,6 +729,8 @@ def read_order_lines(order_names: list[str]) -> dict[str, list]: "rate", "amount", "image", + "custom_code_discount", + "custom_line_attributes", ], order_by="idx asc", ) diff --git a/commera/api/orders.py b/commera/api/orders.py index f2f99d98..5c4096ce 100644 --- a/commera/api/orders.py +++ b/commera/api/orders.py @@ -4,6 +4,7 @@ from frappe.utils import flt from commera.api.payments import system_user_session +from commera.api.shipping import get_charge_amount from commera.plugin_events import fire_order_refunded from commera.utils import update_sales_order_ecommerce_status, validate_document_access @@ -248,12 +249,13 @@ def get_refund_status(order_id: str | int) -> dict: ) total_refunded = sum(flt(pe.paid_amount) for pe in refund_payment_entries) + payable_total = get_charge_amount(order) - if total_refunded >= order.rounded_total: + if total_refunded >= payable_total: return get_refund_refusal(order, _("This order has already been refunded in full."), total_refunded) only_charges = total_refunded >= order.net_total - refundable_amount = order.rounded_total - total_refunded + refundable_amount = payable_total - total_refunded return { "can_refund": True, diff --git a/commera/api/payments.py b/commera/api/payments.py index 8377424d..2d943a45 100644 --- a/commera/api/payments.py +++ b/commera/api/payments.py @@ -4,7 +4,6 @@ from bwh_payments.bwh_payments.utils import get_available_payment_modes, resolve_payment_mode from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry -from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code from frappe import _ from frappe.rate_limiter import rate_limit from frappe.utils import getdate, validate_email_address @@ -21,10 +20,29 @@ get_checkout_summary, get_cod_charge, reprice_selected_option, + reprice_shipping_discount, ) from commera.api.signup import get_placeholder_first_name, validate_single_email, validate_user_names -from commera.checkout_hooks import apply_plugin_fees, filter_payment_methods, get_cart_refusal -from commera.core import _get_cart_quotation, create_party, get_customer_contact, new_cart_quotation +from commera.checkout_hooks import ( + apply_plugin_fees, + filter_payment_methods, + get_cart_refusal, +) +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import ( + clear_ordered_cart, + get_quantities_by_item_code, + get_session_cart, +) +from commera.core import ( + COD_PAYMENT_MODE, + _get_cart_quotation, + create_party, + get_customer_contact, + is_cod, + new_cart_quotation, + system_user_session, +) +from commera.discounts.selection import get_discount_codes, get_unusable_code_reason from commera.guest import ( get_guest_cart_name, is_guest, @@ -34,13 +52,20 @@ ) from commera.order_access import get_order_link, set_order_access_key from commera.plugin_events import fire_event -from commera.utils import get_charge_account_head, get_pickup_addresses, get_pickup_warehouses +from commera.utils import ( + get_charge_account_head, + get_pickup_addresses, + get_pickup_warehouses, +) class CheckoutPriceChangedError(frappe.ValidationError): pass +COD_TOTAL_TTL_SECONDS = 24 * 60 * 60 + + # ERPNext moved its transaction mappers to a sibling `mapper` module; both layouts are in the wild. try: from erpnext.selling.doctype.quotation.mapper import _make_sales_order @@ -52,12 +77,6 @@ class CheckoutPriceChangedError(frappe.ValidationError): except ImportError: from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice -COD_PAYMENT_MODE = "COD" - - -def is_cod(payment_mode: str | None) -> bool: - return (payment_mode or "").strip().casefold() == COD_PAYMENT_MODE.casefold() - def get_open_gateway_payment_request(quotation_name: str) -> str | None: for status in ("Paid", "Pending"): @@ -145,6 +164,7 @@ def open_checkout( validate_delivery_option(quotation, delivery_option) validate_cart_is_not_in_checkout(quotation.name) quotation.flags.strict_plugin_fees = True + drop_refused_codes(quotation) update_delivery_charges(quotation) validate_cart(quotation) validate_expected_total(quotation, payment_mode, expected_total) @@ -153,6 +173,11 @@ def open_checkout( if is_cod(payment_mode): if COD_PAYMENT_MODE not in payment_methods: refuse_payment(_("Cash on delivery is not available."), quotation.name) + frappe.cache.set_value( + get_cod_total_key(quotation.name), + get_payable_total(quotation, payment_mode), + expires_in_sec=COD_TOTAL_TTL_SECONDS, + ) return {"order_url": get_confirmation_url(quotation.name, payment_mode=COD_PAYMENT_MODE)} gateway = resolve_payment_mode(payment_mode) @@ -230,6 +255,34 @@ def validate_cart(quotation): frappe.throw(refusal) +def drop_refused_codes(quotation): + reasons = [] + codes = get_discount_codes(quotation) + kept_codes = [] + for code in codes: + if reason := get_unusable_code_reason(quotation, code): + reasons.append(reason) + else: + kept_codes.append(code) + if not reasons: + return + + quotation.custom_discount_codes = "\n".join(kept_codes) or None + reprice_shipping_discount(quotation) + save_cart_quotation(quotation) + if server_cart := get_session_cart(): + for code in set(codes) - set(kept_codes): + server_cart.remove_discount_code(code) + server_cart.save(ignore_permissions=True) + # nosemgrep: frappe-manual-commit # kept past the throw below, or every retry would refuse again + frappe.db.commit() + frappe.throw( + " ".join([*reasons, _("We removed the code and updated your total.")]), + title=_("Discount Removed"), + exc=CheckoutPriceChangedError, + ) + + def validate_delivery_option(quotation, delivery_option: str | None): # Payment reprices the stored option, so it must be the one the shopper is looking at. if cstr(quotation.custom_delivery_option) == cstr(delivery_option): @@ -261,6 +314,28 @@ def validate_expected_total(quotation, payment_mode: str, expected_total: float ) +def get_cod_total_key(quotation_name: str) -> str: + return f"commera:cod_total:{quotation_name}" + + +def validate_cod_total(quotation): + # Placement re-prices for cash on delivery; a discount that ran out since checkout must not raise it quietly. + confirmed_total = frappe.cache.get_value(get_cod_total_key(quotation.name)) + if confirmed_total is None: + return + total = get_charge_amount(quotation) + precision = quotation.precision("grand_total") + if flt(confirmed_total, precision) == flt(total, precision): + return + frappe.throw( + _("Your order total has changed to {0}. Please review it before placing your order.").format( + fmt_money(total, currency=quotation.currency) + ), + title=_("Order Total Changed"), + exc=CheckoutPriceChangedError, + ) + + def get_payable_total(quotation, payment_mode: str) -> float: if is_cod(payment_mode): return get_checkout_summary(quotation)["cash_on_delivery"]["total"] @@ -285,26 +360,6 @@ def gateway_mode_of_payment(gateway: str) -> str: return mode_of_payment -@contextmanager -def system_user_session(user: str = "Administrator"): - """Place the accounting documents as `user`, then hand the session back. - - ERPNext's get_party_account checks frappe.has_permission directly, so no ignore_permissions reaches it. - """ - # frappe.set_user() mutates frappe.local.session in place, stamping "sid" with the username and wiping - # local.session.data, so restore the snapshot verbatim rather than calling it again for the shopper. - live_session_snapshot = frappe.local.session.copy() - try: - # Audited: the docstring above and the snapshot restore below are why this is safe. - frappe.set_user(user) # nosemgrep: frappe-semgrep-rules.rules.security.frappe-setuser - yield - finally: - frappe.local.session.update(live_session_snapshot) - frappe.local.cache = {} - frappe.local.role_permissions = {} - frappe.local.user_perms = None - - @contextmanager def cart_write_lock(quotation): if quotation.is_new(): @@ -367,6 +422,7 @@ def place_order(quotation, payment_mode: str, gateway_amount=None, gateway_refer # Outside the switch: log_purchase stamps frappe.session.user, so Administrator would own every purchase. stamp_order_owner(sales_order, shopper) + clear_ordered_cart(quotation, shopper) log_purchase(sales_order) fire_event("order_placed", "Sales Order", sales_order.name) if paid: @@ -432,7 +488,13 @@ def generate_quotation_for_cart(cart: dict): def get_checkout_cart(cart: dict) -> dict: + """The server cart's ticked lines; the browser's only without a server cart, or when a guest's is empty.""" cart = frappe.parse_json(cart) or {} + if (server_cart := get_session_cart()) and (server_cart.lines or not is_guest()): + cart["items"] = [ + {"variant": {"item_code": item_code}, "qty": qty} + for item_code, qty in get_quantities_by_item_code(server_cart.get_selected_lines()).items() + ] if len(cart.get("items", [])) < 1: frappe.throw(_("Can't checkout with empty cart")) validate_stock_available(cart["items"]) @@ -505,28 +567,15 @@ def get_quotation_for_cart(cart: dict, unsaved_quotation_doc): "warehouse": ecommerce_warehouse, }, ) + if server_cart := get_session_cart(): + server_cart.update_quotation(unsaved_quotation_doc) save_cart_quotation(unsaved_quotation_doc) # The stored option was priced for the old cart, and payment would re-quote it for the new one. clear_delivery_option(unsaved_quotation_doc) - coupon_code = unsaved_quotation_doc.coupon_code - _remove_coupon_code(unsaved_quotation_doc) - reapply_coupon_code(unsaved_quotation_doc, coupon_code) set_charges(unsaved_quotation_doc) return save_cart_quotation(unsaved_quotation_doc) -def reapply_coupon_code(quotation, coupon_code: str | None): - if not coupon_code: - return - try: - validate_coupon_code(coupon_code) - except frappe.ValidationError: - # An expired coupon is dropped quietly; the shopper only edited their cart. - frappe.clear_last_message() - return - quotation.coupon_code = coupon_code - - def set_charges(quotation): shipping_rule = frappe.get_cached_value("Commera Settings", "Commera Settings", "shipping_rule") if shipping_rule: @@ -537,10 +586,9 @@ def set_charges(quotation): def set_cod_charges(quotation): - cod_charge = get_cod_charge(quotation) - if not cod_charge: - return - add_cod_charge(quotation, cod_charge, get_charge_account_head()) + if cod_charge := get_cod_charge(quotation): + add_cod_charge(quotation, cod_charge, get_charge_account_head()) + # Saved even without a fee: submit keeps the cart's discounts, so this save prices them for cash on delivery. quotation.flags.ignore_permissions = True quotation.save() @@ -788,10 +836,14 @@ def place_cod_order(quotation_name: str): validate_cart(quotation) if COD_PAYMENT_MODE not in get_checkout_payment_methods(quotation, strict=True): refuse_payment(_("Cash on delivery is not available."), quotation.name) + quotation.flags.commera_payment_kind = "cod" + reprice_shipping_discount(quotation) with system_user_session(): set_cod_charges(quotation) + validate_cod_total(quotation) quotation.flags.ignore_permissions = True quotation.submit() + frappe.cache.delete_value(get_cod_total_key(quotation.name)) sales_order = _make_sales_order(quotation_name, ignore_permissions=True) sales_order.custom_ecommerce_payment_mode = COD_PAYMENT_MODE @@ -803,54 +855,12 @@ def place_cod_order(quotation_name: str): # COD orders count as purchases even while the Sales Order stays draft. stamp_order_owner(sales_order, shopper) + clear_ordered_cart(quotation, shopper) log_purchase(sales_order) fire_event("order_placed", "Sales Order", sales_order.name) return sales_order -# Guest checkout: refuses any caller without a guest cart cookie, and is rate limited. -@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method -@rate_limit(limit=30, seconds=60 * 60) -def apply_coupon_code(applied_code: str): - if not applied_code: - frappe.throw(_("Please enter a coupon code")) - quotation = _get_cart_quotation() - coupon_name = frappe.db.get_value("Coupon Code", {"coupon_code": applied_code}, "name") - if not coupon_name: - frappe.throw(_("Please enter a valid coupon code")) - validate_coupon_code(coupon_name) - validate_cart_is_not_in_checkout(quotation.name) - quotation.coupon_code = coupon_name - save_cart_quotation(quotation) - return { - "message": _("Coupon code applied successfully"), - "checkout_summary": get_checkout_summary(quotation), - } - - -# Guest checkout: refuses any caller without a guest cart cookie, and is rate limited. -@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method -@rate_limit(limit=100, seconds=60 * 60) -def remove_coupon_code(): - quotation = _get_cart_quotation() - validate_cart_is_not_in_checkout(quotation.name) - _remove_coupon_code(quotation) - - -def _remove_coupon_code(quotation): - quotation.coupon_code = "" - quotation.items = [item for item in quotation.items if not item.get("is_free_item")] - for item in quotation.items: - item.discount_percentage = 0 - item.discount_amount = 0 - item.distributed_discount_amount = 0 - item.rate = item.price_list_rate - quotation.calculate_taxes_and_totals() - save_cart_quotation(quotation) - quotation.discount_amount = 0 - save_cart_quotation(quotation) - - def add_billing_address(party_name, address): return add_party_address(party_name, address.get("billing_address", {}), "Billing") diff --git a/commera/api/server_cart.py b/commera/api/server_cart.py new file mode 100644 index 00000000..70b319b2 --- /dev/null +++ b/commera/api/server_cart.py @@ -0,0 +1,100 @@ +import frappe +from frappe import _ +from frappe.rate_limiter import rate_limit +from frappe.utils.data import cint, cstr + +from commera.api.shipping import get_checkout_summary +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import ( + MAX_CART_LINES, + CommeraCart, + get_quantities_by_item_code, + get_session_cart, +) +from commera.discounts.selection import get_discount_codes +from commera.sdk import cart as cart_sdk +from commera.www.cart.permalink import get_cart_lines + + +# Guests hold carts before signing in; the cart cookie scopes each call to the caller's own cart. +@frappe.whitelist(allow_guest=True, methods=["GET", "POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=1200, seconds=60 * 60) +def get_server_cart() -> dict | None: + cart = get_session_cart() + return get_cart_state(cart) if cart else None + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=600, seconds=60 * 60) +def sync_server_cart(items: list | str, version: str | None = None) -> dict: + # A stale browser version writes nothing; the server cart comes back instead. + quantities = get_browser_quantities(items) + selection = get_browser_selection(items) + cart = get_session_cart(create=bool(quantities)) + if not cart: + return {"version": None} + if version and not cart.is_new() and version != cart.version: + return {"conflict": True, **get_cart_state(cart)} + + changed = cart.set_quantities(quantities) + if cart.set_selection(selection) or changed or cart.is_new(): + cart.save(ignore_permissions=True) + return {"version": cart.version} + + +@frappe.whitelist(allow_guest=True, methods=["GET", "POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=600, seconds=60 * 60) +def get_priced_server_cart() -> dict | None: + return cart_sdk.get_priced_cart() + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=30, seconds=60 * 60) +def apply_discount_code(code: str) -> dict: + return {**cart_sdk.add_discount_code(cstr(code).strip()), "checkout_summary": get_open_checkout_summary()} + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=100, seconds=60 * 60) +def remove_discount_code(code: str) -> dict: + return { + **cart_sdk.remove_discount_code(cstr(code).strip()), + "checkout_summary": get_open_checkout_summary(), + } + + +def get_open_checkout_summary() -> dict | None: + quotation = cart_sdk.get_open_cart_quotation() + return get_checkout_summary(quotation) if quotation else None + + +def get_browser_quantities(items: list | str) -> dict[str, int]: + quantities = {} + for entry in frappe.parse_json(items) or []: + item_code = cstr(entry.get("item_code")).strip() + qty = cint(entry.get("qty")) + if not item_code or qty < 1: + frappe.throw(_("Each cart line needs an item code and a quantity of at least 1.")) + quantities[item_code] = quantities.get(item_code, 0) + qty + if len(quantities) > MAX_CART_LINES: + frappe.throw(_("A cart holds at most {0} lines.").format(MAX_CART_LINES)) + return quantities + + +def get_browser_selection(items: list | str) -> dict[str, bool]: + selection = {} + for entry in frappe.parse_json(items) or []: + item_code = cstr(entry.get("item_code")).strip() + selection[item_code] = selection.get(item_code, False) or bool(cint(entry.get("selected", 1))) + return selection + + +def get_cart_state(cart: CommeraCart) -> dict: + selected_item_codes = {row.item_code for row in cart.get_selected_lines()} + lines = get_cart_lines(get_quantities_by_item_code(cart.lines), []) + for line in lines: + line["selected"] = line["variant"]["item_code"] in selected_item_codes + return { + "version": cart.version, + "lines": lines, + "discount_codes": get_discount_codes(cart), + } diff --git a/commera/api/shipping.py b/commera/api/shipping.py index b2676882..3acbbe24 100644 --- a/commera/api/shipping.py +++ b/commera/api/shipping.py @@ -5,10 +5,14 @@ from commera.checkout_hooks import PLUGIN_FEE_FIELD, apply_delivery_option_hooks, apply_plugin_fees from commera.core import _get_cart_quotation +from commera.discounts.allocations import get_allocations, get_discount_rows, get_price_discount_rows +from commera.discounts.pricing import refresh_code_discounts +from commera.discounts.shipping import apply_shipping_discounts, set_shipping_discount from commera.utils import ( COD_CHARGE_DESCRIPTION, get_charge_account_head, get_cod_configuration, + get_free_delivery, validate_document_access, ) @@ -228,15 +232,30 @@ def apply_delivery_option(quotation, option: dict): quotation.custom_delivery_charge = flt(option["amount"]) quotation.custom_shipping_provider = option.get("provider") quotation.custom_shipping_service_code = option.get("service_code") + set_shipping_discount(quotation, option) set_delivery_charge_row(quotation, flt(option["amount"]), option["title"]) apply_plugin_fees(quotation) +def reprice_shipping_discount(quotation): + # The option was priced on a prepaid cart; re-price it for the payment mode. + if not quotation.custom_delivery_option: + return + option = { + "title": quotation.custom_delivery_option, + "amount": flt(quotation.custom_delivery_charge) + flt(quotation.custom_shipping_discount), + "provider": quotation.custom_shipping_provider, + "service_code": quotation.custom_shipping_service_code, + } + apply_delivery_option(quotation, apply_shipping_discounts(quotation, [option])[0]) + + def clear_delivery_option(quotation): quotation.custom_delivery_option = None quotation.custom_delivery_charge = 0 quotation.custom_shipping_provider = None quotation.custom_shipping_service_code = None + set_shipping_discount(quotation, None) remove_delivery_charge_row(quotation) quotation.calculate_taxes_and_totals() @@ -324,8 +343,7 @@ def get_charge_lines(taxes, shipping_rule: str | None) -> dict: def get_charge_amount(quotation) -> float: - # rounded_total is 0 when rounding is disabled on the document; grand_total is the billed figure then. - return flt(quotation.rounded_total) or flt(quotation.grand_total) + return flt(quotation.grand_total if quotation.is_rounded_total_disabled() else quotation.rounded_total) def get_checkout_summary(quotation) -> dict: @@ -335,6 +353,10 @@ def get_checkout_summary(quotation) -> dict: clear_pickup_charges(preview) summary = get_charge_summary(preview) + # A prepaid-only discount drops off, and a cash-on-delivery one comes on, before the fee is judged. + preview.flags.commera_payment_kind = "cod" + reprice_shipping_discount(preview) + refresh_code_discounts(preview) cod_charge = get_cod_charge(preview) if cod_charge: account_head = frappe.get_cached_value("Commera Settings", "Commera Settings", "charge_account_head") @@ -348,11 +370,14 @@ def get_charge_summary(quotation) -> dict: charges = charge_lines["shipping"] + charge_lines["cod_charge"] charges += sum(line["amount"] for line in charge_lines["taxes"] + charge_lines["plugin_fees"]) discount_amount = flt(quotation.discount_amount) + # Derived rather than read: with a Grand Total discount the stored net_total is already partly discounted. + subtotal = flt(flt(quotation.grand_total) + discount_amount - charges, quotation.precision("grand_total")) + line_discount = sum(flt(item.get("custom_code_discount")) * flt(item.qty) for item in quotation.items) return { - # Derived rather than read: with a Grand Total discount the stored net_total is already partly discounted. - "subtotal": flt( - flt(quotation.grand_total) + discount_amount - charges, quotation.precision("grand_total") - ), + "subtotal": subtotal, + "items_total": flt(subtotal + line_discount, quotation.precision("grand_total")), + "discounts": get_price_discount_rows(quotation), + "shipping_discounts": get_discount_rows(get_allocations(quotation), ("Shipping",)), "shipping": charge_lines["shipping"], "cod_charge": charge_lines["cod_charge"], "plugin_fees": charge_lines["plugin_fees"], @@ -360,6 +385,7 @@ def get_charge_summary(quotation) -> dict: "discount_amount": discount_amount, "rounding_adjustment": flt(quotation.rounding_adjustment), "total": get_charge_amount(quotation), + **get_free_delivery(quotation), } @@ -457,8 +483,8 @@ def reprice_selected_option(quotation) -> bool: amount = get_charge_amount( quotation.custom_delivery_option, get_cart_context(quotation), - # A Currency field is never None, so the stored price is always billed and a stored 0 stays free. - quoted_amount=quotation.custom_delivery_charge, + # Before its shipping discount, which the hooks below take off again. + quoted_amount=flt(quotation.custom_delivery_charge) + flt(quotation.custom_shipping_discount), shipping_rule=get_store_shipping_rule(), ) options = [ diff --git a/commera/api/utils.py b/commera/api/utils.py index fba8b47a..9aa3483a 100644 --- a/commera/api/utils.py +++ b/commera/api/utils.py @@ -8,7 +8,11 @@ from frappe.utils import cstr from commera.search import query as search_query -from commera.utils import get_available_stocks, get_product_list, validate_document_access +from commera.utils import ( + get_available_stocks, + get_product_list, + validate_document_access, +) def auth_required(func): diff --git a/commera/checkout_hooks.py b/commera/checkout_hooks.py index 54b9ca33..eb09c3c3 100644 --- a/commera/checkout_hooks.py +++ b/commera/checkout_hooks.py @@ -1,11 +1,11 @@ import math -from contextlib import contextmanager import frappe from frappe import _ from frappe.utils.data import cstr, flt -from commera.plugin_events import get_handlers +from commera.discounts.shipping import apply_shipping_discounts +from commera.plugin_events import get_handlers, handle_hook_error from commera.utils import get_charge_account_head PLUGIN_FEE_FIELD = "commera_plugin_fee" @@ -86,7 +86,7 @@ def apply_delivery_option_hooks(quotation, options: list[dict], strict: bool) -> with handle_hook_error("delivery_options", handler, quotation, strict): hooked_options = frappe.get_attr(handler)(quotation, [dict(option) for option in options]) options = get_hooked_delivery_options(options, hooked_options, precision) - return options + return apply_shipping_discounts(quotation, options) def get_hooked_delivery_options(options: list[dict], hooked_options, precision: int) -> list[dict]: @@ -133,21 +133,3 @@ def filter_payment_methods(quotation, methods: list[str], strict: bool) -> list[ ) methods = [method for method in methods if method in kept_methods] return methods - - -@contextmanager -def handle_hook_error(key: str, handler: str, quotation, strict: bool): - message_count = len(frappe.local.message_log) - try: - yield - except Exception: - frappe.log_error( - title=f'commera_hooks["{key}"] hook failed: {handler}'[:140], - reference_doctype="Quotation", - reference_name=quotation.name, - defer_insert=True, - ) - # Whatever the app msgprinted is meant for its developer, never for the shopper. - del frappe.local.message_log[message_count:] - if strict: - frappe.throw(_("Something went wrong, please try again.")) diff --git a/commera/commera_ecommerce/custom/brand.json b/commera/commera_ecommerce/custom/brand.json deleted file mode 100644 index 450596af..00000000 --- a/commera/commera_ecommerce/custom/brand.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "custom_fields": [], - "custom_perms": [], - "doctype": "Brand", - "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-05-22 12:31:47.165795", - "default_value": null, - "doc_type": "Brand", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-05-22 12:31:47.165795", - "modified_by": "Administrator", - "module": null, - "name": "Brand-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"brand\", \"image\", \"description\", \"display_on_website\", \"defaults\", \"brand_defaults\"]" - } - ], - "sync_on_migrate": 1 -} diff --git a/commera/commera_ecommerce/custom/delivery_note.json b/commera/commera_ecommerce/custom/delivery_note.json new file mode 100644 index 00000000..f71d7a3c --- /dev/null +++ b/commera/commera_ecommerce/custom/delivery_note.json @@ -0,0 +1,137 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Delivery Note", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_order_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Delivery Note-custom_code_order_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON list of the discounts on this document and their amounts.", + "docstatus": 0, + "dt": "Delivery Note", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_order_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Allocations", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Delivery Note-custom_discount_allocations", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Delivery Note", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/delivery_note_item.json b/commera/commera_ecommerce/custom/delivery_note_item.json index 0032d367..643a6f2b 100644 --- a/commera/commera_ecommerce/custom/delivery_note_item.json +++ b/commera/commera_ecommerce/custom/delivery_note_item.json @@ -127,81 +127,139 @@ "translatable": 0, "unique": 0, "width": null - } - ], - "custom_perms": [], - "doctype": "Delivery Note Item", - "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:20:48.134321", - "default_value": null, - "doc_type": "Delivery Note Item", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "barcode", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-05-17 10:48:39.259361", - "modified_by": "Administrator", - "module": null, - "name": "Delivery Note Item-barcode-hidden", - "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-05-17 12:49:31.506293", - "default_value": null, - "doc_type": "Delivery Note Item", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, + "dt": "Delivery Note Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", "is_system_generated": 0, - "modified": "2025-05-17 12:49:31.506293", + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Delivery Note Item-main-field_order", + "name": "Delivery Note Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"barcode\", \"has_item_scanned\", \"item_code\", \"item_name\", \"col_break1\", \"customer_item_code\", \"section_break_6\", \"description\", \"brand\", \"item_group\", \"custom_reason_for_return\", \"return_remarks\", \"image_section\", \"image\", \"image_view\", \"quantity_and_rate\", \"qty\", \"stock_uom\", \"col_break2\", \"uom\", \"conversion_factor\", \"stock_qty_sec_break\", \"stock_qty\", \"stock_qty_col_break\", \"returned_qty\", \"section_break_17\", \"price_list_rate\", \"base_price_list_rate\", \"discount_and_margin\", \"margin_type\", \"margin_rate_or_amount\", \"rate_with_margin\", \"column_break_19\", \"discount_percentage\", \"discount_amount\", \"base_rate_with_margin\", \"section_break_1\", \"rate\", \"amount\", \"col_break3\", \"base_rate\", \"base_amount\", \"pricing_rules\", \"stock_uom_rate\", \"is_free_item\", \"grant_commission\", \"section_break_25\", \"net_rate\", \"net_amount\", \"item_tax_template\", \"column_break_28\", \"base_net_rate\", \"base_net_amount\", \"billed_amt\", \"incoming_rate\", \"item_weight_details\", \"weight_per_unit\", \"total_weight\", \"column_break_21\", \"weight_uom\", \"warehouse_and_reference\", \"warehouse\", \"target_warehouse\", \"quality_inspection\", \"col_break4\", \"allow_zero_valuation_rate\", \"against_sales_order\", \"so_detail\", \"against_sales_invoice\", \"si_detail\", \"dn_detail\", \"pick_list_item\", \"section_break_40\", \"pick_serial_and_batch\", \"serial_and_batch_bundle\", \"use_serial_batch_fields\", \"column_break_eaoe\", \"section_break_qyjv\", \"serial_no\", \"column_break_rxvc\", \"batch_no\", \"available_qty_section\", \"actual_qty\", \"actual_batch_qty\", \"column_break_atna\", \"company_total_stock\", \"section_break_kejd\", \"installed_qty\", \"packed_qty\", \"column_break_fguf\", \"received_qty\", \"accounting_details_section\", \"expense_account\", \"column_break_71\", \"item_tax_rate\", \"internal_transfer_section\", \"material_request\", \"purchase_order\", \"column_break_82\", \"purchase_order_item\", \"material_request_item\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"section_break_72\", \"page_break\"]" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:20:48.370044", - "default_value": null, - "doc_type": "Delivery Note Item", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "target_warehouse", + "dt": "Delivery Note Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_discount", "is_system_generated": 0, - "modified": "2025-05-17 10:48:39.289925", + "is_virtual": 0, + "label": "Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Delivery Note Item-target_warehouse-hidden", + "name": "Delivery Note Item-custom_discount", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "1" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ], + "custom_perms": [], + "doctype": "Delivery Note Item", + "links": [], + "property_setters": [], "sync_on_migrate": 1 } diff --git a/commera/commera_ecommerce/custom/item_group.json b/commera/commera_ecommerce/custom/item_group.json index e5a0bd08..3ac29ea2 100644 --- a/commera/commera_ecommerce/custom/item_group.json +++ b/commera/commera_ecommerce/custom/item_group.json @@ -1,69 +1,5 @@ { "custom_fields": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.652498", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "column_break_16", - "fieldtype": "Column Break", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 16, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "include_descendants", - "is_system_generated": 1, - "is_virtual": 0, - "label": null, - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.652498", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-column_break_16", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, { "_assign": null, "_comments": null, @@ -105,7 +41,7 @@ "mandatory_depends_on": null, "modified": "2025-03-09 10:20:10.750158", "modified_by": "Administrator", - "module": "Printechs Fashion", + "module": "Commera Ecommerce", "name": "Item Group-custom_displayname", "no_copy": 0, "non_negative": 0, @@ -127,826 +63,11 @@ "translatable": 1, "unique": 0, "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.427009", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "custom_website_settings", - "fieldtype": "Section Break", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 10, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "taxes", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Website Settings", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.427009", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-custom_website_settings", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.572924", - "default": null, - "depends_on": "show_in_website", - "description": "HTML / Banner that will show on the top of product list.", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "description", - "fieldtype": "Text Editor", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 14, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "website_title", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Description", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.572924", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-description", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.940989", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "filter_attributes", - "fieldtype": "Table", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 18, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "filter_fields", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Attributes", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.940989", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-filter_attributes", - "no_copy": 0, - "non_negative": 0, - "options": "Website Attribute", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.880743", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "filter_fields", - "fieldtype": "Table", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 17, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "website_filters_section", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Item Fields", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.880743", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-filter_fields", - "no_copy": 0, - "non_negative": 0, - "options": "Website Filter Field", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.615849", - "default": "0", - "depends_on": "show_in_website", - "description": "Include Website Items belonging to child Item Groups", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "include_descendants", - "fieldtype": "Check", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 14, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "website_title", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Include Descendants", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.615849", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-include_descendants", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.498013", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "route", - "fieldtype": "Data", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 12, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "show_in_website", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Route", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.498013", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-route", - "no_copy": 1, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 1, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.460389", - "default": "0", - "depends_on": null, - "description": "Make Item Group visible in website", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "show_in_website", - "fieldtype": "Check", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 11, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "custom_website_settings", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Show in Website", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.460389", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-show_in_website", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.734125", - "default": null, - "depends_on": "show_in_website", - "description": "Show this slideshow at the top of the page", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "slideshow", - "fieldtype": "Link", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 18, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "weightage", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Slideshow", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.734125", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-slideshow", - "no_copy": 0, - "non_negative": 0, - "options": "Website Slideshow", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 1, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.839637", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "website_filters_section", - "fieldtype": "Section Break", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 16, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "website_specifications", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Website Filters", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.839637", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-website_filters_section", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.787109", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "website_specifications", - "fieldtype": "Table", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 15, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "description", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Website Specifications", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.787109", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-website_specifications", - "no_copy": 0, - "non_negative": 0, - "options": "Item Website Specification", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.539576", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "website_title", - "fieldtype": "Data", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 13, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "route", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Title", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.539576", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-website_title", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.690734", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "weightage", - "fieldtype": "Int", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 17, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "column_break_16", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Weightage", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.690734", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-weightage", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 0, - "width": null } ], "custom_perms": [], "doctype": "Item Group", "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:19:17.162640", - "default_value": null, - "doc_type": "Item Group", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": "allow_guest_to_view", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-06-06 14:02:34.285335", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-allow_guest_to_view-allow_guest_to_view", - "owner": "Administrator", - "property": "allow_guest_to_view", - "property_type": "Check", - "row_name": null, - "value": "1" - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-06-18 07:57:05.185493", - "default_value": null, - "doc_type": "Item Group", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-06-18 07:57:05.185493", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"gs\", \"item_group_name\", \"custom_displayname\", \"parent_item_group\", \"is_group\", \"image\", \"column_break_5\", \"display_on_website\", \"defaults\", \"item_group_defaults\", \"sec_break_taxes\", \"taxes\", \"custom_website_settings\", \"show_in_website\", \"route\", \"website_title\", \"description\", \"website_specifications\", \"website_filters_section\", \"filter_fields\", \"filter_attributes\", \"include_descendants\", \"column_break_16\", \"weightage\", \"slideshow\", \"lft\", \"old_parent\", \"rgt\"]" - } - ], + "property_setters": [], "sync_on_migrate": 1 } diff --git a/commera/commera_ecommerce/custom/quotation.json b/commera/commera_ecommerce/custom/quotation.json index 74fad6fc..71414206 100644 --- a/commera/commera_ecommerce/custom/quotation.json +++ b/commera/commera_ecommerce/custom/quotation.json @@ -32,7 +32,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "valid_till", + "insert_after": "customer_name", "is_system_generated": 0, "is_virtual": 0, "label": "Is Store Pickup?", @@ -96,7 +96,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "amended_from", + "insert_after": "custom_fulfilment_section", "is_system_generated": 0, "is_virtual": 0, "label": "Store", @@ -128,6 +128,838 @@ "unique": 0, "width": null }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_tracking_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_cart_attributes", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Tracking", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_tracking_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_analytics_session_id", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 15, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_tracking_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Analytics Session ID", + "length": 64, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_analytics_session_id", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_utm_source", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 16, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_analytics_session_id", + "is_system_generated": 0, + "is_virtual": 0, + "label": "UTM Source", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_utm_source", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_utm_medium", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 17, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_utm_source", + "is_system_generated": 0, + "is_virtual": 0, + "label": "UTM Medium", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_utm_medium", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_utm_campaign", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 18, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_utm_medium", + "is_system_generated": 0, + "is_virtual": 0, + "label": "UTM Campaign", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_utm_campaign", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "The Shipping Service the customer chose at checkout. Data rather than a Link, so the storefront still works with bwh_shipping uninstalled.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_delivery_option", + "fieldtype": "Data", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_store", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Delivery Option", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_delivery_option", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "What the customer was quoted and billed for delivery, stored server-side at selection time. The browser never supplies a price.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_delivery_charge", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_delivery_option", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Delivery Charge", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_delivery_charge", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "bwh_shipping Shipping Provider Profile that quoted this option.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_provider", + "fieldtype": "Data", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_fulfilment_column_break", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Provider", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_shipping_provider", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "The provider's carrier service code, so fulfilment books exactly what the customer paid for.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_service_code", + "fieldtype": "Data", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_provider", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Service Code", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_shipping_service_code", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_tracking_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_utm_campaign", + "is_system_generated": 0, + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_tracking_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-09-29 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Hash of the guest shopper's cart cookie; set only on carts started without signing in.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_guest_cart_key", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_tracking_column_break", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Guest Cart Key", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-09-29 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_guest_cart_key", + "no_copy": 1, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 1, + "reqd": 0, + "search_index": 1, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discounts_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_service_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discounts", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_discounts_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Discount codes the shopper applied, one per line.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_codes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discounts_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_discount_codes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, { "_assign": null, "_comments": null, @@ -139,7 +971,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -147,30 +979,30 @@ "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_analytics_session_id", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_discounts_column_break", + "fieldtype": "Column Break", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 15, + "idx": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "amended_from", + "insert_after": "custom_discount_codes", "is_system_generated": 0, "is_virtual": 0, - "label": "Analytics Session ID", - "length": 64, + "label": null, + "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_analytics_session_id", + "name": "Quotation-custom_discounts_column_break", "no_copy": 0, "non_negative": 0, "options": null, @@ -178,6 +1010,70 @@ "permlevel": 0, "placeholder": null, "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_order_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discounts_column_break", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_code_order_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", "print_hide": 1, "print_hide_if_no_value": 0, "print_width": null, @@ -203,7 +1099,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -211,33 +1107,33 @@ "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_utm_source", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_shipping_discount", + "fieldtype": "Currency", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 16, + "idx": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_analytics_session_id", + "insert_after": "custom_code_order_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "UTM Source", + "label": "Shipping Discount", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_utm_source", + "name": "Quotation-custom_shipping_discount", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "currency", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -267,7 +1163,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -275,33 +1171,33 @@ "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_utm_medium", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_shipping_discount_id", + "fieldtype": "Link", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 17, + "idx": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_utm_source", + "insert_after": "custom_shipping_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "UTM Medium", + "label": "Shipping Discount Rule", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_utm_medium", + "name": "Quotation-custom_shipping_discount_id", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "Commera Discount", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -331,38 +1227,38 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, - "description": null, + "description": "JSON list of the discounts on this document and their amounts.", "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_utm_campaign", - "fieldtype": "Data", + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", "hidden": 1, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 18, + "idx": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_utm_medium", + "insert_after": "custom_shipping_discount_id", "is_system_generated": 0, "is_virtual": 0, - "label": "UTM Campaign", + "label": "Discount Allocations", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_utm_campaign", + "name": "Quotation-custom_discount_allocations", "no_copy": 0, "non_negative": 0, "options": null, @@ -395,16 +1291,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, - "description": "The Shipping Service the customer chose at checkout. Data rather than a Link, so the storefront still works with bwh_shipping uninstalled.", + "description": "JSON object of the shopper's cart-level attributes.", "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_delivery_option", - "fieldtype": "Data", + "fieldname": "custom_cart_attributes", + "fieldtype": "Long Text", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -416,17 +1312,17 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_is_store_pickup", + "insert_after": "custom_cart_details_section", "is_system_generated": 0, "is_virtual": 0, - "label": "Delivery Option", + "label": "Cart Attributes", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_delivery_option", + "name": "Quotation-custom_cart_attributes", "no_copy": 0, "non_negative": 0, "options": null, @@ -434,7 +1330,7 @@ "permlevel": 0, "placeholder": null, "precision": "", - "print_hide": 0, + "print_hide": 1, "print_hide_if_no_value": 0, "print_width": null, "read_only": 1, @@ -459,16 +1355,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, - "depends_on": null, - "description": "What the customer was quoted and billed for delivery, stored server-side at selection time. The browser never supplies a price.", + "depends_on": "eval:doc.order_type==\"Shopping Cart\"", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_delivery_charge", - "fieldtype": "Currency", + "fieldname": "custom_ecommerce_tab", + "fieldtype": "Tab Break", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -480,20 +1376,20 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_delivery_option", + "insert_after": "supplier_quotation", "is_system_generated": 0, "is_virtual": 0, - "label": "Delivery Charge", + "label": "Ecommerce", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_delivery_charge", + "name": "Quotation-custom_ecommerce_tab", "no_copy": 0, "non_negative": 0, - "options": "currency", + "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -501,7 +1397,7 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, @@ -523,16 +1419,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, - "description": "bwh_shipping Shipping Provider Profile that quoted this option.", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_shipping_provider", - "fieldtype": "Data", + "fieldname": "custom_fulfilment_section", + "fieldtype": "Section Break", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -544,17 +1440,17 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_delivery_charge", + "insert_after": "custom_ecommerce_tab", "is_system_generated": 0, "is_virtual": 0, - "label": "Shipping Provider", + "label": "Fulfilment", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_shipping_provider", + "name": "Quotation-custom_fulfilment_section", "no_copy": 0, "non_negative": 0, "options": null, @@ -565,7 +1461,7 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, @@ -587,16 +1483,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, - "description": "The provider's carrier service code, so fulfilment books exactly what the customer paid for.", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_shipping_service_code", - "fieldtype": "Data", + "fieldname": "custom_fulfilment_column_break", + "fieldtype": "Column Break", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -608,17 +1504,17 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_shipping_provider", + "insert_after": "custom_delivery_charge", "is_system_generated": 0, "is_virtual": 0, - "label": "Shipping Service Code", + "label": null, "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_shipping_service_code", + "name": "Quotation-custom_fulfilment_column_break", "no_copy": 0, "non_negative": 0, "options": null, @@ -629,7 +1525,7 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, @@ -648,20 +1544,20 @@ "allow_in_quick_entry": 0, "allow_on_submit": 0, "bold": 0, - "collapsible": 0, + "collapsible": 1, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-09-29 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, - "description": "Hash of the guest shopper's cart cookie; set only on carts started without signing in.", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_guest_cart_key", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_cart_details_section", + "fieldtype": "Section Break", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, @@ -672,32 +1568,32 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_shipping_service_code", + "insert_after": "custom_discount_allocations", "is_system_generated": 0, "is_virtual": 0, - "label": "Guest Cart Key", + "label": "Cart Details", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-09-29 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_guest_cart_key", - "no_copy": 1, + "name": "Quotation-custom_cart_details_section", + "no_copy": 0, "non_negative": 0, "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, "precision": "", - "print_hide": 1, + "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, - "report_hide": 1, + "report_hide": 0, "reqd": 0, - "search_index": 1, + "search_index": 0, "show_dashboard": 0, "sort_options": 0, "translatable": 0, @@ -755,29 +1651,6 @@ "row_name": null, "value": "1" }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-03-29 20:37:38.000785", - "default_value": null, - "doc_type": "Quotation", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "disable_rounded_total", - "idx": 0, - "is_system_generated": 0, - "modified": "2025-04-23 13:01:06.684130", - "modified_by": "Administrator", - "module": null, - "name": "Quotation-disable_rounded_total-default", - "owner": "Administrator", - "property": "default", - "property_type": "Text", - "row_name": null, - "value": "0" - }, { "_assign": null, "_comments": null, @@ -824,29 +1697,6 @@ "row_name": null, "value": "0" }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-04-24 14:57:35.884281", - "default_value": null, - "doc_type": "Quotation", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-04-24 14:57:35.884281", - "modified_by": "Administrator", - "module": null, - "name": "Quotation-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"customer_section\", \"title\", \"naming_series\", \"quotation_to\", \"party_name\", \"customer_name\", \"column_break_7\", \"transaction_date\", \"valid_till\", \"custom_is_store_pickup\", \"column_break1\", \"order_type\", \"company\", \"amended_from\", \"store\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"items_section\", \"scan_barcode\", \"items\", \"sec_break23\", \"total_qty\", \"total_net_weight\", \"column_break_28\", \"base_total\", \"base_net_total\", \"column_break_31\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"column_break_36\", \"shipping_rule\", \"column_break_34\", \"incoterm\", \"named_place\", \"section_break_36\", \"taxes\", \"section_break_39\", \"base_total_taxes_and_charges\", \"column_break_42\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"disable_rounded_total\", \"in_words\", \"section_break_44\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_46\", \"additional_discount_percentage\", \"discount_amount\", \"referral_sales_partner\", \"sec_tax_breakup\", \"other_charges_calculation\", \"bundle_items_section\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"address_and_contact_tab\", \"billing_address_section\", \"customer_address\", \"address_display\", \"col_break98\", \"contact_person\", \"contact_display\", \"contact_mobile\", \"contact_email\", \"shipping_address_section\", \"shipping_address_name\", \"column_break_81\", \"shipping_address\", \"company_address_section\", \"company_address\", \"company_address_display\", \"column_break_87\", \"company_contact_person\", \"terms_tab\", \"payment_schedule_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info_tab\", \"subscription_section\", \"auto_repeat\", \"update_auto_repeat_reference\", \"print_settings\", \"letter_head\", \"group_same_items\", \"column_break_73\", \"select_print_heading\", \"language\", \"lost_reasons_section\", \"lost_reasons\", \"competitors\", \"column_break_117\", \"order_lost_reason\", \"additional_info_section\", \"status\", \"customer_group\", \"territory\", \"column_break_108\", \"campaign\", \"source\", \"column_break4\", \"opportunity\", \"supplier_quotation\", \"enq_det\", \"connections_tab\"]" - }, { "_assign": null, "_comments": null, @@ -892,29 +1742,6 @@ "property_type": "Check", "row_name": null, "value": "0" - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:20:48.284205", - "default_value": null, - "doc_type": "Quotation", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "scan_barcode", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-04-23 13:01:06.781942", - "modified_by": "Administrator", - "module": null, - "name": "Quotation-scan_barcode-hidden", - "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" } ], "sync_on_migrate": 1 diff --git a/commera/commera_ecommerce/custom/quotation_item.json b/commera/commera_ecommerce/custom/quotation_item.json new file mode 100644 index 00000000..f58447df --- /dev/null +++ b/commera/commera_ecommerce/custom/quotation_item.json @@ -0,0 +1,201 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation Item-custom_discount", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the cart line's attributes.", + "docstatus": 0, + "dt": "Quotation Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_line_attributes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Line Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation Item-custom_line_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Quotation Item", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/recommended_items.json b/commera/commera_ecommerce/custom/recommended_items.json deleted file mode 100644 index 2cc5069f..00000000 --- a/commera/commera_ecommerce/custom/recommended_items.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "custom_fields": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-03-12 12:12:20.061519", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Recommended Items", - "fetch_from": "website_item.brand", - "fetch_if_empty": 0, - "fieldname": "custom_website_item_brand", - "fieldtype": "Data", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 9, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "website_item_thumbnail", - "is_system_generated": 0, - "is_virtual": 0, - "label": "Website Item Brand", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-03-12 12:12:20.061519", - "modified_by": "12rahulagrawaliitjee@gmail.com", - "module": null, - "name": "Recommended Items-custom_website_item_brand", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "12rahulagrawaliitjee@gmail.com", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 1, - "unique": 0, - "width": null - } - ], - "custom_perms": [], - "doctype": "Recommended Items", - "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-03-12 12:12:19.969154", - "default_value": null, - "doc_type": "Recommended Items", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-03-12 12:12:19.969154", - "modified_by": "12rahulagrawaliitjee@gmail.com", - "module": null, - "name": "Recommended Items-main-field_order", - "owner": "12rahulagrawaliitjee@gmail.com", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"website_item\", \"website_item_name\", \"column_break_2\", \"item_code\", \"more_information_section\", \"route\", \"column_break_6\", \"website_item_image\", \"website_item_thumbnail\", null]" - } - ], - "sync_on_migrate": 1 -} diff --git a/commera/commera_ecommerce/custom/sales_invoice.json b/commera/commera_ecommerce/custom/sales_invoice.json new file mode 100644 index 00000000..e6a86384 --- /dev/null +++ b/commera/commera_ecommerce/custom/sales_invoice.json @@ -0,0 +1,649 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discounts_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_ecommerce_tab", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discounts", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discounts_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Discount codes the shopper applied, one per line.", + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_codes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discounts_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discount_codes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discounts_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_codes", + "is_system_generated": 0, + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discounts_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_order_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discounts_column_break", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_code_order_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_order_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_shipping_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount_id", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount Rule", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_shipping_discount_id", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON list of the discounts on this document and their amounts.", + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount_id", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Allocations", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discount_allocations", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the shopper's cart-level attributes.", + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_cart_attributes", + "fieldtype": "Long Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_cart_details_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Cart Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_cart_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": "eval:doc.custom_discount_codes || doc.custom_discount_allocations || doc.custom_cart_attributes", + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_ecommerce_tab", + "fieldtype": "Tab Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "is_discounted", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Ecommerce", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_ecommerce_tab", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_cart_details_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_allocations", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Cart Details", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_cart_details_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Sales Invoice", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/sales_invoice_item.json b/commera/commera_ecommerce/custom/sales_invoice_item.json new file mode 100644 index 00000000..c8df6575 --- /dev/null +++ b/commera/commera_ecommerce/custom/sales_invoice_item.json @@ -0,0 +1,201 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice Item-custom_discount", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the cart line's attributes.", + "docstatus": 0, + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_line_attributes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Line Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice Item-custom_line_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Sales Invoice Item", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/sales_order.json b/commera/commera_ecommerce/custom/sales_order.json index 3ad79a7e..3ab283a3 100644 --- a/commera/commera_ecommerce/custom/sales_order.json +++ b/commera/commera_ecommerce/custom/sales_order.json @@ -6,46 +6,46 @@ "_liked_by": null, "_user_tags": null, "allow_in_quick_entry": 0, - "allow_on_submit": 1, + "allow_on_submit": 0, "bold": 0, "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-05-27 07:43:30.151623", - "default": null, + "creation": "2025-06-03 08:56:47.997238", + "default": "COD", "depends_on": null, "description": null, "docstatus": 0, "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_animo_api_response", - "fieldtype": "Long Text", + "fieldname": "custom_ecommerce_payment_mode", + "fieldtype": "Link", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 155, + "idx": 14, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_animo_api_status", + "insert_after": "custom_payment_section", "is_system_generated": 0, "is_virtual": 0, - "label": "Animo Api Response", + "label": "Ecommerce Payment Mode", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-05-17 01:16:48.419503", + "modified": "2025-06-03 08:56:47.997238", "modified_by": "Administrator", - "module": "Printechs Fashion", - "name": "Sales Order-custom_animo_api_response", + "module": null, + "name": "Sales Order-custom_ecommerce_payment_mode", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "Mode of Payment", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -75,41 +75,41 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-05-27 07:43:29.979343", - "default": null, + "creation": "2025-06-03 08:56:47.744936", + "default": "Waiting for Approval", "depends_on": null, "description": null, "docstatus": 0, "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_animo_api_status", + "fieldname": "custom_ecommerce_status", "fieldtype": "Select", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 154, + "idx": 13, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, - "in_list_view": 0, + "in_list_view": 1, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "is_internal_customer", + "insert_after": "customer_name", "is_system_generated": 0, "is_virtual": 0, - "label": "Animo Api Status", + "label": "Ecommerce Status", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-05-17 01:10:26.062999", + "modified": "2025-06-03 08:56:47.744936", "modified_by": "Administrator", - "module": "Printechs Fashion", - "name": "Sales Order-custom_animo_api_status", + "module": null, + "name": "Sales Order-custom_ecommerce_status", "no_copy": 0, "non_negative": 0, - "options": "Success\nFailed\nError", + "options": "Waiting for Approval\nOrder Received\nPreparing for Shipment\nShipped\nDelivered\nPartially Returned\nCancelled\nReturned", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -117,14 +117,14 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 0, + "read_only": 1, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, "search_index": 0, "show_dashboard": 0, "sort_options": 0, - "translatable": 0, + "translatable": 1, "unique": 0, "width": null }, @@ -139,21 +139,21 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-06-03 08:56:47.997238", - "default": "COD", + "creation": "2025-04-11 08:42:07.361360", + "default": null, "depends_on": null, "description": null, "docstatus": 0, "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_ecommerce_payment_mode", - "fieldtype": "Link", + "fieldname": "custom_is_store_pickup", + "fieldtype": "Check", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 14, + "idx": 12, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, @@ -163,17 +163,17 @@ "insert_after": "custom_ecommerce_status", "is_system_generated": 0, "is_virtual": 0, - "label": "Ecommerce Payment Mode", + "label": "Is Store Pickup?", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-06-03 08:56:47.997238", + "modified": "2025-04-11 08:42:07.361360", "modified_by": "Administrator", "module": null, - "name": "Sales Order-custom_ecommerce_payment_mode", + "name": "Sales Order-custom_is_store_pickup", "no_copy": 0, "non_negative": 0, - "options": "Mode of Payment", + "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -192,70 +192,6 @@ "unique": 0, "width": null }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-06-03 08:56:47.744936", - "default": "Waiting for Approval", - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Sales Order", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "custom_ecommerce_status", - "fieldtype": "Select", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 13, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "custom_is_store_pickup", - "is_system_generated": 0, - "is_virtual": 0, - "label": "Ecommerce Status", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-06-03 08:56:47.744936", - "modified_by": "Administrator", - "module": null, - "name": "Sales Order-custom_ecommerce_status", - "no_copy": 0, - "non_negative": 0, - "options": "Waiting for Approval\nOrder Received\nPreparing for Shipment\nShipped\nDelivered\nPartially Returned\nCancelled\nReturned", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 1, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 1, - "unique": 0, - "width": null - }, { "_assign": null, "_comments": null, @@ -267,7 +203,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-04-11 08:42:07.361360", + "creation": "2025-04-24 14:58:33.762027", "default": null, "depends_on": null, "description": null, @@ -275,33 +211,33 @@ "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_is_store_pickup", - "fieldtype": "Check", + "fieldname": "custom_store", + "fieldtype": "Link", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 12, + "idx": 19, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "delivery_date", + "insert_after": "custom_fulfilment_section", "is_system_generated": 0, "is_virtual": 0, - "label": "Is Store Pickup?", + "label": "Store", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-04-11 08:42:07.361360", + "modified": "2025-04-24 14:58:33.762027", "modified_by": "Administrator", "module": null, - "name": "Sales Order-custom_is_store_pickup", + "name": "Sales Order-custom_store", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "Warehouse", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -328,10 +264,10 @@ "allow_in_quick_entry": 0, "allow_on_submit": 0, "bold": 0, - "collapsible": 0, + "collapsible": 1, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-04-24 14:58:33.762027", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -339,33 +275,33 @@ "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_store", - "fieldtype": "Link", + "fieldname": "custom_tracking_section", + "fieldtype": "Section Break", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 19, + "idx": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "company", + "insert_after": "custom_cart_attributes", "is_system_generated": 0, "is_virtual": 0, - "label": "Store", + "label": "Tracking", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-04-24 14:58:33.762027", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-custom_store", + "name": "Sales Order-custom_tracking_section", "no_copy": 0, "non_negative": 0, - "options": "Warehouse", + "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -416,7 +352,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "amended_from", + "insert_after": "custom_tracking_section", "is_system_generated": 0, "is_virtual": 0, "label": "Analytics Session ID", @@ -672,7 +608,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_is_store_pickup", + "insert_after": "custom_store", "is_system_generated": 0, "is_virtual": 0, "label": "Delivery Option", @@ -800,7 +736,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_delivery_charge", + "insert_after": "custom_fulfilment_column_break", "is_system_generated": 0, "is_virtual": 0, "label": "Shipping Provider", @@ -896,6 +832,70 @@ "unique": 0, "width": null }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_tracking_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_utm_campaign", + "is_system_generated": 0, + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_tracking_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, { "_assign": null, "_comments": null, @@ -928,7 +928,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_shipping_service_code", + "insert_after": "custom_tracking_column_break", "is_system_generated": 0, "is_virtual": 0, "label": "Order Access Key", @@ -1023,234 +1023,905 @@ "translatable": 0, "unique": 0, "width": null - } - ], - "custom_perms": [], - "doctype": "Sales Order", - "links": [ - { - "creation": "2025-06-09 10:26:21.148448", - "custom": 1, - "docstatus": 0, - "group": null, - "hidden": 0, - "idx": 1, - "is_child_table": 0, - "link_doctype": "Gateway Payment Request", - "link_fieldname": "ref_docname", - "modified": "2025-06-09 10:26:21.148448", - "modified_by": "Administrator", - "name": "vh35ou9602", - "owner": "Administrator", - "parent": "Sales Order", - "parent_doctype": null, - "parentfield": "links", - "parenttype": "Customize Form", - "table_fieldname": null - } - ], - "property_setters": [ + }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.015527", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "base_rounded_total", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discounts_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_ecommerce_payment_mode", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.390638", - "modified_by": "Administrator", - "module": null, - "name": "Sales Order-base_rounded_total-hidden", + "is_virtual": 0, + "label": "Discounts", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_discounts_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Discount codes the shopper applied, one per line.", + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_codes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discounts_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_discount_codes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discounts_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_codes", + "is_system_generated": 0, + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_discounts_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_order_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discounts_column_break", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_code_order_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_order_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_shipping_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount_id", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount Rule", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_shipping_discount_id", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.031423", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON list of the discounts on this document and their amounts.", "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "base_rounded_total", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount_id", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.398769", + "is_virtual": 0, + "label": "Discount Allocations", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-base_rounded_total-print_hide", + "name": "Sales Order-custom_discount_allocations", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "print_hide", - "property_type": "Check", - "row_name": null, - "value": "1" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.077738", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the shopper's cart-level attributes.", "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "disable_rounded_total", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_cart_attributes", + "fieldtype": "Long Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_cart_details_section", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.407508", + "is_virtual": 0, + "label": "Cart Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-disable_rounded_total-default", + "name": "Sales Order-custom_cart_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "default", - "property_type": "Text", - "row_name": null, - "value": "0" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": "eval:doc.order_type==\"Shopping Cart\"", + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_ecommerce_tab", + "fieldtype": "Tab Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "party_account_currency", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Ecommerce", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_ecommerce_tab", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_fulfilment_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_ecommerce_tab", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Fulfilment", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_fulfilment_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:19:12.707806", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "due_date", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_fulfilment_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_delivery_charge", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.416249", + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-due_date-print_hide", + "name": "Sales Order-custom_fulfilment_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "print_hide", - "property_type": "Check", - "row_name": null, - "value": "0" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.498950", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "in_words", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_payment_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_service_code", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.425193", + "is_virtual": 0, + "label": "Payment", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-in_words-hidden", + "name": "Sales Order-custom_payment_section", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.511706", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "in_words", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_cart_details_section", + "fieldtype": "Section Break", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_allocations", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.437044", + "is_virtual": 0, + "label": "Cart Details", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-in_words-print_hide", + "name": "Sales Order-custom_cart_details_section", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "print_hide", - "property_type": "Check", - "row_name": null, - "value": "0" - }, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Sales Order", + "links": [ { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-06-03 08:57:44.591648", - "default_value": null, - "doc_type": "Sales Order", + "creation": "2025-06-09 10:26:21.148448", + "custom": 1, "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.448344", + "group": null, + "hidden": 0, + "idx": 1, + "is_child_table": 0, + "link_doctype": "Gateway Payment Request", + "link_fieldname": "ref_docname", + "modified": "2025-06-09 10:26:21.148448", "modified_by": "Administrator", - "module": null, - "name": "Sales Order-main-field_order", + "name": "vh35ou9602", "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"customer_section\", \"column_break0\", \"title\", \"naming_series\", \"customer\", \"customer_name\", \"tax_id\", \"order_type\", \"column_break_7\", \"transaction_date\", \"delivery_date\", \"custom_is_store_pickup\", \"custom_ecommerce_status\", \"custom_ecommerce_payment_mode\", \"column_break1\", \"po_no\", \"po_date\", \"company\", \"custom_store\", \"skip_delivery_note\", \"amended_from\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"sec_warehouse\", \"scan_barcode\", \"column_break_28\", \"set_warehouse\", \"reserve_stock\", \"items_section\", \"items\", \"section_break_31\", \"total_qty\", \"total_net_weight\", \"column_break_33\", \"base_total\", \"base_net_total\", \"column_break_33a\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"column_break_38\", \"shipping_rule\", \"column_break_49\", \"incoterm\", \"named_place\", \"section_break_40\", \"taxes\", \"section_break_43\", \"base_total_taxes_and_charges\", \"column_break_46\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"in_words\", \"advance_paid\", \"disable_rounded_total\", \"section_break_48\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_50\", \"additional_discount_percentage\", \"discount_amount\", \"sec_tax_breakup\", \"other_charges_calculation\", \"packing_list\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"contact_info\", \"billing_address_column\", \"customer_address\", \"address_display\", \"customer_group\", \"territory\", \"column_break_84\", \"contact_person\", \"contact_display\", \"contact_phone\", \"contact_mobile\", \"contact_email\", \"shipping_address_column\", \"shipping_address_name\", \"shipping_address\", \"column_break_93\", \"dispatch_address_name\", \"dispatch_address\", \"col_break46\", \"company_address\", \"company_address_display\", \"column_break_92\", \"company_contact_person\", \"payment_schedule_section\", \"payment_terms_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info\", \"section_break_78\", \"status\", \"delivery_status\", \"per_delivered\", \"column_break_81\", \"per_billed\", \"per_picked\", \"billing_status\", \"sales_team_section_break\", \"sales_partner\", \"column_break7\", \"amount_eligible_for_commission\", \"commission_rate\", \"total_commission\", \"section_break1\", \"sales_team\", \"loyalty_points_redemption\", \"loyalty_points\", \"column_break_116\", \"loyalty_amount\", \"subscription_section\", \"from_date\", \"to_date\", \"column_break_108\", \"auto_repeat\", \"update_auto_repeat_reference\", \"printing_details\", \"letter_head\", \"group_same_items\", \"column_break4\", \"select_print_heading\", \"language\", \"additional_info_section\", \"is_internal_customer\", \"custom_animo_api_status\", \"custom_animo_api_response\", \"represents_company\", \"column_break_152\", \"source\", \"inter_company_order_reference\", \"campaign\", \"party_account_currency\", \"connections_tab\"]" - }, + "parent": "Sales Order", + "parent_doctype": null, + "parentfield": "links", + "parenttype": "Customize Form", + "table_fieldname": null + } + ], + "property_setters": [ { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-06-09 10:26:21.153761", + "creation": "2025-03-29 20:37:38.015527", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, + "doctype_or_field": "DocField", + "field_name": "base_rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-09 10:26:21.153761", + "modified": "2025-06-06 14:02:34.390638", "modified_by": "Administrator", "module": null, - "name": "Sales Order-main-links_order", + "name": "Sales Order-base_rounded_total-hidden", "owner": "Administrator", - "property": "links_order", - "property_type": "Small Text", + "property": "hidden", + "property_type": "Check", "row_name": null, - "value": "[\"vh3cpo4afa\", \"vh35ou9602\"]" + "value": "0" }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:19:12.712546", + "creation": "2025-03-29 20:37:38.031423", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "payment_schedule", + "field_name": "base_rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.464788", + "modified": "2025-06-06 14:02:34.398769", "modified_by": "Administrator", "module": null, - "name": "Sales Order-payment_schedule-print_hide", + "name": "Sales Order-base_rounded_total-print_hide", "owner": "Administrator", "property": "print_hide", "property_type": "Check", @@ -1262,18 +1933,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.046560", + "creation": "2025-03-29 20:37:38.498950", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "rounded_total", + "field_name": "in_words", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.474369", + "modified": "2025-06-06 14:02:34.425193", "modified_by": "Administrator", "module": null, - "name": "Sales Order-rounded_total-hidden", + "name": "Sales Order-in_words-hidden", "owner": "Administrator", "property": "hidden", "property_type": "Check", @@ -1285,18 +1956,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.062243", + "creation": "2025-03-29 20:37:38.511706", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "rounded_total", + "field_name": "in_words", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.483896", + "modified": "2025-06-06 14:02:34.437044", "modified_by": "Administrator", "module": null, - "name": "Sales Order-rounded_total-print_hide", + "name": "Sales Order-in_words-print_hide", "owner": "Administrator", "property": "print_hide", "property_type": "Check", @@ -1308,41 +1979,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:20:48.306479", - "default_value": null, - "doc_type": "Sales Order", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "scan_barcode", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-06-06 14:02:34.494206", - "modified_by": "Administrator", - "module": null, - "name": "Sales Order-scan_barcode-hidden", - "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:20:43.217392", + "creation": "2025-03-29 20:37:38.046560", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "tax_id", + "field_name": "rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.504141", + "modified": "2025-06-06 14:02:34.474369", "modified_by": "Administrator", "module": null, - "name": "Sales Order-tax_id-hidden", + "name": "Sales Order-rounded_total-hidden", "owner": "Administrator", "property": "hidden", "property_type": "Check", @@ -1354,18 +2002,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:20:43.228483", + "creation": "2025-03-29 20:37:38.062243", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "tax_id", + "field_name": "rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.514262", + "modified": "2025-06-06 14:02:34.483896", "modified_by": "Administrator", "module": null, - "name": "Sales Order-tax_id-print_hide", + "name": "Sales Order-rounded_total-print_hide", "owner": "Administrator", "property": "print_hide", "property_type": "Check", diff --git a/commera/commera_ecommerce/custom/sales_order_item.json b/commera/commera_ecommerce/custom/sales_order_item.json new file mode 100644 index 00000000..4d485304 --- /dev/null +++ b/commera/commera_ecommerce/custom/sales_order_item.json @@ -0,0 +1,201 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order Item-custom_discount", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the cart line's attributes.", + "docstatus": 0, + "dt": "Sales Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_line_attributes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Line Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order Item-custom_line_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Sales Order Item", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/warehouse.json b/commera/commera_ecommerce/custom/warehouse.json index 916a33eb..e1f9555d 100644 --- a/commera/commera_ecommerce/custom/warehouse.json +++ b/commera/commera_ecommerce/custom/warehouse.json @@ -68,30 +68,6 @@ "custom_perms": [], "doctype": "Warehouse", "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-04-11 08:39:41.822511", - "default_value": null, - "doc_type": "Warehouse", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-04-11 08:39:41.822511", - "modified_by": "Administrator", - "module": null, - "name": "Warehouse-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"warehouse_detail\", \"disabled\", \"warehouse_name\", \"column_break_3\", \"is_group\", \"parent_warehouse\", \"is_rejected_warehouse\", \"column_break_4\", \"custom_store_pickup\", \"account\", \"company\", \"address_and_contact\", \"address_html\", \"column_break_10\", \"contact_html\", \"warehouse_contact_info\", \"email_id\", \"phone_no\", \"mobile_no\", \"column_break0\", \"address_line_1\", \"address_line_2\", \"city\", \"state\", \"pin\", \"transit_section\", \"warehouse_type\", \"column_break_qajx\", \"default_in_transit_warehouse\", \"tree_details\", \"lft\", \"rgt\", \"old_parent\"]" - } - ], + "property_setters": [], "sync_on_migrate": 1 } diff --git a/commera/commera_ecommerce/doctype/commera_cart/__init__.py b/commera/commera_ecommerce/doctype/commera_cart/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.json b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.json new file mode 100644 index 00000000..95199467 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.json @@ -0,0 +1,78 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user", + "cart_key", + "attributes", + "plugin_fields", + "discount_codes", + "lines" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "read_only": 1, + "unique": 1 + }, + { + "description": "Hash of the guest cart cookie. Empty for a signed-in shopper.", + "fieldname": "cart_key", + "fieldtype": "Data", + "hidden": 1, + "label": "Guest Cart Key", + "read_only": 1, + "unique": 1 + }, + { + "description": "JSON object of cart-level attributes, copied to the order.", + "fieldname": "attributes", + "fieldtype": "Small Text", + "label": "Attributes" + }, + { + "description": "JSON object of plugin Quotation fields set before checkout; copied to the Quotation at checkout.", + "fieldname": "plugin_fields", + "fieldtype": "Long Text", + "label": "Plugin Fields", + "read_only": 1 + }, + { + "description": "Commera Discount codes, one per line.", + "fieldname": "discount_codes", + "fieldtype": "Small Text", + "label": "Discount Codes" + }, + { + "fieldname": "lines", + "fieldtype": "Table", + "label": "Lines", + "options": "Commera Cart Line" + } + ], + "links": [], + "modified": "2026-10-21 18:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Cart", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py new file mode 100644 index 00000000..16b3a694 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py @@ -0,0 +1,438 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import create_batch +from frappe.utils.data import add_days, cint, cstr, flt, get_datetime, now_datetime + +from commera.api.cart import validate_stock_available +from commera.discounts.allocations import get_allocations, get_price_discount_rows +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import get_code_discount, normalize_code +from commera.discounts.doctype.commera_discount.commera_discount import SHIPPING, DiscountCodeError +from commera.discounts.preview import get_preview_quotation +from commera.discounts.pricing import get_saving, select_discounts +from commera.discounts.selection import ( + Selection, + get_discount, + get_discount_codes, + get_unmet_reason, + get_unusable_code_reason, + validate_new_discount_code, +) +from commera.guest import clear_guest_cart_cookie, get_guest_cart_key, is_guest, set_guest_cart_cookie +from commera.plugin_events import fire_event, get_handlers +from commera.utils import get_available_stocks, get_free_delivery + +MAX_CART_LINES = 50 +# Discount pricing slows down with every unit, so even an item that never runs out has a limit. +MAX_LINE_QUANTITY = 100 +ABANDONED_GUEST_CART_DAYS = 30 + + +def get_max_line_quantity() -> int: + return cint(frappe.get_cached_doc("Commera Settings").max_cart_line_quantity) or MAX_LINE_QUANTITY + + +class CommeraCart(Document): + def validate(self): + if len(self.lines) > MAX_CART_LINES: + frappe.throw(_("A cart holds at most {0} lines.").format(MAX_CART_LINES)) + max_line_quantity = get_max_line_quantity() + for line in self.lines: + if cint(line.qty) < 1: + frappe.throw(_("Row {0}: the quantity must be at least 1.").format(line.idx)) + if cint(line.qty) > max_line_quantity: + frappe.throw( + _("Row {0}: a cart line holds at most {1} units.").format(line.idx, max_line_quantity) + ) + attributes = parse_attributes(line.attributes) + line.attributes = frappe.as_json(attributes, indent=None) if attributes else None + self.validate_stock() + + def validate_stock(self): + # Only what grew: a line stock fell under since it was added must not block every other cart edit. + previous = self.get_doc_before_save() + previous_quantities = get_quantities_by_item_code(previous.lines) if previous else {} + grown = { + item_code: qty + for item_code, qty in get_quantities_by_item_code(self.lines).items() + if qty > previous_quantities.get(item_code, 0) + } + if grown: + validate_stock_available( + [{"variant": {"item_code": item_code}, "qty": qty} for item_code, qty in grown.items()] + ) + + def cap_at_stock(self): + quantities = get_quantities_by_item_code(self.lines) + warehouse = frappe.get_cached_value("Commera Settings", "Commera Settings", "ecommerce_warehouse") + stock_by_item_code = get_available_stocks(list(quantities), warehouse) + for item_code, qty in quantities.items(): + stock = stock_by_item_code.get(item_code, {}) + available = max(cint(stock.get("stock_qty")), 0) + if not stock.get("unlimited") and qty > available: + rows = [row for row in self.lines if row.item_code == item_code] + change_quantity_from_the_end(self, rows, available - qty) + + def after_insert(self): + # Most guest carts never reach checkout, so the event is only logged when an app listens for it. + if get_handlers("commera_events", "cart_created"): + fire_event("cart_created", self.doctype, self.name, data={"user": self.user}) + + @property + def version(self) -> str: + return str(get_datetime(self.modified)) + + def add_lines(self, lines: list[dict]): + for line in lines: + attributes = parse_attributes(line.get("attributes")) + qty = cint(line.get("qty") or 1) + selected = cint(line.get("selected", 1)) + existing_line = next( + ( + row + for row in self.lines + if row.item_code == line["item_code"] and parse_attributes(row.attributes) == attributes + ), + None, + ) + if existing_line: + existing_line.qty = cint(existing_line.qty) + qty + existing_line.selected = cint(existing_line.selected) or selected + else: + self.append( + "lines", + { + "item_code": line["item_code"], + "qty": qty, + "attributes": attributes, + "selected": selected, + }, + ) + + def update_lines(self, lines: list[dict]): + for line in lines: + row = self.get_line(line["line_id"]) + if "attributes" in line: + row.attributes = parse_attributes(line["attributes"]) + if "qty" in line: + row.qty = cint(line["qty"]) + self.lines = [row for row in self.lines if cint(row.qty) > 0] + + def remove_lines(self, line_ids: list[str]): + for line_id in line_ids: + self.get_line(line_id) + self.lines = [row for row in self.lines if row.name not in line_ids] + + def get_line(self, line_id: str): + row = next((row for row in self.lines if row.name == line_id), None) + if not row: + frappe.throw(_("Cart line {0} was not found.").format(line_id), frappe.DoesNotExistError) + return row + + def set_quantities(self, quantities: dict[str, int]) -> bool: + changed = False + for item_code in {row.item_code for row in self.lines} - set(quantities): + self.lines = [row for row in self.lines if row.item_code != item_code] + changed = True + + for item_code, qty in quantities.items(): + rows = [row for row in self.lines if row.item_code == item_code] + delta = cint(qty) - sum(cint(row.qty) for row in rows) + if not delta: + continue + changed = True + if not rows: + self.append("lines", {"item_code": item_code, "qty": cint(qty)}) + continue + change_quantity_from_the_end(self, rows, delta) + return changed + + def set_selection(self, selection: dict[str, bool]) -> bool: + changed = False + for row in self.lines: + selected = cint(selection.get(row.item_code, 1)) + changed = changed or cint(row.selected) != selected + # Set even when unchanged: a new line's None would take the field's default of 1 on insert. + row.selected = selected + return changed + + def get_selected_lines(self) -> list: + return [row for row in self.lines if cint(row.selected)] + + def add_discount_code(self, code: str): + codes = [applied_code for applied_code in get_discount_codes(self) if get_code_discount(applied_code)] + preview = self.get_quotation_preview() + # A code that does not combine still joins the cart: pricing keeps whichever saves the shopper more. + code = validate_new_discount_code(code, codes, active_codes=[]) + if refusal := get_unusable_code_reason(preview, code): + frappe.throw(refusal, DiscountCodeError) + self.discount_codes = "\n".join([*codes, code]) + + def remove_discount_code(self, code: str): + code = normalize_code(code) + self.discount_codes = "\n".join(name for name in get_discount_codes(self) if name != code) + + def get_lines_by_item_code(self) -> dict[str, list]: + lines_by_item_code = {} + for row in self.get_selected_lines(): + lines_by_item_code.setdefault(row.item_code, []).append(row) + return lines_by_item_code + + def get_priced_cart(self) -> dict: + # Unticked lines are priced on their own, without discounts. + quotation = self.get_quotation_preview() + selected_lines = self.get_selected_lines() + items_by_row = dict(zip([row.name for row in selected_lines], quotation.items, strict=True)) + if other_lines := [row for row in self.lines if not cint(row.selected)]: + plain_quotation = get_preview_quotation( + get_preview_lines(other_lines), user=self.user, flags={"commera_discounts": []} + ) + items_by_row.update(zip([row.name for row in other_lines], plain_quotation.items, strict=True)) + return { + "name": self.name, + "version": self.version, + "attributes": parse_attributes(self.attributes), + "currency": quotation.currency, + "discount_codes": get_code_states(quotation), + "automatic_discounts": get_automatic_discounts(quotation), + "discounts": get_price_discount_rows(quotation), + "lines": [get_priced_line(row, items_by_row[row.name]) for row in self.lines], + "subtotal": flt(quotation.total), + "order_discount": flt(quotation.custom_code_order_discount), + "total": flt(quotation.net_total), + **get_free_delivery(quotation), + } + + def get_quotation_preview(self): + fields = {**frappe.parse_json(self.plugin_fields or "{}"), "custom_cart_attributes": self.attributes} + return get_preview_quotation( + get_preview_lines(self.get_selected_lines()), self.discount_codes, self.user, fields=fields + ) + + def update_quotation(self, quotation): + # Split by attributes only while the qty still matches the ticked lines. + lines_by_item_code = self.get_lines_by_item_code() + items = [] + for item in quotation.items: + lines = lines_by_item_code.get(item.item_code, []) + row = {"item_code": item.item_code, "warehouse": item.warehouse} + if len(lines) > 1 and sum(cint(line.qty) for line in lines) == cint(item.qty): + items.extend( + {**row, "qty": line.qty, "custom_line_attributes": line.attributes} for line in lines + ) + else: + items.append( + {**row, "qty": item.qty, "custom_line_attributes": lines[0].attributes if lines else None} + ) + quotation.set("items", items) + quotation.custom_discount_codes = self.discount_codes + quotation.custom_cart_attributes = self.attributes + quotation.update(frappe.parse_json(self.plugin_fields or "{}")) + + def set_attributes(self, values: dict): + attributes = { + **parse_attributes(self.attributes), + **{cstr(key): value for key, value in values.items()}, + } + attributes = {key: cstr(value) for key, value in attributes.items() if value is not None} + self.attributes = frappe.as_json(attributes, indent=None) if attributes else None + + def set_plugin_fields(self, values: dict): + self.plugin_fields = frappe.as_json( + {**frappe.parse_json(self.plugin_fields or "{}"), **values}, indent=None + ) + + +def get_preview_lines(rows: list) -> list[dict]: + return [{"item_code": row.item_code, "qty": row.qty} for row in rows] + + +def get_quantities_by_item_code(rows: list) -> dict[str, int]: + quantities = {} + for row in rows: + quantities[row.item_code] = quantities.get(row.item_code, 0) + cint(row.qty) + return quantities + + +def change_quantity_from_the_end(cart, rows: list, delta: int): + if delta > 0: + rows[-1].qty = cint(rows[-1].qty) + delta + return + for row in reversed(rows): + taken = min(cint(row.qty), -delta) + row.qty = cint(row.qty) - taken + delta += taken + cart.lines = [row for row in cart.lines if cint(row.qty) > 0] + + +def get_code_states(quotation) -> list[dict]: + applied = {(row["discount"], row.get("code")) for row in get_allocations(quotation)} + cart = get_cart_context(quotation) + selections = select_discounts(cart) + states = [] + for code in get_discount_codes(quotation): + discount_name = get_code_discount(code) + discount = get_discount(discount_name) if discount_name else None + is_applied = (discount_name, code) in applied + states.append( + { + "code": code, + "title": discount.title if discount else code, + "discount_class": discount.discount_class if discount else None, + "applied": is_applied, + "reason": None if is_applied else get_code_reason(cart, code, selections), + } + ) + return states + + +def get_code_reason(cart, code: str, selections: list) -> str: + if reason := get_unusable_code_reason(cart.doc, code): + return reason + discount = get_discount(get_code_discount(code)) + if reason := get_unmet_reason(discount, cart): + return reason + if not discount.gives_anything(cart): + if discount.discount_class == SHIPPING: + return _("{0} does not cover the delivery option you chose.").format(discount.title) + return _("Nothing in your cart qualifies for {0}.").format(discount.title) + if discount.discount_class == SHIPPING and any(selection.code == code for selection in selections): + return _("{0} comes off the delivery charge once you choose a delivery option.").format( + discount.title + ) + blocking = next((other for other in selections if not discount.can_combine_with(other.discount)), None) + if blocking: + label = blocking.code or blocking.discount.title + own = [Selection(discount, code), *(s for s in selections if discount.can_combine_with(s.discount))] + if flt(get_saving(cart, own), 2) < flt(get_saving(cart, selections), 2): + return _("{0} doesn't combine with {1}, which saves you more.").format(discount.title, label) + return _("{0} doesn't combine with {1}.").format(discount.title, label) + return _("{0} cannot be combined with your other discounts.").format(discount.title) + + +def get_automatic_discounts(quotation) -> list[dict]: + return [ + {"title": row["title"], "amount": row["amount"]} + for row in get_allocations(quotation) + if not row.get("code") + ] + + +def get_priced_line(row, item) -> dict: + return { + "line_id": row.name, + "item_code": row.item_code, + "title": item.item_name, + "qty": cint(row.qty), + "selected": bool(cint(row.selected)), + "attributes": parse_attributes(row.attributes), + "price": flt(item.price_list_rate), + "rate": flt(item.rate), + "code_discount": flt(item.custom_code_discount), + "discount": item.custom_discount, + "amount": flt(item.amount), + } + + +def parse_attributes(attributes) -> dict: + attributes = frappe.parse_json(attributes) if attributes else {} + if not isinstance(attributes, dict): + frappe.throw(_("Line attributes must be a JSON object.")) + return {cstr(key): cstr(value) for key, value in attributes.items()} + + +def get_session_cart(create: bool = False) -> CommeraCart | None: + filters = {"user": frappe.session.user} if not is_guest() else {"cart_key": get_guest_cart_key()} + if is_guest() and not filters["cart_key"]: + filters = None + name = frappe.db.get_value("Commera Cart", filters, "name", order_by="modified desc") if filters else None + if name: + return frappe.get_doc("Commera Cart", name, for_update=True) + if not create: + return None + + cart = frappe.new_doc("Commera Cart") + if is_guest(): + cart.cart_key = set_guest_cart_cookie() + else: + cart.user = frappe.session.user + return cart + + +def clear_ordered_cart(quotation, shopper: str | None): + # modified is left alone so the next browser sync is not a version conflict. + cart = frappe.qb.DocType("Commera Cart") + if quotation.custom_guest_cart_key: + condition = cart.cart_key == quotation.custom_guest_cart_key + elif shopper: + condition = cart.user == shopper + else: + return + cart_name = frappe.qb.from_(cart).select(cart.name).where(condition).run(pluck=True) + if not cart_name: + return + frappe.qb.update(cart).set(cart.discount_codes, None).set(cart.attributes, None).where( + cart.name == cart_name[0] + ).run() + # The webhook can place the order while the shopper never comes back to sync the browser cart. + line = frappe.qb.DocType("Commera Cart Line") + frappe.qb.from_(line).delete().where( + (line.parent == cart_name[0]) & (line.parenttype == "Commera Cart") & (line.selected == 1) + ).run() + + +def delete_abandoned_guest_carts(): + cutoff = add_days(now_datetime(), -ABANDONED_GUEST_CART_DAYS) + names = frappe.get_all( + "Commera Cart", filters={"user": ["is", "not set"], "modified": ["<", cutoff]}, pluck="name" + ) + for batch in create_batch(names, 500): + frappe.db.delete("Commera Cart Line", {"parent": ["in", batch], "parenttype": "Commera Cart"}) + frappe.db.delete("Commera Cart", {"name": ["in", batch]}) + frappe.db.commit() # nosemgrep: frappe-manual-commit # a daily job; each batch stands alone + + +def keep_guest_cart(login_manager=None): + # A failed merge is logged, never a reason to refuse sign-in. + cart_key = get_guest_cart_key() + guest_cart_name = cart_key and frappe.db.get_value("Commera Cart", {"cart_key": cart_key}, "name") + if not guest_cart_name or is_guest(): + return + frappe.db.savepoint("keep_guest_cart") + try: + guest_cart = frappe.get_doc("Commera Cart", guest_cart_name, for_update=True) + account_cart = get_session_cart() + if account_cart: + merge_carts(guest_cart, account_cart) + else: + guest_cart.update({"user": frappe.session.user, "cart_key": None}) + guest_cart.save(ignore_permissions=True) + except Exception: + frappe.db.rollback(save_point="keep_guest_cart") + frappe.log_error(title=f"Could not keep the guest cart {guest_cart_name} on sign-in") + clear_guest_cart_cookie() + + +def merge_carts(guest_cart: CommeraCart, account_cart: CommeraCart): + account_cart.add_lines( + [ + { + "item_code": row.item_code, + "qty": row.qty, + "attributes": parse_attributes(row.attributes), + "selected": row.selected, + } + for row in guest_cart.lines + ] + ) + account_cart.cap_at_stock() + account_cart.set_attributes(parse_attributes(guest_cart.attributes)) + for code in get_discount_codes(guest_cart): + try: + account_cart.add_discount_code(code) + except frappe.ValidationError: + # A code the account cart refuses, such as one already used, is left behind. + frappe.clear_last_message() + account_cart.save(ignore_permissions=True) + frappe.delete_doc("Commera Cart", guest_cart.name, ignore_permissions=True, force=True) diff --git a/commera/commera_ecommerce/doctype/commera_cart_line/__init__.py b/commera/commera_ecommerce/doctype/commera_cart_line/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json new file mode 100644 index 00000000..c5840d1c --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": ["item_code", "qty", "attributes", "selected"], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fieldname": "qty", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Quantity", + "non_negative": 1, + "reqd": 1 + }, + { + "description": "JSON object of line properties, for example a gift note.", + "fieldname": "attributes", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Attributes" + }, + { + "default": "1", + "fieldname": "selected", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Include in Checkout" + } + ], + "istable": 1, + "links": [], + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Cart Line", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.py b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.py new file mode 100644 index 00000000..cb48b7ec --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraCartLine(Document): + pass diff --git a/commera/commera_ecommerce/doctype/commera_settings/commera_settings.json b/commera/commera_ecommerce/doctype/commera_settings/commera_settings.json index 85851ea2..e8f86792 100644 --- a/commera/commera_ecommerce/doctype/commera_settings/commera_settings.json +++ b/commera/commera_ecommerce/doctype/commera_settings/commera_settings.json @@ -34,6 +34,7 @@ "print_format", "allow_guest_checkout", "guest_order_link_days", + "max_cart_line_quantity", "payment_mode_section", "column_break_iirq", "column_break_oopm", @@ -861,6 +862,14 @@ "label": "Guest Order Link Full Details (Days)", "non_negative": 1 }, + { + "default": "100", + "description": "The most units of one item a shopper can put in the cart, even when the item never runs out of stock. Blank or 0 means 100.", + "fieldname": "max_cart_line_quantity", + "fieldtype": "Int", + "label": "Most Units per Cart Line", + "non_negative": 1 + }, { "fieldname": "storefront_apps_tab", "fieldtype": "Tab Break", @@ -879,7 +888,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-10-08 10:00:00.000000", + "modified": "2026-10-21 10:00:00.000000", "modified_by": "Administrator", "module": "Commera Ecommerce", "name": "Commera Settings", diff --git a/commera/core.py b/commera/core.py index b114de3b..6a269667 100644 --- a/commera/core.py +++ b/commera/core.py @@ -1,4 +1,5 @@ import os +from contextlib import contextmanager import frappe import frappe.defaults @@ -10,6 +11,32 @@ from commera.guest import get_guest_cart_name, is_guest, validate_guest_checkout_enabled +COD_PAYMENT_MODE = "COD" + + +def is_cod(payment_mode: str | None) -> bool: + return (payment_mode or "").strip().casefold() == COD_PAYMENT_MODE.casefold() + + +@contextmanager +def system_user_session(user: str = "Administrator"): + """Place the accounting documents as `user`, then hand the session back. + + ERPNext's get_party_account checks frappe.has_permission directly, so no ignore_permissions reaches it. + """ + # frappe.set_user() mutates frappe.local.session in place, stamping "sid" with the username and wiping + # local.session.data, so restore the snapshot verbatim rather than calling it again for the shopper. + live_session_snapshot = frappe.local.session.copy() + try: + # Audited: the docstring above and the snapshot restore below are why this is safe. + frappe.set_user(user) # nosemgrep: frappe-semgrep-rules.rules.security.frappe-setuser + yield + finally: + frappe.local.session.update(live_session_snapshot) + frappe.local.cache = {} + frappe.local.role_permissions = {} + frappe.local.user_perms = None + def generate_otp(): """Generates a cryptographically secure random OTP""" @@ -113,8 +140,6 @@ def get_party(user=None): if link: party_doc = frappe.get_cached_doc(link.link_doctype, link.link_name) if not frappe.db.exists("Portal User", {"parent": party_doc.name, "user": user}): - from commera.api.payments import system_user_session - party_doc.append("portal_users", {"user": user}) party_doc.flags.ignore_mandatory = True # Customer.on_update rewrites the primary Contact, which the shopper cannot write. diff --git a/commera/discounts/__init__.py b/commera/discounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/allocations.py b/commera/discounts/allocations.py new file mode 100644 index 00000000..78ef8286 --- /dev/null +++ b/commera/discounts/allocations.py @@ -0,0 +1,49 @@ +import json + +import frappe +from frappe.utils.data import flt + +ALLOCATIONS_FIELD = "custom_discount_allocations" + + +def get_allocations(doc) -> list[dict]: + return frappe.parse_json(doc.get(ALLOCATIONS_FIELD) or "[]") + + +def set_allocations(doc, allocations: list[dict]): + doc.set(ALLOCATIONS_FIELD, json.dumps(allocations) if allocations else None) + + +def get_allocation(selection, discount_class: str, amount: float, line: int | None = None) -> dict: + return { + "discount": selection.discount.name, + "code": selection.code, + "title": selection.discount.title, + "discount_class": discount_class, + "amount": flt(amount, 2), + "line": line, + } + + +def get_discount_rows(allocations: list[dict], classes: tuple = (), line: int | None = None) -> list[dict]: + amounts = {} + for allocation in allocations: + if line is not None and allocation.get("line") != line: + continue + if classes and allocation.get("discount_class") not in classes: + continue + label = allocation.get("code") or allocation.get("title") + amounts[label] = amounts.get(label, 0) + flt(allocation.get("amount")) + return [{"label": label, "amount": flt(amount, 2)} for label, amount in amounts.items()] + + +def get_order_discount_rows(doc) -> list[dict]: + rows = get_discount_rows(get_allocations(doc), ("Order",)) + # A discount ERPNext gave on its own, such as a Pricing Rule, has no allocation to name it. + other = flt(flt(doc.get("discount_amount")) - sum(row["amount"] for row in rows), 2) + return [*rows, {"label": None, "amount": other}] if other > 0 else rows + + +def get_price_discount_rows(doc) -> list[dict]: + # Includes line and order discounts so a list-price summary adds up. + return get_discount_rows(get_allocations(doc), ("Product",)) + get_order_discount_rows(doc) diff --git a/commera/discounts/cart_context.py b/commera/discounts/cart_context.py new file mode 100644 index 00000000..31afbb29 --- /dev/null +++ b/commera/discounts/cart_context.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass, field + +import frappe +from erpnext.setup.utils import get_exchange_rate +from frappe.utils.data import cint, cstr, flt + +from commera.api.cart import get_cart_price +from commera.core import is_cod + + +@dataclass +class CartContext: + doc: object + payment_kind: str + customer: str | None + customer_group: str | None + email: str | None + phone: str | None + sale_by_item_code: dict = field(default_factory=dict) + exchange_rates: dict = field(default_factory=dict) + offered_option: dict | None = None + + @property + def items(self) -> list: + return self.doc.items + + @property + def currency(self) -> str: + return self.doc.currency + + @property + def subtotal(self) -> float: + return sum(flt(item.amount) for item in self.items) + + @property + def quantity(self) -> int: + return sum(cint(item.qty) for item in self.items) + + @property + def delivery_option(self) -> dict | None: + # Priced before any shipping discount. + if self.offered_option: + return self.offered_option + if not self.doc.get("custom_delivery_option"): + return None + return { + "title": self.doc.custom_delivery_option, + "amount": flt(self.doc.get("custom_delivery_charge")) + + flt(self.doc.get("custom_shipping_discount")), + } + + def get_exchange_rate(self, currency: str | None) -> float: + currency = currency or frappe.get_cached_value("Company", self.doc.company, "default_currency") + if currency == self.currency: + return 1.0 + if currency not in self.exchange_rates: + self.exchange_rates[currency] = flt( + get_exchange_rate(currency, self.currency, self.doc.get("transaction_date"), "for_selling") + ) + return self.exchange_rates[currency] + + def convert(self, amount: float, currency: str | None) -> float: + return flt(amount) * self.get_exchange_rate(currency) if flt(amount) else 0.0 + + def is_on_sale(self, item_code: str) -> bool: + if item_code not in self.sale_by_item_code: + settings = frappe.get_cached_doc("Commera Settings") + regular_price, price = get_cart_price( + item_code, settings.get_default_price_list(), settings.get_sale_price_list() + ) + self.sale_by_item_code[item_code] = flt(price) < flt(regular_price) + return self.sale_by_item_code[item_code] + + +def get_cart_context(doc) -> CartContext: + customer = doc.get("customer") or (doc.party_name if doc.get("quotation_to") == "Customer" else None) + return CartContext( + doc=doc, + payment_kind=get_payment_kind(doc), + customer=customer, + customer_group=frappe.get_cached_value("Customer", customer, "customer_group") if customer else None, + email=cstr(doc.get("contact_email")).strip().lower() or None, + phone=doc.get("contact_mobile") or doc.get("contact_phone") or None, + ) + + +def get_payment_kind(doc) -> str: + """A cart is priced as prepaid until checkout says otherwise; an order knows how it is paid.""" + if doc.flags.commera_payment_kind: + return doc.flags.commera_payment_kind + return "cod" if is_cod(doc.get("custom_ecommerce_payment_mode")) else "prepaid" diff --git a/commera/discounts/codes.py b/commera/discounts/codes.py new file mode 100644 index 00000000..55b4bdbf --- /dev/null +++ b/commera/discounts/codes.py @@ -0,0 +1,92 @@ +import secrets + +import frappe +from frappe import _ +from frappe.utils.data import cint, cstr + +from commera.plugin_events import get_handlers, handle_hook_error + +MAX_GENERATED_CODES = 5000 +MAX_CODES_IN_REQUEST = 200 +# Leaves room for the 8 random characters within a code's 64. +MAX_PREFIX_LENGTH = 56 +# No 0/O or 1/I: a code read off a receipt must not be mistyped. +CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def normalize_code(code: str | None) -> str: + return cstr(code).strip().upper() + + +def get_code_discount(code: str) -> str | None: + return frappe.get_cached_value("Commera Discount Code", normalize_code(code), "discount") + + +def add_code(discount: str, code: str, is_generated: bool = False) -> str: + return ( + frappe.get_doc( + { + "doctype": "Commera Discount Code", + "code": code, + "discount": discount, + "is_generated": is_generated, + } + ) + .insert(ignore_permissions=True) + .name + ) + + +def validate_code_count(count: int, prefix: str = ""): + if not 0 < cint(count) <= MAX_GENERATED_CODES: + frappe.throw(_("Generate between 1 and {0} codes at a time.").format(MAX_GENERATED_CODES)) + if len(normalize_code(prefix)) > MAX_PREFIX_LENGTH: + frappe.throw(_("A code prefix holds at most {0} characters.").format(MAX_PREFIX_LENGTH)) + + +def generate_codes(discount: str, count: int, prefix: str = "") -> list[str]: + validate_code_count(count, prefix) + count, prefix = cint(count), normalize_code(prefix) + codes = [] + while len(codes) < count: + batch = { + prefix + "".join(secrets.choice(CODE_ALPHABET) for _index in range(8)) + for _row in range(count - len(codes)) + } + taken = set( + frappe.get_all("Commera Discount Code", filters={"name": ["in", list(batch)]}, pluck="name") + ) + codes += [add_code(discount, code, is_generated=True) for code in batch - taken] + return codes + + +def enqueue_codes(discount: str, count: int, prefix: str = "") -> int: + """Returns how many were made now; a large batch is made by a background job instead.""" + validate_code_count(count, prefix) + if cint(count) <= MAX_CODES_IN_REQUEST: + return len(generate_codes(discount, count, prefix)) + frappe.enqueue( + generate_codes, + queue="long", + enqueue_after_commit=True, + discount=discount, + count=cint(count), + prefix=prefix, + ) + return 0 + + +def get_discount_code_refusal(quotation, code: str) -> str | None: + for handler in get_handlers("commera_hooks", "validate_discount_code"): + reason = None + with handle_hook_error( + "validate_discount_code", handler, quotation, bool(quotation.flags.strict_plugin_fees) + ): + reason = frappe.get_attr(handler)(quotation, code) + if reason and not isinstance(reason, str): + raise TypeError( + f'commera_hooks["validate_discount_code"] must return a str or None, got {reason!r}' + ) + if reason: + return reason + return None diff --git a/commera/discounts/doctype/__init__.py b/commera/discounts/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount/__init__.py b/commera/discounts/doctype/commera_discount/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount/commera_discount.json b/commera/discounts/doctype/commera_discount/commera_discount.json new file mode 100644 index 00000000..07b9084a --- /dev/null +++ b/commera/discounts/doctype/commera_discount/commera_discount.json @@ -0,0 +1,470 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "method", + "discount_type", + "column_break_main", + "enabled", + "starts_on", + "ends_on", + "section_value", + "value_type", + "value", + "maximum_discount", + "fixed_applies_to", + "column_break_value", + "currency", + "tiered", + "tiers", + "section_targets", + "applies_to", + "exclude_sale_items", + "targets", + "section_buy_x_get_y", + "buy_requirement", + "buy_value", + "uses_per_order", + "column_break_get", + "get_quantity", + "get_value_type", + "get_value", + "section_shipping", + "delivery_options", + "column_break_shipping", + "shipping_percentage", + "maximum_shipping_rate", + "section_conditions", + "minimum_requirement", + "minimum_subtotal", + "minimum_quantity", + "payment_condition", + "column_break_conditions", + "eligibility", + "customer_groups", + "customers", + "section_combinations", + "combines_with_product", + "combines_with_order", + "combines_with_shipping", + "section_limits", + "usage_limit", + "once_per_customer", + "budget", + "column_break_limits", + "times_used", + "discount_given", + "sales", + "section_reporting", + "campaign", + "column_break_reporting", + "discount_account" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1, + "in_list_view": 1, + "description": "Shoppers see this for an automatic discount. A code discount shows its code." + }, + { + "fieldname": "method", + "fieldtype": "Select", + "label": "Method", + "options": "Code\nAutomatic", + "default": "Code", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "discount_type", + "fieldtype": "Select", + "label": "Discount Type", + "options": "Amount Off Products\nAmount Off Order\nBuy X Get Y\nFree Shipping", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled", + "default": "1" + }, + { + "fieldname": "starts_on", + "fieldtype": "Date", + "label": "Starts On", + "default": "Today" + }, + { + "fieldname": "ends_on", + "fieldtype": "Date", + "label": "Ends On" + }, + { + "fieldname": "section_value", + "fieldtype": "Section Break", + "label": "Value", + "depends_on": "eval:['Amount Off Products','Amount Off Order'].includes(doc.discount_type)" + }, + { + "fieldname": "value_type", + "fieldtype": "Select", + "label": "Value Type", + "options": "Percentage\nFixed Amount", + "default": "Percentage" + }, + { + "fieldname": "value", + "fieldtype": "Float", + "label": "Value", + "non_negative": 1 + }, + { + "fieldname": "maximum_discount", + "fieldtype": "Currency", + "label": "Maximum Discount", + "options": "currency", + "non_negative": 1, + "depends_on": "eval:doc.value_type == 'Percentage'", + "description": "Caps a percentage discount for the whole order. 0 means no cap." + }, + { + "fieldname": "fixed_applies_to", + "fieldtype": "Select", + "label": "Fixed Amount Comes Off", + "options": "Each Item\nOnce Per Order", + "default": "Each Item", + "depends_on": "eval:doc.discount_type == 'Amount Off Products' && doc.value_type == 'Fixed Amount'" + }, + { + "fieldname": "column_break_value", + "fieldtype": "Column Break" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "description": "Fixed amounts, caps and budgets apply only to carts in this currency." + }, + { + "fieldname": "tiered", + "fieldtype": "Check", + "label": "Spend Tiers", + "depends_on": "eval:doc.discount_type == 'Amount Off Order'", + "description": "The best tier the subtotal reaches replaces the single value." + }, + { + "fieldname": "tiers", + "fieldtype": "Table", + "label": "Tiers", + "options": "Commera Discount Tier", + "depends_on": "eval:doc.tiered" + }, + { + "fieldname": "section_targets", + "fieldtype": "Section Break", + "label": "Products", + "depends_on": "eval:['Amount Off Products','Buy X Get Y'].includes(doc.discount_type)" + }, + { + "fieldname": "applies_to", + "fieldtype": "Select", + "label": "Applies To", + "options": "All Products\nSpecific Products\nSpecific Collections\nSpecific Brands", + "default": "All Products", + "depends_on": "eval:doc.discount_type == 'Amount Off Products'" + }, + { + "fieldname": "exclude_sale_items", + "fieldtype": "Check", + "label": "Skip Items Already On Sale", + "description": "An item whose sale price is below its regular price gets no extra discount and does not count towards an order discount." + }, + { + "fieldname": "targets", + "fieldtype": "Table", + "label": "Targets", + "options": "Commera Discount Target", + "description": "Applies To and Excluded rows for product discounts; Buys and Gets rows for Buy X Get Y." + }, + { + "fieldname": "section_buy_x_get_y", + "fieldtype": "Section Break", + "label": "Buy X Get Y", + "depends_on": "eval:doc.discount_type == 'Buy X Get Y'" + }, + { + "fieldname": "buy_requirement", + "fieldtype": "Select", + "label": "Customer Buys", + "options": "Quantity\nAmount", + "default": "Quantity" + }, + { + "fieldname": "buy_value", + "fieldtype": "Float", + "label": "Buy Quantity Or Amount", + "non_negative": 1 + }, + { + "fieldname": "uses_per_order", + "fieldtype": "Int", + "label": "Maximum Uses Per Order", + "non_negative": 1, + "description": "0 means as many times as the cart allows." + }, + { + "fieldname": "column_break_get", + "fieldtype": "Column Break" + }, + { + "fieldname": "get_quantity", + "fieldtype": "Int", + "label": "Customer Gets Quantity", + "non_negative": 1, + "default": "1" + }, + { + "fieldname": "get_value_type", + "fieldtype": "Select", + "label": "At", + "options": "Free\nPercentage\nFixed Amount", + "default": "Free" + }, + { + "fieldname": "get_value", + "fieldtype": "Float", + "label": "Get Value", + "non_negative": 1, + "depends_on": "eval:doc.get_value_type != 'Free'" + }, + { + "fieldname": "section_shipping", + "fieldtype": "Section Break", + "label": "Delivery", + "depends_on": "eval:doc.discount_type == 'Free Shipping'" + }, + { + "fieldname": "delivery_options", + "fieldtype": "Small Text", + "label": "Delivery Options", + "description": "Delivery option titles, one per line. Empty means every option." + }, + { + "fieldname": "column_break_shipping", + "fieldtype": "Column Break" + }, + { + "fieldname": "shipping_percentage", + "fieldtype": "Float", + "label": "Off The Delivery Charge (%)", + "default": "100", + "non_negative": 1 + }, + { + "fieldname": "maximum_shipping_rate", + "fieldtype": "Currency", + "label": "Skip Rates Above", + "options": "currency", + "non_negative": 1, + "description": "0 means no limit." + }, + { + "fieldname": "section_conditions", + "fieldtype": "Section Break", + "label": "Conditions" + }, + { + "fieldname": "minimum_requirement", + "fieldtype": "Select", + "label": "Minimum Purchase", + "options": "None\nSubtotal\nQuantity", + "default": "None" + }, + { + "fieldname": "minimum_subtotal", + "fieldtype": "Currency", + "label": "Minimum Subtotal", + "options": "currency", + "non_negative": 1, + "depends_on": "eval:doc.minimum_requirement == 'Subtotal'" + }, + { + "fieldname": "minimum_quantity", + "fieldtype": "Int", + "label": "Minimum Items", + "non_negative": 1, + "depends_on": "eval:doc.minimum_requirement == 'Quantity'" + }, + { + "fieldname": "payment_condition", + "fieldtype": "Select", + "label": "Payment", + "options": "Any\nPrepaid\nCash On Delivery", + "default": "Any" + }, + { + "fieldname": "column_break_conditions", + "fieldtype": "Column Break" + }, + { + "fieldname": "eligibility", + "fieldtype": "Select", + "label": "Customers", + "options": "Everyone\nFirst Order\nCustomer Groups\nSpecific Customers", + "default": "Everyone" + }, + { + "fieldname": "customer_groups", + "fieldtype": "Table MultiSelect", + "label": "Customer Groups", + "options": "Commera Discount Customer Group", + "depends_on": "eval:doc.eligibility == 'Customer Groups'" + }, + { + "fieldname": "customers", + "fieldtype": "Table MultiSelect", + "label": "Specific Customers", + "options": "Commera Discount Customer", + "depends_on": "eval:doc.eligibility == 'Specific Customers'" + }, + { + "fieldname": "section_combinations", + "fieldtype": "Section Break", + "label": "Combinations", + "description": "Two discounts stack only when each allows the other's class." + }, + { + "fieldname": "combines_with_product", + "fieldtype": "Check", + "label": "Combines With Product Discounts" + }, + { + "fieldname": "combines_with_order", + "fieldtype": "Check", + "label": "Combines With Order Discounts" + }, + { + "fieldname": "combines_with_shipping", + "fieldtype": "Check", + "label": "Combines With Shipping Discounts", + "default": "1" + }, + { + "fieldname": "section_limits", + "fieldtype": "Section Break", + "label": "Limits" + }, + { + "fieldname": "usage_limit", + "fieldtype": "Int", + "label": "Total Uses", + "non_negative": 1, + "description": "0 means no limit." + }, + { + "fieldname": "once_per_customer", + "fieldtype": "Check", + "label": "Once Per Customer", + "description": "Matched by customer, email or phone." + }, + { + "fieldname": "budget", + "fieldtype": "Currency", + "label": "Budget", + "options": "currency", + "non_negative": 1, + "description": "Stops once this much discount is given. 0 means no budget." + }, + { + "fieldname": "column_break_limits", + "fieldtype": "Column Break" + }, + { + "fieldname": "times_used", + "fieldtype": "Int", + "label": "Times Used", + "read_only": 1, + "no_copy": 1, + "default": "0" + }, + { + "fieldname": "discount_given", + "fieldtype": "Currency", + "label": "Discount Given", + "options": "currency", + "read_only": 1, + "no_copy": 1, + "default": "0" + }, + { + "fieldname": "sales", + "fieldtype": "Currency", + "label": "Sales With The Discount", + "options": "currency", + "read_only": 1, + "no_copy": 1, + "default": "0" + }, + { + "fieldname": "section_reporting", + "fieldtype": "Section Break", + "label": "Reporting And Accounting", + "collapsible": 1 + }, + { + "fieldname": "campaign", + "fieldtype": "Link", + "label": "Campaign", + "options": "UTM Campaign" + }, + { + "fieldname": "column_break_reporting", + "fieldtype": "Column Break" + }, + { + "fieldname": "discount_account", + "fieldtype": "Link", + "label": "Discount Account", + "options": "Account", + "description": "Posted here when Selling Settings > Enable Discount Accounting is on." + } + ], + "links": [], + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1, + "export": 1, + "report": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "autoname": "format:DISC-{#####}", + "title_field": "title", + "show_title_field_in_link": 1, + "search_fields": "discount_type,method" +} diff --git a/commera/discounts/doctype/commera_discount/commera_discount.py b/commera/discounts/doctype/commera_discount/commera_discount.py new file mode 100644 index 00000000..6707ffc6 --- /dev/null +++ b/commera/discounts/doctype/commera_discount/commera_discount.py @@ -0,0 +1,341 @@ +import frappe +from erpnext.setup.utils import get_exchange_rate +from frappe import _ +from frappe.model.document import Document +from frappe.utils.data import cint, flt, getdate, today +from frappe.utils.nestedset import get_descendants_of + +from commera.discounts.codes import add_code, normalize_code + +PRODUCT = "Product" +ORDER = "Order" +SHIPPING = "Shipping" +CLASS_BY_TYPE = { + "Amount Off Products": PRODUCT, + "Buy X Get Y": PRODUCT, + "Amount Off Order": ORDER, + "Free Shipping": SHIPPING, +} +PERCENTAGE = "Percentage" +MAX_CUSTOMERS = 100 + + +class DiscountCodeError(frappe.ValidationError): + pass + + +class CommeraDiscount(Document): + """flags.code: the code a new Code discount is made with; its title defaults to it.""" + + def validate(self): + self.validate_code() + self.validate_values() + if self.starts_on and self.ends_on and getdate(self.starts_on) > getdate(self.ends_on): + frappe.throw(_("Starts On must be on or before Ends On.")) + self.tiers = sorted(self.tiers, key=lambda tier: flt(tier.minimum_subtotal)) + self.validate_targets() + if self.enabled and self.eligibility == "Customer Groups" and not self.customer_groups: + frappe.throw(_("Choose at least one customer group, or let everyone use this discount.")) + if self.enabled and self.eligibility == "Specific Customers" and not self.customers: + frappe.throw(_("Choose at least one customer, or let everyone use this discount.")) + if len(self.customers) > MAX_CUSTOMERS: + frappe.throw(_("A discount can be limited to at most {0} customers.").format(MAX_CUSTOMERS)) + + def validate_code(self): + code = normalize_code(self.flags.code) + if self.method != "Code": + if code: + frappe.throw(_("Only a code discount takes codes.")) + return + if self.is_new() and "code" in self.flags and not code: + frappe.throw(_("A code discount needs a code.")) + self.title = self.title or code or None + + def after_insert(self): + if self.method == "Code" and normalize_code(self.flags.code): + add_code(self.name, self.flags.code) + + def on_trash(self): + if frappe.db.exists("Commera Discount Use", {"discount": self.name}): + frappe.throw( + _("Orders used this discount, so it is kept for their records. Switch it off instead.") + ) + for code in frappe.get_all("Commera Discount Code", filters={"discount": self.name}, pluck="name"): + frappe.delete_doc("Commera Discount Code", code, ignore_permissions=True) + + def validate_values(self): + percentages = [self.shipping_percentage] + if self.value_type == PERCENTAGE: + percentages += [self.value, *(tier.value for tier in self.tiers)] + if self.get_value_type == PERCENTAGE: + percentages.append(self.get_value) + if any(flt(percentage) > 100 for percentage in percentages): + frappe.throw(_("A percentage cannot be more than 100.")) + + def validate_targets(self): + roles = {target.role for target in self.targets} + if self.discount_type == "Buy X Get Y": + if not {"Buys", "Gets"} <= roles or not flt(self.buy_value) or not cint(self.get_quantity): + frappe.throw(_("Buy X Get Y needs what the customer buys, how much, and what they get.")) + elif roles & {"Buys", "Gets"}: + frappe.throw(_("Buys and Gets rows are only for Buy X Get Y.")) + + @property + def discount_class(self) -> str: + return CLASS_BY_TYPE[self.discount_type] + + @property + def budget_left(self) -> float | None: + """In the company currency, which Discount given is kept in; the budget is in the discount's.""" + if not flt(self.budget): + return None + return max(flt(self.budget) * self.get_company_exchange_rate() - flt(self.discount_given), 0) + + def get_company_exchange_rate(self) -> float: + company = frappe.get_cached_doc("Commera Settings").get( + "company" + ) or frappe.defaults.get_global_default("company") + company_currency = frappe.get_cached_value("Company", company, "default_currency") + if not self.currency or self.currency == company_currency: + return 1.0 + return flt(get_exchange_rate(self.currency, company_currency, today(), "for_selling")) + + @property + def status(self) -> str: + if not self.enabled: + return "Disabled" + if self.starts_on and getdate(self.starts_on) > getdate(today()): + return "Scheduled" + if self.ends_on and getdate(self.ends_on) < getdate(today()): + return "Expired" + if (self.usage_limit and cint(self.times_used) >= cint(self.usage_limit)) or self.budget_left == 0: + return "Used Up" + return "Active" + + def validate_usable(self): + if reason := self.get_unusable_reason(): + frappe.throw(reason, DiscountCodeError) + + def get_unusable_reason(self) -> str | None: + messages = { + "Disabled": _("{0} is not active."), + "Scheduled": _("{0} is not active yet."), + "Expired": _("{0} has expired."), + "Used Up": _("{0} has been used up."), + } + return messages[self.status].format(self.title) if self.status in messages else None + + def can_combine_with(self, other: "CommeraDiscount") -> bool: + return self.is_combinable_with(other.discount_class) and other.is_combinable_with(self.discount_class) + + def is_combinable_with(self, discount_class: str) -> bool: + fieldname = { + PRODUCT: "combines_with_product", + ORDER: "combines_with_order", + SHIPPING: "combines_with_shipping", + } + return bool(self.get(fieldname[discount_class])) + + def is_for_currency(self, cart) -> bool: + return cart.get_exchange_rate(self.currency) > 0 + + def get_money(self, amount: float, cart) -> float: + return cart.convert(amount, self.currency) + + def get_budget_left(self, cart) -> float | None: + budget_left = self.budget_left + return None if budget_left is None else cart.convert(budget_left, None) + + def get_unmet_condition(self, cart) -> str | None: + if reason := self.get_unmet_minimum(cart): + return reason + if self.payment_condition == "Prepaid" and cart.payment_kind == "cod": + return _("{0} is for prepaid orders only.").format(self.title) + if self.payment_condition == "Cash On Delivery" and cart.payment_kind != "cod": + return _("{0} is for cash on delivery orders only.").format(self.title) + if self.eligibility == "Customer Groups" and not self.is_for_customer_group(cart): + return _("{0} is not available for your account.").format(self.title) + if self.eligibility == "Specific Customers" and not self.is_for_customer(cart): + return _("{0} is not available for your account.").format(self.title) + return None + + def get_unmet_minimum(self, cart) -> str | None: + minimum_subtotal = self.get_money(self.minimum_subtotal, cart) + if self.minimum_requirement == "Subtotal" and cart.subtotal < minimum_subtotal: + return _("Add {0} more to use {1}.").format( + frappe.format_value( + minimum_subtotal - cart.subtotal, {"fieldtype": "Currency"}, currency=cart.currency + ), + self.title, + ) + if self.minimum_requirement == "Quantity" and cart.quantity < cint(self.minimum_quantity): + return _("{0} needs at least {1} items.").format(self.title, cint(self.minimum_quantity)) + return None + + def gives_anything(self, cart) -> bool: + # A zero discount must not take a place a combinable one could use. + if self.discount_class == PRODUCT: + return any(self.get_line_discounts(cart).values()) + if self.discount_class == ORDER: + return self.get_order_discount(cart) > 0 + # Before a delivery option is chosen there is nothing to judge a shipping discount on. + if not cart.delivery_option: + return True + return self.get_delivery_discount(cart.delivery_option, cart) > 0 + + def is_for_customer_group(self, cart) -> bool: + if not cart.customer_group or cart.doc.get("custom_guest_cart_key"): + return False + return any( + cart.customer_group == row.customer_group + or cart.customer_group in get_descendants_of("Customer Group", row.customer_group) + for row in self.customer_groups + ) + + def is_for_customer(self, cart) -> bool: + # A guest who types a listed customer's email is booked to that customer, but is not them. + if not cart.customer or cart.doc.get("custom_guest_cart_key"): + return False + return any(row.customer == cart.customer for row in self.customers) + + def is_for_line(self, item, cart, role: str = "Applies To") -> bool: + if self.exclude_sale_items and cart.is_on_sale(item.item_code): + return False + if self.has_target_match(item, "Excluded"): + return False + if role == "Applies To" and self.applies_to == "All Products": + return True + return self.has_target_match(item, role) + + def has_target_match(self, item, role: str) -> bool: + variant_of = frappe.get_cached_value("Item", item.item_code, "variant_of") + for target in self.targets: + if target.role != role: + continue + if target.target_type == "Item" and target.target in (item.item_code, variant_of): + return True + if target.target_type == "Brand" and target.target == item.brand: + return True + if target.target_type == "Item Group" and item.item_group in get_item_group_tree(target.target): + return True + return False + + def get_line_discounts(self, cart) -> dict[int, float]: + # Keyed by idx: an unsaved preview's rows have no name. + if self.discount_type == "Buy X Get Y": + return self.get_buy_x_get_y_discounts(cart) + + lines = [item for item in cart.items if self.is_for_line(item, cart)] + if self.value_type == PERCENTAGE: + discounts = {item.idx: flt(item.amount) * flt(self.value) / 100 for item in lines} + return cap_discounts(discounts, self.get_money(self.maximum_discount, cart)) + value = self.get_money(self.value, cart) + if self.fixed_applies_to == "Each Item": + return {item.idx: min(value, flt(item.rate)) * flt(item.qty) for item in lines} + return spread_amount(value, {item.idx: flt(item.amount) for item in lines}) + + def get_buy_x_get_y_discounts(self, cart) -> dict[int, float]: + """The cheapest units the customer gets are discounted; the units that earn them cannot also be them.""" + # One bucket per line, cheapest first: quantities are taken arithmetically, never unit by unit. + buy_lines = {item.idx for item in cart.items if self.is_for_line(item, cart, "Buys")} + get_lines = {item.idx for item in cart.items if self.is_for_line(item, cart, "Gets")} + buckets = sorted( + [flt(item.rate), item.idx, flt(item.qty)] + for item in cart.items + if flt(item.qty) > 0 and (item.idx in buy_lines or item.idx in get_lines) + ) + discounts = {} + applications = 0 + while not self.uses_per_order or applications < cint(self.uses_per_order): + rewarded = take_quantity(buckets, cint(self.get_quantity), get_lines) + if not rewarded or not self.take_buy_units(buckets, buy_lines, cart): + break + for rate, idx, qty in rewarded: + discounts[idx] = discounts.get(idx, 0) + self.get_reward(rate, cart) * qty + applications += 1 + return discounts + + def take_buy_units(self, buckets: list, buy_lines: set[int], cart) -> bool: + buys = [bucket for bucket in buckets if bucket[1] in buy_lines] + if self.buy_requirement == "Quantity": + # The dearest bought units earn the reward, leaving the cheap ones to be rewarded. + return bool(take_quantity(buckets, flt(self.buy_value), buy_lines, dearest_first=True)) + # An amount requirement is met once by everything bought; it does not repeat per order. + if sum(rate * qty for rate, _idx, qty in buys) < self.get_money(self.buy_value, cart): + return False + buckets.clear() + return True + + def get_reward(self, rate: float, cart) -> float: + if self.get_value_type == "Free": + return rate + if self.get_value_type == PERCENTAGE: + return rate * flt(self.get_value) / 100 + return min(self.get_money(self.get_value, cart), rate) + + def get_order_discount(self, cart) -> float: + subtotal = sum( + flt(item.amount) + for item in cart.items + if not (self.exclude_sale_items and cart.is_on_sale(item.item_code)) + ) + value = self.get_tier_value(subtotal, cart) if self.tiered else flt(self.value) + if value is None: + return 0.0 + if self.value_type != PERCENTAGE: + return min(self.get_money(value, cart), subtotal) + discount = subtotal * value / 100 + maximum_discount = self.get_money(self.maximum_discount, cart) + return min(discount, maximum_discount) if maximum_discount else discount + + def get_tier_value(self, subtotal: float, cart) -> float | None: + reached = [tier for tier in self.tiers if subtotal >= self.get_money(tier.minimum_subtotal, cart)] + return flt(max(reached, key=lambda tier: flt(tier.minimum_subtotal)).value) if reached else None + + def get_delivery_discount(self, option: dict, cart) -> float: + titles = {line.strip() for line in (self.delivery_options or "").splitlines() if line.strip()} + if titles and option["title"] not in titles: + return 0.0 + amount = flt(option["amount"]) + maximum_shipping_rate = self.get_money(self.maximum_shipping_rate, cart) + if maximum_shipping_rate and amount > maximum_shipping_rate: + return 0.0 + return amount * flt(self.shipping_percentage) / 100 + + +def take_quantity(buckets: list, quantity: float, lines: set[int], dearest_first: bool = False) -> list: + """Takes `quantity` units from the wanted lines' buckets, or nothing when there are not enough.""" + wanted = [bucket for bucket in buckets if bucket[1] in lines] + if dearest_first: + wanted.reverse() + if not quantity or sum(bucket[2] for bucket in wanted) < quantity: + return [] + taken = [] + for bucket in wanted: + qty = min(bucket[2], quantity) + taken.append((bucket[0], bucket[1], qty)) + bucket[2] -= qty + quantity -= qty + if quantity <= 0: + break + buckets[:] = [bucket for bucket in buckets if bucket[2] > 0] + return taken + + +def cap_discounts(discounts: dict[int, float], cap: float) -> dict[int, float]: + total = sum(discounts.values()) + if not cap or total <= cap: + return discounts + return {name: amount * cap / total for name, amount in discounts.items()} + + +def spread_amount(amount: float, amounts: dict[int, float]) -> dict[int, float]: + total = sum(amounts.values()) + if not total: + return {} + amount = min(amount, total) + return {name: amount * value / total for name, value in amounts.items()} + + +def get_item_group_tree(item_group: str) -> set[str]: + return {item_group, *get_descendants_of("Item Group", item_group)} diff --git a/commera/discounts/doctype/commera_discount_code/__init__.py b/commera/discounts/doctype/commera_discount_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_code/commera_discount_code.json b/commera/discounts/doctype/commera_discount_code/commera_discount_code.json new file mode 100644 index 00000000..6fad82b0 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_code/commera_discount_code.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "code", + "discount", + "is_generated", + "times_used" + ], + "fields": [ + { + "fieldname": "code", + "fieldtype": "Data", + "label": "Code", + "reqd": 1, + "unique": 1, + "in_list_view": 1, + "description": "What the shopper types. Stored in capitals; matching ignores case." + }, + { + "fieldname": "discount", + "fieldtype": "Link", + "label": "Discount", + "options": "Commera Discount", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1, + "search_index": 1 + }, + { + "fieldname": "is_generated", + "fieldtype": "Check", + "label": "Generated", + "read_only": 1, + "description": "A generated code can be used once." + }, + { + "fieldname": "times_used", + "fieldtype": "Int", + "label": "Times Used", + "read_only": 1, + "no_copy": 1, + "default": "0", + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Code", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1, + "export": 1, + "report": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "autoname": "field:code" +} diff --git a/commera/discounts/doctype/commera_discount_code/commera_discount_code.py b/commera/discounts/doctype/commera_discount_code/commera_discount_code.py new file mode 100644 index 00000000..660d253d --- /dev/null +++ b/commera/discounts/doctype/commera_discount_code/commera_discount_code.py @@ -0,0 +1,23 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils.data import cint + +from commera.discounts.codes import normalize_code + +MAX_CODE_LENGTH = 64 + + +class CommeraDiscountCode(Document): + def before_insert(self): + self.code = normalize_code(self.code) + if not self.code or len(self.code) > MAX_CODE_LENGTH or any(char.isspace() for char in self.code): + frappe.throw(_("A code needs 1 to {0} characters and no spaces.").format(MAX_CODE_LENGTH)) + if frappe.db.exists("Commera Discount Code", self.code): + frappe.throw(_("The code {0} is already in use.").format(self.code), frappe.DuplicateEntryError) + + def validate(self): + if frappe.get_cached_value("Commera Discount", self.discount, "method") != "Code": + frappe.throw(_("Only a code discount takes codes.")) + if not self.is_new() and self.has_value_changed("discount") and cint(self.times_used): + frappe.throw(_("{0} has been used, so it stays with its discount.").format(self.name)) diff --git a/commera/discounts/doctype/commera_discount_customer/__init__.py b/commera/discounts/doctype/commera_discount_customer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.json b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.json new file mode 100644 index 00000000..de16da87 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2026-10-21 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "customer", + "customer_name" + ], + "fields": [ + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer", + "reqd": 1, + "in_list_view": 1 + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "read_only": 1, + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Customer", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.py b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.py new file mode 100644 index 00000000..c0004fc0 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountCustomer(Document): + pass diff --git a/commera/discounts/doctype/commera_discount_customer_group/__init__.py b/commera/discounts/doctype/commera_discount_customer_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.json b/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.json new file mode 100644 index 00000000..5893fbd3 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.json @@ -0,0 +1,31 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "customer_group" + ], + "fields": [ + { + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group", + "reqd": 1, + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Customer Group", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.py b/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.py new file mode 100644 index 00000000..e5c7f36c --- /dev/null +++ b/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountCustomerGroup(Document): + pass diff --git a/commera/discounts/doctype/commera_discount_target/__init__.py b/commera/discounts/doctype/commera_discount_target/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_target/commera_discount_target.json b/commera/discounts/doctype/commera_discount_target/commera_discount_target.json new file mode 100644 index 00000000..35943d57 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_target/commera_discount_target.json @@ -0,0 +1,50 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "role", + "target_type", + "target" + ], + "fields": [ + { + "fieldname": "role", + "fieldtype": "Select", + "label": "Role", + "options": "Applies To\nExcluded\nBuys\nGets", + "default": "Applies To", + "reqd": 1, + "in_list_view": 1 + }, + { + "fieldname": "target_type", + "fieldtype": "Select", + "label": "Target Type", + "options": "Item\nItem Group\nBrand", + "reqd": 1, + "in_list_view": 1 + }, + { + "fieldname": "target", + "fieldtype": "Dynamic Link", + "label": "Target", + "options": "target_type", + "reqd": 1, + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Target", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_target/commera_discount_target.py b/commera/discounts/doctype/commera_discount_target/commera_discount_target.py new file mode 100644 index 00000000..dbe483d0 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_target/commera_discount_target.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountTarget(Document): + pass diff --git a/commera/discounts/doctype/commera_discount_tier/__init__.py b/commera/discounts/doctype/commera_discount_tier/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.json b/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.json new file mode 100644 index 00000000..a361a198 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "minimum_subtotal", + "value" + ], + "fields": [ + { + "fieldname": "minimum_subtotal", + "fieldtype": "Currency", + "label": "Subtotal From", + "reqd": 1, + "in_list_view": 1, + "non_negative": 1 + }, + { + "fieldname": "value", + "fieldtype": "Float", + "label": "Value", + "reqd": 1, + "in_list_view": 1, + "non_negative": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Tier", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.py b/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.py new file mode 100644 index 00000000..38443ef7 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountTier(Document): + pass diff --git a/commera/discounts/doctype/commera_discount_use/__init__.py b/commera/discounts/doctype/commera_discount_use/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_use/commera_discount_use.json b/commera/discounts/doctype/commera_discount_use/commera_discount_use.json new file mode 100644 index 00000000..2cee47d7 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_use/commera_discount_use.json @@ -0,0 +1,112 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "discount", + "code", + "sales_order", + "amount", + "order_total", + "base_amount", + "base_order_total", + "column_break_use", + "customer", + "email", + "phone" + ], + "fields": [ + { + "fieldname": "discount", + "fieldtype": "Link", + "label": "Discount", + "options": "Commera Discount", + "reqd": 1, + "in_list_view": 1, + "search_index": 1 + }, + { + "fieldname": "code", + "fieldtype": "Link", + "label": "Code", + "options": "Commera Discount Code", + "in_list_view": 1, + "search_index": 1 + }, + { + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Sales Order", + "options": "Sales Order", + "reqd": 1, + "in_list_view": 1, + "search_index": 1 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Discount Amount", + "in_list_view": 1 + }, + { + "fieldname": "order_total", + "fieldtype": "Currency", + "label": "Order Total" + }, + { + "description": "In the company currency, at the order's exchange rate when it was placed.", + "fieldname": "base_amount", + "fieldtype": "Currency", + "label": "Discount Amount (Company Currency)" + }, + { + "fieldname": "base_order_total", + "fieldtype": "Currency", + "label": "Order Total (Company Currency)" + }, + { + "fieldname": "column_break_use", + "fieldtype": "Column Break" + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer", + "search_index": 1 + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "options": "Email", + "search_index": 1 + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "search_index": 1 + } + ], + "links": [], + "modified": "2026-10-21 18:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Use", + "owner": "Administrator", + "permissions": [ + { + "read": 1, + "role": "System Manager", + "export": 1, + "report": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "in_create": 1, + "read_only": 1 +} diff --git a/commera/discounts/doctype/commera_discount_use/commera_discount_use.py b/commera/discounts/doctype/commera_discount_use/commera_discount_use.py new file mode 100644 index 00000000..85388ad5 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_use/commera_discount_use.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountUse(Document): + pass diff --git a/commera/discounts/preview.py b/commera/discounts/preview.py new file mode 100644 index 00000000..4c0d13b4 --- /dev/null +++ b/commera/discounts/preview.py @@ -0,0 +1,39 @@ +import frappe + +from commera.core import system_user_session +from commera.discounts.pricing import apply_code_discounts + + +def get_preview_quotation( + lines: list[dict], + codes: str | None = None, + user: str | None = None, + flags=None, + fields: dict | None = None, +): + # Elevated, like checkout: ERPNext reads Items the shopper cannot. + settings = frappe.get_cached_doc("Commera Settings") + quotation = frappe.new_doc("Quotation") + quotation.update( + { + "quotation_to": "Customer", + "company": settings.get("company") or frappe.defaults.get_global_default("company"), + "order_type": "Shopping Cart", + "selling_price_list": settings.sale_price_list, + "custom_discount_codes": codes, + "party_name": user and frappe.db.get_value("Portal User", {"user": user}, "parent"), + "contact_email": user, + } + ) + quotation.update(fields or {}) + quotation.flags.update(flags or {}) + for line in lines: + quotation.append( + "items", + {"item_code": line["item_code"], "qty": line["qty"], "warehouse": settings.ecommerce_warehouse}, + ) + with system_user_session(): + quotation.run_method("set_missing_values") + quotation.calculate_taxes_and_totals() + apply_code_discounts(quotation) + return quotation diff --git a/commera/discounts/pricing.py b/commera/discounts/pricing.py new file mode 100644 index 00000000..2773a97b --- /dev/null +++ b/commera/discounts/pricing.py @@ -0,0 +1,504 @@ +import math + +import frappe +from frappe import _ +from frappe.utils.data import flt + +from commera.discounts.allocations import ALLOCATIONS_FIELD, get_allocation, get_allocations, set_allocations +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import get_code_discount +from commera.discounts.doctype.commera_discount.commera_discount import ORDER, PRODUCT, SHIPPING +from commera.discounts.selection import ( + Selection, + get_candidates, + get_discount, + get_discount_codes, + get_unmet_reason, +) + +try: + from erpnext.accounts.services.payment_schedule import PaymentScheduleService +except ImportError: + PaymentScheduleService = None + +MAX_PRICED_COMBINATIONS = 20 +# At most 3^(12/3) = 81 combinations, however many automatic discounts are live. +MAX_SEARCHED_SELECTIONS = 12 + + +def is_storefront_cart(doc) -> bool: + return doc.doctype == "Quotation" and doc.order_type == "Shopping Cart" + + +def is_repriced(doc) -> bool: + """Only a cart being shopped picks and prices discounts; once submitted, its amounts are the order's.""" + return doc.flags.commera_discounts is not None or (is_storefront_cart(doc) and doc.docstatus == 0) + + +def remove_code_discounts(doc, method=None): + # Clamped at zero: other code may already have reset these fields. + if doc.get("is_return"): + return + keep_lines = not is_repriced(doc) + for item in doc.items: + if flt(item.get("custom_code_discount")): + item.discount_amount = max(flt(item.discount_amount) - flt(item.custom_code_discount), 0) + item.rate = (flt(item.rate_with_margin) or flt(item.price_list_rate)) - item.discount_amount + if not keep_lines: + item.custom_code_discount = 0 + item.custom_discount = None + + if flt(doc.get("custom_code_order_discount")): + if keep_lines: + validate_order_discount_edit(doc) + doc.discount_amount = max(flt(doc.discount_amount) - flt(doc.custom_code_order_discount), 0) + doc.custom_code_order_discount = 0 + + +def validate_order_discount_edit(doc): + # Shown with the code discount inside; a lower figure loses staff's edit. + previous = doc.get_doc_before_save() + code_discount = flt(doc.custom_code_order_discount) + if not previous or flt(previous.discount_amount) == flt(doc.discount_amount): + return + if flt(doc.discount_amount) < code_discount: + frappe.throw( + _( + "Additional Discount Amount includes {0} from discount codes, so it cannot be less than that. " + "To give more off, enter {0} plus the extra amount." + ).format(frappe.format_value(code_discount, {"fieldtype": "Currency"}, currency=doc.currency)) + ) + + +def drop_copied_discounts(sales_order, method=None): + # A Desk duplicate copies the allocations; only the first order from a cart, or its amendment, keeps them. + if is_repriced(sales_order) or sales_order.amended_from or not get_allocations(sales_order): + return + quotation = next( + (item.prevdoc_docname for item in sales_order.items if item.get("prevdoc_docname")), None + ) + if ( + quotation + and frappe.db.get_value("Quotation", quotation, "docstatus") == 1 + and not frappe.db.exists("Sales Order Item", {"prevdoc_docname": quotation}) + ): + return + sales_order.flags.commera_discounts = [] + remove_code_discounts(sales_order) + sales_order.flags.commera_discounts = None + set_allocations(sales_order, []) + + +def apply_code_discounts(doc, method=None): + if not is_repriced(doc): + apply_recorded_discounts(doc) + return + selections = select_discounts(get_cart_context(doc)) + allocations = apply_line_discounts(doc, [s for s in selections if s.discount.discount_class == PRODUCT]) + doc.calculate_taxes_and_totals() + allocations += apply_order_discount(doc, [s for s in selections if s.discount.discount_class == ORDER]) + doc.calculate_taxes_and_totals() + allocations += get_shipping_allocations(doc) + set_allocations(doc, allocations) + if allocations: + set_amounts_from_grand_total(doc) + + +def apply_line_discounts(doc, selections: list) -> list[dict]: + cart = get_cart_context(doc) + best_by_line = {} + + def offer(idx: int, amount: float, allocation: dict): + if amount > best_by_line.get(idx, (0, None))[0]: + best_by_line[idx] = (amount, allocation) + + for selection in selections: + line_discounts = cap_by_budget( + selection.discount.get_budget_left(cart), selection.discount.get_line_discounts(cart) + ) + for idx, amount in line_discounts.items(): + offer(idx, amount, get_allocation(selection, PRODUCT, 0, idx)) + + allocations = [] + for item in doc.items: + if item.idx not in best_by_line: + continue + amount, allocation = best_by_line[item.idx] + if flt(item.qty) <= 0: + continue + # Rounded down: a unit rate cannot carry a part of a paisa, and rounding up would overshoot a budget. + unit_discount = round_down( + min(amount / flt(item.qty), flt(item.rate)), item.precision("discount_amount") + ) + add_line_discount(item, unit_discount) + item.custom_discount = allocation["discount"] + allocations.append({**allocation, "amount": flt(unit_discount * flt(item.qty), 2)}) + return allocations + + +def add_line_discount(item, unit_discount: float): + # ERPNext rebuilds a percentage discount from the price list, which would drop this share. + item.discount_percentage = 0 + item.discount_amount = flt(item.discount_amount) + unit_discount + item.rate = flt(item.rate) - unit_discount + item.custom_code_discount = unit_discount + + +def apply_order_discount(doc, selections: list) -> list[dict]: + cart = get_cart_context(doc) + offers = [] + for selection in selections: + amount = flt(selection.discount.get_order_discount(cart)) + budget_left = selection.discount.get_budget_left(cart) + if budget_left is not None and amount > budget_left: + amount = round_down(budget_left, doc.precision("discount_amount")) + offers.append((amount, get_allocation(selection, ORDER, 0))) + + allocations = [] + subtotal = flt(doc.total) + for amount, allocation in offers: + amount = flt( + min(amount, subtotal - sum(row["amount"] for row in allocations)), + doc.precision("discount_amount"), + ) + if amount > 0: + allocations.append({**allocation, "amount": amount}) + + total = sum(row["amount"] for row in allocations) + if total: + # Inclusive tax: discount Grand Total so ERPNext works the tax out inside it. + if not flt(doc.discount_amount): + doc.apply_discount_on = "Grand Total" if has_inclusive_tax(doc) else "Net Total" + # ERPNext's own percentage discount is kept as an amount, so this share can be added to it. + doc.discount_amount = flt(doc.discount_amount) + total + doc.additional_discount_percentage = 0 + doc.custom_code_order_discount = total + return allocations + + +def get_saving(cart, selections: list) -> float: + doc = frappe.copy_doc(cart.doc) + doc.flags.commera_discounts = [] + remove_code_discounts(doc) + doc.calculate_taxes_and_totals() + allocations = apply_line_discounts(doc, [s for s in selections if s.discount.discount_class == PRODUCT]) + doc.calculate_taxes_and_totals() + allocations += apply_order_discount(doc, [s for s in selections if s.discount.discount_class == ORDER]) + option = cart.delivery_option + shipping = [ + s.discount.get_delivery_discount(option, cart) + for s in selections + if option and s.discount.discount_class == SHIPPING + ] + return sum(flt(row["amount"]) for row in allocations) + max(shipping, default=0) + + +def select_discounts(cart) -> list[Selection]: + # On a tie, the code applied first wins. + applicable = [] + for selection in get_candidates(cart.doc): + discount = selection.discount + if discount.status != "Active" or not discount.is_for_currency(cart): + continue + if any(other.discount.name == discount.name for other in applicable): + continue + if get_unmet_reason(discount, cart) or not discount.gives_anything(cart): + continue + applicable.append(selection) + + combinations = get_combinations(get_searched_selections(applicable, cart))[:MAX_PRICED_COMBINATIONS] + if len(combinations) == 1: + return combinations[0] + return max(combinations, key=lambda combination: flt(get_saving(cart, combination), 2)) + + +def get_searched_selections(selections: list[Selection], cart) -> list[Selection]: + # Every code the shopper typed is searched; past the cap, only the automatic discounts that save most alone. + if len(selections) <= MAX_SEARCHED_SELECTIONS: + return selections + codes = [selection for selection in selections if selection.code] + automatic = [selection for selection in selections if not selection.code] + automatic.sort(key=lambda selection: get_solo_saving(selection.discount, cart), reverse=True) + kept = {id(selection) for selection in codes + automatic[: MAX_SEARCHED_SELECTIONS - len(codes)]} + return [selection for selection in selections if id(selection) in kept] + + +def get_solo_saving(discount, cart) -> float: + if discount.discount_class == PRODUCT: + return sum(discount.get_line_discounts(cart).values()) + if discount.discount_class == ORDER: + return flt(discount.get_order_discount(cart)) + option = cart.delivery_option + return discount.get_delivery_discount(option, cart) if option else 0.0 + + +def get_combinations(selections: list[Selection]) -> list[list[Selection]]: + """Every largest set of discounts that all combine with each other, in the order the selections came.""" + order = {id(selection): index for index, selection in enumerate(selections)} + + def combines(selection, other) -> bool: + return selection is not other and selection.discount.can_combine_with(other.discount) + + def extend(combination: list, candidates: list, skipped: list): + if not candidates and not skipped: + yield combination + return + # Bron-Kerbosch with a pivot: a set the pivot could join is found from the pivot's own branch. + pivot = max(candidates + skipped, key=lambda other: sum(combines(other, c) for c in candidates)) + for selection in [c for c in candidates if not combines(c, pivot)]: + yield from extend( + [*combination, selection], + [other for other in candidates if combines(other, selection)], + [other for other in skipped if combines(other, selection)], + ) + candidates.remove(selection) + skipped.append(selection) + + combinations = [ + sorted(combination, key=lambda selection: order[id(selection)]) + for combination in extend([], list(selections), []) + ] + return sorted(combinations, key=lambda combination: [order[id(selection)] for selection in combination]) + + +def has_inclusive_tax(doc) -> bool: + return any(tax.included_in_print_rate for tax in doc.get("taxes") or []) + + +def apply_recorded_discounts(doc): + recorded = get_allocations(doc) + if not recorded: + return + if doc.doctype == "Sales Order" and doc.docstatus == 0 and not doc.is_new(): + recorded = drop_unmet_order_discounts(doc, recorded) + + allocations = [] + for item in doc.items: + if not flt(item.get("custom_code_discount")): + continue + if not doc.get("is_return"): + add_line_discount(item, flt(item.custom_code_discount)) + row = next( + ( + row + for row in recorded + if row["discount_class"] == PRODUCT and row.get("discount") == item.custom_discount + ), + None, + ) + if row: + amount = flt(flt(item.custom_code_discount) * flt(item.qty), 2) + allocations.append({**row, "amount": amount, "line": item.idx}) + doc.calculate_taxes_and_totals() + + order_rows = [row for row in recorded if row["discount_class"] == ORDER] + source = get_discount_source(doc) if order_rows else None + shares = get_order_discount_shares(doc, order_rows, source) + if order_rows: + doc.discount_amount = get_other_order_discount(doc, source) + sum(shares) + doc.additional_discount_percentage = 0 + doc.custom_code_order_discount = sum(shares) + doc.calculate_taxes_and_totals() + allocations += [{**row, "amount": share} for row, share in zip(order_rows, shares, strict=True)] + allocations += [row for row in recorded if row["discount_class"] == SHIPPING] + set_allocations(doc, allocations) + set_amounts_from_grand_total(doc) + set_discount_links(doc, allocations) + + +def drop_unmet_order_discounts(doc, recorded: list[dict]) -> list[dict]: + # Judged before the line discounts go back on, as the cart was. + cart = get_cart_context(doc) + kept = [] + for row in recorded: + reason = row["discount_class"] == ORDER and get_discount(row["discount"]).get_unmet_minimum(cart) + if reason: + frappe.msgprint( + _("{0} no longer applies to this order. {1}").format(row.get("code") or row["title"], reason) + ) + else: + kept.append(row) + return kept + + +def set_discount_links(doc, allocations: list[dict]): + discounts = { + discount.name: discount + for discount in frappe.get_all( + "Commera Discount", + filters={"name": ["in", list({row["discount"] for row in allocations})]}, + fields=["name", "campaign", "discount_account"], + ) + } + if not doc.get("utm_campaign"): + doc.utm_campaign = next( + ( + discounts[row["discount"]].campaign + for row in allocations + if discounts.get(row["discount"], {}).get("campaign") + ), + None, + ) + if doc.doctype != "Sales Invoice": + return + for item in doc.items: + if account := discounts.get(item.get("custom_discount"), {}).get("discount_account"): + item.discount_account = account + order_accounts = { + discounts[row["discount"]].discount_account + for row in allocations + if row["discount_class"] == ORDER and discounts.get(row["discount"], {}).get("discount_account") + } + if len(order_accounts) == 1 and not doc.get("additional_discount_account"): + doc.additional_discount_account = order_accounts.pop() + + +def set_amounts_from_grand_total(doc): + """ERPNext writes these during validate, before this hook takes the discount off the total.""" + doc.set_total_in_words() + if not doc.meta.has_field("payment_schedule") or doc.get("is_return"): + return + if PaymentScheduleService: + PaymentScheduleService(doc).set_payment_schedule() + else: + doc.set_payment_schedule() + + +def get_other_order_discount(doc, source) -> float: + # A new credit note carries our whole share; ERPNext's is net of earlier returns. + code_discount = flt(doc.custom_code_order_discount) + if not (doc.get("is_return") and code_discount > 0): + return flt(doc.discount_amount) - code_discount + reversed_by_siblings = ( + sum(abs(flt(sibling.custom_code_order_discount)) for sibling in source[1]) if source else 0 + ) + return flt(doc.discount_amount) + code_discount - reversed_by_siblings + + +def get_order_discount_shares(doc, order_rows: list[dict], source) -> list[float]: + # The document completing the source takes the remainder, so parts sum exactly. + if not source: + return [flt(row["amount"]) for row in order_rows] + + source_doc, siblings, doc_total = source + source_total = abs(flt(source_doc.total)) + covered = abs(doc_total) + sum(abs(flt(sibling.total)) for sibling in siblings) + is_complete = covered >= source_total - 0.005 + sign = -1 if doc.get("is_return") else 1 + precision = doc.precision("discount_amount") + + shares = [] + source_rows = get_allocations(source_doc) + for row in order_rows: + key = get_allocation_key(row) + source_amount = sum(abs(flt(r["amount"])) for r in source_rows if get_allocation_key(r) == key) + used = sum( + abs(flt(r["amount"])) + for sibling in siblings + for r in get_allocations(sibling) + if get_allocation_key(r) == key + ) + left = max(source_amount - used, 0) + share = left + if not is_complete and source_total: + share = min(left, source_amount * abs(doc_total) / source_total) + shares.append(sign * flt(share, precision)) + return shares + + +def get_allocation_key(row: dict) -> tuple: + return (row["discount_class"], row.get("discount"), row.get("code")) + + +# The item field naming the order a delivery or invoice line comes from. +ORDER_LINK_FIELDS = {"Sales Invoice": "sales_order", "Delivery Note": "against_sales_order"} + + +def get_discount_source(doc): + """The document this one's order discount is a share of, the earlier shares of it, and this one's part.""" + fields = ["name", "total", "custom_code_order_discount", ALLOCATIONS_FIELD] + if doc.get("is_return"): + if not doc.return_against: + return None + filters = {"return_against": doc.return_against, "is_return": 1, "name": ["!=", doc.name or ""]} + source_doctype, source_name = doc.doctype, doc.return_against + siblings = frappe.get_all(doc.doctype, filters={**filters, "docstatus": 1}, fields=fields) + doc_total = flt(doc.total) + elif link_field := ORDER_LINK_FIELDS.get(doc.doctype): + source_doctype, source_name = "Sales Order", get_discounted_order(doc, link_field) + if not source_name: + return None + siblings = get_order_siblings(doc, link_field, source_name, fields) + doc_total = sum(flt(item.amount) for item in doc.items if item.get(link_field) == source_name) + else: + source_name = next((item.prevdoc_docname for item in doc.items if item.get("prevdoc_docname")), None) + if not source_name: + return None + source_doctype, siblings, doc_total = "Quotation", [], flt(doc.total) + + source_doc = frappe.db.get_value(source_doctype, source_name, fields, as_dict=True) + return (source_doc, siblings, doc_total) if source_doc else None + + +def get_discounted_order(doc, link_field: str) -> str | None: + orders = sorted({item.get(link_field) for item in doc.items if item.get(link_field)}) + if len(orders) <= 1: + return orders[0] if orders else None + discounted = frappe.get_all( + "Sales Order", filters={"name": ["in", orders], "custom_code_order_discount": [">", 0]}, pluck="name" + ) + if len(discounted) > 1: + frappe.throw( + _("Orders {0} each have a discount code order discount. Make a separate {1} for each.").format( + ", ".join(sorted(discounted)), _(doc.doctype) + ) + ) + return discounted[0] if discounted else orders[0] + + +def get_order_siblings(doc, link_field: str, sales_order: str, fields: list[str]) -> list: + # Only their lines from this order count: a combined document also carries other orders' lines. + item_doctype = f"{doc.doctype} Item" + lines = frappe.get_all( + item_doctype, + filters={link_field: sales_order, "docstatus": 1, "parenttype": doc.doctype}, + fields=["parent", "amount"], + ) + totals = {} + for line in lines: + if line.parent != doc.name: + totals[line.parent] = totals.get(line.parent, 0) + flt(line.amount) + if not totals: + return [] + siblings = frappe.get_all( + doc.doctype, filters={"name": ["in", list(totals)], "is_return": 0}, fields=fields + ) + for sibling in siblings: + sibling.total = totals[sibling.name] + return siblings + + +def get_shipping_allocations(doc) -> list[dict]: + discount = doc.get("custom_shipping_discount_id") + if not discount or not flt(doc.get("custom_shipping_discount")): + return [] + code = next((code for code in get_discount_codes(doc) if get_code_discount(code) == discount), None) + return [get_allocation(Selection(get_discount(discount), code), SHIPPING, doc.custom_shipping_discount)] + + +def round_down(amount: float, precision: int) -> float: + # The epsilon keeps a float like 8.999999999 from dropping a whole paisa. + return math.floor(flt(amount) * 10**precision + 1e-6) / 10**precision + + +def cap_by_budget(budget_left: float | None, line_discounts: dict[int, float]) -> dict[int, float]: + total = sum(line_discounts.values()) + if budget_left is None or total <= budget_left: + return line_discounts + return {idx: amount * budget_left / total for idx, amount in line_discounts.items()} if total else {} + + +def refresh_code_discounts(doc): + remove_code_discounts(doc) + doc.calculate_taxes_and_totals() + apply_code_discounts(doc) diff --git a/commera/discounts/selection.py b/commera/discounts/selection.py new file mode 100644 index 00000000..6bab6b9a --- /dev/null +++ b/commera/discounts/selection.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass + +import frappe +from frappe import _ +from frappe.utils.data import cstr, flt + +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import get_code_discount, get_discount_code_refusal, normalize_code +from commera.discounts.doctype.commera_discount.commera_discount import ( + CommeraDiscount, + DiscountCodeError, +) +from commera.discounts.usage import has_used_before, is_first_order + +MAX_DISCOUNT_CODES = 5 + + +@dataclass +class Selection: + discount: CommeraDiscount + code: str | None = None + + +def get_discount_codes(doc) -> list[str]: + fieldname = "discount_codes" if doc.doctype == "Commera Cart" else "custom_discount_codes" + return [code for code in cstr(doc.get(fieldname)).splitlines() if code.strip()] + + +def get_discount(name: str) -> CommeraDiscount: + return frappe.get_cached_doc("Commera Discount", name) + + +def validate_new_discount_code(code: str, codes: list[str], active_codes: list[str] | None = None) -> str: + # Only active_codes can refuse it for not combining. + code = normalize_code(code) + discount_name = get_code_discount(code) + if not discount_name: + frappe.throw(_("{0} is not a valid discount code.").format(code), DiscountCodeError) + if code in codes: + frappe.throw(_("{0} is already applied.").format(code), DiscountCodeError) + if len(codes) >= MAX_DISCOUNT_CODES: + frappe.throw( + _("A cart holds at most {0} discount codes.").format(MAX_DISCOUNT_CODES), DiscountCodeError + ) + + if is_single_use_spent(code): + frappe.throw(_("{0} has already been used.").format(code), DiscountCodeError) + discount = get_discount(discount_name) + discount.validate_usable() + for applied_code in codes: + applied_name = get_code_discount(applied_code) + if applied_name == discount_name: + frappe.throw( + _("{0} gives the same discount as {1}, which is already applied.").format(code, applied_code), + DiscountCodeError, + ) + if active_codes is not None and applied_code not in active_codes: + continue + if applied_name and not discount.can_combine_with(get_discount(applied_name)): + frappe.throw(_("{0} cannot be combined with {1}.").format(code, applied_code), DiscountCodeError) + return code + + +def get_unusable_code_reason(doc, code: str) -> str | None: + discount_name = get_code_discount(code) + if not discount_name: + return _("{0} is no longer a valid discount code.").format(code) + if is_single_use_spent(code): + return _("{0} has already been used.").format(code) + discount = get_discount(discount_name) + if reason := discount.get_unusable_reason(): + return reason + if discount.once_per_customer and has_used_before(discount.name, get_cart_context(doc)): + return _("You have already used {0}.").format(discount.title) + return get_discount_code_refusal(doc, code) + + +def is_single_use_spent(code: str) -> bool: + is_generated, times_used = frappe.get_cached_value( + "Commera Discount Code", code, ["is_generated", "times_used"] + ) + return bool(is_generated and times_used) + + +def get_candidates(doc) -> list[Selection]: + if doc.flags.commera_discounts is not None: + return [Selection(discount) for discount in doc.flags.commera_discounts] + + candidates = [] + for code in get_discount_codes(doc): + discount_name = get_code_discount(code) + if discount_name and not is_single_use_spent(code) and not get_discount_code_refusal(doc, code): + candidates.append(Selection(get_discount(discount_name), code)) + automatic = frappe.get_all( + "Commera Discount", filters={"enabled": 1, "method": "Automatic"}, pluck="name", order_by="creation" + ) + return candidates + [Selection(get_discount(name)) for name in automatic] + + +def get_unmet_reason(discount: CommeraDiscount, cart) -> str | None: + if reason := discount.get_unmet_condition(cart): + return reason + is_first = cart.doc.flags.commera_first_order + if discount.eligibility == "First Order" and not ( + is_first if is_first is not None else is_first_order(cart) + ): + return _("{0} is for a first order only.").format(discount.title) + if discount.once_per_customer and has_used_before(discount.name, cart): + return _("You have already used {0}.").format(discount.title) + return None diff --git a/commera/discounts/shipping.py b/commera/discounts/shipping.py new file mode 100644 index 00000000..8e0c0dba --- /dev/null +++ b/commera/discounts/shipping.py @@ -0,0 +1,36 @@ +from frappe.utils.data import flt + +from commera.discounts.cart_context import get_cart_context +from commera.discounts.doctype.commera_discount.commera_discount import SHIPPING +from commera.discounts.pricing import select_discounts + + +def apply_shipping_discounts(quotation, options: list[dict]) -> list[dict]: + # Runs after the plugin hooks, so shown price equals charged price. + return [get_discounted_option(option, quotation) for option in options] + + +def get_discounted_option(option: dict, quotation) -> dict: + cart = get_cart_context(quotation) + cart.offered_option = option + selections = [s for s in select_discounts(cart) if s.discount.discount_class == SHIPPING] + if not selections: + return option + best_amount, best_discount = max( + (s.discount.get_delivery_discount(option, cart), s.discount.name) for s in selections + ) + if not best_amount: + return option + option = dict(option) + option["original_amount"] = option["amount"] + option["amount"] = flt(option["amount"] - best_amount, 2) + option["is_free"] = not option["amount"] + option["shipping_discount"] = best_discount + return option + + +def set_shipping_discount(quotation, option: dict | None): + option = option or {} + original_amount = flt(option.get("original_amount")) + quotation.custom_shipping_discount = original_amount - flt(option.get("amount")) if original_amount else 0 + quotation.custom_shipping_discount_id = option.get("shipping_discount") diff --git a/commera/discounts/usage.py b/commera/discounts/usage.py new file mode 100644 index 00000000..e30f8fe0 --- /dev/null +++ b/commera/discounts/usage.py @@ -0,0 +1,179 @@ +import frappe +from frappe import _ +from frappe.query_builder.functions import Count, Sum +from frappe.utils.data import cint, flt + +from commera.discounts.allocations import get_allocations +from commera.discounts.cart_context import get_cart_context +from commera.plugin_events import fire_event + + +class DiscountLimitError(frappe.ValidationError): + pass + + +def has_used_before( + discount: str, cart, excluded_orders: list[str] | None = None, for_update: bool = False +) -> bool: + use = frappe.qb.DocType("Commera Discount Use") + identity = None + for field, value in (("customer", cart.customer), ("email", cart.email), ("phone", cart.phone)): + if value: + identity = (use[field] == value) if identity is None else identity | (use[field] == value) + if identity is None: + return False + query = frappe.qb.from_(use).select(use.name).where((use.discount == discount) & identity).limit(1) + if excluded_orders: + query = query.where(use.sales_order.notin(excluded_orders)) + if for_update: + query = query.for_update() + return bool(query.run()) + + +def is_first_order(cart) -> bool: + # Draft orders count: cash on delivery stays draft. + sales_order = frappe.qb.DocType("Sales Order") + identity = None + if cart.customer: + identity = sales_order.customer == cart.customer + if cart.email: + by_email = sales_order.contact_email == cart.email + identity = by_email if identity is None else identity | by_email + if identity is None: + return True + query = ( + frappe.qb.from_(sales_order) + .select(sales_order.name) + .where(identity & (sales_order.docstatus < 2) & (sales_order.order_type == "Shopping Cart")) + .limit(1) + ) + if cart.doc.doctype == "Sales Order" and cart.doc.name: + query = query.where(sales_order.name != cart.doc.name) + return not query.run() + + +def get_earlier_orders(sales_order) -> list[str]: + orders = [] + name = sales_order.amended_from + while name: + orders.append(name) + name = frappe.db.get_value("Sales Order", name, "amended_from") + return orders + + +def record_discount_uses(sales_order, method=None): + # A use counts from placement; cancel, amend, bill, return or delete never changes it. + amounts = {} + for allocation in get_allocations(sales_order): + key = (allocation["discount"], allocation.get("code")) + amounts[key] = amounts.get(key, 0) + flt(allocation["amount"]) + if not amounts: + return + + # An amended order is the same placement as the order it replaces, so it keeps that order's uses. + earlier_orders = get_earlier_orders(sales_order) if sales_order.amended_from else [] + conversion_rate = flt(sales_order.conversion_rate) or 1 + base_amounts = {} + for (discount, _code), amount in amounts.items(): + base_amounts[discount] = base_amounts.get(discount, 0) + flt(amount * conversion_rate, 2) + validate_discount_limits(sales_order, amounts, base_amounts, earlier_orders) + if earlier_orders and frappe.db.exists("Commera Discount Use", {"sales_order": ["in", earlier_orders]}): + return + + for (discount, code), amount in amounts.items(): + frappe.get_doc( + { + "doctype": "Commera Discount Use", + "discount": discount, + "code": code, + "sales_order": sales_order.name, + "amount": amount, + "order_total": flt(sales_order.grand_total), + "base_amount": flt(amount * conversion_rate, 2), + "base_order_total": flt(sales_order.base_grand_total), + "customer": sales_order.customer, + "email": (sales_order.contact_email or "").lower() or None, + "phone": sales_order.contact_mobile or sales_order.contact_phone or None, + } + ).insert(ignore_permissions=True) + add_usage(base_amounts, {code for _discount, code in amounts if code}, flt(sales_order.base_grand_total)) + for (discount, code), amount in amounts.items(): + fire_event( + "discount_used", + "Sales Order", + sales_order.name, + data={"discount": discount, "code": code, "amount": amount}, + key=f"{discount}-{code or ''}", + ) + + +def validate_discount_limits(sales_order, amounts: dict, base_amounts: dict, earlier_orders: list[str]): + # A locking read waits for a concurrent placement and sees its commit; a plain read would not (REPEATABLE READ). + cart = get_cart_context(sales_order) + labels = {discount: code for discount, code in amounts} + for discount_name in sorted(base_amounts): + counters = frappe.db.get_value( + "Commera Discount", discount_name, ["times_used", "discount_given"], as_dict=True, for_update=True + ) + discount = frappe.get_cached_doc("Commera Discount", discount_name) + label = labels[discount_name] or discount.title + earlier_uses, earlier_amount = get_earlier_usage({"discount": discount_name}, earlier_orders) + if discount.usage_limit and cint(counters.times_used) - earlier_uses >= cint(discount.usage_limit): + frappe.throw(_("{0} has been used up by other orders.").format(label), DiscountLimitError) + if flt(discount.budget): + budget = flt(discount.budget) * discount.get_company_exchange_rate() + given = flt(counters.discount_given) - earlier_amount + base_amounts[discount_name] + if flt(given, 2) > flt(budget, 2): + frappe.throw(_("{0} has run out of budget.").format(label), DiscountLimitError) + if discount.once_per_customer and has_used_before( + discount_name, cart, earlier_orders, for_update=True + ): + frappe.throw( + _("This customer has already used {0} on another order.").format(label), DiscountLimitError + ) + + for code in sorted(code for _discount, code in amounts if code): + is_generated, times_used = frappe.db.get_value( + "Commera Discount Code", code, ["is_generated", "times_used"], for_update=True + ) + if is_generated and cint(times_used) - get_earlier_usage({"code": code}, earlier_orders)[0] > 0: + frappe.throw( + _("{0} is a single-use code and another order has used it.").format(code), DiscountLimitError + ) + + +def get_earlier_usage(filters: dict, earlier_orders: list[str]) -> tuple[int, float]: + if not earlier_orders: + return 0, 0.0 + use = frappe.qb.DocType("Commera Discount Use") + query = ( + frappe.qb.from_(use) + .select(Count(use.name), Sum(use.base_amount)) + .where(use.sales_order.isin(earlier_orders)) + ) + for field, value in filters.items(): + query = query.where(use[field] == value) + count, amount = query.run()[0] + return cint(count), flt(amount) + + +def add_usage(base_amounts: dict[str, float], codes: set[str], base_order_total: float): + # Increments, not recounts: the update reads the latest committed counter, whatever this snapshot holds. + discount = frappe.qb.DocType("Commera Discount") + for name, base_amount in base_amounts.items(): + ( + frappe.qb.update(discount) + .set(discount.times_used, discount.times_used + 1) + .set(discount.discount_given, discount.discount_given + base_amount) + .set(discount.sales, discount.sales + base_order_total) + .where(discount.name == name) + ).run() + frappe.clear_document_cache("Commera Discount", name) + code_table = frappe.qb.DocType("Commera Discount Code") + for code in codes: + ( + frappe.qb.update(code_table) + .set(code_table.times_used, code_table.times_used + 1) + .where(code_table.name == code) + ).run() + frappe.clear_document_cache("Commera Discount Code", code) diff --git a/commera/fixtures/email_template.json b/commera/fixtures/email_template.json index b0841c8f..8f8126ea 100644 --- a/commera/fixtures/email_template.json +++ b/commera/fixtures/email_template.json @@ -15,17 +15,17 @@ "modified": "2025-05-21 13:30:13.212018", "name": "Order Cancellation", "response": "

<p>Hello {{ customer_name or customer }},</p>


<p>We’ve received your request to cancel order <strong>{{ doc.name }}</strong>. The order has now been successfully cancelled.</p>


<p>If this was a mistake or if you need help placing a new order, feel free to reach out to our support team.</p>



<p>Thank you for visiting us.</p>

<p>Commera</p>


", - "response_html": "\n\n\n
\n \n \n
\n \"{{\n
\n \n \n
\n

Dear {{ customer_name }},

\n
\n\n

We’re writing to confirm that your Sales Order placed on {{ transaction_date }} has been successfully cancelled.

\n \n

Here are the details of the cancelled order:

\n \n \n \n \n \n \n \n \n \n \n \n {% for item in items %}\n \n \n \n \n \n {% endfor %}\n \n
ItemQuantityPrice
{{ item.item_name }}{{ item.qty }}{{ frappe.utils.fmt_money(item.amount, currency=currency) }}
\n \n \n {% if taxes %}\n

Taxes & Additional Charges:

\n \n \n \n \n \n \n \n \n {% for tax in taxes %}\n \n \n \n \n {% endfor %}\n \n
DescriptionAmount
{{ tax.description or tax.account_head }}{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }}
\n {% endif %}\n \n \n
\n

\n Total Cancelled Amount: \n {{ frappe.utils.fmt_money(rounded_total, currency=currency) }}\n

\n
\n \n \n
\n

If a payment was made, your refund will be processed as per our refund policy.

\n

For any questions or assistance, feel free to contact our support team.

\n \n
\n\n \n \n

Warm regards,

\n

{{ company }} Team

\n \n \n
\n

© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.

\n
\n \n
", + "response_html": "\n\n\n
\n \n \n
\n \"{{\n
\n \n \n
\n

Dear {{ customer_name }},

\n
\n\n

We’re writing to confirm that your Sales Order placed on {{ transaction_date }} has been successfully cancelled.

\n \n

Here are the details of the cancelled order:

\n \n \n \n \n \n \n \n \n \n \n \n {% for item in items %}\n \n \n \n \n \n {% endfor %}\n \n
ItemQuantityPrice
{{ item.item_name }}{{ item.qty }}{{ frappe.utils.fmt_money(item.amount, currency=currency) }}
\n \n \n {% if taxes %}\n

Taxes & Additional Charges:

\n \n \n \n \n \n \n \n \n {% for tax in taxes %}\n \n \n \n \n {% endfor %}\n \n
DescriptionAmount
{{ tax.description or tax.account_head }}{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }}
\n {% endif %}\n \n \n
\n

\n Total Cancelled Amount: \n {{ frappe.utils.fmt_money(payable_total, currency=currency) }}\n

\n
\n \n \n
\n

If a payment was made, your refund will be processed as per our refund policy.

\n

For any questions or assistance, feel free to contact our support team.

\n \n
\n\n \n \n

Warm regards,

\n

{{ company }} Team

\n \n \n
\n

© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.

\n
\n \n
", "subject": "{{company}}: Your Order Has Been Cancelled!", "use_html": 1 }, { "docstatus": 0, "doctype": "Email Template", - "modified": "2026-09-29 12:00:00.000000", + "modified": "2026-10-20 12:00:00.000000", "name": "Order Confirmation", "response": "


", - "response_html": "\n
\n \n \n
\n \"{{\n
\n \n \n
\n

Dear {{ customer_name }},

\n
\n \n

Thank you for your order with {{ company }}! We're pleased to confirm your Sales Order placed on {{ transaction_date }}.

\n \n

Below is a summary of your order:

\n \n \n \n \n \n \n \n \n \n \n \n {% for item in items %}\n \n \n \n \n \n {% endfor %}\n \n
ItemQuantityPrice
{{ item.item_name }}{{ item.qty }}{{ frappe.utils.fmt_money(item.amount, currency=currency) }}
\n \n \n {% if taxes %}\n

Taxes & Additional Charges:

\n \n \n \n \n \n \n \n \n {% for tax in taxes %}\n \n \n \n \n {% endfor %}\n \n
DescriptionAmount
{{ tax.description or tax.account_head }}{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }}
\n {% endif %}\n \n \n
\n

\n Total Amount: \n {{ frappe.utils.fmt_money(rounded_total, currency=currency) }}\n

\n
\n \n {% if order_link %}\n
\n View your order\n
\n {% endif %}\n\n \n
\n

If you have any questions or need further assistance, please don't hesitate to contact our customer support team.

\n
\n \n \n

Warm regards,

\n

{{ company }} Team

\n \n \n
\n

© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.

\n
\n \n
", + "response_html": "\n
\n \n \n
\n \"{{\n
\n \n \n
\n

Dear {{ customer_name }},

\n
\n \n

Thank you for your order with {{ company }}! We're pleased to confirm your Sales Order placed on {{ transaction_date }}.

\n \n

Below is a summary of your order:

\n \n \n \n \n \n \n \n \n \n \n \n {% for item in items %}\n \n \n \n \n \n {% endfor %}\n \n
ItemQuantityPrice
{{ item.item_name }}{{ item.qty }}{{ frappe.utils.fmt_money(item.amount + (item.custom_code_discount or 0) * item.qty, currency=currency) }}
\n \n {% if discount_rows %}\n \n \n {% for discount in discount_rows %}\n \n \n \n \n {% endfor %}\n \n
Discount{% if discount.label %} ({{ discount.label | e }}){% endif %}−{{ frappe.utils.fmt_money(discount.amount, currency=currency) }}
\n {% endif %}\n\n \n {% if taxes %}\n

Taxes & Additional Charges:

\n \n \n \n \n \n \n \n \n {% for tax in taxes %}\n \n \n \n \n {% endfor %}\n \n
DescriptionAmount
{{ tax.description or tax.account_head }}{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }}
\n {% endif %}\n \n \n
\n

\n Total Amount: \n {{ frappe.utils.fmt_money(payable_total, currency=currency) }}\n

\n
\n \n {% if order_link %}\n
\n View your order\n
\n {% endif %}\n\n \n
\n

If you have any questions or need further assistance, please don't hesitate to contact our customer support team.

\n
\n \n \n

Warm regards,

\n

{{ company }} Team

\n \n \n
\n

© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.

\n
\n \n
", "subject": "{{company}}: Your Order Has Been Confirmed!", "use_html": 1 } diff --git a/commera/guest.py b/commera/guest.py index c370bde9..4be861ed 100644 --- a/commera/guest.py +++ b/commera/guest.py @@ -38,6 +38,10 @@ def set_guest_cart_cookie() -> str: return sha256_hash(token) +def clear_guest_cart_cookie(): + frappe.local.cookie_manager.delete_cookie(GUEST_CART_COOKIE) + + def get_guest_cart_name(cart_key: str | None = None) -> str | None: cart_key = cart_key or get_guest_cart_key() if not cart_key: diff --git a/commera/hooks.py b/commera/hooks.py index e1ed6b39..4b169e8b 100644 --- a/commera/hooks.py +++ b/commera/hooks.py @@ -92,6 +92,7 @@ ] before_request = ["commera.utils.before_request"] +on_session_creation = "commera.commera_ecommerce.doctype.commera_cart.commera_cart.keep_guest_cart" # Runs before every core renderer (frappe/website/path_resolver.py); can_render() opts out when no theme. page_renderer = ["commera.shop_themes.theme_resolver.ThemePageRenderer"] @@ -135,8 +136,18 @@ "Gateway Payment Request": { "on_update": "commera.api.payment_hooks.on_payment_request_update", }, + "Quotation": { + "before_validate": "commera.discounts.pricing.remove_code_discounts", + "validate": "commera.discounts.pricing.apply_code_discounts", + }, "Sales Order": { - "after_insert": "commera.jobs.send_order_success_acknowledgement", + "before_insert": "commera.discounts.pricing.drop_copied_discounts", + "before_validate": "commera.discounts.pricing.remove_code_discounts", + "validate": "commera.discounts.pricing.apply_code_discounts", + "after_insert": [ + "commera.discounts.usage.record_discount_uses", + "commera.jobs.send_order_success_acknowledgement", + ], "on_submit": [ "commera.jobs.send_order_success_acknowledgement", "commera.utils.update_so_status_from_related_doc", @@ -173,9 +184,15 @@ "after_rename": "commera.search.sync.after_rename", "on_trash": "commera.search.sync.on_trash", }, - "Sales Invoice": {"on_submit": "commera.utils.update_so_status_from_related_doc"}, + "Sales Invoice": { + "before_validate": "commera.discounts.pricing.remove_code_discounts", + "validate": "commera.discounts.pricing.apply_code_discounts", + "on_submit": "commera.utils.update_so_status_from_related_doc", + }, "Payment Entry": {"on_submit": "commera.plugin_events.on_payment_entry_submit"}, "Delivery Note": { + "before_validate": "commera.discounts.pricing.remove_code_discounts", + "validate": "commera.discounts.pricing.apply_code_discounts", "after_insert": "commera.utils.update_so_status_from_related_doc", "on_submit": "commera.utils.update_so_status_from_related_doc", "on_cancel": "commera.utils.update_so_status_from_related_doc", @@ -221,6 +238,8 @@ "Bulk Style Attribute Configurator Creation Log", "Commera Event", "Commera Event Delivery", + # A use is never given back, so its row outlives a deleted order. + "Commera Discount Use", ] # Apps @@ -481,6 +500,7 @@ "daily": [ "commera.jobs.delete_notified_oos", "commera.jobs.delete_old_draft_quotations", + "commera.commera_ecommerce.doctype.commera_cart.commera_cart.delete_abandoned_guest_carts", "commera.search.build.rebuild_index_nightly", "commera.og.generator.clear_old_cards", ], diff --git a/commera/jobs.py b/commera/jobs.py index 228f875c..36a117c8 100644 --- a/commera/jobs.py +++ b/commera/jobs.py @@ -5,6 +5,8 @@ from frappe.utils import add_to_date, get_url, now_datetime from commera.api.payments import COD_PAYMENT_MODE +from commera.api.shipping import get_charge_amount +from commera.discounts.allocations import get_price_discount_rows from commera.order_access import get_order_link, set_order_access_key @@ -22,6 +24,8 @@ def send_order_success_acknowledgement(doc, method): doc_args = doc.as_dict() set_order_access_key(doc) doc_args["order_link"] = get_order_link(doc) + doc_args["discount_rows"] = get_price_discount_rows(doc) + doc_args["payable_total"] = get_charge_amount(doc) order_confirmation_template_name = frappe.get_cached_value( "Commera Settings", "Commera Settings", @@ -60,6 +64,7 @@ def get_portal_user_email(customer: str) -> str | None: def send_order_cancel_acknowledgement(doc, method): try: doc_args = doc.as_dict() + doc_args["payable_total"] = get_charge_amount(doc) order_confirmation_template_name = frappe.get_cached_value( "Commera Settings", "Commera Settings", diff --git a/commera/modules.txt b/commera/modules.txt index 4021e0cf..0fcca05d 100644 --- a/commera/modules.txt +++ b/commera/modules.txt @@ -1,3 +1,4 @@ Commera Commera Ecommerce Shop Themes +Discounts diff --git a/commera/patches.txt b/commera/patches.txt index eacc4876..51473aa0 100644 --- a/commera/patches.txt +++ b/commera/patches.txt @@ -3,6 +3,7 @@ # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations commera.patches.create_shop_themes_module commera.patches.rename_modules_and_commera_settings +commera.patches.rename_item_discount_code_field commera.patches.rename_pixio_theme_to_summer [post_model_sync] @@ -19,3 +20,5 @@ commera.patches.move_item_group_links_onto_the_menu_entry commera.patches.seed_shop_web_page_route commera.patches.allow_guest_on_order_detail commera.patches.move_service_shipping_rules_to_store_rule +commera.patches.drop_frozen_order_field_order +commera.patches.drop_frozen_core_field_order diff --git a/commera/patches/drop_frozen_core_field_order.py b/commera/patches/drop_frozen_core_field_order.py new file mode 100644 index 00000000..a5150937 --- /dev/null +++ b/commera/patches/drop_frozen_core_field_order.py @@ -0,0 +1,12 @@ +from commera.patches.drop_frozen_order_field_order import drop_shipped_setters + +SHIPPED_LAYOUT_SETTERS = { + "Item Group-main-field_order": "038a79562519da4c3df7a8e3921ae0c92e5e61cdcfece60c4cbf020081363ca9", + "Warehouse-main-field_order": "e8383bbfbc9aa55ae2f4bc3bc5a01edc6a7bb87d9c61defc8575881e9c97b384", + "Brand-main-field_order": "74540ff8ff75b505f5a93d21c825f6e067bc4fd527ea89ffd72d8d70544333ec", + "Delivery Note Item-main-field_order": "fee5d0443313a385bd608a5138106f7e883e29c3834275eaee8f9c2e4f5aafbb", +} + + +def execute(): + drop_shipped_setters(SHIPPED_LAYOUT_SETTERS) diff --git a/commera/patches/drop_frozen_order_field_order.py b/commera/patches/drop_frozen_order_field_order.py new file mode 100644 index 00000000..d85d0e5b --- /dev/null +++ b/commera/patches/drop_frozen_order_field_order.py @@ -0,0 +1,27 @@ +import hashlib + +import frappe + +# sha256 of each value the commera fixtures used to ship, so a site's own customization is left alone. +SHIPPED_LAYOUT_SETTERS = { + "Sales Order-main-field_order": "cba8f8616496e2dfda61f4474996c43910b7df240e55dbfa8c1398c2abe72fcb", + "Sales Order-main-links_order": "a2d48f3a6831935f12674f0eac058d148b0f65bb9fe0dae96120259a2d141adc", + "Quotation-main-field_order": "fc32caf2a4beb9a801c80a1cf79da5535bda0c0e2ac3b9254b5001c8ebd85ae6", +} + + +def execute(): + drop_shipped_setters(SHIPPED_LAYOUT_SETTERS) + + +def drop_shipped_setters(shipped_setters): + setters = frappe.get_all( + "Property Setter", + filters={"name": ["in", list(shipped_setters)]}, + fields=["name", "doc_type", "value"], + ) + for setter in setters: + if hashlib.sha256((setter.value or "").encode()).hexdigest() != shipped_setters[setter.name]: + continue + frappe.delete_doc("Property Setter", setter.name, ignore_permissions=True, force=True) + frappe.clear_cache(doctype=setter.doc_type) diff --git a/commera/patches/rename_item_discount_code_field.py b/commera/patches/rename_item_discount_code_field.py new file mode 100644 index 00000000..9ceb6b0d --- /dev/null +++ b/commera/patches/rename_item_discount_code_field.py @@ -0,0 +1,22 @@ +import frappe + +DOCTYPES = ("Quotation Item", "Sales Order Item", "Sales Invoice Item") +OLD_FIELDNAME = "custom_discount_code" +NEW_FIELDNAME = "custom_discount" + + +def execute(): + # Before the fixture sync, which would otherwise add an empty custom_discount next to the filled old column. + custom_field = frappe.qb.DocType("Custom Field") + for doctype in DOCTYPES: + old_name, new_name = f"{doctype}-{OLD_FIELDNAME}", f"{doctype}-{NEW_FIELDNAME}" + if frappe.db.exists("Custom Field", old_name) and not frappe.db.exists("Custom Field", new_name): + ( + frappe.qb.update(custom_field) + .set(custom_field.name, new_name) + .set(custom_field.fieldname, NEW_FIELDNAME) + .where(custom_field.name == old_name) + ).run() + if frappe.db.has_column(doctype, OLD_FIELDNAME) and not frappe.db.has_column(doctype, NEW_FIELDNAME): + frappe.db.rename_column(doctype, OLD_FIELDNAME, NEW_FIELDNAME) + frappe.clear_cache(doctype=doctype) diff --git a/commera/plugin_events.py b/commera/plugin_events.py index 06d7d60e..35e7d5ec 100644 --- a/commera/plugin_events.py +++ b/commera/plugin_events.py @@ -1,4 +1,5 @@ from collections import defaultdict +from contextlib import contextmanager import frappe from frappe import _ @@ -11,6 +12,7 @@ from pypika.terms import Case, ExistsCriterion from rq.job import JobStatus +from commera.discounts.allocations import get_allocations from commera.sdk import API_VERSION, STORE_ORDER_TYPE, as_plugin_user from commera.sdk.events import HANDLER_HOOKS, CommeraEvent @@ -98,6 +100,7 @@ def get_order_snapshot(sales_order: str | int) -> dict: sales_order_table.grand_total, sales_order_table.custom_ecommerce_payment_mode, sales_order_table.docstatus, + sales_order_table.custom_discount_allocations, ) .where(sales_order_table.name == sales_order) .run(as_dict=True)[0] @@ -109,6 +112,7 @@ def get_order_snapshot(sales_order: str | int) -> dict: "payment_mode": order.custom_ecommerce_payment_mode, "docstatus": order.docstatus, "shipping_address": get_shipping_address(order), + "discounts": get_allocations(order), } @@ -833,3 +837,21 @@ def validate_plugin_fieldnames(doctype: str, fieldnames) -> None: ), frappe.ValidationError, ) + + +@contextmanager +def handle_hook_error(key: str, handler: str, quotation, strict: bool): + message_count = len(frappe.local.message_log) + try: + yield + except Exception: + frappe.log_error( + title=f'commera_hooks["{key}"] hook failed: {handler}'[:140], + reference_doctype="Quotation", + reference_name=quotation.name, + defer_insert=True, + ) + # Whatever the app msgprinted is meant for its developer, never for the shopper. + del frappe.local.message_log[message_count:] + if strict: + frappe.throw(_("Something went wrong, please try again.")) diff --git a/commera/public/js/extends/sales_order.js b/commera/public/js/extends/sales_order.js index f02b66a1..f4337e46 100644 --- a/commera/public/js/extends/sales_order.js +++ b/commera/public/js/extends/sales_order.js @@ -36,7 +36,7 @@ frappe.ui.form.on('Sales Order', { change: () => { const checked = d.get_value('refund_charges'); const new_amount = checked - ? frm.doc.rounded_total - r.amount_refunded + ? r.refundable_amount : frm.doc.net_total - r.amount_refunded; d.set_value('refund_amount', new_amount); diff --git a/commera/public/themes/summer_theme/css/cart.css b/commera/public/themes/summer_theme/css/cart.css index fcefd16e..de3d85c9 100644 --- a/commera/public/themes/summer_theme/css/cart.css +++ b/commera/public/themes/summer_theme/css/cart.css @@ -127,3 +127,18 @@ line-height: 28px; } } + +.check-tbl .product-item-img .form-check-input { + width: 1.25em; + height: 1.25em; + cursor: pointer; +} + +/* A column of its own would push the remove button off a 360px screen again. */ +@media only screen and (max-width: 575px) { + .check-tbl .product-item-img .form-check-input { + position: absolute; + top: 4px; + left: 4px; + } +} diff --git a/commera/public/themes/summer_theme/css/summer.css b/commera/public/themes/summer_theme/css/summer.css index ea1dd2b2..97a3ee77 100644 --- a/commera/public/themes/summer_theme/css/summer.css +++ b/commera/public/themes/summer_theme/css/summer.css @@ -400,3 +400,37 @@ button:focus-visible { pointer-events: unset; } } + +.checkout-discount-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 4px 2px 10px; + border: 1px dashed var(--border-color); + border-radius: 6px; + font-size: 13px; + font-weight: 500; + color: var(--title); +} + +.checkout-discount-reason { + max-width: 240px; + margin: 4px 0 0; + font-size: 12px; + line-height: 18px; + color: var(--bs-body-color); +} + +.checkout-discount-chip button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + min-height: 24px; + padding: 0; + border: 0; + background: none; + color: inherit; + line-height: 1; + cursor: pointer; +} diff --git a/commera/sdk/__init__.py b/commera/sdk/__init__.py index 9858cc9c..9d50e60d 100644 --- a/commera/sdk/__init__.py +++ b/commera/sdk/__init__.py @@ -6,7 +6,7 @@ API_VERSION = 1 STORE_ORDER_TYPE = "Shopping Cart" -MODULES = ("cart", "catalog", "orders") +MODULES = ("cart", "catalog", "discounts", "orders") __all__ = ["API_VERSION", "STORE_ORDER_TYPE", "as_plugin_user", *MODULES] diff --git a/commera/sdk/cart.py b/commera/sdk/cart.py index aeb0cf88..83db25b5 100644 --- a/commera/sdk/cart.py +++ b/commera/sdk/cart.py @@ -3,13 +3,26 @@ from frappe.utils.data import flt from commera.api.payments import cart_write_lock, save_cart_quotation, validate_cart_is_not_in_checkout -from commera.api.shipping import get_checkout_summary +from commera.api.shipping import get_checkout_summary, reprice_shipping_discount from commera.checkout_hooks import apply_plugin_fees +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import CommeraCart, get_session_cart from commera.core import _get_cart_quotation +from commera.discounts.allocations import get_allocations +from commera.guest import get_guest_cart_name, is_guest from commera.plugin_events import get_plugin_fieldnames, validate_plugin_fieldnames -from commera.sdk.types import Cart, CheckoutSummary +from commera.sdk.types import Cart, CheckoutSummary, NewCartLine, PricedCart -__all__ = ["get_cart", "set_cart_fields"] +__all__ = [ + "add_discount_code", + "add_lines", + "get_cart", + "get_priced_cart", + "remove_discount_code", + "remove_lines", + "set_cart_attributes", + "set_cart_fields", + "update_lines", +] def get_cart() -> Cart | None: @@ -29,9 +42,13 @@ def get_cart() -> Cart | None: "qty": flt(row.qty), "rate": flt(row.rate), "amount": flt(row.amount), + "discount": flt(row.custom_code_discount) * flt(row.qty), + "attributes": frappe.parse_json(row.custom_line_attributes or "{}"), } for row in quotation.items ], + "discounts": get_allocations(quotation), + "attributes": frappe.parse_json(quotation.custom_cart_attributes or "{}"), "total": flt(quotation.total), "grand_total": flt(quotation.grand_total), "plugin_fields": { @@ -41,12 +58,16 @@ def get_cart() -> Cart | None: def set_cart_fields(values: dict) -> CheckoutSummary: - """Save an app's own Quotation fields on the shopper's cart and re-price its plugin fees. Each field must - start with the name of an installed Commera plugin; a cart already in payment is refused.""" + """Before checkout the fields wait on the server cart; a cart in payment is refused.""" validate_plugin_fieldnames("Quotation", values) - quotation = _get_cart_quotation() - if quotation.is_new(): - frappe.throw(_("Add something to your cart first."), frappe.DoesNotExistError) + quotation = get_open_cart_quotation() + if not quotation: + cart = get_session_cart(create=True) + cart.set_plugin_fields(values) + cart.save(ignore_permissions=True) + preview = cart.get_quotation_preview() + apply_plugin_fees(preview) + return get_checkout_summary(preview) with cart_write_lock(quotation): validate_cart_is_not_in_checkout(quotation.name) @@ -54,3 +75,78 @@ def set_cart_fields(values: dict) -> CheckoutSummary: apply_plugin_fees(quotation) save_cart_quotation(quotation) return get_checkout_summary(quotation) + + +def get_open_cart_quotation(): + if is_guest(): + cart_name = get_guest_cart_name() + return frappe.get_doc("Quotation", cart_name) if cart_name else None + quotation = _get_cart_quotation() + return None if quotation.is_new() else quotation + + +def set_cart_attributes(values: dict) -> PricedCart: + """None removes a key; attributes reach the order as `attributes`.""" + cart = get_session_cart(create=True) + cart.set_attributes(values) + return save_server_cart(cart) + + +def get_priced_cart() -> PricedCart | None: + cart = get_session_cart() + return cart.get_priced_cart() if cart else None + + +def add_lines(lines: list[NewCartLine]) -> PricedCart: + """The storefront shows new lines on the shopper's next page load.""" + cart = get_session_cart(create=True) + cart.add_lines(lines) + return save_server_cart(cart) + + +def update_lines(lines: list[dict]) -> PricedCart: + cart = get_existing_cart() + cart.update_lines(lines) + return save_server_cart(cart) + + +def remove_lines(line_ids: list[str]) -> PricedCart: + cart = get_existing_cart() + cart.remove_lines(line_ids) + return save_server_cart(cart) + + +def add_discount_code(code: str) -> PricedCart: + cart = get_existing_cart() + cart.add_discount_code(code) + return save_discount_codes(cart) + + +def remove_discount_code(code: str) -> PricedCart: + cart = get_existing_cart() + cart.remove_discount_code(code) + return save_discount_codes(cart) + + +def save_discount_codes(cart: CommeraCart) -> PricedCart: + """Codes also go on an open checkout: it copies the cart's codes only when it is made.""" + if quotation := get_open_cart_quotation(): + with cart_write_lock(quotation): + validate_cart_is_not_in_checkout(quotation.name) + quotation.custom_discount_codes = cart.discount_codes + # The delivery discount was chosen with the old codes. + reprice_shipping_discount(quotation) + save_cart_quotation(quotation) + return save_server_cart(cart) + + +def get_existing_cart() -> CommeraCart: + cart = get_session_cart() + if not cart: + frappe.throw(_("Add something to your cart first."), frappe.DoesNotExistError) + return cart + + +def save_server_cart(cart: CommeraCart) -> PricedCart: + cart.save(ignore_permissions=True) + return cart.get_priced_cart() diff --git a/commera/sdk/discounts.py b/commera/sdk/discounts.py new file mode 100644 index 00000000..fb23feb8 --- /dev/null +++ b/commera/sdk/discounts.py @@ -0,0 +1,129 @@ +import frappe + +from commera.discounts.allocations import get_allocations +from commera.discounts.codes import add_code as add_discount_code +from commera.discounts.codes import generate_codes as generate_discount_codes +from commera.discounts.preview import get_preview_quotation +from commera.sdk.types import Discount, DiscountAllocation, DiscountInput + +__all__ = [ + "add_code", + "create_discount", + "generate_codes", + "get_discount", + "get_order_discounts", + "list_discounts", + "preview_cart", + "update_discount", +] + +DOCTYPE = "Commera Discount" + + +def get_discount(name: str) -> Discount: + """Needs read permission on Commera Discount.""" + doc = frappe.get_doc(DOCTYPE, name) + doc.check_permission("read") + return as_discount(doc) + + +def list_discounts(status: str | None = None, limit: int = 100) -> list[Discount]: + """status is computed, so it filters after the read (may return < limit).""" + rows = frappe.get_list(DOCTYPE, fields=["*"], order_by="creation desc", limit=limit) + if not rows: + return [] + names = [row.name for row in rows] + tables = {} + for field in frappe.get_meta(DOCTYPE).get_table_fields(): + for child in frappe.get_all( + field.options, + filters={"parent": ["in", names], "parenttype": DOCTYPE, "parentfield": field.fieldname}, + fields=["*"], + order_by="idx asc", + ): + tables.setdefault(child.parent, {}).setdefault(field.fieldname, []).append(child) + codes = get_codes(names) + discounts = [ + as_discount(frappe.get_doc({"doctype": DOCTYPE, **row, **tables.get(row.name, {})}), codes) + for row in rows + ] + return [discount for discount in discounts if not status or discount["status"] == status] + + +def create_discount(values: DiscountInput, code: str | None = None) -> Discount: + doc = frappe.get_doc({"doctype": DOCTYPE, "method": "Code", **values}) + doc.flags.code = code + doc.insert() + return as_discount(doc) + + +def update_discount(name: str, values: dict) -> Discount: + """A table in values replaces the stored rows.""" + doc = frappe.get_doc(DOCTYPE, name) + doc.update(values) + doc.save() + return as_discount(doc) + + +def add_code(discount: str, code: str) -> str: + frappe.has_permission(DOCTYPE, "write", discount, throw=True) + return add_discount_code(discount, code) + + +def generate_codes(discount: str, count: int, prefix: str = "") -> list[str]: + frappe.has_permission(DOCTYPE, "write", discount, throw=True) + return generate_discount_codes(discount, count, prefix) + + +def preview_cart(lines: list[dict], codes: list[str] | None = None, payment_kind: str = "prepaid") -> dict: + flags = {"commera_payment_kind": "cod" if payment_kind == "cod" else "prepaid"} + user = None if frappe.session.user == "Guest" else frappe.session.user + quotation = get_preview_quotation(lines, "\n".join(codes or []), user, flags) + return { + "lines": [ + { + "item_code": item.item_code, + "qty": item.qty, + "price": item.price_list_rate, + "rate": item.rate, + "amount": item.amount, + "discount": item.custom_discount, + } + for item in quotation.items + ], + "discounts": get_allocations(quotation), + "subtotal": quotation.total, + "total": quotation.net_total, + } + + +def get_order_discounts(sales_order: str) -> list[DiscountAllocation]: + doc = frappe.get_doc("Sales Order", sales_order) + doc.check_permission("read") + return get_allocations(doc) + + +def get_codes(discounts: list[str]) -> dict[str, list[str]]: + codes = {} + if not discounts: + return codes + for row in frappe.get_all( + "Commera Discount Code", + filters={"discount": ["in", discounts]}, + fields=["name", "discount"], + order_by="creation asc", + ): + codes.setdefault(row.discount, []).append(row.name) + return codes + + +def as_discount(doc, codes: dict[str, list[str]] | None = None) -> Discount: + if codes is None: + codes = get_codes([doc.name]) + return { + **doc.as_dict(no_default_fields=True, no_child_table_fields=True), + "name": doc.name, + "status": doc.status, + "discount_class": doc.discount_class, + "codes": codes.get(doc.name, []), + } diff --git a/commera/sdk/events.py b/commera/sdk/events.py index 1fd0c911..5ada9424 100644 --- a/commera/sdk/events.py +++ b/commera/sdk/events.py @@ -12,8 +12,16 @@ "order_returned", "product_updated", "inventory_changed", + "cart_created", + "discount_used", + ), + "commera_hooks": ( + "validate_cart", + "validate_discount_code", + "cart_fees", + "delivery_options", + "payment_methods", ), - "commera_hooks": ("validate_cart", "cart_fees", "delivery_options", "payment_methods"), } diff --git a/commera/sdk/orders.py b/commera/sdk/orders.py index b0d4889d..0c9a7bf8 100644 --- a/commera/sdk/orders.py +++ b/commera/sdk/orders.py @@ -5,6 +5,7 @@ from commera.api.admin.orders import MAX_PAGE_LENGTH, describe_state, read_orders, read_paid_orders from commera.api.shipping import get_charge_lines, is_connector_installed, read_order_taxes +from commera.discounts.allocations import get_allocations from commera.plugin_events import read_shipping_addresses, validate_plugin_fieldnames from commera.sdk.types import Order @@ -86,9 +87,13 @@ def get_order_result(order, paid_orders: set, taxes: list, shipping_address, ext "rate": flt(line.rate), "amount": flt(line.amount), "image": line.image, + "discount": flt(line.custom_code_discount) * flt(line.qty), + "attributes": frappe.parse_json(line.custom_line_attributes or "{}"), } for line in order.lines ], + "discounts": get_allocations(order), + "attributes": frappe.parse_json(order.custom_cart_attributes or "{}"), "tags": order.tags, "plugin_fields": {fieldname: order.get(fieldname) for fieldname in extra_fields}, } diff --git a/commera/sdk/types.py b/commera/sdk/types.py index f5936e1e..04772174 100644 --- a/commera/sdk/types.py +++ b/commera/sdk/types.py @@ -9,8 +9,14 @@ "Charge", "ChargeSummary", "CheckoutSummary", + "Discount", + "DiscountAllocation", + "DiscountInput", + "NewCartLine", "Order", "OrderLine", + "PricedCart", + "PricedCartLine", "Stage", ] @@ -47,6 +53,8 @@ class OrderLine(TypedDict): rate: float amount: float image: str | None + discount: float + attributes: dict[str, str] class Order(TypedDict): @@ -79,6 +87,8 @@ class Order(TypedDict): items: list[OrderLine] tags: list[str] plugin_fields: dict[str, Any] + discounts: list["DiscountAllocation"] + attributes: dict[str, str] class CartLine(TypedDict): @@ -87,6 +97,8 @@ class CartLine(TypedDict): qty: float rate: float amount: float + discount: float + attributes: dict[str, str] class Cart(TypedDict): @@ -94,13 +106,108 @@ class Cart(TypedDict): customer: str | None currency: str items: list[CartLine] + discounts: list["DiscountAllocation"] + attributes: dict[str, str] total: float grand_total: float plugin_fields: dict[str, Any] +class DiscountInput(TypedDict, total=False): + title: str + # "Code" or "Automatic". + method: str + # "Amount Off Products", "Amount Off Order", "Buy X Get Y" or "Free Shipping". + discount_type: str + # "Percentage" or "Fixed Amount". + value_type: str + value: float + maximum_discount: float + # {"role": "Applies To" | "Excluded" | "Buys" | "Gets", "target_type": "Item" | "Item Group" | "Brand", "target"} + targets: list[dict] + tiers: list[dict] + starts_on: date + ends_on: date | None + + +class Discount(DiscountInput, total=False): + name: str + # Active, Scheduled, Expired, Used Up or Disabled. + status: str + # Product, Order or Shipping. + discount_class: str + codes: list[str] + times_used: int + discount_given: float + sales: float + + +class DiscountAllocation(TypedDict): + discount: str + code: str | None + title: str + # Product, Order or Shipping. + discount_class: str + amount: float + # The cart line's idx for a product discount. + line: int | None + + +class NewCartLine(TypedDict, total=False): + item_code: str + qty: int + attributes: dict[str, str] + # Whether the line goes to checkout; True when left out. + selected: bool + + +class PricedCartLine(TypedDict): + line_id: str + item_code: str + title: str + qty: int + selected: bool + attributes: dict[str, str] + price: float + rate: float + # Per unit. + code_discount: float + # The Commera Discount on this line. + discount: str | None + amount: float + + +class DiscountRow(TypedDict): + # The code, or the automatic discount's title; None for a discount ERPNext gave on its own. + label: str | None + amount: float + + +class PricedCart(TypedDict): + """Before shipping and tax.""" + + name: str + version: str + attributes: dict[str, str] + currency: str + # {"code", "class"}, where class is "Product" or "Order". + discount_codes: list[dict] + lines: list[PricedCartLine] + discounts: list[DiscountRow] + subtotal: float + order_discount: float + total: float + # The store Shipping Rule's free band, judged after every discount; None when it has none. + free_delivery_above: float | None + free_delivery_shortfall: float + + class ChargeSummary(TypedDict): subtotal: float + # Before discount codes; see discounts. + items_total: float + discounts: list[DiscountRow] + shipping_discounts: list[DiscountRow] shipping: float cod_charge: float plugin_fees: list[Charge] @@ -108,6 +215,8 @@ class ChargeSummary(TypedDict): discount_amount: float rounding_adjustment: float total: float + free_delivery_above: float | None + free_delivery_shortfall: float class CheckoutSummary(ChargeSummary): diff --git a/commera/templates/components/checkout_product_details.html b/commera/templates/components/checkout_product_details.html index 705e8896..351aec73 100644 --- a/commera/templates/components/checkout_product_details.html +++ b/commera/templates/components/checkout_product_details.html @@ -37,29 +37,27 @@
{{ _('Subtotal') }} - {{get_currency_symbol()}} + {{get_currency_symbol()}} {{ "%.2f"|format(checkout_summary.items_total or 0) }}
- {% if cart_quotation.coupon_code %} -
- {{ _('Coupon Discount') }} - −{{get_currency_symbol()}} + {% for discount in checkout_summary.discounts or [] %} +
+ {{ _('Discount') }}{% if discount.label %} ({{ discount.label | e }}){% endif %} + −{{get_currency_symbol()}} {{ "%.2f"|format(discount.amount) }}
- {% endif %} + {% endfor %}
{{ _('non-refundable delivery charge') }} - {{get_currency_symbol()}} - {{_('Free')}}
- {% if cod_charge_applicable_below > cart_quotation.rounded_total and cod_charge %} + {% if cod_charge_applicable_below > cart_payable_total and cod_charge %}
{{ _('COD Charges') }} diff --git a/commera/templates/components/promo_code_component.html b/commera/templates/components/promo_code_component.html index d0c16432..8a3726a8 100644 --- a/commera/templates/components/promo_code_component.html +++ b/commera/templates/components/promo_code_component.html @@ -1,21 +1,8 @@ {% import "templates/macros/modal.html" as modal %} -{% if cart_quotation.coupon_code %} - - - {{coupon_code}} - - - -{% else %} +{% from "templates/macros/discount_code_chips.html" import discount_code_chips %} +
+ {{ discount_code_chips("discount_codes", "$store.cart.saving_code", "remove_discount_code") }} +
{% call modal.modal(button_text='apply promo code',button_classes='text-md text-secondary-accent underline decoration-accent md:text-base capitalize', modal_header='Apply a promo code', header_alignment='center',modal_classes='w-fit') %} @@ -35,57 +22,34 @@
{% endcall %} -{% endif %} \ No newline at end of file diff --git a/commera/templates/includes/cart-permalink.html b/commera/templates/includes/cart-permalink.html index 957c70c4..7ab0d3d7 100644 --- a/commera/templates/includes/cart-permalink.html +++ b/commera/templates/includes/cart-permalink.html @@ -3,12 +3,14 @@ Alpine.data("cart_permalink", (config) => ({ async init() { if (!config.lines.length) return; - this.$store.cart.clear(); + await this.$store.cart.wait_until_loaded(); + if (!config.is_merge) this.$store.cart.clear(); for (const line of config.lines) { this.$store.cart.add(line.item, JSON.stringify(line.variant), line.price, line.default_price, line.brand, line.sizes, line.qty); } // $persist writes on Alpine's next flush; navigating first can leave the old cart in storage. await this.$nextTick(); + await this.$store.cart.sync(); if (!config.notices.length) window.location.replace(config.next_url); }, })); diff --git a/commera/templates/includes/cart-state.html b/commera/templates/includes/cart-state.html index 13b38809..33991b6d 100644 --- a/commera/templates/includes/cart-state.html +++ b/commera/templates/includes/cart-state.html @@ -1,7 +1,202 @@ -
+ await $store.cart.load_priced(); + await resume_checkout_after_sign_in($data);"> -
+
{% call banner() %}
- {{_('Add items worth')}} {{get_currency_symbol()}} + {{_('Add items worth')}} {{get_currency_symbol()}} {{_('more to get free delivery')}}
{% endcall %} @@ -80,10 +66,11 @@

{{ plugin_slot("cart_banner") }}
-
    +