diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 791f285..036c4dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,14 +46,14 @@ jobs: grep -rn "def test" > /dev/null - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 check-latest: true - name: Cache pip @@ -93,11 +93,8 @@ jobs: bench get-app ls_shop $GITHUB_WORKSPACE bench setup requirements --dev bench get-app erpnext - bench get-app payments - bench get-app webshop bench new-site --db-root-password root --admin-password admin test_site bench --site test_site install-app erpnext - bench --site test_site install-app webshop bench --site test_site install-app ls_shop bench build env: diff --git a/ls_shop/api/checkout.py b/ls_shop/api/checkout.py index 23f2f1d..8ad435c 100644 --- a/ls_shop/api/checkout.py +++ b/ls_shop/api/checkout.py @@ -1,7 +1,7 @@ import frappe -from webshop.webshop.shopping_cart.cart import _get_cart_quotation, get_cart_quotation from ls_shop.api.payments import set_charges +from ls_shop.core import _get_cart_quotation from ls_shop.utils import get_delivery_configuration diff --git a/ls_shop/api/payments.py b/ls_shop/api/payments.py index 6e5e5a9..0c53d4c 100644 --- a/ls_shop/api/payments.py +++ b/ls_shop/api/payments.py @@ -5,11 +5,8 @@ from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code from erpnext.selling.doctype.quotation.quotation import _make_sales_order from frappe.utils import getdate -from webshop.webshop.shopping_cart.cart import ( - _get_cart_quotation, - get_cart_quotation, -) +from ls_shop.core import _get_cart_quotation from ls_shop.utils import get_cod_configuration diff --git a/ls_shop/core.py b/ls_shop/core.py index 610f075..d37688e 100644 --- a/ls_shop/core.py +++ b/ls_shop/core.py @@ -1,6 +1,12 @@ import os import frappe +import frappe.defaults +from frappe import _ +from frappe.contacts.doctype.address.address import get_address_display +from frappe.contacts.doctype.contact.contact import get_contact_name +from frappe.utils import get_fullname +from frappe.utils.nestedset import get_root_of def generate_otp(): @@ -20,3 +26,145 @@ def send_otp(email): return frappe.sendmail(recipients=email, subject="Your OTP", message=f"Your OTP: {otp}", now=True) + + +def _get_default_territory() -> str: + return frappe.db.get_single_value("Selling Settings", "territory") or get_root_of("Territory") + + +def _create_party_for_user(user: str): + fullname = get_fullname(user) or user + customer = frappe.new_doc("Customer") + customer_group = frappe.db.get_single_value("Lifestyle Settings", "Lifestyle Settings", "customer_group") + customer.update( + { + "customer_name": fullname, + "customer_type": "Individual", + "customer_group": customer_group, + "territory": _get_default_territory(), + } + ) + customer.append("portal_users", {"user": user}) + customer.flags.ignore_mandatory = True + customer.insert(ignore_permissions=True) + + contact = frappe.new_doc("Contact") + contact.update({"first_name": fullname, "email_ids": [{"email_id": user, "is_primary": 1}]}) + contact.append("links", {"link_doctype": "Customer", "link_name": customer.name}) + contact.flags.ignore_mandatory = True + contact.insert(ignore_permissions=True) + + return customer + + +def get_party(user=None): + if not user: + user = frappe.session.user + + if user == "Guest": + raise frappe.PermissionError + + contact_name = get_contact_name(user) + if contact_name: + contact = frappe.get_cached_doc("Contact", contact_name) + link = next( + (l for l in contact.links if l.link_doctype in {"Customer", "Supplier"}), + 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}): + party_doc.append("portal_users", {"user": user}) + party_doc.flags.ignore_permissions = True + party_doc.flags.ignore_mandatory = True + party_doc.save() + return party_doc + + if portal_party := frappe.db.get_value("Portal User", {"user": user}, "parent"): + if frappe.db.exists("Customer", portal_party): + return frappe.get_cached_doc("Customer", portal_party) + + return _create_party_for_user(user) + + +def _get_cart_quotation(party=None): + if not party: + party = get_party() + + quotation = frappe.get_all( + "Quotation", + fields=["name"], + filters={ + "party_name": party.name, + "contact_email": frappe.session.user, + "order_type": "Shopping Cart", + "docstatus": 0, + }, + order_by="modified desc", + limit_page_length=1, + pluck="name", + ) + + if quotation: + return frappe.get_cached_doc("Quotation", quotation[0]) + lifestyle_settings = frappe.get_cached_doc("Lifestyle Settings") + company = lifestyle_settings.get("company") or frappe.get_cached_value( + "Global Defaults", "Global Defaults", "default_company" + ) + quotation_doc = frappe.new_doc("Quotation") + quotation_doc.quotation_to = party.doctype + quotation_doc.company = company + quotation_doc.order_type = "Shopping Cart" + quotation_doc.party_name = party.name + quotation_doc.contact_person = frappe.db.get_value("Contact", {"email_id": frappe.session.user}) + quotation_doc.contact_email = frappe.session.user + quotation_doc.flags.ignore_permissions = True + quotation_doc.run_method("set_missing_values") + if sale_price_list := frappe.get_cached_value( + "Lifestyle Settings", "Lifestyle Settings", "sale_price_list" + ): + quotation_doc.selling_price_list = sale_price_list + return quotation_doc + + +def get_address_docs(party=None): + if not party: + party = get_party() + if not party: + return [] + + address_names = frappe.get_all( + "Dynamic Link", + filters={ + "parenttype": "Address", + "link_doctype": party.doctype, + "link_name": party.name, + }, + pluck="parent", + ) + + if not address_names: + return [] + + addresses = frappe.get_all( + "Address", + filters={"name": ("in", address_names)}, + fields=[ + "name", + "address_title", + "address_type", + "address_line1", + "address_line2", + "city", + "state", + "country", + "pincode", + "phone", + "email_id", + ], + ) + + for address in addresses: + address["display"] = get_address_display(address) + + return addresses diff --git a/ls_shop/hooks.py b/ls_shop/hooks.py index fe8a872..e9696d3 100644 --- a/ls_shop/hooks.py +++ b/ls_shop/hooks.py @@ -5,7 +5,6 @@ app_email = "rahul@buildwithhussain.com" app_license = "agpl-3.0" -required_apps = ["webshop"] website_redirects = [ {"source": "/products", "target": "/en/products"}, @@ -87,6 +86,7 @@ } after_install = "ls_shop.migrate.after_install" +after_migrate = "ls_shop.migrate.after_migrate" doc_events = { diff --git a/ls_shop/lifestyle_shop_ecommerce/custom/sales_order.json b/ls_shop/lifestyle_shop_ecommerce/custom/sales_order.json index a44977c..5d59a1c 100644 --- a/ls_shop/lifestyle_shop_ecommerce/custom/sales_order.json +++ b/ls_shop/lifestyle_shop_ecommerce/custom/sales_order.json @@ -407,26 +407,6 @@ "parentfield": "links", "parenttype": "Customize Form", "table_fieldname": null - }, - { - "creation": "2025-06-09 10:26:21.097257", - "custom": 1, - "docstatus": 0, - "group": null, - "hidden": 0, - "idx": 0, - "is_child_table": 0, - "link_doctype": "Tabby Payment Request", - "link_fieldname": "ref_docname", - "modified": "2025-06-09 10:26:21.097257", - "modified_by": "Administrator", - "name": "vh3cpo4afa", - "owner": "Administrator", - "parent": "Sales Order", - "parent_doctype": null, - "parentfield": "links", - "parenttype": "Customize Form", - "table_fieldname": null } ], "property_setters": [ diff --git a/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.json b/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.json index bc22fd5..47ddeef 100644 --- a/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.json +++ b/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.json @@ -7,6 +7,7 @@ "field_order": [ "branding_section", "store_name", + "company", "column_break_brand", "brand_logo", "footer_logo", @@ -646,12 +647,19 @@ "fieldname": "publish_all_items", "fieldtype": "Button", "label": "Publish All Items to Website" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-10-07 15:34:41.977684", + "modified": "2026-04-29 12:00:00.000000", "modified_by": "Administrator", "module": "Lifestyle Shop Ecommerce", "name": "Lifestyle Settings", diff --git a/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.py b/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.py index fe94d73..6058a4a 100644 --- a/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.py +++ b/ls_shop/lifestyle_shop_ecommerce/doctype/lifestyle_settings/lifestyle_settings.py @@ -34,6 +34,7 @@ class LifestyleSettings(Document): cod_charge: DF.Currency cod_charge_applicable_below: DF.Currency cod_enabled: DF.Check + company: DF.Link contact_email: DF.Data | None contact_phone: DF.Data | None copyright_text: DF.Data | None @@ -86,18 +87,10 @@ def validate(self): frappe.throw(frappe._("At least one payment method (Telr, Tabby, or COD) must be enabled.")) def get_default_price_list(self): - return ( - self.default_price_list - if self.default_price_list - else frappe.get_cached_value("Webshop Settings", "Webshop Settings", "price_list") - ) + return self.default_price_list def get_sale_price_list(self): - return ( - self.sale_price_list - if self.sale_price_list - else frappe.get_cached_value("Webshop Settings", "Webshop Settings", "price_list") - ) + return self.sale_price_list @frappe.whitelist() def enqueue_publish_all_variants(self, attribute: str): diff --git a/ls_shop/lifestyle_shop_ecommerce/report/orphaned_payments/orphaned_payments.py b/ls_shop/lifestyle_shop_ecommerce/report/orphaned_payments/orphaned_payments.py index 08c2a72..4085635 100644 --- a/ls_shop/lifestyle_shop_ecommerce/report/orphaned_payments/orphaned_payments.py +++ b/ls_shop/lifestyle_shop_ecommerce/report/orphaned_payments/orphaned_payments.py @@ -59,16 +59,14 @@ def get_data(): PaymentEntry = DocType("Payment Entry") PaymentEntryReference = DocType("Payment Entry Reference") Telr_payment_request = DocType("Telr Payment Request") - tabby_payment_request = DocType("Tabby Payment Request") + tabby_installed = bool(frappe.db.exists("DocType", "Tabby Payment Request")) - orphaned_payments = ( + query = ( qb.from_(PaymentEntry) .left_join(PaymentEntryReference) .on(PaymentEntryReference.parent == PaymentEntry.name) .left_join(Telr_payment_request) .on(Telr_payment_request.telr_order_ref == PaymentEntry.reference_no) - .left_join(tabby_payment_request) - .on(tabby_payment_request.tabby_order_ref == PaymentEntry.reference_no) .select( PaymentEntry.name, PaymentEntry.paid_amount, @@ -76,15 +74,29 @@ def get_data(): PaymentEntry.posting_date, PaymentEntry.docstatus, Telr_payment_request.status.as_("telr_status"), - tabby_payment_request.status.as_("tabby_status"), ) - .where( + ) + + if tabby_installed: + tabby_payment_request = DocType("Tabby Payment Request") + query = ( + query.left_join(tabby_payment_request) + .on(tabby_payment_request.tabby_order_ref == PaymentEntry.reference_no) + .select(tabby_payment_request.status.as_("tabby_status")) + .where( + ((PaymentEntry.docstatus == 1) & (PaymentEntryReference.name.isnull())) + | ((PaymentEntry.docstatus == 2) & (Telr_payment_request.status != "Refunded")) + | ((PaymentEntry.docstatus == 2) & (tabby_payment_request.status != "REFUND")) + ) + ) + else: + query = query.where( ((PaymentEntry.docstatus == 1) & (PaymentEntryReference.name.isnull())) | ((PaymentEntry.docstatus == 2) & (Telr_payment_request.status != "Refunded")) - | ((PaymentEntry.docstatus == 2) & (tabby_payment_request.status != "REFUND")) ) - ).run(as_dict=True) - for payment in orphaned_payments: + + for payment in query.run(as_dict=True): + tabby_status = payment.get("tabby_status") if tabby_installed else None data.append( { "payment_entry": payment.name, @@ -92,7 +104,7 @@ def get_data(): "payment_mode": payment.mode_of_payment, "posting_date": payment.posting_date, "cancelled": payment.docstatus == 2, - "refunded": (payment.telr_status == "Refunded" or payment.tabby_status == "REFUND"), + "refunded": (payment.telr_status == "Refunded" or tabby_status == "REFUND"), } ) return data diff --git a/ls_shop/migrate.py b/ls_shop/migrate.py index 2739d71..9bd5aa4 100644 --- a/ls_shop/migrate.py +++ b/ls_shop/migrate.py @@ -13,6 +13,43 @@ def after_install(): frappe.errprint(error_msg) frappe.errprint(traceback.format_exc()) + register_optional_doctype_links() + + +def after_migrate(): + register_optional_doctype_links() + + +def register_optional_doctype_links(): + """Add Customize Form connections for optional integrations whose doctypes are + provided by tabby_frappe. + """ + add_sales_order_link_if_doctype_exists("Tabby Payment Request", "ref_docname") + + +def add_sales_order_link_if_doctype_exists(link_doctype: str, link_fieldname: str): + if not frappe.db.exists("DocType", link_doctype): + return + + customize_form = frappe.get_doc({"doctype": "Customize Form", "doc_type": "Sales Order"}) + customize_form.run_method("fetch_to_customize") + if any(row.link_doctype == link_doctype for row in (customize_form.get("links") or [])): + return + + customize_form.append( + "links", + { + "link_doctype": link_doctype, + "link_fieldname": link_fieldname, + }, + ) + try: + customize_form.save() + except Exception: + import traceback + + frappe.log_error(traceback.format_exc(), f"ls_shop: optional link for {link_doctype}") + def create_payment_modes(): modes = {"Telr"} diff --git a/ls_shop/public/css/tailwind.css b/ls_shop/public/css/tailwind.css index 7859bba..70faa1b 100644 --- a/ls_shop/public/css/tailwind.css +++ b/ls_shop/public/css/tailwind.css @@ -9,7 +9,6 @@ --color-red-400: oklch(70.4% 0.191 22.216); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-600: oklch(57.7% 0.245 27.325); - --color-red-700: oklch(50.5% 0.213 27.518); --color-red-800: #ac0005; --color-red-900: #991b1f; --color-orange-400: oklch(75% 0.183 55.934); @@ -386,9 +385,6 @@ .order-3 { order: 3; } - .order-4 { - order: 4; - } .order-5 { order: 5; } @@ -687,6 +683,9 @@ .h-12 { height: calc(var(--spacing) * 12); } + .h-16 { + height: calc(var(--spacing) * 16); + } .h-20 { height: calc(var(--spacing) * 20); } @@ -795,6 +794,9 @@ .w-28 { width: calc(var(--spacing) * 28); } + .w-32 { + width: calc(var(--spacing) * 32); + } .w-34 { width: calc(var(--spacing) * 34); } @@ -1280,15 +1282,9 @@ .border-red-600 { border-color: var(--color-red-600); } - .border-red-700 { - border-color: var(--color-red-700); - } .border-red-800 { border-color: var(--color-red-800); } - .border-red-900 { - border-color: var(--color-red-900); - } .border-yellow-600 { border-color: var(--color-yellow-600); } @@ -1364,9 +1360,6 @@ .bg-red-800 { background-color: var(--color-red-800); } - .bg-red-900 { - background-color: var(--color-red-900); - } .bg-slate-800 { background-color: var(--color-slate-800); } @@ -1726,15 +1719,6 @@ .text-red-600 { color: var(--color-red-600); } - .text-red-700 { - color: var(--color-red-700); - } - .text-red-800 { - color: var(--color-red-800); - } - .text-red-900 { - color: var(--color-red-900); - } .text-white { color: var(--color-white); } @@ -1756,9 +1740,6 @@ .decoration-gray-400 { text-decoration-color: var(--color-gray-400); } - .decoration-red-900 { - text-decoration-color: var(--color-red-900); - } .decoration-1 { text-decoration-thickness: 1px; } @@ -1781,9 +1762,6 @@ .accent-red-800 { accent-color: var(--color-red-800); } - .accent-red-900 { - accent-color: var(--color-red-900); - } .opacity-0 { opacity: 0%; } @@ -1839,9 +1817,6 @@ outline-style: var(--tw-outline-style); outline-width: 1px; } - .outline-red-900 { - outline-color: var(--color-red-900); - } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -2004,13 +1979,6 @@ } } } - .hover\:bg-red-800 { - &:hover { - @media (hover: hover) { - background-color: var(--color-red-800); - } - } - } .hover\:bg-red-900 { &:hover { @media (hover: hover) { @@ -2060,27 +2028,6 @@ } } } - .hover\:text-red-600 { - &:hover { - @media (hover: hover) { - color: var(--color-red-600); - } - } - } - .hover\:text-red-800 { - &:hover { - @media (hover: hover) { - color: var(--color-red-800); - } - } - } - .hover\:text-red-900 { - &:hover { - @media (hover: hover) { - color: var(--color-red-900); - } - } - } .hover\:text-white { &:hover { @media (hover: hover) { @@ -2102,11 +2049,6 @@ } } } - .focus\:border-red-900 { - &:focus { - border-color: var(--color-red-900); - } - } .focus\:ring-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); @@ -2133,11 +2075,6 @@ --tw-ring-color: var(--color-gray-700); } } - .focus\:ring-red-900 { - &:focus { - --tw-ring-color: var(--color-red-900); - } - } .focus\:outline-hidden { &:focus { --tw-outline-style: none; @@ -2440,21 +2377,6 @@ order: 2; } } - .md\:order-3 { - @media (width >= 48rem) { - order: 3; - } - } - .md\:order-4 { - @media (width >= 48rem) { - order: 4; - } - } - .md\:order-5 { - @media (width >= 48rem) { - order: 5; - } - } .md\:col-span-1 { @media (width >= 48rem) { grid-column: span 1 / span 1; diff --git a/ls_shop/utils.py b/ls_shop/utils.py index 45260b1..9587168 100644 --- a/ls_shop/utils.py +++ b/ls_shop/utils.py @@ -9,10 +9,8 @@ from frappe.query_builder.functions import Count, Min, Sum from frappe.utils import add_days, flt, get_datetime, now_datetime from pypika import Order -from webshop.webshop.shopping_cart.cart import ( - get_address_docs, - get_party, -) + +from ls_shop.core import get_address_docs, get_party def get_complete_nested_links(parent_group): diff --git a/ls_shop/www/cart/checkout.py b/ls_shop/www/cart/checkout.py index 70c40b1..e9a6dd9 100644 --- a/ls_shop/www/cart/checkout.py +++ b/ls_shop/www/cart/checkout.py @@ -1,8 +1,8 @@ import frappe from frappe.query_builder import DocType from frappe.utils.caching import site_cache -from webshop.webshop.shopping_cart.cart import _get_cart_quotation, get_cart_quotation +from ls_shop.core import _get_cart_quotation from ls_shop.utils import ( format_addresses, get_addresses, @@ -134,7 +134,23 @@ def get_store_pickup_addresses(): # Map address to warehouse address_to_warehouse = {link["parent"]: link["link_name"] for link in links} address_names = list(address_to_warehouse.keys()) - addresses = frappe.get_all("Address", filters={"name": ["in", address_names]}, fields=["*"]) + addresses = frappe.get_all( + "Address", + filters={"name": ["in", address_names]}, + fields=[ + "name", + "address_title", + "address_type", + "address_line1", + "address_line2", + "city", + "state", + "country", + "pincode", + "phone", + "email_id", + ], + ) # Format and attach warehouse info formatted_addresses = format_addresses(addresses, address_type="Shop") for addr in formatted_addresses: