diff --git a/.flake8 b/.flake8 index 56c9b9a3699..5735456ae7d 100644 --- a/.flake8 +++ b/.flake8 @@ -28,6 +28,7 @@ ignore = B007, B950, W191, + E124, # closing bracket, irritating while writing QB code max-line-length = 200 exclude=.github/helper/semgrep_rules diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 903196847dc..859146bbcde 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -8,7 +8,10 @@ sudo apt-get install redis-server libcups2-dev pip install frappe-bench -git clone https://github.com/frappe/frappe --branch "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" --depth 1 +frappeuser=${FRAPPE_USER:-"frappe"} +frappebranch=${FRAPPE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF##*/}}} + +git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1 bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench mkdir ~/frappe-bench/sites/test_site @@ -37,10 +40,14 @@ if [ "$DB" == "postgres" ];then echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres; fi -wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz -tar -xf /tmp/wkhtmltox.tar.xz -C /tmp -sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf -sudo chmod o+x /usr/local/bin/wkhtmltopdf + +install_whktml() { + wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz + tar -xf /tmp/wkhtmltox.tar.xz -C /tmp + sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf + sudo chmod o+x /usr/local/bin/wkhtmltopdf +} +install_whktml & cd ~/frappe-bench || exit @@ -54,5 +61,5 @@ bench get-app erpnext "${GITHUB_WORKSPACE}" if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi bench start &> bench_run_logs.txt & +CI=Yes bench build --app frappe & bench --site test_site reinstall --yes -bench build --app frappe diff --git a/.github/stale.yml b/.github/stale.yml index 8b7cb9be3ef..1c2dcf3ba9c 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -30,6 +30,7 @@ issues: exemptLabels: - valid - to-validate + - QA markComment: > This issue has been automatically marked as inactive because it has not had recent activity and it wasn't validated by maintainer team. It will be diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index 7347a5856a2..40f93651f4a 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -6,12 +6,23 @@ on: - '**.js' - '**.md' - '**.html' - workflow_dispatch: push: branches: [ develop ] paths-ignore: - '**.js' - '**.md' + workflow_dispatch: + inputs: + user: + description: 'user' + required: true + default: 'frappe' + type: string + branch: + description: 'Branch name' + default: 'develop' + required: false + type: string concurrency: group: server-mariadb-develop-${{ github.event.number }} @@ -95,6 +106,8 @@ jobs: env: DB: mariadb TYPE: server + FRAPPE_USER: ${{ github.event.inputs.user }} + FRAPPE_BRANCH: ${{ github.event.inputs.branch }} - name: Run Tests run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator --with-coverage diff --git a/.mergify.yml b/.mergify.yml index f3d04096cfc..b7d1df4524f 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -14,9 +14,39 @@ pull_request_rules: close: comment: message: | - @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. + @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch + - name: backport to develop + conditions: + - label="backport develop" + actions: + backport: + branches: + - develop + assignees: + - "{{ author }}" + + - name: backport to version-14-hotfix + conditions: + - label="backport version-14-hotfix" + actions: + backport: + branches: + - version-14-hotfix + assignees: + - "{{ author }}" + + - name: backport to version-14-pre-release + conditions: + - label="backport version-14-pre-release" + actions: + backport: + branches: + - version-14-pre-release + assignees: + - "{{ author }}" + - name: backport to version-13-hotfix conditions: - label="backport version-13-hotfix" @@ -55,4 +85,4 @@ pull_request_rules: branches: - version-12-pre-release assignees: - - "{{ author }}" \ No newline at end of file + - "{{ author }}" diff --git a/cypress/integration/test_bulk_transaction_processing.js b/cypress/integration/test_bulk_transaction_processing.js new file mode 100644 index 00000000000..428ec5100b5 --- /dev/null +++ b/cypress/integration/test_bulk_transaction_processing.js @@ -0,0 +1,44 @@ +describe("Bulk Transaction Processing", () => { + before(() => { + cy.login(); + cy.visit("/app/website"); + }); + + it("Creates To Sales Order", () => { + cy.visit("/app/sales-order"); + cy.url().should("include", "/sales-order"); + cy.window() + .its("frappe.csrf_token") + .then((csrf_token) => { + return cy + .request({ + url: "/api/method/erpnext.tests.ui_test_bulk_transaction_processing.create_records", + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": csrf_token, + }, + timeout: 60000, + }) + .then((res) => { + expect(res.status).eq(200); + }); + }); + cy.wait(5000); + cy.get( + ".list-row-head > .list-header-subject > .list-row-col > .list-check-all" + ).check({ force: true }); + cy.wait(3000); + cy.get(".actions-btn-group > .btn-primary").click({ force: true }); + cy.wait(3000); + cy.get(".dropdown-menu-right > .user-action > .dropdown-item") + .contains("Sales Invoice") + .click({ force: true }); + cy.wait(3000); + cy.get(".modal-content > .modal-footer > .standard-actions") + .contains("Yes") + .click({ force: true }); + cy.contains("Creation of Sales Invoice successful"); + }); +}); diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 0b4696c8034..bef6661254f 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -2,8 +2,6 @@ import inspect import frappe -from erpnext.hooks import regional_overrides - __version__ = '14.0.0-dev' def get_default_company(user=None): @@ -121,14 +119,17 @@ def allow_regional(fn): @erpnext.allow_regional def myfunction(): pass''' + def caller(*args, **kwargs): - region = get_region() - fn_name = inspect.getmodule(fn).__name__ + '.' + fn.__name__ - if region in regional_overrides and fn_name in regional_overrides[region]: - return frappe.get_attr(regional_overrides[region][fn_name])(*args, **kwargs) - else: + overrides = frappe.get_hooks("regional_overrides", {}).get(get_region()) + function_path = f"{inspect.getmodule(fn).__name__}.{fn.__name__}" + + if not overrides or function_path not in overrides: return fn(*args, **kwargs) + # Priority given to last installed app + return frappe.get_attr(overrides[function_path][-1])(*args, **kwargs) + return caller def get_last_membership(member): diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 55ea571ebf8..9a35a247ddd 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -7,35 +7,30 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "accounts_transactions_settings_section", - "over_billing_allowance", - "role_allowed_to_over_bill", - "credit_controller", - "make_payment_via_journal_entry", - "column_break_11", - "check_supplier_invoice_uniqueness", + "invoice_and_billing_tab", + "enable_features_section", "unlink_payment_on_cancellation_of_invoice", - "automatically_fetch_payment_terms", - "delete_linked_ledger_entries", - "book_asset_depreciation_entry_automatically", "unlink_advance_payment_on_cancelation_of_order", + "column_break_13", + "delete_linked_ledger_entries", + "invoicing_features_section", + "check_supplier_invoice_uniqueness", + "automatically_fetch_payment_terms", + "column_break_17", "enable_common_party_accounting", - "post_change_gl_entries", "enable_discount_accounting", - "tax_settings_section", - "determine_address_tax_category_from", - "column_break_19", - "add_taxes_from_item_tax_template", - "period_closing_settings_section", - "acc_frozen_upto", - "frozen_accounts_modifier", - "column_break_4", + "report_setting_section", + "use_custom_cash_flow", "deferred_accounting_settings_section", "book_deferred_entries_based_on", "column_break_18", "automatically_process_deferred_accounting_entry", "book_deferred_entries_via_journal_entry", "submit_journal_entries", + "tax_settings_section", + "determine_address_tax_category_from", + "column_break_19", + "add_taxes_from_item_tax_template", "print_settings", "show_inclusive_tax_in_print", "column_break_12", @@ -43,8 +38,25 @@ "currency_exchange_section", "allow_stale", "stale_days", - "report_settings_sb", - "use_custom_cash_flow" + "invoicing_settings_tab", + "accounts_transactions_settings_section", + "over_billing_allowance", + "column_break_11", + "role_allowed_to_over_bill", + "credit_controller", + "make_payment_via_journal_entry", + "pos_tab", + "pos_setting_section", + "post_change_gl_entries", + "assets_tab", + "asset_settings_section", + "book_asset_depreciation_entry_automatically", + "closing_settings_tab", + "period_closing_settings_section", + "acc_frozen_upto", + "column_break_25", + "frozen_accounts_modifier", + "report_settings_sb" ], "fields": [ { @@ -70,10 +82,6 @@ "label": "Determine Address Tax Category From", "options": "Billing Address\nShipping Address" }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, { "fieldname": "credit_controller", "fieldtype": "Link", @@ -83,6 +91,7 @@ }, { "default": "0", + "description": "Enabling ensure each Sales Invoice has a unique value in Supplier Invoice No. field", "fieldname": "check_supplier_invoice_uniqueness", "fieldtype": "Check", "label": "Check Supplier Invoice Number Uniqueness" @@ -168,7 +177,7 @@ "description": "Only select this if you have set up the Cash Flow Mapper documents", "fieldname": "use_custom_cash_flow", "fieldtype": "Check", - "label": "Use Custom Cash Flow Format" + "label": "Enable Custom Cash Flow Format" }, { "default": "0", @@ -241,7 +250,7 @@ { "fieldname": "accounts_transactions_settings_section", "fieldtype": "Section Break", - "label": "Transactions Settings" + "label": "Credit Limit Settings" }, { "fieldname": "column_break_11", @@ -272,9 +281,72 @@ }, { "default": "0", + "description": "Learn about Common Party", "fieldname": "enable_common_party_accounting", "fieldtype": "Check", "label": "Enable Common Party Accounting" + }, + { + "fieldname": "enable_features_section", + "fieldtype": "Section Break", + "label": "Invoice Cancellation" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_25", + "fieldtype": "Column Break" + }, + { + "fieldname": "asset_settings_section", + "fieldtype": "Section Break", + "label": "Asset Settings" + }, + { + "fieldname": "invoicing_settings_tab", + "fieldtype": "Tab Break", + "label": "Credit Limits" + }, + { + "fieldname": "assets_tab", + "fieldtype": "Tab Break", + "label": "Assets" + }, + { + "fieldname": "closing_settings_tab", + "fieldtype": "Tab Break", + "label": "Accounts Closing" + }, + { + "fieldname": "pos_setting_section", + "fieldtype": "Section Break", + "label": "POS Setting" + }, + { + "fieldname": "invoice_and_billing_tab", + "fieldtype": "Tab Break", + "label": "Invoice and Billing" + }, + { + "fieldname": "invoicing_features_section", + "fieldtype": "Section Break", + "label": "Invoicing Features" + }, + { + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, + { + "fieldname": "pos_tab", + "fieldtype": "Tab Break", + "label": "POS" + }, + { + "fieldname": "report_setting_section", + "fieldtype": "Section Break", + "label": "Report Setting" } ], "icon": "icon-cog", @@ -282,7 +354,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-10-11 17:42:36.427699", + "modified": "2022-02-04 12:32:36.805652", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", @@ -309,5 +381,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index 335f8502c7a..46ba27c004d 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -14,6 +14,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }); }, + onload: function (frm) { + frm.trigger('bank_account'); + }, + refresh: function (frm) { frappe.require("bank-reconciliation-tool.bundle.js", () => frm.trigger("make_reconciliation_tool") @@ -51,7 +55,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { bank_account: function (frm) { frappe.db.get_value( "Bank Account", - frm.bank_account, + frm.doc.bank_account, "account", (r) => { frappe.db.get_value( @@ -60,6 +64,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { "account_currency", (r) => { frm.currency = r.account_currency; + frm.trigger("render_chart"); } ); } @@ -124,7 +129,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { } }, - render_chart(frm) { + render_chart: frappe.utils.debounce((frm) => { frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager( { $reconciliation_tool_cards: frm.get_field( @@ -136,7 +141,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { currency: frm.currency, } ); - }, + }, 500), render(frm) { if (frm.doc.bank_account) { diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 4211bd0169d..f3351ddcba4 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder.custom import ConstantColumn from frappe.utils import flt from erpnext import get_company_currency @@ -275,6 +276,10 @@ def check_matching(bank_account, company, transaction, document_types): } matching_vouchers = [] + + matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, + document_types, filters)) + for query in subquery: matching_vouchers.extend( frappe.db.sql(query, filters,) @@ -311,6 +316,114 @@ def get_queries(bank_account, company, transaction, document_types): return queries +def get_loan_vouchers(bank_account, transaction, document_types, filters): + vouchers = [] + amount_condition = True if "exact_match" in document_types else False + + if transaction.withdrawal > 0 and "loan_disbursement" in document_types: + vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters)) + + if transaction.deposit > 0 and "loan_repayment" in document_types: + vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters)) + + return vouchers + +def get_ld_matching_query(bank_account, amount_condition, filters): + loan_disbursement = frappe.qb.DocType("Loan Disbursement") + matching_reference = loan_disbursement.reference_number == filters.get("reference_number") + matching_party = loan_disbursement.applicant_type == filters.get("party_type") and \ + loan_disbursement.applicant == filters.get("party") + + rank = ( + frappe.qb.terms.Case() + .when(matching_reference, 1) + .else_(0) + ) + + rank1 = ( + frappe.qb.terms.Case() + .when(matching_party, 1) + .else_(0) + ) + + query = frappe.qb.from_(loan_disbursement).select( + rank + rank1 + 1, + ConstantColumn("Loan Disbursement").as_("doctype"), + loan_disbursement.name, + loan_disbursement.disbursed_amount, + loan_disbursement.reference_number, + loan_disbursement.reference_date, + loan_disbursement.applicant_type, + loan_disbursement.disbursement_date + ).where( + loan_disbursement.docstatus == 1 + ).where( + loan_disbursement.clearance_date.isnull() + ).where( + loan_disbursement.disbursement_account == bank_account + ) + + if amount_condition: + query.where( + loan_disbursement.disbursed_amount == filters.get('amount') + ) + else: + query.where( + loan_disbursement.disbursed_amount <= filters.get('amount') + ) + + vouchers = query.run(as_list=True) + + return vouchers + +def get_lr_matching_query(bank_account, amount_condition, filters): + loan_repayment = frappe.qb.DocType("Loan Repayment") + matching_reference = loan_repayment.reference_number == filters.get("reference_number") + matching_party = loan_repayment.applicant_type == filters.get("party_type") and \ + loan_repayment.applicant == filters.get("party") + + rank = ( + frappe.qb.terms.Case() + .when(matching_reference, 1) + .else_(0) + ) + + rank1 = ( + frappe.qb.terms.Case() + .when(matching_party, 1) + .else_(0) + ) + + query = frappe.qb.from_(loan_repayment).select( + rank + rank1 + 1, + ConstantColumn("Loan Repayment").as_("doctype"), + loan_repayment.name, + loan_repayment.amount_paid, + loan_repayment.reference_number, + loan_repayment.reference_date, + loan_repayment.applicant_type, + loan_repayment.posting_date + ).where( + loan_repayment.docstatus == 1 + ).where( + loan_repayment.clearance_date.isnull() + ).where( + loan_repayment.payment_account == bank_account + ) + + if amount_condition: + query.where( + loan_repayment.amount_paid == filters.get('amount') + ) + else: + query.where( + loan_repayment.amount_paid <= filters.get('amount') + ) + + vouchers = query.run() + + return vouchers + def get_pe_matching_query(amount_condition, account_from_to, transaction): # get matching payment entries query if transaction.deposit > 0: @@ -348,7 +461,6 @@ def get_je_matching_query(amount_condition, transaction): # We have mapping at the bank level # So one bank could have both types of bank accounts like asset and liability # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type - company_account = frappe.get_value("Bank Account", transaction.bank_account, "account") cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit" return f""" diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 44cea31ed38..a476cab55f7 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -2,9 +2,10 @@ # For license information, please see license.txt +from functools import reduce + import frappe from frappe.utils import flt -from six.moves import reduce from erpnext.controllers.status_updater import StatusUpdater @@ -48,7 +49,8 @@ class BankTransaction(StatusUpdater): def clear_linked_payment_entries(self, for_cancel=False): for payment_entry in self.payment_entries: - if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim"]: + if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim", "Loan Repayment", + "Loan Disbursement"]: self.clear_simple_entry(payment_entry, for_cancel=for_cancel) elif payment_entry.payment_document == "Sales Invoice": @@ -115,11 +117,18 @@ def get_paid_amount(payment_entry, currency, bank_account): payment_entry.payment_entry, paid_amount_field) elif payment_entry.payment_document == "Journal Entry": - return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, "sum(credit_in_account_currency)") + return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, + "sum(credit_in_account_currency)") elif payment_entry.payment_document == "Expense Claim": return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed") + elif payment_entry.payment_document == "Loan Disbursement": + return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "disbursed_amount") + + elif payment_entry.payment_document == "Loan Repayment": + return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "amount_paid") + else: frappe.throw("Please reconcile {0}: {1} manually".format(payment_entry.payment_document, payment_entry.payment_entry)) diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 72b6893faf5..d84b8e07d35 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -109,7 +109,7 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"): frappe.get_doc({ "doctype": "Bank", "bank_name":bank_name, - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass @@ -119,7 +119,7 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"): "account_name":"Checking Account", "bank": bank_name, "account": account_name - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass @@ -184,7 +184,7 @@ def add_vouchers(): "supplier_group":"All Supplier Groups", "supplier_type": "Company", "supplier_name": "Conrad Electronic" - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass @@ -203,7 +203,7 @@ def add_vouchers(): "supplier_group":"All Supplier Groups", "supplier_type": "Company", "supplier_name": "Mr G" - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass @@ -227,7 +227,7 @@ def add_vouchers(): "supplier_group":"All Supplier Groups", "supplier_type": "Company", "supplier_name": "Poore Simon's" - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass @@ -237,7 +237,7 @@ def add_vouchers(): "customer_group":"All Customer Groups", "customer_type": "Company", "customer_name": "Poore Simon's" - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass @@ -266,7 +266,7 @@ def add_vouchers(): "customer_group":"All Customer Groups", "customer_type": "Company", "customer_name": "Fayva" - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass diff --git a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json index 22cf797fc3f..02c6875fb36 100644 --- a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json +++ b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json @@ -1,94 +1,34 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:mapping", - "beta": 0, - "creation": "2018-02-08 10:18:48.513608", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2018-02-08 10:18:48.513608", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "mapping" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mapping", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Mapping", - "length": 0, - "no_copy": 0, - "options": "Cash Flow Mapping", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "mapping", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Mapping", + "options": "Cash Flow Mapping", + "reqd": 1, + "unique": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-02-08 10:33:39.413930", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Cash Flow Mapping Template Details", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2022-02-21 03:34:57.902332", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Cash Flow Mapping Template Details", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/cost_center/cost_center.js b/erpnext/accounts/doctype/cost_center/cost_center.js index ee23b1be5c5..632fab0197c 100644 --- a/erpnext/accounts/doctype/cost_center/cost_center.js +++ b/erpnext/accounts/doctype/cost_center/cost_center.js @@ -15,17 +15,6 @@ frappe.ui.form.on('Cost Center', { } } }); - - frm.set_query("cost_center", "distributed_cost_center", function() { - return { - filters: { - company: frm.doc.company, - is_group: 0, - enable_distributed_cost_center: 0, - name: ['!=', frm.doc.name] - } - }; - }); }, refresh: function(frm) { if (!frm.is_new()) { diff --git a/erpnext/accounts/doctype/cost_center/cost_center.json b/erpnext/accounts/doctype/cost_center/cost_center.json index e7fa954e018..7cbb290947e 100644 --- a/erpnext/accounts/doctype/cost_center/cost_center.json +++ b/erpnext/accounts/doctype/cost_center/cost_center.json @@ -16,9 +16,6 @@ "cb0", "is_group", "disabled", - "section_break_9", - "enable_distributed_cost_center", - "distributed_cost_center", "lft", "rgt", "old_parent" @@ -122,31 +119,13 @@ "fieldname": "disabled", "fieldtype": "Check", "label": "Disabled" - }, - { - "default": "0", - "fieldname": "enable_distributed_cost_center", - "fieldtype": "Check", - "label": "Enable Distributed Cost Center" - }, - { - "depends_on": "eval:doc.is_group==0", - "fieldname": "section_break_9", - "fieldtype": "Section Break" - }, - { - "depends_on": "enable_distributed_cost_center", - "fieldname": "distributed_cost_center", - "fieldtype": "Table", - "label": "Distributed Cost Center", - "options": "Distributed Cost Center" } ], "icon": "fa fa-money", "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-06-17 16:09:30.025214", + "modified": "2022-01-31 13:22:58.916273", "modified_by": "Administrator", "module": "Accounts", "name": "Cost Center", @@ -189,5 +168,6 @@ "search_fields": "parent_cost_center, is_group", "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/cost_center/cost_center.py b/erpnext/accounts/doctype/cost_center/cost_center.py index 7ae0a72e3d1..07cc0764e38 100644 --- a/erpnext/accounts/doctype/cost_center/cost_center.py +++ b/erpnext/accounts/doctype/cost_center/cost_center.py @@ -4,7 +4,6 @@ import frappe from frappe import _ -from frappe.utils import cint from frappe.utils.nestedset import NestedSet from erpnext.accounts.utils import validate_field_number @@ -20,24 +19,6 @@ class CostCenter(NestedSet): def validate(self): self.validate_mandatory() self.validate_parent_cost_center() - self.validate_distributed_cost_center() - - def validate_distributed_cost_center(self): - if cint(self.enable_distributed_cost_center): - if not self.distributed_cost_center: - frappe.throw(_("Please enter distributed cost center")) - if sum(x.percentage_allocation for x in self.distributed_cost_center) != 100: - frappe.throw(_("Total percentage allocation for distributed cost center should be equal to 100")) - if not self.get('__islocal'): - if not cint(frappe.get_cached_value("Cost Center", {"name": self.name}, "enable_distributed_cost_center")) \ - and self.check_if_part_of_distributed_cost_center(): - frappe.throw(_("Cannot enable Distributed Cost Center for a Cost Center already allocated in another Distributed Cost Center")) - if next((True for x in self.distributed_cost_center if x.cost_center == x.parent), False): - frappe.throw(_("Parent Cost Center cannot be added in Distributed Cost Center")) - if check_if_distributed_cost_center_enabled(list(x.cost_center for x in self.distributed_cost_center)): - frappe.throw(_("A Distributed Cost Center cannot be added in the Distributed Cost Center allocation table.")) - else: - self.distributed_cost_center = [] def validate_mandatory(self): if self.cost_center_name != self.company and not self.parent_cost_center: @@ -64,10 +45,10 @@ class CostCenter(NestedSet): @frappe.whitelist() def convert_ledger_to_group(self): - if cint(self.enable_distributed_cost_center): - frappe.throw(_("Cost Center with enabled distributed cost center can not be converted to group")) - if self.check_if_part_of_distributed_cost_center(): - frappe.throw(_("Cost Center Already Allocated in a Distributed Cost Center cannot be converted to group")) + if self.if_allocation_exists_against_cost_center(): + frappe.throw(_("Cost Center with Allocation records can not be converted to a group")) + if self.check_if_part_of_cost_center_allocation(): + frappe.throw(_("Cost Center is a part of Cost Center Allocation, hence cannot be converted to a group")) if self.check_gle_exists(): frappe.throw(_("Cost Center with existing transactions can not be converted to group")) self.is_group = 1 @@ -81,8 +62,17 @@ class CostCenter(NestedSet): return frappe.db.sql("select name from `tabCost Center` where \ parent_cost_center = %s and docstatus != 2", self.name) - def check_if_part_of_distributed_cost_center(self): - return frappe.db.get_value("Distributed Cost Center", {"cost_center": self.name}) + def if_allocation_exists_against_cost_center(self): + return frappe.db.get_value("Cost Center Allocation", filters = { + "main_cost_center": self.name, + "docstatus": 1 + }) + + def check_if_part_of_cost_center_allocation(self): + return frappe.db.get_value("Cost Center Allocation Percentage", filters = { + "cost_center": self.name, + "docstatus": 1 + }) def before_rename(self, olddn, newdn, merge=False): # Add company abbr if not provided @@ -126,8 +116,4 @@ def on_doctype_update(): def get_name_with_number(new_account, account_number): if account_number and not new_account[0].isdigit(): new_account = account_number + " - " + new_account - return new_account - -def check_if_distributed_cost_center_enabled(cost_center_list): - value_list = frappe.get_list("Cost Center", {"name": ["in", cost_center_list]}, "enable_distributed_cost_center", as_list=1) - return next((True for x in value_list if x[0]), False) + return new_account \ No newline at end of file diff --git a/erpnext/accounts/doctype/cost_center/test_cost_center.py b/erpnext/accounts/doctype/cost_center/test_cost_center.py index f8615ec03a5..ff50a211241 100644 --- a/erpnext/accounts/doctype/cost_center/test_cost_center.py +++ b/erpnext/accounts/doctype/cost_center/test_cost_center.py @@ -23,33 +23,6 @@ class TestCostCenter(unittest.TestCase): self.assertRaises(frappe.ValidationError, cost_center.save) - def test_validate_distributed_cost_center(self): - - if not frappe.db.get_value('Cost Center', {'name': '_Test Cost Center - _TC'}): - frappe.get_doc(test_records[0]).insert() - - if not frappe.db.get_value('Cost Center', {'name': '_Test Cost Center 2 - _TC'}): - frappe.get_doc(test_records[1]).insert() - - invalid_distributed_cost_center = frappe.get_doc({ - "company": "_Test Company", - "cost_center_name": "_Test Distributed Cost Center", - "doctype": "Cost Center", - "is_group": 0, - "parent_cost_center": "_Test Company - _TC", - "enable_distributed_cost_center": 1, - "distributed_cost_center": [{ - "cost_center": "_Test Cost Center - _TC", - "percentage_allocation": 40 - }, { - "cost_center": "_Test Cost Center 2 - _TC", - "percentage_allocation": 50 - } - ] - }) - - self.assertRaises(frappe.ValidationError, invalid_distributed_cost_center.save) - def create_cost_center(**args): args = frappe._dict(args) if args.cost_center_name: diff --git a/erpnext/accounts/doctype/distributed_cost_center/__init__.py b/erpnext/accounts/doctype/cost_center_allocation/__init__.py similarity index 100% rename from erpnext/accounts/doctype/distributed_cost_center/__init__.py rename to erpnext/accounts/doctype/cost_center_allocation/__init__.py diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.js b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.js new file mode 100644 index 00000000000..ab0baab24a0 --- /dev/null +++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.js @@ -0,0 +1,19 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Cost Center Allocation', { + setup: function(frm) { + let filters = {"is_group": 0}; + if (frm.doc.company) { + $.extend(filters, { + "company": frm.doc.company + }); + } + + frm.set_query('main_cost_center', function() { + return { + filters: filters + }; + }); + } +}); diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.json b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.json new file mode 100644 index 00000000000..45ab886d33d --- /dev/null +++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.json @@ -0,0 +1,128 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "CC-ALLOC-.#####", + "creation": "2022-01-13 20:07:29.871109", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "main_cost_center", + "company", + "column_break_2", + "valid_from", + "section_break_5", + "allocation_percentages", + "amended_from" + ], + "fields": [ + { + "fieldname": "main_cost_center", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Main Cost Center", + "options": "Cost Center", + "reqd": 1 + }, + { + "default": "Today", + "fieldname": "valid_from", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Valid From", + "reqd": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break" + }, + { + "fetch_from": "main_cost_center.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "allocation_percentages", + "fieldtype": "Table", + "label": "Cost Center Allocation Percentages", + "options": "Cost Center Allocation Percentage", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Cost Center Allocation", + "print_hide": 1, + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2022-01-31 11:47:12.086253", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Cost Center Allocation", + "name_case": "UPPER CASE", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py new file mode 100644 index 00000000000..bad3fb4f96c --- /dev/null +++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py @@ -0,0 +1,90 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import add_days, format_date, getdate + + +class MainCostCenterCantBeChild(frappe.ValidationError): + pass +class InvalidMainCostCenter(frappe.ValidationError): + pass +class InvalidChildCostCenter(frappe.ValidationError): + pass +class WrongPercentageAllocation(frappe.ValidationError): + pass +class InvalidDateError(frappe.ValidationError): + pass + +class CostCenterAllocation(Document): + def validate(self): + self.validate_total_allocation_percentage() + self.validate_from_date_based_on_existing_gle() + self.validate_backdated_allocation() + self.validate_main_cost_center() + self.validate_child_cost_centers() + + def validate_total_allocation_percentage(self): + total_percentage = sum([d.percentage for d in self.get("allocation_percentages", [])]) + + if total_percentage != 100: + frappe.throw(_("Total percentage against cost centers should be 100"), WrongPercentageAllocation) + + def validate_from_date_based_on_existing_gle(self): + # Check if GLE exists against the main cost center + # If exists ensure from date is set after posting date of last GLE + + last_gle_date = frappe.db.get_value("GL Entry", + {"cost_center": self.main_cost_center, "is_cancelled": 0}, + "posting_date", order_by="posting_date desc") + + if last_gle_date: + if getdate(self.valid_from) <= getdate(last_gle_date): + frappe.throw(_("Valid From must be after {0} as last GL Entry against the cost center {1} posted on this date") + .format(last_gle_date, self.main_cost_center), InvalidDateError) + + def validate_backdated_allocation(self): + # Check if there are any future existing allocation records against the main cost center + # If exists, warn the user about it + + future_allocation = frappe.db.get_value("Cost Center Allocation", filters = { + "main_cost_center": self.main_cost_center, + "valid_from": (">=", self.valid_from), + "name": ("!=", self.name), + "docstatus": 1 + }, fieldname=['valid_from', 'name'], order_by='valid_from', as_dict=1) + + if future_allocation: + frappe.msgprint(_("Another Cost Center Allocation record {0} applicable from {1}, hence this allocation will be applicable upto {2}") + .format(frappe.bold(future_allocation.name), frappe.bold(format_date(future_allocation.valid_from)), + frappe.bold(format_date(add_days(future_allocation.valid_from, -1)))), + title=_("Warning!"), indicator="orange", alert=1 + ) + + def validate_main_cost_center(self): + # Main cost center itself cannot be entered in child table + if self.main_cost_center in [d.cost_center for d in self.allocation_percentages]: + frappe.throw(_("Main Cost Center {0} cannot be entered in the child table") + .format(self.main_cost_center), MainCostCenterCantBeChild) + + # If main cost center is used for allocation under any other cost center, + # allocation cannot be done against it + parent = frappe.db.get_value("Cost Center Allocation Percentage", filters = { + "cost_center": self.main_cost_center, + "docstatus": 1 + }, fieldname='parent') + if parent: + frappe.throw(_("{0} cannot be used as a Main Cost Center because it has been used as child in Cost Center Allocation {1}") + .format(self.main_cost_center, parent), InvalidMainCostCenter) + + def validate_child_cost_centers(self): + # Check if child cost center is used as main cost center in any existing allocation + main_cost_centers = [d.main_cost_center for d in + frappe.get_all("Cost Center Allocation", {'docstatus': 1}, 'main_cost_center')] + + for d in self.allocation_percentages: + if d.cost_center in main_cost_centers: + frappe.throw(_("Cost Center {0} cannot be used for allocation as it is used as main cost center in other allocation record.") + .format(d.cost_center), InvalidChildCostCenter) \ No newline at end of file diff --git a/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py b/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py new file mode 100644 index 00000000000..9cf4c002172 --- /dev/null +++ b/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py @@ -0,0 +1,156 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest + +import frappe +from frappe.utils import add_days, today + +from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center +from erpnext.accounts.doctype.cost_center_allocation.cost_center_allocation import ( + InvalidChildCostCenter, + InvalidDateError, + InvalidMainCostCenter, + MainCostCenterCantBeChild, + WrongPercentageAllocation, +) +from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry + + +class TestCostCenterAllocation(unittest.TestCase): + def setUp(self): + cost_centers = ["Main Cost Center 1", "Main Cost Center 2", "Sub Cost Center 1", "Sub Cost Center 2"] + for cc in cost_centers: + create_cost_center(cost_center_name=cc, company="_Test Company") + + def test_gle_based_on_cost_center_allocation(self): + cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC", + { + "Sub Cost Center 1 - _TC": 60, + "Sub Cost Center 2 - _TC": 40 + } + ) + + jv = make_journal_entry("_Test Cash - _TC", "Sales - _TC", 100, + cost_center = "Main Cost Center 1 - _TC", submit=True) + + expected_values = [ + ["Sub Cost Center 1 - _TC", 0.0, 60], + ["Sub Cost Center 2 - _TC", 0.0, 40] + ] + + gle = frappe.qb.DocType("GL Entry") + gl_entries = ( + frappe.qb.from_(gle) + .select(gle.cost_center, gle.debit, gle.credit) + .where(gle.voucher_type == 'Journal Entry') + .where(gle.voucher_no == jv.name) + .where(gle.account == 'Sales - _TC') + .orderby(gle.cost_center) + ).run(as_dict=1) + + self.assertTrue(gl_entries) + + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_values[i][0], gle.cost_center) + self.assertEqual(expected_values[i][1], gle.debit) + self.assertEqual(expected_values[i][2], gle.credit) + + cca.cancel() + jv.cancel() + + def test_main_cost_center_cant_be_child(self): + # Main cost center itself cannot be entered in child table + cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC", + { + "Sub Cost Center 1 - _TC": 60, + "Main Cost Center 1 - _TC": 40 + }, save=False + ) + + self.assertRaises(MainCostCenterCantBeChild, cca.save) + + def test_invalid_main_cost_center(self): + # If main cost center is used for allocation under any other cost center, + # allocation cannot be done against it + cca1 = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC", + { + "Sub Cost Center 1 - _TC": 60, + "Sub Cost Center 2 - _TC": 40 + } + ) + + cca2 = create_cost_center_allocation("_Test Company", "Sub Cost Center 1 - _TC", + { + "Sub Cost Center 2 - _TC": 100 + }, save=False + ) + + self.assertRaises(InvalidMainCostCenter, cca2.save) + + cca1.cancel() + + def test_if_child_cost_center_has_any_allocation_record(self): + # Check if any child cost center is used as main cost center in any other existing allocation + cca1 = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC", + { + "Sub Cost Center 1 - _TC": 60, + "Sub Cost Center 2 - _TC": 40 + } + ) + + cca2 = create_cost_center_allocation("_Test Company", "Main Cost Center 2 - _TC", + { + "Main Cost Center 1 - _TC": 60, + "Sub Cost Center 1 - _TC": 40 + }, save=False + ) + + self.assertRaises(InvalidChildCostCenter, cca2.save) + + cca1.cancel() + + def test_total_percentage(self): + cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC", + { + "Sub Cost Center 1 - _TC": 40, + "Sub Cost Center 2 - _TC": 40 + }, save=False + ) + self.assertRaises(WrongPercentageAllocation, cca.save) + + def test_valid_from_based_on_existing_gle(self): + # GLE posted against Sub Cost Center 1 on today + jv = make_journal_entry("_Test Cash - _TC", "Sales - _TC", 100, + cost_center = "Main Cost Center 1 - _TC", posting_date=today(), submit=True) + + # try to set valid from as yesterday + cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC", + { + "Sub Cost Center 1 - _TC": 60, + "Sub Cost Center 2 - _TC": 40 + }, valid_from=add_days(today(), -1), save=False + ) + + self.assertRaises(InvalidDateError, cca.save) + + jv.cancel() + +def create_cost_center_allocation(company, main_cost_center, allocation_percentages, + valid_from=None, valid_upto=None, save=True, submit=True): + doc = frappe.new_doc("Cost Center Allocation") + doc.main_cost_center = main_cost_center + doc.company = company + doc.valid_from = valid_from or today() + doc.valid_upto = valid_upto + for cc, percentage in allocation_percentages.items(): + doc.append("allocation_percentages", { + "cost_center": cc, + "percentage": percentage + }) + if save: + doc.save() + if submit: + doc.submit() + + return doc \ No newline at end of file diff --git a/erpnext/accounts/print_format/gst_pos_invoice/__init__.py b/erpnext/accounts/doctype/cost_center_allocation_percentage/__init__.py similarity index 100% rename from erpnext/accounts/print_format/gst_pos_invoice/__init__.py rename to erpnext/accounts/doctype/cost_center_allocation_percentage/__init__.py diff --git a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.json b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json similarity index 63% rename from erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.json rename to erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json index 45b0e2df9ba..7e50962a87f 100644 --- a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.json +++ b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json @@ -1,12 +1,13 @@ { "actions": [], - "creation": "2020-03-19 12:34:01.500390", + "allow_rename": 1, + "creation": "2022-01-13 20:07:30.096306", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "cost_center", - "percentage_allocation" + "percentage" ], "fields": [ { @@ -18,23 +19,23 @@ "reqd": 1 }, { - "fieldname": "percentage_allocation", - "fieldtype": "Float", + "fieldname": "percentage", + "fieldtype": "Percent", "in_list_view": 1, - "label": "Percentage Allocation", + "label": "Percentage (%)", "reqd": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-03-19 12:54:43.674655", + "modified": "2022-02-01 22:22:31.589523", "modified_by": "Administrator", "module": "Accounts", - "name": "Distributed Cost Center", + "name": "Cost Center Allocation Percentage", "owner": "Administrator", "permissions": [], - "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1 + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.py b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.py new file mode 100644 index 00000000000..7d20efb6d5e --- /dev/null +++ b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CostCenterAllocationPercentage(Document): + pass diff --git a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py index 5ba06913310..ca482c8c4ec 100644 --- a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py +++ b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py @@ -39,9 +39,6 @@ def test_create_test_data(): "selling_cost_center": "Main - _TC", "income_account": "Sales - _TC" }], - "show_in_website": 1, - "route":"-test-tesla-car", - "website_warehouse": "Stores - _TC" }) item.insert() # create test item price diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json index 77c9e95b759..b42d712d88a 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json @@ -2,7 +2,7 @@ "actions": [], "allow_import": 1, "allow_rename": 1, - "creation": "2018-11-22 22:45:00.370913", + "creation": "2022-01-19 01:09:13.297137", "doctype": "DocType", "document_type": "Setup", "editable_grid": 1, @@ -10,6 +10,9 @@ "field_order": [ "title", "company", + "column_break_3", + "disabled", + "section_break_5", "taxes" ], "fields": [ @@ -36,10 +39,24 @@ "label": "Company", "options": "Company", "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break" } ], "links": [], - "modified": "2021-03-08 19:50:21.416513", + "modified": "2022-01-18 21:11:23.105589", "modified_by": "Administrator", "module": "Accounts", "name": "Item Tax Template", @@ -82,6 +99,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "title", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 617b376128b..3cc28a3dc8f 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -8,6 +8,7 @@ frappe.provide("erpnext.journal_entry"); frappe.ui.form.on("Journal Entry", { setup: function(frm) { frm.add_fetch("bank_account", "account", "account"); + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice']; }, refresh: function(frm) { diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index 19d8d49dfea..6e7b80e7310 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -166,8 +166,9 @@ class OpeningInvoiceCreationTool(Document): frappe.scrub(row.party_type): row.party, "is_pos": 0, "doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice", - "update_stock": 0, - "invoice_number": row.invoice_number + "update_stock": 0, # important: https://github.com/frappe/erpnext/pull/23559 + "invoice_number": row.invoice_number, + "disable_rounded_total": 1 }) accounting_dimension = get_accounting_dimensions() diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 6700e9b975d..3eaf6a28f37 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -1,11 +1,7 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -import unittest - import frappe -from frappe.cache_manager import clear_doctype_cache -from frappe.custom.doctype.property_setter.property_setter import make_property_setter from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( create_dimension, @@ -14,14 +10,17 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( get_temporary_opening_account, ) +from erpnext.tests.utils import ERPNextTestCase test_dependencies = ["Customer", "Supplier", "Accounting Dimension"] -class TestOpeningInvoiceCreationTool(unittest.TestCase): - def setUp(self): +class TestOpeningInvoiceCreationTool(ERPNextTestCase): + @classmethod + def setUpClass(self): if not frappe.db.exists("Company", "_Test Opening Invoice Company"): make_company() create_dimension() + return super().setUpClass() def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None, department=None): doc = frappe.get_single("Opening Invoice Creation Tool") @@ -31,26 +30,20 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase): return doc.make_invoices() def test_opening_sales_invoice_creation(self): - property_setter = make_property_setter("Sales Invoice", "update_stock", "default", 1, "Check") - try: - invoices = self.make_invoices(company="_Test Opening Invoice Company") + invoices = self.make_invoices(company="_Test Opening Invoice Company") - self.assertEqual(len(invoices), 2) - expected_value = { - "keys": ["customer", "outstanding_amount", "status"], - 0: ["_Test Customer", 300, "Overdue"], - 1: ["_Test Customer 1", 250, "Overdue"], - } - self.check_expected_values(invoices, expected_value) + self.assertEqual(len(invoices), 2) + expected_value = { + "keys": ["customer", "outstanding_amount", "status"], + 0: ["_Test Customer", 300, "Overdue"], + 1: ["_Test Customer 1", 250, "Overdue"], + } + self.check_expected_values(invoices, expected_value) - si = frappe.get_doc("Sales Invoice", invoices[0]) + si = frappe.get_doc("Sales Invoice", invoices[0]) - # Check if update stock is not enabled - self.assertEqual(si.update_stock, 0) - - finally: - property_setter.delete() - clear_doctype_cache("Sales Invoice") + # Check if update stock is not enabled + self.assertEqual(si.update_stock, 0) def check_expected_values(self, invoices, expected_value, invoice_type="Sales"): doctype = "Sales Invoice" if invoice_type == "Sales" else "Purchase Invoice" diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 02a144d3e79..0d8f079d7af 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1077,7 +1077,7 @@ def get_outstanding_reference_documents(args): if d.voucher_type in ("Purchase Invoice"): d["bill_no"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "bill_no") - # Get all SO / PO which are not fully billed or aginst which full advance not paid + # Get all SO / PO which are not fully billed or against which full advance not paid orders_to_be_billed = [] if (args.get("party_type") != "Student"): orders_to_be_billed = get_orders_to_be_billed(args.get("posting_date"),args.get("party_type"), diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 6a84a65e713..d72d8f70180 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -291,7 +291,7 @@ class PaymentRequest(Document): if not status: return - shopping_cart_settings = frappe.get_doc("Shopping Cart Settings") + shopping_cart_settings = frappe.get_doc("E Commerce Settings") if status in ["Authorized", "Completed"]: redirect_to = None @@ -435,13 +435,13 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): """, (ref_dt, ref_dn)) return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 -def get_gateway_details(args): +def get_gateway_details(args): # nosemgrep """return gateway and payment account of default payment gateway""" if args.get("payment_gateway_account"): return get_payment_gateway_account(args.get("payment_gateway_account")) if args.order_type == "Shopping Cart": - payment_gateway_account = frappe.get_doc("Shopping Cart Settings").payment_gateway_account + payment_gateway_account = frappe.get_doc("E Commerce Settings").payment_gateway_account return get_payment_gateway_account(payment_gateway_account) gateway_account = get_payment_gateway_account({"is_default": 1}) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 134bccf3d1a..9b3b3aa4149 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -42,7 +42,6 @@ class POSInvoice(SalesInvoice): self.validate_serialised_or_batched_item() self.validate_stock_availablility() self.validate_return_items_qty() - self.validate_non_stock_items() self.set_status() self.set_account_for_mode_of_payment() self.validate_pos() @@ -158,22 +157,40 @@ class POSInvoice(SalesInvoice): frappe.throw(_("Row #{}: Serial No. {} has already been transacted into another Sales Invoice. Please select valid serial no.") .format(item.idx, bold_delivered_serial_nos), title=_("Item Unavailable")) + def validate_invalid_serial_nos(self, item): + serial_nos = get_serial_nos(item.serial_no) + error_msg = [] + invalid_serials, msg = "", "" + for serial_no in serial_nos: + if not frappe.db.exists('Serial No', serial_no): + invalid_serials = invalid_serials + (", " if invalid_serials else "") + serial_no + msg = (_("Row #{}: Following Serial numbers for item {} are Invalid: {}").format(item.idx, frappe.bold(item.get("item_code")), frappe.bold(invalid_serials))) + if invalid_serials: + error_msg.append(msg) + + if error_msg: + frappe.throw(error_msg, title=_("Invalid Item"), as_list=True) + def validate_stock_availablility(self): + from erpnext.stock.stock_ledger import is_negative_stock_allowed + if self.is_return or self.docstatus != 1: return - - allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') for d in self.get('items'): + is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item')) + if is_service_item: + return if d.serial_no: self.validate_pos_reserved_serial_nos(d) self.validate_delivered_serial_nos(d) + self.validate_invalid_serial_nos(d) elif d.batch_no: self.validate_pos_reserved_batch_qty(d) else: - if allow_negative_stock: + if is_negative_stock_allowed(item_code=d.item_code): return - available_stock = get_stock_availability(d.item_code, d.warehouse) + available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse) item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty) if flt(available_stock) <= 0: @@ -244,14 +261,6 @@ class POSInvoice(SalesInvoice): .format(d.idx, bold_serial_no, bold_return_against) ) - def validate_non_stock_items(self): - for d in self.get("items"): - is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item") - if not is_stock_item: - if not frappe.db.exists('Product Bundle', d.item_code): - frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.") - .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item")) - def validate_mode_of_payment(self): if len(self.payments) == 0: frappe.throw(_("At least one mode of payment is required for POS invoice.")) @@ -430,7 +439,6 @@ class POSInvoice(SalesInvoice): self.paid_amount = 0 def set_account_for_mode_of_payment(self): - self.payments = [d for d in self.payments if d.amount or d.base_amount or d.default] for pay in self.payments: if not pay.account: pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account") @@ -491,12 +499,18 @@ class POSInvoice(SalesInvoice): @frappe.whitelist() def get_stock_availability(item_code, warehouse): if frappe.db.get_value('Item', item_code, 'is_stock_item'): + is_stock_item = True bin_qty = get_bin_qty(item_code, warehouse) pos_sales_qty = get_pos_reserved_qty(item_code, warehouse) - return bin_qty - pos_sales_qty + return bin_qty - pos_sales_qty, is_stock_item else: + is_stock_item = False if frappe.db.exists('Product Bundle', item_code): - return get_bundle_availability(item_code, warehouse) + return get_bundle_availability(item_code, warehouse), is_stock_item + else: + # Is a service item + return 0, is_stock_item + def get_bundle_availability(bundle_item_code, warehouse): product_bundle = frappe.get_doc('Product Bundle', bundle_item_code) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 56479a0b77d..cf8affdd010 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -354,6 +354,24 @@ class TestPOSInvoice(unittest.TestCase): pos2.insert() self.assertRaises(frappe.ValidationError, pos2.submit) + def test_invalid_serial_no_validation(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item + + se = make_serialized_item(company='_Test Company', + target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC') + serial_nos = se.get("items")[0].serial_no + 'wrong' + + pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC', + account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC', + expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC', + item=se.get("items")[0].item_code, rate=1000, qty=2, do_not_save=1) + + pos.get('items')[0].has_serial_no = 1 + pos.get('items')[0].serial_no = serial_nos + pos.insert() + + self.assertRaises(frappe.ValidationError, pos.submit) + def test_loyalty_points(self): from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( get_loyalty_program_details_with_points, @@ -568,23 +586,29 @@ class TestPOSInvoice(unittest.TestCase): item_price.insert() pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10) pr.save() - pos_inv = create_pos_invoice(qty=1, do_not_submit=1) - pos_inv.items[0].rate = 300 - pos_inv.save() - self.assertEquals(pos_inv.items[0].discount_percentage, 10) - # rate shouldn't change - self.assertEquals(pos_inv.items[0].rate, 405) - pos_inv.ignore_pricing_rule = 1 - pos_inv.items[0].rate = 300 - pos_inv.save() - self.assertEquals(pos_inv.ignore_pricing_rule, 1) - # rate should change since pricing rules are ignored - self.assertEquals(pos_inv.items[0].rate, 300) + try: + pos_inv = create_pos_invoice(qty=1, do_not_submit=1) + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.items[0].discount_percentage, 10) + # rate shouldn't change + self.assertEquals(pos_inv.items[0].rate, 405) - item_price.delete() - pos_inv.delete() - pr.delete() + pos_inv.ignore_pricing_rule = 1 + pos_inv.save() + self.assertEquals(pos_inv.ignore_pricing_rule, 1) + # rate should reset since pricing rules are ignored + self.assertEquals(pos_inv.items[0].rate, 450) + + pos_inv.items[0].rate = 300 + pos_inv.save() + self.assertEquals(pos_inv.items[0].rate, 300) + + finally: + item_price.delete() + pos_inv.delete() + pr.delete() def create_pos_invoice(**args): diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 0720d9b2e91..ddca68a57b9 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -84,12 +84,20 @@ class POSInvoiceMergeLog(Document): sales_invoice.set_posting_time = 1 sales_invoice.posting_date = getdate(self.posting_date) sales_invoice.save() + self.write_off_fractional_amount(sales_invoice, data) sales_invoice.submit() self.consolidated_invoice = sales_invoice.name return sales_invoice.name + def write_off_fractional_amount(self, invoice, data): + pos_invoice_grand_total = sum(d.grand_total for d in data) + + if abs(pos_invoice_grand_total - invoice.grand_total) < 1: + invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total) + invoice.save() + def process_merging_into_credit_note(self, data): credit_note = self.get_new_sales_invoice() credit_note.is_return = 1 @@ -102,6 +110,7 @@ class POSInvoiceMergeLog(Document): # TODO: return could be against multiple sales invoice which could also have been consolidated? # credit_note.return_against = self.consolidated_invoice credit_note.save() + self.write_off_fractional_amount(credit_note, data) credit_note.submit() self.consolidated_credit_note = credit_note.name @@ -135,9 +144,15 @@ class POSInvoiceMergeLog(Document): i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse): found = True i.qty = i.qty + item.qty + i.amount = i.amount + item.net_amount + i.net_amount = i.amount + i.base_amount = i.base_amount + item.base_net_amount + i.base_net_amount = i.base_amount if not found: item.rate = item.net_rate + item.amount = item.net_amount + item.base_amount = item.base_net_amount item.price_list_rate = 0 si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) items.append(si_item) @@ -169,6 +184,7 @@ class POSInvoiceMergeLog(Document): found = True if not found: payments.append(payment) + rounding_adjustment += doc.rounding_adjustment rounded_total += doc.rounded_total base_rounding_adjustment += doc.base_rounding_adjustment diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index 3555da83a40..5930aa097f7 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -12,6 +12,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import ( consolidate_pos_invoices, ) +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry class TestPOSInvoiceMergeLog(unittest.TestCase): @@ -150,3 +151,132 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") frappe.db.sql("delete from `tabPOS Invoice`") + + + def test_consolidation_round_off_error_1(self): + ''' + Test round off error in consolidated invoice creation if POS Invoice has inclusive tax + ''' + + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + make_stock_entry( + to_warehouse="_Test Warehouse - _TC", + item_code="_Test Item", + rate=8000, + qty=10, + ) + + init_user_and_profile() + + inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True) + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True) + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000 + }) + inv2.insert() + inv2.submit() + + consolidate_pos_invoices() + + inv.load_from_db() + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + self.assertEqual(consolidated_invoice.outstanding_amount, 0) + self.assertEqual(consolidated_invoice.status, 'Paid') + + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") + + def test_consolidation_round_off_error_2(self): + ''' + Test the same case as above but with an Unpaid POS Invoice + ''' + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + make_stock_entry( + to_warehouse="_Test Warehouse - _TC", + item_code="_Test Item", + rate=8000, + qty=10, + ) + + init_user_and_profile() + + inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True) + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True) + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 7.5, + "included_in_print_rate": 1 + }) + inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000 + }) + inv2.insert() + inv2.submit() + + inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True) + inv3.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000 + }) + inv3.insert() + inv3.submit() + + consolidate_pos_invoices() + + inv.load_from_db() + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + self.assertEqual(consolidated_invoice.outstanding_amount, 800) + self.assertNotEqual(consolidated_invoice.status, 'Paid') + + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index ac96b045a22..933fda8a0a5 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -249,13 +249,17 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa "free_item_data": [], "parent": args.parent, "parenttype": args.parenttype, - "child_docname": args.get('child_docname') + "child_docname": args.get('child_docname'), }) if args.ignore_pricing_rule or not args.item_code: if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"): - item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), - item_details, args.get('item_code')) + item_details = remove_pricing_rule_for_item( + args.get("pricing_rules"), + item_details, + item_code=args.get("item_code"), + rate=args.get("price_list_rate"), + ) return item_details update_args_for_pricing_rule(args) @@ -308,8 +312,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa if not doc: return item_details elif args.get("pricing_rules"): - item_details = remove_pricing_rule_for_item(args.get("pricing_rules"), - item_details, args.get('item_code')) + item_details = remove_pricing_rule_for_item( + args.get("pricing_rules"), + item_details, + item_code=args.get("item_code"), + rate=args.get("price_list_rate"), + ) return item_details @@ -390,7 +398,7 @@ def apply_price_discount_rule(pricing_rule, item_details, args): item_details[field] += (pricing_rule.get(field, 0) if pricing_rule else args.get(field, 0)) -def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): +def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None): from erpnext.accounts.doctype.pricing_rule.utils import ( get_applied_pricing_rules, get_pricing_rule_items, @@ -403,6 +411,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): if pricing_rule.rate_or_discount == 'Discount Percentage': item_details.discount_percentage = 0.0 item_details.discount_amount = 0.0 + item_details.rate = rate or 0.0 if pricing_rule.rate_or_discount == 'Discount Amount': item_details.discount_amount = 0.0 @@ -421,6 +430,7 @@ def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): item_details.applied_on_items = ','.join(items) item_details.pricing_rules = '' + item_details.pricing_rule_removed = True return item_details @@ -432,9 +442,12 @@ def remove_pricing_rules(item_list): out = [] for item in item_list: item = frappe._dict(item) - if item.get('pricing_rules'): - out.append(remove_pricing_rule_for_item(item.get("pricing_rules"), - item, item.item_code)) + if item.get("pricing_rules"): + out.append( + remove_pricing_rule_for_item( + item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate") + ) + ) return out diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 5746a840f30..8338a5b0ada 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -628,6 +628,67 @@ class TestPricingRule(unittest.TestCase): for doc in [si, si1]: doc.delete() + def test_remove_pricing_rule(self): + item = make_item("Water Flask") + make_item_price("Water Flask", "_Test Price List", 100) + + pricing_rule_record = { + "doctype": "Pricing Rule", + "title": "_Test Water Flask Rule", + "apply_on": "Item Code", + "price_or_product_discount": "Price", + "items": [{ + "item_code": "Water Flask", + }], + "selling": 1, + "currency": "INR", + "rate_or_discount": "Discount Percentage", + "discount_percentage": 20, + "company": "_Test Company" + } + rule = frappe.get_doc(pricing_rule_record) + rule.insert() + + si = create_sales_invoice(do_not_save=True, item_code="Water Flask") + si.selling_price_list = "_Test Price List" + si.save() + + self.assertEqual(si.items[0].price_list_rate, 100) + self.assertEqual(si.items[0].discount_percentage, 20) + self.assertEqual(si.items[0].rate, 80) + + si.ignore_pricing_rule = 1 + si.save() + + self.assertEqual(si.items[0].discount_percentage, 0) + self.assertEqual(si.items[0].rate, 100) + + si.delete() + rule.delete() + frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete() + item.delete() + + def test_multiple_pricing_rules_with_min_qty(self): + make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4, + apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1") + make_pricing_rule(discount_percentage=10, selling=1, priority=2, min_qty=4, + apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 2") + + si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1, currency="USD") + item = si.items[0] + item.stock_qty = 1 + si.save() + self.assertFalse(item.discount_percentage) + item.qty = 5 + item.stock_qty = 5 + si.save() + self.assertEqual(item.discount_percentage, 30) + si.delete() + + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1") + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2") + + test_dependencies = ["Campaign"] def make_pricing_rule(**args): diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 02bfc9defd7..7792590c9c7 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -73,7 +73,7 @@ def sorted_by_priority(pricing_rules, args, doc=None): for key in sorted(pricing_rule_dict): pricing_rules_list.extend(pricing_rule_dict.get(key)) - return pricing_rules_list or pricing_rules + return pricing_rules_list def filter_pricing_rule_based_on_condition(pricing_rules, doc=None): filtered_pricing_rules = [] diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index b3642181ac0..2c3156175fb 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -178,8 +178,8 @@ class PurchaseInvoice(BuyingController): if self.supplier and account.account_type != "Payable": frappe.throw( - _("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.") - .format(frappe.bold("Credit To")), title=_("Invalid Account") + _("Please ensure {} account {} is a Payable account. Change the account type to Payable or select a different account.") + .format(frappe.bold("Credit To"), frappe.bold(self.credit_to)), title=_("Invalid Account") ) self.party_account_currency = account.account_currency @@ -537,8 +537,11 @@ class PurchaseInvoice(BuyingController): voucher_wise_stock_value = {} if self.update_stock: - for d in frappe.get_all('Stock Ledger Entry', - fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], filters={'voucher_no': self.name}): + stock_ledger_entries = frappe.get_all("Stock Ledger Entry", + fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], + filters={"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0} + ) + for d in stock_ledger_entries: voucher_wise_stock_value.setdefault((d.voucher_detail_no, d.warehouse), d.stock_value_difference) valuation_tax_accounts = [d.account_head for d in self.get("taxes") @@ -548,6 +551,10 @@ class PurchaseInvoice(BuyingController): exchange_rate_map, net_rate_map = get_purchase_document_details(self) enable_discount_accounting = cint(frappe.db.get_single_value('Accounts Settings', 'enable_discount_accounting')) + provisional_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, \ + 'enable_provisional_accounting_for_non_stock_items')) + + purchase_receipt_doc_map = {} for item in self.get("items"): if flt(item.base_net_amount): @@ -643,19 +650,23 @@ class PurchaseInvoice(BuyingController): else: amount = flt(item.base_net_amount + item.item_tax_amount, item.precision("base_net_amount")) - auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items')) - - if auto_accounting_for_non_stock_items: - service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed") - + if provisional_accounting_for_non_stock_items: if item.purchase_receipt: + provisional_account = self.get_company_default("default_provisional_account") + purchase_receipt_doc = purchase_receipt_doc_map.get(item.purchase_receipt) + + if not purchase_receipt_doc: + purchase_receipt_doc = frappe.get_doc("Purchase Receipt", item.purchase_receipt) + purchase_receipt_doc_map[item.purchase_receipt] = purchase_receipt_doc + # Post reverse entry for Stock-Received-But-Not-Billed if it is booked in Purchase Receipt expense_booked_in_pr = frappe.db.get_value('GL Entry', {'is_cancelled': 0, 'voucher_type': 'Purchase Receipt', 'voucher_no': item.purchase_receipt, 'voucher_detail_no': item.pr_detail, - 'account':service_received_but_not_billed_account}, ['name']) + 'account':provisional_account}, ['name']) if expense_booked_in_pr: - expense_account = service_received_but_not_billed_account + # Intentionally passing purchase invoice item to handle partial billing + purchase_receipt_doc.add_provisional_gl_entry(item, gl_entries, self.posting_date, reverse=1) if not self.is_internal_transfer(): gl_entries.append(self.get_gl_dict({ diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js index f6ff83add8c..82d00308db4 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js @@ -56,4 +56,14 @@ frappe.listview_settings["Purchase Invoice"] = { ]; } }, + + onload: function(listview) { + listview.page.add_action_item(__("Purchase Receipt"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Purchase Receipt"); + }); + + listview.page.add_action_item(__("Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Payment"); + }); + } }; diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 21846bb76c8..d51a008d943 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -11,12 +11,17 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today import erpnext from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.buying.doctype.supplier.test_supplier import create_supplier from erpnext.controllers.accounts_controller import get_payment_terms from erpnext.controllers.buying_controller import QtyMismatchError from erpnext.exceptions import InvalidCurrency from erpnext.projects.doctype.project.test_project import make_project from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + make_purchase_invoice as create_purchase_invoice_from_receipt, +) from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( get_taxes, make_purchase_receipt, @@ -1147,8 +1152,6 @@ class TestPurchaseInvoice(unittest.TestCase): def test_purchase_invoice_advance_taxes(self): from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry - from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice - from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order # create a new supplier to test supplier = create_supplier(supplier_name = '_Test TDS Advance Supplier', @@ -1221,6 +1224,45 @@ class TestPurchaseInvoice(unittest.TestCase): payment_entry.load_from_db() self.assertEqual(payment_entry.taxes[0].allocated_amount, 0) + def test_provisional_accounting_entry(self): + item = create_item("_Test Non Stock Item", is_stock_item=0) + provisional_account = create_account(account_name="Provision Account", + parent_account="Current Liabilities - _TC", company="_Test Company") + + company = frappe.get_doc('Company', '_Test Company') + company.enable_provisional_accounting_for_non_stock_items = 1 + company.default_provisional_account = provisional_account + company.save() + + pr = make_purchase_receipt(item_code="_Test Non Stock Item", posting_date=add_days(nowdate(), -2)) + + pi = create_purchase_invoice_from_receipt(pr.name) + pi.set_posting_time = 1 + pi.posting_date = add_days(pr.posting_date, -1) + pi.items[0].expense_account = 'Cost of Goods Sold - _TC' + pi.save() + pi.submit() + + # Check GLE for Purchase Invoice + expected_gle = [ + ['Cost of Goods Sold - _TC', 250, 0, add_days(pr.posting_date, -1)], + ['Creditors - _TC', 0, 250, add_days(pr.posting_date, -1)] + ] + + check_gl_entries(self, pi.name, expected_gle, pi.posting_date) + + expected_gle_for_purchase_receipt = [ + ["Provision Account - _TC", 250, 0, pr.posting_date], + ["_Test Account Cost for Goods Sold - _TC", 0, 250, pr.posting_date], + ["Provision Account - _TC", 0, 250, pi.posting_date], + ["_Test Account Cost for Goods Sold - _TC", 250, 0, pi.posting_date] + ] + + check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date) + + company.enable_provisional_accounting_for_non_stock_items = 0 + company.save() + def check_gl_entries(doc, voucher_no, expected_gle, posting_date): gl_entries = frappe.db.sql("""select account, debit, credit, posting_date from `tabGL Entry` diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india.js b/erpnext/accounts/doctype/sales_invoice/regional/india.js index 6336db16ebc..f54bce8aac7 100644 --- a/erpnext/accounts/doctype/sales_invoice/regional/india.js +++ b/erpnext/accounts/doctype/sales_invoice/regional/india.js @@ -1,6 +1,8 @@ {% include "erpnext/regional/india/taxes.js" %} +{% include "erpnext/regional/india/e_invoice/einvoice.js" %} erpnext.setup_auto_gst_taxation('Sales Invoice'); +erpnext.setup_einvoice_actions('Sales Invoice') frappe.ui.form.on("Sales Invoice", { setup: function(frm) { diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js index d9d6634c397..f01325d80bd 100644 --- a/erpnext/accounts/doctype/sales_invoice/regional/india_list.js +++ b/erpnext/accounts/doctype/sales_invoice/regional/india_list.js @@ -36,4 +36,139 @@ frappe.listview_settings['Sales Invoice'].onload = function (list_view) { }; list_view.page.add_actions_menu_item(__('Generate E-Way Bill JSON'), action, false); + + const generate_irns = () => { + const docnames = list_view.get_checked_items(true); + if (docnames && docnames.length) { + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.generate_einvoices', + args: { docnames }, + freeze: true, + freeze_message: __('Generating E-Invoices...') + }); + } else { + frappe.msgprint({ + message: __('Please select at least one sales invoice to generate IRN'), + title: __('No Invoice Selected'), + indicator: 'red' + }); + } + }; + + const cancel_irns = () => { + const docnames = list_view.get_checked_items(true); + + const fields = [ + { + "label": "Reason", + "fieldname": "reason", + "fieldtype": "Select", + "reqd": 1, + "default": "1-Duplicate", + "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"] + }, + { + "label": "Remark", + "fieldname": "remark", + "fieldtype": "Data", + "reqd": 1 + } + ]; + + const d = new frappe.ui.Dialog({ + title: __("Cancel IRN"), + fields: fields, + primary_action: function() { + const data = d.get_values(); + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.cancel_irns', + args: { + doctype: list_view.doctype, + docnames, + reason: data.reason.split('-')[0], + remark: data.remark + }, + freeze: true, + freeze_message: __('Cancelling E-Invoices...'), + }); + d.hide(); + }, + primary_action_label: __('Submit') + }); + d.show(); + }; + + let einvoicing_enabled = false; + frappe.db.get_single_value("E Invoice Settings", "enable").then(enabled => { + einvoicing_enabled = enabled; + }); + + list_view.$result.on("change", "input[type=checkbox]", () => { + if (einvoicing_enabled) { + const docnames = list_view.get_checked_items(true); + // show/hide e-invoicing actions when no sales invoices are checked + if (docnames && docnames.length) { + // prevent adding actions twice if e-invoicing action group already exists + if (list_view.page.get_inner_group_button(__('E-Invoicing')).length == 0) { + list_view.page.add_inner_button(__('Generate IRNs'), generate_irns, __('E-Invoicing')); + list_view.page.add_inner_button(__('Cancel IRNs'), cancel_irns, __('E-Invoicing')); + } + } else { + list_view.page.remove_inner_button(__('Generate IRNs'), __('E-Invoicing')); + list_view.page.remove_inner_button(__('Cancel IRNs'), __('E-Invoicing')); + } + } + }); + + frappe.realtime.on("bulk_einvoice_generation_complete", (data) => { + const { failures, user, invoices } = data; + + if (invoices.length != failures.length) { + frappe.msgprint({ + message: __('{0} e-invoices generated successfully', [invoices.length]), + title: __('Bulk E-Invoice Generation Complete'), + indicator: 'orange' + }); + } + + if (failures && failures.length && user == frappe.session.user) { + let message = ` + Failed to generate IRNs for following ${failures.length} sales invoices: + + `; + frappe.msgprint({ + message: message, + title: __('Bulk E-Invoice Generation Complete'), + indicator: 'orange' + }); + } + }); + + frappe.realtime.on("bulk_einvoice_cancellation_complete", (data) => { + const { failures, user, invoices } = data; + + if (invoices.length != failures.length) { + frappe.msgprint({ + message: __('{0} e-invoices cancelled successfully', [invoices.length]), + title: __('Bulk E-Invoice Cancellation Complete'), + indicator: 'orange' + }); + } + + if (failures && failures.length && user == frappe.session.user) { + let message = ` + Failed to cancel IRNs for following ${failures.length} sales invoices: + + `; + frappe.msgprint({ + message: message, + title: __('Bulk E-Invoice Cancellation Complete'), + indicator: 'orange' + }); + } + }); }; diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 39dfd8d76d5..af6a52a6429 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -469,7 +469,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e let row = frappe.get_doc(d.doctype, d.name) set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail) }); - frm.trigger("calculate_timesheet_totals"); + this.frm.trigger("calculate_timesheet_totals"); } } }; diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index f04e7eacb93..b894f90c7e1 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -285,7 +285,7 @@ class SalesInvoice(SellingController): filters={ invoice_or_credit_note: self.name }, pluck="pos_closing_entry" ) - if pos_closing_entry: + if pos_closing_entry and pos_closing_entry[0]: msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( frappe.bold("Consolidated Sales Invoice"), get_link_to_form("POS Closing Entry", pos_closing_entry[0]) @@ -294,6 +294,8 @@ class SalesInvoice(SellingController): def before_cancel(self): self.check_if_consolidated_invoice() + + super(SalesInvoice, self).before_cancel() self.update_time_sheet(None) def on_cancel(self): @@ -570,7 +572,10 @@ class SalesInvoice(SellingController): frappe.throw(msg, title=_("Invalid Account")) if self.customer and account.account_type != "Receivable": - msg = _("Please ensure {} account is a Receivable account.").format(frappe.bold("Debit To")) + " " + msg = _("Please ensure {} account {} is a Receivable account.").format( + frappe.bold("Debit To"), + frappe.bold(self.debit_to) + ) + " " msg += _("Change the account type to Receivable or select a different account.") frappe.throw(msg, title=_("Invalid Account")) @@ -1247,14 +1252,14 @@ class SalesInvoice(SellingController): def update_billing_status_in_dn(self, update_modified=True): updated_delivery_notes = [] for d in self.get("items"): - if d.dn_detail: + if d.so_detail: + updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified) + elif d.dn_detail: billed_amt = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item` where dn_detail=%s and docstatus=1""", d.dn_detail) billed_amt = billed_amt and billed_amt[0][0] or 0 frappe.db.set_value("Delivery Note Item", d.dn_detail, "billed_amt", billed_amt, update_modified=update_modified) updated_delivery_notes.append(d.delivery_note) - elif d.so_detail: - updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified) for dn in set(updated_delivery_notes): frappe.get_doc("Delivery Note", dn).update_billing_percentage(update_modified=update_modified) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js index 06e6f511839..1130284ecc5 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js @@ -21,5 +21,15 @@ frappe.listview_settings['Sales Invoice'] = { }; return [__(doc.status), status_colors[doc.status], "status,=,"+doc.status]; }, - right_column: "grand_total" + right_column: "grand_total", + + onload: function(listview) { + listview.page.add_action_item(__("Delivery Note"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Delivery Note"); + }); + + listview.page.add_action_item(__("Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Payment"); + }); + } }; diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 55e38530600..941061f2a22 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2100,6 +2100,54 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(data['billLists'][0]['actualFromStateCode'],7) self.assertEqual(data['billLists'][0]['fromStateCode'],27) + def test_einvoice_submission_without_irn(self): + # init + einvoice_settings = frappe.get_doc('E Invoice Settings') + einvoice_settings.enable = 1 + einvoice_settings.applicable_from = nowdate() + einvoice_settings.append('credentials', { + 'company': '_Test Company', + 'gstin': '27AAECE4835E1ZR', + 'username': 'test', + 'password': 'test' + }) + einvoice_settings.save() + + country = frappe.flags.country + frappe.flags.country = 'India' + + si = make_sales_invoice_for_ewaybill() + self.assertRaises(frappe.ValidationError, si.submit) + + si.irn = 'test_irn' + si.submit() + + # reset + einvoice_settings = frappe.get_doc('E Invoice Settings') + einvoice_settings.enable = 0 + frappe.flags.country = country + + def test_einvoice_json(self): + from erpnext.regional.india.e_invoice.utils import make_einvoice, validate_totals + + si = get_sales_invoice_for_e_invoice() + si.discount_amount = 100 + si.save() + + einvoice = make_einvoice(si) + self.assertTrue(einvoice['EwbDtls']) + validate_totals(einvoice) + + si.apply_discount_on = 'Net Total' + si.save() + einvoice = make_einvoice(si) + validate_totals(einvoice) + + [d.set('included_in_print_rate', 1) for d in si.taxes] + si.save() + einvoice = make_einvoice(si) + validate_totals(einvoice) + def test_item_tax_net_range(self): item = create_item("T Shirt") diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py index b5909447dc8..1d30934df92 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py +++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py @@ -46,7 +46,7 @@ def valdiate_taxes_and_charges_template(doc): for tax in doc.get("taxes"): validate_taxes_and_charges(tax) - validate_account_head(tax, doc) + validate_account_head(tax.idx, tax.account_head, doc.company) validate_cost_center(tax, doc) validate_inclusive_tax(tax, doc) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index 7e5129911e4..792e7d21a78 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -71,7 +71,8 @@ class ShippingRule(Document): if doc.currency != doc.company_currency: shipping_amount = flt(shipping_amount / doc.conversion_rate, 2) - self.add_shipping_rule_to_tax_table(doc, shipping_amount) + if shipping_amount: + self.add_shipping_rule_to_tax_table(doc, shipping_amount) def get_shipping_amount_from_rules(self, value): for condition in self.get("conditions"): diff --git a/erpnext/accounts/doctype/tax_category/tax_category.json b/erpnext/accounts/doctype/tax_category/tax_category.json index f7145af44c3..44a339f31df 100644 --- a/erpnext/accounts/doctype/tax_category/tax_category.json +++ b/erpnext/accounts/doctype/tax_category/tax_category.json @@ -2,12 +2,13 @@ "actions": [], "allow_rename": 1, "autoname": "field:title", - "creation": "2018-11-22 23:38:39.668804", + "creation": "2022-01-19 01:09:28.920486", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "title" + "title", + "disabled" ], "fields": [ { @@ -18,14 +19,21 @@ "label": "Title", "reqd": 1, "unique": 1 + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-03 11:50:38.748872", + "modified": "2022-01-18 21:13:41.161017", "modified_by": "Administrator", "module": "Accounts", "name": "Tax Category", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -65,5 +73,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.py b/erpnext/accounts/doctype/tax_rule/tax_rule.py index a16377c847c..2d94bc376a0 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.py @@ -98,7 +98,7 @@ class TaxRule(Document): def validate_use_for_shopping_cart(self): '''If shopping cart is enabled and no tax rule exists for shopping cart, enable this one''' if (not self.use_for_shopping_cart - and cint(frappe.db.get_single_value('Shopping Cart Settings', 'enabled')) + and cint(frappe.db.get_single_value('E Commerce Settings', 'enabled')) and not frappe.db.get_value('Tax Rule', {'use_for_shopping_cart': 1, 'name': ['!=', self.name]})): self.use_for_shopping_cart = 1 diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 1836db6477f..d24d56b4bbb 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -2,6 +2,8 @@ # License: GNU General Public License v3. See license.txt +import copy + import frappe from frappe import _ from frappe.model.meta import get_field_precision @@ -51,49 +53,57 @@ def validate_accounting_period(gl_map): .format(frappe.bold(accounting_periods[0].name)), ClosedAccountingPeriod) def process_gl_map(gl_map, merge_entries=True, precision=None): + if not gl_map: + return [] + + gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision) + if merge_entries: gl_map = merge_similar_entries(gl_map, precision) - for entry in gl_map: - # toggle debit, credit if negative entry - if flt(entry.debit) < 0: - entry.credit = flt(entry.credit) - flt(entry.debit) - entry.debit = 0.0 - if flt(entry.debit_in_account_currency) < 0: - entry.credit_in_account_currency = \ - flt(entry.credit_in_account_currency) - flt(entry.debit_in_account_currency) - entry.debit_in_account_currency = 0.0 - - if flt(entry.credit) < 0: - entry.debit = flt(entry.debit) - flt(entry.credit) - entry.credit = 0.0 - - if flt(entry.credit_in_account_currency) < 0: - entry.debit_in_account_currency = \ - flt(entry.debit_in_account_currency) - flt(entry.credit_in_account_currency) - entry.credit_in_account_currency = 0.0 - - update_net_values(entry) + gl_map = toggle_debit_credit_if_negative(gl_map) return gl_map -def update_net_values(entry): - # In some scenarios net value needs to be shown in the ledger - # This method updates net values as debit or credit - if entry.post_net_value and entry.debit and entry.credit: - if entry.debit > entry.credit: - entry.debit = entry.debit - entry.credit - entry.debit_in_account_currency = entry.debit_in_account_currency \ - - entry.credit_in_account_currency - entry.credit = 0 - entry.credit_in_account_currency = 0 - else: - entry.credit = entry.credit - entry.debit - entry.credit_in_account_currency = entry.credit_in_account_currency \ - - entry.debit_in_account_currency +def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None): + cost_center_allocation = get_cost_center_allocation_data(gl_map[0]["company"], gl_map[0]["posting_date"]) + if not cost_center_allocation: + return gl_map - entry.debit = 0 - entry.debit_in_account_currency = 0 + new_gl_map = [] + for d in gl_map: + cost_center = d.get("cost_center") + if cost_center and cost_center_allocation.get(cost_center): + for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items(): + gle = copy.deepcopy(d) + gle.cost_center = sub_cost_center + for field in ("debit", "credit", "debit_in_account_currency", "credit_in_company_currency"): + gle[field] = flt(flt(d.get(field)) * percentage / 100, precision) + new_gl_map.append(gle) + else: + new_gl_map.append(d) + + return new_gl_map + +def get_cost_center_allocation_data(company, posting_date): + par = frappe.qb.DocType("Cost Center Allocation") + child = frappe.qb.DocType("Cost Center Allocation Percentage") + + records = ( + frappe.qb.from_(par).inner_join(child).on(par.name == child.parent) + .select(par.main_cost_center, child.cost_center, child.percentage) + .where(par.docstatus == 1) + .where(par.company == company) + .where(par.valid_from <= posting_date) + .orderby(par.valid_from, order=frappe.qb.desc) + ).run(as_dict=True) + + cc_allocation = frappe._dict() + for d in records: + cc_allocation.setdefault(d.main_cost_center, frappe._dict())\ + .setdefault(d.cost_center, d.percentage) + + return cc_allocation def merge_similar_entries(gl_map, precision=None): merged_gl_map = [] @@ -145,6 +155,49 @@ def check_if_in_list(gle, gl_map, dimensions=None): if same_head: return e +def toggle_debit_credit_if_negative(gl_map): + for entry in gl_map: + # toggle debit, credit if negative entry + if flt(entry.debit) < 0: + entry.credit = flt(entry.credit) - flt(entry.debit) + entry.debit = 0.0 + + if flt(entry.debit_in_account_currency) < 0: + entry.credit_in_account_currency = \ + flt(entry.credit_in_account_currency) - flt(entry.debit_in_account_currency) + entry.debit_in_account_currency = 0.0 + + if flt(entry.credit) < 0: + entry.debit = flt(entry.debit) - flt(entry.credit) + entry.credit = 0.0 + + if flt(entry.credit_in_account_currency) < 0: + entry.debit_in_account_currency = \ + flt(entry.debit_in_account_currency) - flt(entry.credit_in_account_currency) + entry.credit_in_account_currency = 0.0 + + update_net_values(entry) + + return gl_map + +def update_net_values(entry): + # In some scenarios net value needs to be shown in the ledger + # This method updates net values as debit or credit + if entry.post_net_value and entry.debit and entry.credit: + if entry.debit > entry.credit: + entry.debit = entry.debit - entry.credit + entry.debit_in_account_currency = entry.debit_in_account_currency \ + - entry.credit_in_account_currency + entry.credit = 0 + entry.credit_in_account_currency = 0 + else: + entry.credit = entry.credit - entry.debit + entry.credit_in_account_currency = entry.credit_in_account_currency \ + - entry.debit_in_account_currency + + entry.debit = 0 + entry.debit_in_account_currency = 0 + def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False): if not from_repost: validate_cwip_accounts(gl_map) @@ -266,13 +319,18 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, """ if not gl_entries: - gl_entries = frappe.get_all("GL Entry", - fields = ["*"], - filters = { - "voucher_type": voucher_type, - "voucher_no": voucher_no, - "is_cancelled": 0 - }) + gl_entry = frappe.qb.DocType("GL Entry") + gl_entries = (frappe.qb.from_( + gl_entry + ).select( + '*' + ).where( + gl_entry.voucher_type == voucher_type + ).where( + gl_entry.voucher_no == voucher_no + ).where( + gl_entry.is_cancelled == 0 + ).for_update()).run(as_dict=1) if gl_entries: validate_accounting_period(gl_entries) @@ -280,23 +338,24 @@ def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None, set_as_cancel(gl_entries[0]['voucher_type'], gl_entries[0]['voucher_no']) for entry in gl_entries: - entry['name'] = None - debit = entry.get('debit', 0) - credit = entry.get('credit', 0) + new_gle = copy.deepcopy(entry) + new_gle['name'] = None + debit = new_gle.get('debit', 0) + credit = new_gle.get('credit', 0) - debit_in_account_currency = entry.get('debit_in_account_currency', 0) - credit_in_account_currency = entry.get('credit_in_account_currency', 0) + debit_in_account_currency = new_gle.get('debit_in_account_currency', 0) + credit_in_account_currency = new_gle.get('credit_in_account_currency', 0) - entry['debit'] = credit - entry['credit'] = debit - entry['debit_in_account_currency'] = credit_in_account_currency - entry['credit_in_account_currency'] = debit_in_account_currency + new_gle['debit'] = credit + new_gle['credit'] = debit + new_gle['debit_in_account_currency'] = credit_in_account_currency + new_gle['credit_in_account_currency'] = debit_in_account_currency - entry['remarks'] = "On cancellation of " + entry['voucher_no'] - entry['is_cancelled'] = 1 + new_gle['remarks'] = "On cancellation of " + new_gle['voucher_no'] + new_gle['is_cancelled'] = 1 - if entry['debit'] or entry['credit']: - make_entry(entry, adv_adj, "Yes") + if new_gle['debit'] or new_gle['credit']: + make_entry(new_gle, adv_adj, "Yes") def check_freezing_date(posting_date, adv_adj=False): diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 6b4b43d30b7..d6f6c5bcb69 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -58,7 +58,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company= frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError) party = frappe.get_doc(party_type, party) - currency = party.default_currency if party.get("default_currency") else get_company_currency(company) + currency = party.get("default_currency") or currency or get_company_currency(company) party_address, shipping_address = set_address_details(party_details, party, party_type, doctype, company, party_address, company_address, shipping_address) set_contact_details(party_details, party, party_type) @@ -307,7 +307,7 @@ def validate_party_gle_currency(party_type, party, company, party_account_curren .format(frappe.bold(party_type), frappe.bold(party), frappe.bold(existing_gle_currency), frappe.bold(company)), InvalidAccountCurrency) def validate_party_accounts(doc): - + from erpnext.controllers.accounts_controller import validate_account_head companies = [] for account in doc.get("accounts"): @@ -330,6 +330,9 @@ def validate_party_accounts(doc): if doc.default_currency != party_account_currency and doc.default_currency != company_default_currency: frappe.throw(_("Billing currency must be equal to either default company's currency or party account currency")) + # validate if account is mapped for same company + validate_account_head(account.idx, account.account, account.company) + @frappe.whitelist() def get_due_date(posting_date, party_type, party, company=None, bill_date=None): diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py b/erpnext/accounts/print_format/gst_e_invoice/__init__.py similarity index 100% rename from erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py rename to erpnext/accounts/print_format/gst_e_invoice/__init__.py diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html new file mode 100644 index 00000000000..e6580493095 --- /dev/null +++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html @@ -0,0 +1,173 @@ +{%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value -%} +{%- set einvoice = json.loads(doc.signed_einvoice) -%} + +
+
+ {% if letter_head and not no_letterhead %} +
{{ letter_head }}
+ {% endif %} + +
+ {% if print_settings.repeat_header_footer %} + + {% endif %} +
1. Transaction Details
+
+
+
+
+
{{ einvoice.Irn }}
+
+
+
+
{{ einvoice.AckNo }}
+
+
+
+
{{ frappe.utils.format_datetime(einvoice.AckDt, "dd/MM/yyyy hh:mm:ss") }}
+
+
+
+
{{ einvoice.TranDtls.SupTyp }}
+
+
+
+
{{ einvoice.DocDtls.Typ }}
+
+
+
+
{{ einvoice.DocDtls.No }}
+
+
+
+ +
+
+
2. Party Details
+
+ {%- set seller = einvoice.SellerDtls -%} +
+
Seller
+

{{ seller.Gstin }}

+

{{ seller.LglNm }}

+

{{ seller.Addr1 }}

+ {%- if seller.Addr2 -%}

{{ seller.Addr2 }}

{% endif %} +

{{ seller.Loc }}

+

{{ frappe.db.get_value("Address", doc.company_address, "gst_state") }} - {{ seller.Pin }}

+ + {%- if einvoice.ShipDtls -%} + {%- set shipping = einvoice.ShipDtls -%} +
Shipped From
+

{{ shipping.Gstin }}

+

{{ shipping.LglNm }}

+

{{ shipping.Addr1 }}

+ {%- if shipping.Addr2 -%}

{{ shipping.Addr2 }}

{% endif %} +

{{ shipping.Loc }}

+

{{ frappe.db.get_value("Address", doc.shipping_address_name, "gst_state") }} - {{ shipping.Pin }}

+ {% endif %} +
+ {%- set buyer = einvoice.BuyerDtls -%} +
+
Buyer
+

{{ buyer.Gstin }}

+

{{ buyer.LglNm }}

+

{{ buyer.Addr1 }}

+ {%- if buyer.Addr2 -%}

{{ buyer.Addr2 }}

{% endif %} +

{{ buyer.Loc }}

+

{{ frappe.db.get_value("Address", doc.customer_address, "gst_state") }} - {{ buyer.Pin }}

+ + {%- if einvoice.DispDtls -%} + {%- set dispatch = einvoice.DispDtls -%} +
Dispatched From
+ {%- if dispatch.Gstin -%}

{{ dispatch.Gstin }}

{% endif %} +

{{ dispatch.LglNm }}

+

{{ dispatch.Addr1 }}

+ {%- if dispatch.Addr2 -%}

{{ dispatch.Addr2 }}

{% endif %} +

{{ dispatch.Loc }}

+

{{ frappe.db.get_value("Address", doc.dispatch_address_name, "gst_state") }} - {{ dispatch.Pin }}

+ {% endif %} +
+
+
+
3. Item Details
+ + + + + + + + + + + + + + + + + + {% for item in einvoice.ItemList %} + + + + + + + + + + + + + + {% endfor %} + +
Sr. No.ItemHSN CodeQtyUOMRateDiscountTaxable AmountTax RateOther ChargesTotal
{{ item.SlNo }}{{ item.PrdDesc }}{{ item.HsnCd }}{{ item.Qty }}{{ item.Unit }}{{ frappe.utils.fmt_money(item.UnitPrice, None, "INR") }}{{ frappe.utils.fmt_money(item.Discount, None, "INR") }}{{ frappe.utils.fmt_money(item.AssAmt, None, "INR") }}{{ item.GstRt + item.CesRt }} %{{ frappe.utils.fmt_money(0, None, "INR") }}{{ frappe.utils.fmt_money(item.TotItemVal, None, "INR") }}
+
+
+
4. Value Details
+ + + + + + + + + + + + + + + + + {%- set value_details = einvoice.ValDtls -%} + + + + + + + + + + + + + +
Taxable AmountCGSTSGSTIGSTCESSState CESSDiscountOther ChargesRound OffTotal Value
{{ frappe.utils.fmt_money(value_details.AssVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.CgstVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.SgstVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.IgstVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.CesVal, None, "INR") }}{{ frappe.utils.fmt_money(0, None, "INR") }}{{ frappe.utils.fmt_money(value_details.Discount, None, "INR") }}{{ frappe.utils.fmt_money(value_details.OthChrg, None, "INR") }}{{ frappe.utils.fmt_money(value_details.RndOffAmt, None, "INR") }}{{ frappe.utils.fmt_money(value_details.TotInvVal, None, "INR") }}
+
+
diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json new file mode 100644 index 00000000000..1001199a092 --- /dev/null +++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json @@ -0,0 +1,24 @@ +{ + "align_labels_right": 1, + "creation": "2020-10-10 18:01:21.032914", + "custom_format": 0, + "default_print_language": "en-US", + "disabled": 1, + "doc_type": "Sales Invoice", + "docstatus": 0, + "doctype": "Print Format", + "font": "Default", + "html": "", + "idx": 0, + "line_breaks": 1, + "modified": "2020-10-23 19:54:40.634936", + "modified_by": "Administrator", + "module": "Accounts", + "name": "GST E-Invoice", + "owner": "Administrator", + "print_format_builder": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 1, + "standard": "Yes" +} \ No newline at end of file diff --git a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json b/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json deleted file mode 100644 index 1aa1c02968f..00000000000 --- a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "align_labels_right": 0, - "creation": "2017-08-08 12:33:04.773099", - "custom_format": 1, - "disabled": 0, - "doc_type": "Sales Invoice", - "docstatus": 0, - "doctype": "Print Format", - "font": "Default", - "html": "\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n

\n\t{{ doc.company }}
\n\t{% if doc.company_address_display %}\n\t\t{% set company_address = doc.company_address_display.replace(\"\\n\", \" \").replace(\"
\", \" \") %}\n\t\t{% if \"GSTIN\" not in company_address %}\n\t\t\t{{ company_address }}\n\t\t\t{{ _(\"GSTIN\") }}:{{ doc.company_gstin }}\n\t\t{% else %}\n\t\t\t{{ company_address.replace(\"GSTIN\", \"
GSTIN\") }}\n\t\t{% endif %}\n\t{% endif %}\n\t
\n\t{% if doc.docstatus == 0 %}\n\t\t{{ doc.status + \" \"+ (doc.select_print_heading or _(\"Invoice\")) }}
\n\t{% else %}\n\t\t{{ doc.select_print_heading or _(\"Invoice\") }}
\n\t{% endif %}\n

\n

\n\t{{ _(\"Receipt No\") }}: {{ doc.name }}
\n\t{{ _(\"Date\") }}: {{ doc.get_formatted(\"posting_date\") }}
\n\t{% if doc.grand_total > 50000 %}\n\t\t{% set customer_address = doc.address_display.replace(\"\\n\", \" \").replace(\"
\", \" \") %}\n\t\t{{ _(\"Customer\") }}:
\n\t\t{{ doc.customer_name }}
\n\t\t{{ customer_address }}\n\t{% endif %}\n

\n\n
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t{%- for item in doc.items -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- endfor -%}\n\t\n
{{ _(\"Item\") }}{{ _(\"Qty\") }}{{ _(\"Amount\") }}
\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t
{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.gst_hsn_code -%}\n\t\t\t\t\t
{{ _(\"HSN/SAC\") }}: {{ item.gst_hsn_code }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t
{{ _(\"Serial No\") }}: {{ item.serial_no }}\n\t\t\t\t{%- endif -%}\n\t\t\t
{{ item.qty }}
@ {{ item.rate }}
{{ item.get_formatted(\"amount\") }}
\n\n\t\n\t\t\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t\n\t\t\t\t\n\t\t\t{% else %}\n\t\t\t\t\n\t\t\t\t\n\t\t\t{% endif %}\n\t\t\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if (not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) and row.tax_amount != 0 -%}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\t\t{%- if doc.discount_amount -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- endif -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- if doc.rounded_total -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{%- endif -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t{%- if doc.change_amount -%}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t{%- endif -%}\n\t\n
\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t
\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t
\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Grand Total\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Rounded Total\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Paid Amount\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t
\n\t\t\t\t{{ _(\"Change Amount\") }}\n\t\t\t\n\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t
\n

{{ doc.terms or \"\" }}

\n

{{ _(\"Thank you, please visit again.\") }}

", - "idx": 0, - "line_breaks": 0, - "modified": "2020-04-29 16:39:12.936215", - "modified_by": "Administrator", - "module": "Accounts", - "name": "GST POS Invoice", - "owner": "Administrator", - "print_format_builder": 0, - "print_format_type": "Jinja", - "raw_printing": 0, - "show_section_headings": 0, - "standard": "Yes" -} \ No newline at end of file diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 4559fa94a4a..8e3bd8be1b9 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -5,7 +5,6 @@ import frappe from frappe import _, scrub from frappe.utils import cint, flt -from six import iteritems from erpnext.accounts.party import get_partywise_advanced_payment_amount from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport @@ -40,7 +39,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): if self.filters.show_gl_balance: gl_balance_map = get_gl_balance(self.filters.report_date) - for party, party_dict in iteritems(self.party_total): + for party, party_dict in self.party_total.items(): if party_dict.outstanding == 0: continue diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index dc1f7aae42e..f10a5eab102 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -120,11 +120,11 @@ def check_opening_balance(asset, liability, equity): opening_balance = 0 float_precision = cint(frappe.db.get_default("float_precision")) or 2 if asset: - opening_balance = flt(asset[0].get("opening_balance", 0), float_precision) + opening_balance = flt(asset[-1].get("opening_balance", 0), float_precision) if liability: - opening_balance -= flt(liability[0].get("opening_balance", 0), float_precision) + opening_balance -= flt(liability[-1].get("opening_balance", 0), float_precision) if equity: - opening_balance -= flt(equity[0].get("opening_balance", 0), float_precision) + opening_balance -= flt(equity[-1].get("opening_balance", 0), float_precision) opening_balance = flt(opening_balance, float_precision) if opening_balance: diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py index 6c401fb8f3b..b72d2669775 100644 --- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py +++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py @@ -4,7 +4,12 @@ import frappe from frappe import _ -from frappe.utils import flt, getdate, nowdate +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Sum +from frappe.utils import flt, getdate +from pypika import CustomFunction + +from erpnext.accounts.utils import get_balance_on def execute(filters=None): @@ -18,7 +23,6 @@ def execute(filters=None): data = get_entries(filters) - from erpnext.accounts.utils import get_balance_on balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) total_debit, total_credit = 0,0 @@ -118,7 +122,21 @@ def get_columns(): ] def get_entries(filters): - journal_entries = frappe.db.sql(""" + journal_entries = get_journal_entries(filters) + + payment_entries = get_payment_entries(filters) + + loan_entries = get_loan_entries(filters) + + pos_entries = [] + if filters.include_pos_transactions: + pos_entries = get_pos_entries(filters) + + return sorted(list(payment_entries)+list(journal_entries+list(pos_entries) + list(loan_entries)), + key=lambda k: getdate(k['posting_date'])) + +def get_journal_entries(filters): + return frappe.db.sql(""" select "Journal Entry" as payment_document, jv.posting_date, jv.name as payment_entry, jvd.debit_in_account_currency as debit, jvd.credit_in_account_currency as credit, jvd.against_account, @@ -130,7 +148,8 @@ def get_entries(filters): and ifnull(jv.clearance_date, '4000-01-01') > %(report_date)s and ifnull(jv.is_opening, 'No') = 'No'""", filters, as_dict=1) - payment_entries = frappe.db.sql(""" +def get_payment_entries(filters): + return frappe.db.sql(""" select "Payment Entry" as payment_document, name as payment_entry, reference_no, reference_date as ref_date, @@ -145,9 +164,8 @@ def get_entries(filters): and ifnull(clearance_date, '4000-01-01') > %(report_date)s """, filters, as_dict=1) - pos_entries = [] - if filters.include_pos_transactions: - pos_entries = frappe.db.sql(""" +def get_pos_entries(filters): + return frappe.db.sql(""" select "Sales Invoice Payment" as payment_document, sip.name as payment_entry, sip.amount as debit, si.posting_date, si.debit_to as against_account, sip.clearance_date, @@ -161,8 +179,42 @@ def get_entries(filters): si.posting_date ASC, si.name DESC """, filters, as_dict=1) - return sorted(list(payment_entries)+list(journal_entries+list(pos_entries)), - key=lambda k: k['posting_date'] or getdate(nowdate())) +def get_loan_entries(filters): + loan_docs = [] + for doctype in ["Loan Disbursement", "Loan Repayment"]: + loan_doc = frappe.qb.DocType(doctype) + ifnull = CustomFunction('IFNULL', ['value', 'default']) + + if doctype == "Loan Disbursement": + amount_field = (loan_doc.disbursed_amount).as_("credit") + posting_date = (loan_doc.disbursement_date).as_("posting_date") + account = loan_doc.disbursement_account + else: + amount_field = (loan_doc.amount_paid).as_("debit") + posting_date = (loan_doc.posting_date).as_("posting_date") + account = loan_doc.payment_account + + entries = frappe.qb.from_(loan_doc).select( + ConstantColumn(doctype).as_("payment_document"), + (loan_doc.name).as_("payment_entry"), + (loan_doc.reference_number).as_("reference_no"), + (loan_doc.reference_date).as_("ref_date"), + amount_field, + posting_date, + ).where( + loan_doc.docstatus == 1 + ).where( + account == filters.get('account') + ).where( + posting_date <= getdate(filters.get('report_date')) + ).where( + ifnull(loan_doc.clearance_date, '4000-01-01') > getdate(filters.get('report_date')) + ).run(as_dict=1) + + loan_docs.extend(entries) + + return loan_docs + def get_amounts_not_reflected_in_system(filters): je_amount = frappe.db.sql(""" @@ -182,7 +234,40 @@ def get_amounts_not_reflected_in_system(filters): pe_amount = flt(pe_amount[0][0]) if pe_amount else 0.0 - return je_amount + pe_amount + loan_amount = get_loan_amount(filters) + + return je_amount + pe_amount + loan_amount + +def get_loan_amount(filters): + total_amount = 0 + for doctype in ["Loan Disbursement", "Loan Repayment"]: + loan_doc = frappe.qb.DocType(doctype) + ifnull = CustomFunction('IFNULL', ['value', 'default']) + + if doctype == "Loan Disbursement": + amount_field = Sum(loan_doc.disbursed_amount) + posting_date = (loan_doc.disbursement_date).as_("posting_date") + account = loan_doc.disbursement_account + else: + amount_field = Sum(loan_doc.amount_paid) + posting_date = (loan_doc.posting_date).as_("posting_date") + account = loan_doc.payment_account + + amount = frappe.qb.from_(loan_doc).select( + amount_field + ).where( + loan_doc.docstatus == 1 + ).where( + account == filters.get('account') + ).where( + posting_date > getdate(filters.get('report_date')) + ).where( + ifnull(loan_doc.clearance_date, '4000-01-01') <= getdate(filters.get('report_date')) + ).run()[0][0] + + total_amount += flt(amount) + + return amount def get_balance_row(label, amount, account_currency): if amount > 0: diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index 3bb590a564d..56ee5008cf6 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -29,18 +29,6 @@ def execute(filters=None): dimension_items = cam_map.get(dimension) if dimension_items: data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, 0) - else: - DCC_allocation = frappe.db.sql('''SELECT parent, sum(percentage_allocation) as percentage_allocation - FROM `tabDistributed Cost Center` - WHERE cost_center IN %(dimension)s - AND parent NOT IN %(dimension)s - GROUP BY parent''',{'dimension':[dimension]}) - if DCC_allocation: - filters['budget_against_filter'] = [DCC_allocation[0][0]] - ddc_cam_map = get_dimension_account_month_map(filters) - dimension_items = ddc_cam_map.get(DCC_allocation[0][0]) - if dimension_items: - data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation[0][1]) chart = get_chart_data(filters, columns, data) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 758e3e93379..1e20f7be3e4 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -354,9 +354,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): if d.parent_account: account = d.parent_account_name - # if not accounts_by_name.get(account): - # continue - for company in companies: accounts_by_name[account][company] = \ accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0) @@ -367,7 +364,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0) def get_account_heads(root_type, companies, filters): - accounts = get_accounts(root_type, filters) + accounts = get_accounts(root_type, companies) if not accounts: return None, None, None @@ -396,7 +393,7 @@ def update_parent_account_names(accounts): for account in accounts: if account.parent_account: - account["parent_account_name"] = name_to_account_map[account.parent_account] + account["parent_account_name"] = name_to_account_map.get(account.parent_account) return accounts @@ -419,12 +416,19 @@ def get_subsidiary_companies(company): return frappe.db.sql_list("""select name from `tabCompany` where lft >= {0} and rgt <= {1} order by lft, rgt""".format(lft, rgt)) -def get_accounts(root_type, filters): - return frappe.db.sql(""" select name, is_group, company, - parent_account, lft, rgt, root_type, report_type, account_name, account_number - from - `tabAccount` where company = %s and root_type = %s - """ , (filters.get('company'), root_type), as_dict=1) +def get_accounts(root_type, companies): + accounts = [] + added_accounts = [] + + for company in companies: + for account in frappe.get_all("Account", fields=["name", "is_group", "company", + "parent_account", "lft", "rgt", "root_type", "report_type", "account_name", "account_number"], + filters={"company": company, "root_type": root_type}): + if account.account_name not in added_accounts: + accounts.append(account) + added_accounts.append(account.account_name) + + return accounts def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters): data = [] diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py index 1e89b650c71..db28cdfdd3c 100644 --- a/erpnext/accounts/report/financial_statements.py +++ b/erpnext/accounts/report/financial_statements.py @@ -282,7 +282,8 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency total_row = { "account_name": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)), "account": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)), - "currency": company_currency + "currency": company_currency, + "opening_balance": 0.0 } for row in out: @@ -294,6 +295,7 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency total_row.setdefault("total", 0.0) total_row["total"] += flt(row["total"]) + total_row["opening_balance"] += row["opening_balance"] row["total"] = "" if "total" in total_row: @@ -387,42 +389,15 @@ def set_gl_entries_by_account( key: value }) - distributed_cost_center_query = "" - if filters and filters.get('cost_center'): - distributed_cost_center_query = """ - UNION ALL - SELECT posting_date, - account, - debit*(DCC_allocation.percentage_allocation/100) as debit, - credit*(DCC_allocation.percentage_allocation/100) as credit, - is_opening, - fiscal_year, - debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency, - credit_in_account_currency*(DCC_allocation.percentage_allocation/100) as credit_in_account_currency, - account_currency - FROM `tabGL Entry`, - ( - SELECT parent, sum(percentage_allocation) as percentage_allocation - FROM `tabDistributed Cost Center` - WHERE cost_center IN %(cost_center)s - AND parent NOT IN %(cost_center)s - GROUP BY parent - ) as DCC_allocation - WHERE company=%(company)s - {additional_conditions} - AND posting_date <= %(to_date)s - AND is_cancelled = 0 - AND cost_center = DCC_allocation.parent - """.format(additional_conditions=additional_conditions.replace("and cost_center in %(cost_center)s ", '')) - - gl_entries = frappe.db.sql("""select posting_date, account, debit, credit, is_opening, fiscal_year, debit_in_account_currency, credit_in_account_currency, account_currency from `tabGL Entry` + gl_entries = frappe.db.sql(""" + select posting_date, account, debit, credit, is_opening, fiscal_year, + debit_in_account_currency, credit_in_account_currency, account_currency from `tabGL Entry` where company=%(company)s {additional_conditions} and posting_date <= %(to_date)s - and is_cancelled = 0 - {distributed_cost_center_query}""".format( - additional_conditions=additional_conditions, - distributed_cost_center_query=distributed_cost_center_query), gl_filters, as_dict=True) #nosec + and is_cancelled = 0""".format( + additional_conditions=additional_conditions), gl_filters, as_dict=True + ) if filters and filters.get('presentation_currency'): convert_to_presentation_currency(gl_entries, get_currency(filters), filters.get('company')) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 7f279205477..4ff0297dba7 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -176,44 +176,7 @@ def get_gl_entries(filters, accounting_dimensions): if accounting_dimensions: dimension_fields = ', '.join(accounting_dimensions) + ',' - distributed_cost_center_query = "" - if filters and filters.get('cost_center'): - select_fields_with_percentage = """, debit*(DCC_allocation.percentage_allocation/100) as debit, - credit*(DCC_allocation.percentage_allocation/100) as credit, - debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency, - credit_in_account_currency*(DCC_allocation.percentage_allocation/100) as credit_in_account_currency """ - - distributed_cost_center_query = """ - UNION ALL - SELECT name as gl_entry, - posting_date, - account, - party_type, - party, - voucher_type, - voucher_no, {dimension_fields} - cost_center, project, - against_voucher_type, - against_voucher, - account_currency, - remarks, against, - is_opening, `tabGL Entry`.creation {select_fields_with_percentage} - FROM `tabGL Entry`, - ( - SELECT parent, sum(percentage_allocation) as percentage_allocation - FROM `tabDistributed Cost Center` - WHERE cost_center IN %(cost_center)s - AND parent NOT IN %(cost_center)s - GROUP BY parent - ) as DCC_allocation - WHERE company=%(company)s - {conditions} - AND posting_date <= %(to_date)s - AND cost_center = DCC_allocation.parent - """.format(dimension_fields=dimension_fields,select_fields_with_percentage=select_fields_with_percentage, conditions=get_conditions(filters).replace("and cost_center in %(cost_center)s ", '')) - - gl_entries = frappe.db.sql( - """ + gl_entries = frappe.db.sql(""" select name as gl_entry, posting_date, account, party_type, party, voucher_type, voucher_no, {dimension_fields} @@ -222,13 +185,11 @@ def get_gl_entries(filters, accounting_dimensions): remarks, against, is_opening, creation {select_fields} from `tabGL Entry` where company=%(company)s {conditions} - {distributed_cost_center_query} {order_by_statement} - """.format( - dimension_fields=dimension_fields, select_fields=select_fields, conditions=get_conditions(filters), distributed_cost_center_query=distributed_cost_center_query, - order_by_statement=order_by_statement - ), - filters, as_dict=1) + """.format( + dimension_fields=dimension_fields, select_fields=select_fields, + conditions=get_conditions(filters), order_by_statement=order_by_statement + ), filters, as_dict=1) if filters.get('presentation_currency'): return convert_to_presentation_currency(gl_entries, currency_map, filters.get('company')) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js index 685f2d6176b..158ff4d3437 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.js +++ b/erpnext/accounts/report/gross_profit/gross_profit.js @@ -8,20 +8,22 @@ frappe.query_reports["Gross Profit"] = { "label": __("Company"), "fieldtype": "Link", "options": "Company", - "reqd": 1, - "default": frappe.defaults.get_user_default("Company") + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 }, { "fieldname":"from_date", "label": __("From Date"), "fieldtype": "Date", - "default": frappe.defaults.get_user_default("year_start_date") + "default": frappe.defaults.get_user_default("year_start_date"), + "reqd": 1 }, { "fieldname":"to_date", "label": __("To Date"), "fieldtype": "Date", - "default": frappe.defaults.get_user_default("year_end_date") + "default": frappe.defaults.get_user_default("year_end_date"), + "reqd": 1 }, { "fieldname":"sales_invoice", @@ -42,6 +44,11 @@ frappe.query_reports["Gross Profit"] = { "parent_field": "parent_invoice", "initial_depth": 3, "formatter": function(value, row, column, data, default_formatter) { + if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) { + column._options = "Sales Invoice"; + } else { + column._options = "Item"; + } value = default_formatter(value, row, column, data); if (data && (data.indent == 0.0 || row[1].content == "Total")) { diff --git a/erpnext/accounts/report/gross_profit/gross_profit.json b/erpnext/accounts/report/gross_profit/gross_profit.json index 76c560ad247..0730ffd77e5 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.json +++ b/erpnext/accounts/report/gross_profit/gross_profit.json @@ -1,5 +1,5 @@ { - "add_total_row": 0, + "add_total_row": 1, "columns": [], "creation": "2013-02-25 17:03:34", "disable_prepared_report": 0, @@ -9,7 +9,7 @@ "filters": [], "idx": 3, "is_standard": "Yes", - "modified": "2021-11-13 19:14:23.730198", + "modified": "2022-02-11 10:18:36.956558", "modified_by": "Administrator", "module": "Accounts", "name": "Gross Profit", diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 84effc0f467..b03bb9bb13f 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -70,43 +70,42 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ data.append(row) def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_columns, data): - for idx, src in enumerate(gross_profit_data.grouped_data): + for src in gross_profit_data.grouped_data: row = [] for col in group_wise_columns.get(scrub(filters.group_by)): row.append(src.get(col)) row.append(filters.currency) - if idx == len(gross_profit_data.grouped_data)-1: - row[0] = "Total" data.append(row) def get_columns(group_wise_columns, filters): columns = [] column_map = frappe._dict({ - "parent": _("Sales Invoice") + ":Link/Sales Invoice:120", - "invoice_or_item": _("Sales Invoice") + ":Link/Sales Invoice:120", - "posting_date": _("Posting Date") + ":Date:100", - "posting_time": _("Posting Time") + ":Data:100", - "item_code": _("Item Code") + ":Link/Item:100", - "item_name": _("Item Name") + ":Data:100", - "item_group": _("Item Group") + ":Link/Item Group:100", - "brand": _("Brand") + ":Link/Brand:100", - "description": _("Description") +":Data:100", - "warehouse": _("Warehouse") + ":Link/Warehouse:100", - "qty": _("Qty") + ":Float:80", - "base_rate": _("Avg. Selling Rate") + ":Currency/currency:100", - "buying_rate": _("Valuation Rate") + ":Currency/currency:100", - "base_amount": _("Selling Amount") + ":Currency/currency:100", - "buying_amount": _("Buying Amount") + ":Currency/currency:100", - "gross_profit": _("Gross Profit") + ":Currency/currency:100", - "gross_profit_percent": _("Gross Profit %") + ":Percent:100", - "project": _("Project") + ":Link/Project:100", - "sales_person": _("Sales person"), - "allocated_amount": _("Allocated Amount") + ":Currency/currency:100", - "customer": _("Customer") + ":Link/Customer:100", - "customer_group": _("Customer Group") + ":Link/Customer Group:100", - "territory": _("Territory") + ":Link/Territory:100" + "parent": {"label": _('Sales Invoice'), "fieldname": "parent_invoice", "fieldtype": "Link", "options": "Sales Invoice", "width": 120}, + "invoice_or_item": {"label": _('Sales Invoice'), "fieldtype": "Link", "options": "Sales Invoice", "width": 120}, + "posting_date": {"label": _('Posting Date'), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, + "posting_time": {"label": _('Posting Time'), "fieldname": "posting_time", "fieldtype": "Data", "width": 100}, + "item_code": {"label": _('Item Code'), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 100}, + "item_name": {"label": _('Item Name'), "fieldname": "item_name", "fieldtype": "Data", "width": 100}, + "item_group": {"label": _('Item Group'), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100}, + "brand": {"label": _('Brand'), "fieldtype": "Link", "options": "Brand", "width": 100}, + "description": {"label": _('Description'), "fieldname": "description", "fieldtype": "Data", "width": 100}, + "warehouse": {"label": _('Warehouse'), "fieldname": "warehouse", "fieldtype": "Link", "options": "warehouse", "width": 100}, + "qty": {"label": _('Qty'), "fieldname": "qty", "fieldtype": "Float", "width": 80}, + "base_rate": {"label": _('Avg. Selling Rate'), "fieldname": "avg._selling_rate", "fieldtype": "Currency", "options": "currency", "width": 100}, + "buying_rate": {"label": _('Valuation Rate'), "fieldname": "valuation_rate", "fieldtype": "Currency", "options": "currency", "width": 100}, + "base_amount": {"label": _('Selling Amount'), "fieldname": "selling_amount", "fieldtype": "Currency", "options": "currency", "width": 100}, + "buying_amount": {"label": _('Buying Amount'), "fieldname": "buying_amount", "fieldtype": "Currency", "options": "currency", "width": 100}, + "gross_profit": {"label": _('Gross Profit'), "fieldname": "gross_profit", "fieldtype": "Currency", "options": "currency", "width": 100}, + "gross_profit_percent": {"label": _('Gross Profit Percent'), "fieldname": "gross_profit_%", + "fieldtype": "Percent", "width": 100}, + "project": {"label": _('Project'), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 100}, + "sales_person": {"label": _('Sales Person'), "fieldname": "sales_person", "fieldtype": "Data","width": 100}, + "allocated_amount": {"label": _('Allocated Amount'), "fieldname": "allocated_amount", "fieldtype": "Currency", "options": "currency", "width": 100}, + "customer": {"label": _('Customer'), "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 100}, + "customer_group": {"label": _('Customer Group'), "fieldname": "customer_group", "fieldtype": "Link", "options": "customer", "width": 100}, + "territory": {"label": _('Territory'), "fieldname": "territory", "fieldtype": "Link", "options": "territory", "width": 100}, }) for col in group_wise_columns.get(scrub(filters.group_by)): @@ -173,7 +172,7 @@ class GrossProfitGenerator(object): buying_amount = 0 for row in reversed(self.si_list): - if self.skip_row(row, self.product_bundles): + if self.skip_row(row): continue row.base_amount = flt(row.base_net_amount, self.currency_precision) @@ -223,16 +222,6 @@ class GrossProfitGenerator(object): self.get_average_rate_based_on_group_by() def get_average_rate_based_on_group_by(self): - # sum buying / selling totals for group - self.totals = frappe._dict( - qty=0, - base_amount=0, - buying_amount=0, - gross_profit=0, - gross_profit_percent=0, - base_rate=0, - buying_rate=0 - ) for key in list(self.grouped): if self.filters.get("group_by") != "Invoice": for i, row in enumerate(self.grouped[key]): @@ -244,7 +233,6 @@ class GrossProfitGenerator(object): new_row.base_amount += flt(row.base_amount, self.currency_precision) new_row = self.set_average_rate(new_row) self.grouped_data.append(new_row) - self.add_to_totals(new_row) else: for i, row in enumerate(self.grouped[key]): if row.indent == 1.0: @@ -258,17 +246,6 @@ class GrossProfitGenerator(object): if (flt(row.qty) or row.base_amount): row = self.set_average_rate(row) self.grouped_data.append(row) - self.add_to_totals(row) - - self.set_average_gross_profit(self.totals) - - if self.filters.get("group_by") == "Invoice": - self.totals.indent = 0.0 - self.totals.parent_invoice = "" - self.totals.invoice_or_item = "Total" - self.si_list.append(self.totals) - else: - self.grouped_data.append(self.totals) def is_not_invoice_row(self, row): return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get("group_by") != "Invoice" @@ -284,11 +261,6 @@ class GrossProfitGenerator(object): new_row.gross_profit_percent = flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision) \ if new_row.base_amount else 0 - def add_to_totals(self, new_row): - for key in self.totals: - if new_row.get(key): - self.totals[key] += new_row[key] - def get_returned_invoice_items(self): returned_invoices = frappe.db.sql(""" select @@ -306,12 +278,12 @@ class GrossProfitGenerator(object): self.returned_invoices.setdefault(inv.return_against, frappe._dict())\ .setdefault(inv.item_code, []).append(inv) - def skip_row(self, row, product_bundles): + def skip_row(self, row): if self.filters.get("group_by") != "Invoice": if not row.get(scrub(self.filters.get("group_by", ""))): return True - elif row.get("is_return") == 1: - return True + + return False def get_buying_amount_from_product_bundle(self, row, product_bundle): buying_amount = 0.0 @@ -369,20 +341,37 @@ class GrossProfitGenerator(object): return self.average_buying_rate[item_code] def get_last_purchase_rate(self, item_code, row): - condition = '' - if row.project: - condition += " AND a.project=%s" % (frappe.db.escape(row.project)) - elif row.cost_center: - condition += " AND a.cost_center=%s" % (frappe.db.escape(row.cost_center)) - if self.filters.to_date: - condition += " AND modified='%s'" % (self.filters.to_date) + purchase_invoice = frappe.qb.DocType("Purchase Invoice") + purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") - last_purchase_rate = frappe.db.sql(""" - select (a.base_rate / a.conversion_factor) - from `tabPurchase Invoice Item` a - where a.item_code = %s and a.docstatus=1 - {0} - order by a.modified desc limit 1""".format(condition), item_code) + query = (frappe.qb.from_(purchase_invoice_item) + .inner_join( + purchase_invoice + ).on( + purchase_invoice.name == purchase_invoice_item.parent + ).select( + purchase_invoice_item.base_rate / purchase_invoice_item.conversion_factor + ).where( + purchase_invoice.docstatus == 1 + ).where( + purchase_invoice.posting_date <= self.filters.to_date + ).where( + purchase_invoice_item.item_code == item_code + )) + + if row.project: + query.where( + purchase_invoice_item.project == row.project + ) + + if row.cost_center: + query.where( + purchase_invoice_item.cost_center == row.cost_center + ) + + query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc) + query.limit(1) + last_purchase_rate = query.run() return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0 diff --git a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py index 3dcb86267c1..f4b8731ba87 100644 --- a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py +++ b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py @@ -109,7 +109,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name): def prepare_data(accounts, filters, total_row, parent_children_map, based_on): data = [] - new_accounts = accounts company_currency = frappe.get_cached_value('Company', filters.get("company"), "default_currency") for d in accounts: @@ -123,19 +122,6 @@ def prepare_data(accounts, filters, total_row, parent_children_map, based_on): "currency": company_currency, "based_on": based_on } - if based_on == 'cost_center': - cost_center_doc = frappe.get_doc("Cost Center",d.name) - if not cost_center_doc.enable_distributed_cost_center: - DCC_allocation = frappe.db.sql("""SELECT parent, sum(percentage_allocation) as percentage_allocation - FROM `tabDistributed Cost Center` - WHERE cost_center IN %(cost_center)s - AND parent NOT IN %(cost_center)s - GROUP BY parent""",{'cost_center': [d.name]}) - if DCC_allocation: - for account in new_accounts: - if account['name'] == DCC_allocation[0][0]: - for value in value_fields: - d[value] += account[value]*(DCC_allocation[0][1]/100) for key in value_fields: row[key] = flt(d.get(key, 0.0), 3) diff --git a/erpnext/accounts/report/tax_detail/test_tax_detail.py b/erpnext/accounts/report/tax_detail/test_tax_detail.py index bf668ab779d..621de825ea3 100644 --- a/erpnext/accounts/report/tax_detail/test_tax_detail.py +++ b/erpnext/accounts/report/tax_detail/test_tax_detail.py @@ -61,7 +61,7 @@ class TestTaxDetail(unittest.TestCase): # Create GL Entries: db_doc.submit() else: - db_doc.insert() + db_doc.insert(ignore_if_duplicate=True) except frappe.exceptions.DuplicateEntryError: pass diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index caee1a10bbb..e6cbff5d429 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -23,7 +23,7 @@ def validate_filters(filters): def get_result(filters, tds_docs, tds_accounts, tax_category_map): supplier_map = get_supplier_pan_map() tax_rate_map = get_tax_rate_map(filters) - gle_map = get_gle_map(filters, tds_docs) + gle_map = get_gle_map(tds_docs) out = [] for name, details in gle_map.items(): @@ -43,7 +43,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map): if entry.account in tds_accounts: tds_deducted += (entry.credit - entry.debit) - total_amount_credited += (entry.credit - entry.debit) + total_amount_credited += entry.credit if tds_deducted: row = { @@ -78,7 +78,7 @@ def get_supplier_pan_map(): return supplier_map -def get_gle_map(filters, documents): +def get_gle_map(documents): # create gle_map of the form # {"purchase_invoice": list of dict of all gle created for this invoice} gle_map = {} @@ -86,7 +86,7 @@ def get_gle_map(filters, documents): gle = frappe.db.get_all('GL Entry', { "voucher_no": ["in", documents], - "credit": (">", 0) + "is_cancelled": 0 }, ["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"], ) @@ -184,21 +184,28 @@ def get_tds_docs(filters): payment_entries = [] journal_entries = [] tax_category_map = {} + or_filters = {} + bank_accounts = frappe.get_all('Account', {'is_group': 0, 'account_type': 'Bank'}, pluck="name") tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')}, pluck="account") query_filters = { - "credit": ('>', 0), "account": ("in", tds_accounts), "posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]), - "is_cancelled": 0 + "is_cancelled": 0, + "against": ("not in", bank_accounts) } - if filters.get('supplier'): - query_filters.update({'against': filters.get('supplier')}) + if filters.get("supplier"): + del query_filters["account"] + del query_filters["against"] + or_filters = { + "against": filters.get('supplier'), + "party": filters.get('supplier') + } - tds_docs = frappe.get_all("GL Entry", query_filters, ["voucher_no", "voucher_type", "against", "party"]) + tds_docs = frappe.get_all("GL Entry", filters=query_filters, or_filters=or_filters, fields=["voucher_no", "voucher_type", "against", "party"]) for d in tds_docs: if d.voucher_type == "Purchase Invoice": diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py index 78c109ab947..4ed966dcb9d 100644 --- a/erpnext/accounts/test/test_reports.py +++ b/erpnext/accounts/test/test_reports.py @@ -39,10 +39,11 @@ class TestReports(unittest.TestCase): def test_execute_all_accounts_reports(self): """Test that all script report in stock modules are executable with supported filters""" for report, filter in REPORT_FILTER_TEST_CASES: - execute_script_report( - report_name=report, - module="Accounts", - filters=filter, - default_filters=DEFAULT_FILTERS, - optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, - ) + with self.subTest(report=report): + execute_script_report( + report_name=report, + module="Accounts", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 39e84e3ceff..b17b90ba6ee 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -847,7 +847,7 @@ def create_payment_gateway_account(gateway, payment_channel="Email"): "payment_account": bank_account.name, "currency": bank_account.account_currency, "payment_channel": payment_channel - }).insert(ignore_permissions=True) + }).insert(ignore_permissions=True, ignore_if_duplicate=True) except frappe.DuplicateEntryError: # already exists, due to a reinstall? diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json index 203ea20882f..a456c7fb57a 100644 --- a/erpnext/accounts/workspace/accounting/accounting.json +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -1023,6 +1023,17 @@ "onboard": 0, "type": "Link" }, + { + "dependencies": "Cost Center", + "hidden": 0, + "is_query_report": 0, + "label": "Cost Center Allocation", + "link_count": 0, + "link_to": "Cost Center Allocation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "dependencies": "Cost Center", "hidden": 0, diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 153f5c537a2..f414930d722 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -108,6 +108,10 @@ frappe.ui.form.on('Asset', { frm.trigger("create_asset_repair"); }, __("Manage")); + frm.add_custom_button(__("Split Asset"), function() { + frm.trigger("split_asset"); + }, __("Manage")); + if (frm.doc.status != 'Fully Depreciated') { frm.add_custom_button(__("Adjust Asset Value"), function() { frm.trigger("create_asset_value_adjustment"); @@ -322,6 +326,43 @@ frappe.ui.form.on('Asset', { }); }, + split_asset: function(frm) { + const title = __('Split Asset'); + + const fields = [ + { + fieldname: 'split_qty', + fieldtype: 'Int', + label: __('Split Qty'), + reqd: 1 + } + ]; + + let dialog = new frappe.ui.Dialog({ + title: title, + fields: fields + }); + + dialog.set_primary_action(__('Split'), function() { + const dialog_data = dialog.get_values(); + frappe.call({ + args: { + "asset_name": frm.doc.name, + "split_qty": cint(dialog_data.split_qty) + }, + method: "erpnext.assets.doctype.asset.asset.split_asset", + callback: function(r) { + let doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } + }); + + dialog.hide(); + }); + + dialog.show(); + }, + create_asset_value_adjustment: function(frm) { frappe.call({ args: { diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index 0a7c0413962..6e6bbf1cd29 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -3,7 +3,7 @@ "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", - "creation": "2016-03-01 17:01:27.920130", + "creation": "2022-01-18 02:26:55.975005", "doctype": "DocType", "document_type": "Document", "engine": "InnoDB", @@ -23,6 +23,7 @@ "asset_name", "asset_category", "location", + "split_from", "custodian", "department", "disposal_date", @@ -142,6 +143,7 @@ }, { "allow_on_submit": 1, + "fetch_from": "item_code.image", "fieldname": "image", "fieldtype": "Attach Image", "hidden": 1, @@ -482,6 +484,13 @@ "fieldtype": "Section Break", "label": "Finance Books" }, + { + "fieldname": "split_from", + "fieldtype": "Link", + "label": "Split From", + "options": "Asset", + "read_only": 1 + }, { "fieldname": "asset_quantity", "fieldtype": "Int", @@ -509,7 +518,7 @@ "link_fieldname": "asset" } ], - "modified": "2022-01-18 12:57:36.741192", + "modified": "2022-01-30 20:19:24.680027", "modified_by": "Administrator", "module": "Assets", "name": "Asset", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index ac64a95a53c..ea473fa7bb5 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -38,7 +38,8 @@ class Asset(AccountsController): self.validate_item() self.validate_cost_center() self.set_missing_values() - self.prepare_depreciation_data() + if not self.split_from: + self.prepare_depreciation_data() self.validate_gross_and_purchase_amount() if self.get("schedules"): self.validate_expected_value_after_useful_life() @@ -202,143 +203,143 @@ class Asset(AccountsController): start = self.clear_depreciation_schedule() for finance_book in self.get('finance_books'): - self.validate_asset_finance_books(finance_book) + self._make_depreciation_schedule(finance_book, start, date_of_sale) - # value_after_depreciation - current Asset value - if self.docstatus == 1 and finance_book.value_after_depreciation: - value_after_depreciation = flt(finance_book.value_after_depreciation) - else: - value_after_depreciation = (flt(self.gross_purchase_amount) - - flt(self.opening_accumulated_depreciation)) + def _make_depreciation_schedule(self, finance_book, start, date_of_sale): + self.validate_asset_finance_books(finance_book) - finance_book.value_after_depreciation = value_after_depreciation + value_after_depreciation = self._get_value_after_depreciation(finance_book) + finance_book.value_after_depreciation = value_after_depreciation - number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \ - cint(self.number_of_depreciations_booked) + number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \ + cint(self.number_of_depreciations_booked) - has_pro_rata = self.check_is_pro_rata(finance_book) + has_pro_rata = self.check_is_pro_rata(finance_book) + if has_pro_rata: + number_of_pending_depreciations += 1 - if has_pro_rata: - number_of_pending_depreciations += 1 + skip_row = False - skip_row = False + for n in range(start[finance_book.idx-1], number_of_pending_depreciations): + # If depreciation is already completed (for double declining balance) + if skip_row: continue - for n in range(start[finance_book.idx-1], number_of_pending_depreciations): - # If depreciation is already completed (for double declining balance) - if skip_row: continue + depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) - depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) + if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: + schedule_date = add_months(finance_book.depreciation_start_date, + n * cint(finance_book.frequency_of_depreciation)) - if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: - schedule_date = add_months(finance_book.depreciation_start_date, - n * cint(finance_book.frequency_of_depreciation)) + # schedule date will be a year later from start date + # so monthly schedule date is calculated by removing 11 months from it + monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1) - # schedule date will be a year later from start date - # so monthly schedule date is calculated by removing 11 months from it - monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1) - - # if asset is being sold - if date_of_sale: - from_date = self.get_from_date(finance_book.finance_book) - depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, - from_date, date_of_sale) - - if depreciation_amount > 0: - self.append("schedules", { - "schedule_date": date_of_sale, - "depreciation_amount": depreciation_amount, - "depreciation_method": finance_book.depreciation_method, - "finance_book": finance_book.finance_book, - "finance_book_id": finance_book.idx - }) - - break - - # For first row - if has_pro_rata and not self.opening_accumulated_depreciation and n==0: - from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too - depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, - from_date, finance_book.depreciation_start_date) - - # For first depr schedule date will be the start date - # so monthly schedule date is calculated by removing month difference between use date and start date - monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1) - - # For last row - elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: - if not self.flags.increase_in_asset_life: - # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission - self.to_date = add_months(self.available_for_use_date, - (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation)) - - depreciation_amount_without_pro_rata = depreciation_amount - - depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, - depreciation_amount, schedule_date, self.to_date) - - depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata, - depreciation_amount, finance_book.finance_book) - - monthly_schedule_date = add_months(schedule_date, 1) - schedule_date = add_days(schedule_date, days) - last_schedule_date = schedule_date - - if not depreciation_amount: continue - value_after_depreciation -= flt(depreciation_amount, - self.precision("gross_purchase_amount")) - - # Adjust depreciation amount in the last period based on the expected value after useful life - if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 - and value_after_depreciation != finance_book.expected_value_after_useful_life) - or value_after_depreciation < finance_book.expected_value_after_useful_life): - depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life) - skip_row = True + # if asset is being sold + if date_of_sale: + from_date = self.get_from_date(finance_book.finance_book) + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, + from_date, date_of_sale) if depreciation_amount > 0: - # With monthly depreciation, each depreciation is divided by months remaining until next date - if self.allow_monthly_depreciation: - # month range is 1 to 12 - # In pro rata case, for first and last depreciation, month range would be different - month_range = months \ - if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \ - else finance_book.frequency_of_depreciation + self._add_depreciation_row(date_of_sale, depreciation_amount, finance_book.depreciation_method, + finance_book.finance_book, finance_book.idx) - for r in range(month_range): - if (has_pro_rata and n == 0): - # For first entry of monthly depr - if r == 0: - days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date) - per_day_amt = depreciation_amount / days - depreciation_amount_for_current_month = per_day_amt * days_until_first_depr - depreciation_amount -= depreciation_amount_for_current_month - date = monthly_schedule_date - amount = depreciation_amount_for_current_month - else: - date = add_months(monthly_schedule_date, r) - amount = depreciation_amount / (month_range - 1) - elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1: - # For last entry of monthly depr - date = last_schedule_date - amount = depreciation_amount / month_range + break + + # For first row + if has_pro_rata and not self.opening_accumulated_depreciation and n==0: + from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, + from_date, finance_book.depreciation_start_date) + + # For first depr schedule date will be the start date + # so monthly schedule date is calculated by removing month difference between use date and start date + monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1) + + # For last row + elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: + if not self.flags.increase_in_asset_life: + # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission + self.to_date = add_months(self.available_for_use_date, + (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation)) + + depreciation_amount_without_pro_rata = depreciation_amount + + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, + depreciation_amount, schedule_date, self.to_date) + + depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata, + depreciation_amount, finance_book.finance_book) + + monthly_schedule_date = add_months(schedule_date, 1) + schedule_date = add_days(schedule_date, days) + last_schedule_date = schedule_date + + if not depreciation_amount: continue + value_after_depreciation -= flt(depreciation_amount, + self.precision("gross_purchase_amount")) + + # Adjust depreciation amount in the last period based on the expected value after useful life + if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 + and value_after_depreciation != finance_book.expected_value_after_useful_life) + or value_after_depreciation < finance_book.expected_value_after_useful_life): + depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life) + skip_row = True + + if depreciation_amount > 0: + # With monthly depreciation, each depreciation is divided by months remaining until next date + if self.allow_monthly_depreciation: + # month range is 1 to 12 + # In pro rata case, for first and last depreciation, month range would be different + month_range = months \ + if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \ + else finance_book.frequency_of_depreciation + + for r in range(month_range): + if (has_pro_rata and n == 0): + # For first entry of monthly depr + if r == 0: + days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date) + per_day_amt = depreciation_amount / days + depreciation_amount_for_current_month = per_day_amt * days_until_first_depr + depreciation_amount -= depreciation_amount_for_current_month + date = monthly_schedule_date + amount = depreciation_amount_for_current_month else: date = add_months(monthly_schedule_date, r) - amount = depreciation_amount / month_range + amount = depreciation_amount / (month_range - 1) + elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1: + # For last entry of monthly depr + date = last_schedule_date + amount = depreciation_amount / month_range + else: + date = add_months(monthly_schedule_date, r) + amount = depreciation_amount / month_range - self.append("schedules", { - "schedule_date": date, - "depreciation_amount": amount, - "depreciation_method": finance_book.depreciation_method, - "finance_book": finance_book.finance_book, - "finance_book_id": finance_book.idx - }) - else: - self.append("schedules", { - "schedule_date": schedule_date, - "depreciation_amount": depreciation_amount, - "depreciation_method": finance_book.depreciation_method, - "finance_book": finance_book.finance_book, - "finance_book_id": finance_book.idx - }) + self._add_depreciation_row(date, amount, finance_book.depreciation_method, + finance_book.finance_book, finance_book.idx) + else: + self._add_depreciation_row(schedule_date, depreciation_amount, finance_book.depreciation_method, + finance_book.finance_book, finance_book.idx) + + def _add_depreciation_row(self, schedule_date, depreciation_amount, depreciation_method, finance_book, finance_book_id): + self.append("schedules", { + "schedule_date": schedule_date, + "depreciation_amount": depreciation_amount, + "depreciation_method": depreciation_method, + "finance_book": finance_book, + "finance_book_id": finance_book_id + }) + + def _get_value_after_depreciation(self, finance_book): + # value_after_depreciation - current Asset value + if self.docstatus == 1 and finance_book.value_after_depreciation: + value_after_depreciation = flt(finance_book.value_after_depreciation) + else: + value_after_depreciation = (flt(self.gross_purchase_amount) - + flt(self.opening_accumulated_depreciation)) + + return value_after_depreciation # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales # JE: Journal Entry, FB: Finance Book @@ -348,7 +349,6 @@ class Asset(AccountsController): depr_schedule = [] for schedule in self.get('schedules'): - # to update start when there are JEs linked with all the schedule rows corresponding to an FB if len(start) == (int(schedule.finance_book_id) - 2): start.append(num_of_depreciations_completed) @@ -417,11 +417,12 @@ class Asset(AccountsController): def validate_asset_finance_books(self, row): if flt(row.expected_value_after_useful_life) >= flt(self.gross_purchase_amount): frappe.throw(_("Row {0}: Expected Value After Useful Life must be less than Gross Purchase Amount") - .format(row.idx)) + .format(row.idx), title=_("Invalid Schedule")) if not row.depreciation_start_date: if not self.available_for_use_date: - frappe.throw(_("Row {0}: Depreciation Start Date is required").format(row.idx)) + frappe.throw(_("Row {0}: Depreciation Start Date is required") + .format(row.idx), title=_("Invalid Schedule")) row.depreciation_start_date = get_last_day(self.available_for_use_date) if not self.is_existing_asset: @@ -439,8 +440,9 @@ class Asset(AccountsController): else: self.number_of_depreciations_booked = 0 - if cint(self.number_of_depreciations_booked) > cint(row.total_number_of_depreciations): - frappe.throw(_("Number of Depreciations Booked cannot be greater than Total Number of Depreciations")) + if flt(row.total_number_of_depreciations) <= cint(self.number_of_depreciations_booked): + frappe.throw(_("Row {0}: Total Number of Depreciations cannot be less than or equal to Number of Depreciations Booked") + .format(row.idx), title=_("Invalid Schedule")) if row.depreciation_start_date and getdate(row.depreciation_start_date) < getdate(self.purchase_date): frappe.throw(_("Depreciation Row {0}: Next Depreciation Date cannot be before Purchase Date") @@ -924,3 +926,113 @@ def get_depreciation_amount(asset, depreciable_value, row): depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) return depreciation_amount + +@frappe.whitelist() +def split_asset(asset_name, split_qty): + asset = frappe.get_doc("Asset", asset_name) + split_qty = cint(split_qty) + + if split_qty >= asset.asset_quantity: + frappe.throw(_("Split qty cannot be grater than or equal to asset qty")) + + remaining_qty = asset.asset_quantity - split_qty + + new_asset = create_new_asset_after_split(asset, split_qty) + update_existing_asset(asset, remaining_qty) + + return new_asset + +def update_existing_asset(asset, remaining_qty): + remaining_gross_purchase_amount = flt((asset.gross_purchase_amount * remaining_qty) / asset.asset_quantity) + opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * remaining_qty) / asset.asset_quantity) + + frappe.db.set_value("Asset", asset.name, { + 'opening_accumulated_depreciation': opening_accumulated_depreciation, + 'gross_purchase_amount': remaining_gross_purchase_amount, + 'asset_quantity': remaining_qty + }) + + for finance_book in asset.get('finance_books'): + value_after_depreciation = flt((finance_book.value_after_depreciation * remaining_qty)/asset.asset_quantity) + expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * remaining_qty)/asset.asset_quantity) + frappe.db.set_value('Asset Finance Book', finance_book.name, 'value_after_depreciation', value_after_depreciation) + frappe.db.set_value('Asset Finance Book', finance_book.name, 'expected_value_after_useful_life', expected_value_after_useful_life) + + accumulated_depreciation = 0 + + for term in asset.get('schedules'): + depreciation_amount = flt((term.depreciation_amount * remaining_qty)/asset.asset_quantity) + frappe.db.set_value('Depreciation Schedule', term.name, 'depreciation_amount', depreciation_amount) + accumulated_depreciation += depreciation_amount + frappe.db.set_value('Depreciation Schedule', term.name, 'accumulated_depreciation_amount', accumulated_depreciation) + +def create_new_asset_after_split(asset, split_qty): + new_asset = frappe.copy_doc(asset) + new_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.asset_quantity) + opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * split_qty) / asset.asset_quantity) + + new_asset.gross_purchase_amount = new_gross_purchase_amount + new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation + new_asset.asset_quantity = split_qty + new_asset.split_from = asset.name + accumulated_depreciation = 0 + + for finance_book in new_asset.get('finance_books'): + finance_book.value_after_depreciation = flt((finance_book.value_after_depreciation * split_qty)/asset.asset_quantity) + finance_book.expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * split_qty)/asset.asset_quantity) + + for term in new_asset.get('schedules'): + depreciation_amount = flt((term.depreciation_amount * split_qty)/asset.asset_quantity) + term.depreciation_amount = depreciation_amount + accumulated_depreciation += depreciation_amount + term.accumulated_depreciation_amount = accumulated_depreciation + + new_asset.submit() + new_asset.set_status() + + for term in new_asset.get('schedules'): + # Update references in JV + if term.journal_entry: + add_reference_in_jv_on_split(term.journal_entry, new_asset.name, asset.name, term.depreciation_amount) + + return new_asset + +def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount): + journal_entry = frappe.get_doc('Journal Entry', entry_name) + entries_to_add = [] + idx = len(journal_entry.get('accounts')) + 1 + + for account in journal_entry.get('accounts'): + if account.reference_name == old_asset_name: + entries_to_add.append(frappe.copy_doc(account).as_dict()) + if account.credit: + account.credit = account.credit - depreciation_amount + account.credit_in_account_currency = account.credit_in_account_currency - \ + account.exchange_rate * depreciation_amount + elif account.debit: + account.debit = account.debit - depreciation_amount + account.debit_in_account_currency = account.debit_in_account_currency - \ + account.exchange_rate * depreciation_amount + + for entry in entries_to_add: + entry.reference_name = new_asset_name + if entry.credit: + entry.credit = depreciation_amount + entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount + elif entry.debit: + entry.debit = depreciation_amount + entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount + + entry.idx = idx + idx += 1 + + journal_entry.append('accounts', entry) + + journal_entry.flags.ignore_validate_update_after_submit = True + journal_entry.save() + + # Repost GL Entries + journal_entry.docstatus = 2 + journal_entry.make_gl_entries(1) + journal_entry.docstatus = 1 + journal_entry.make_gl_entries() \ No newline at end of file diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index b9545f46a9b..ffd1065efc0 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -7,7 +7,7 @@ import frappe from frappe.utils import add_days, add_months, cstr, flt, get_last_day, getdate, nowdate from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice -from erpnext.assets.doctype.asset.asset import make_sales_invoice +from erpnext.assets.doctype.asset.asset import make_sales_invoice, split_asset from erpnext.assets.doctype.asset.depreciation import ( post_depreciation_entries, restore_asset, @@ -245,6 +245,57 @@ class TestAsset(AssetSetup): si.cancel() self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated") + def test_asset_splitting(self): + asset = create_asset( + calculate_depreciation = 1, + asset_quantity=10, + available_for_use_date = '2020-01-01', + purchase_date = '2020-01-01', + expected_value_after_useful_life = 0, + total_number_of_depreciations = 6, + number_of_depreciations_booked = 1, + frequency_of_depreciation = 10, + depreciation_start_date = '2021-01-01', + opening_accumulated_depreciation=20000, + gross_purchase_amount=120000, + submit = 1 + ) + + post_depreciation_entries(date="2021-01-01") + + self.assertEqual(asset.asset_quantity, 10) + self.assertEqual(asset.gross_purchase_amount, 120000) + self.assertEqual(asset.opening_accumulated_depreciation, 20000) + + new_asset = split_asset(asset.name, 2) + asset.load_from_db() + + self.assertEqual(new_asset.asset_quantity, 2) + self.assertEqual(new_asset.gross_purchase_amount, 24000) + self.assertEqual(new_asset.opening_accumulated_depreciation, 4000) + self.assertEqual(new_asset.split_from, asset.name) + self.assertEqual(new_asset.schedules[0].depreciation_amount, 4000) + self.assertEqual(new_asset.schedules[1].depreciation_amount, 4000) + + self.assertEqual(asset.asset_quantity, 8) + self.assertEqual(asset.gross_purchase_amount, 96000) + self.assertEqual(asset.opening_accumulated_depreciation, 16000) + self.assertEqual(asset.schedules[0].depreciation_amount, 16000) + self.assertEqual(asset.schedules[1].depreciation_amount, 16000) + + journal_entry = asset.schedules[0].journal_entry + + jv = frappe.get_doc('Journal Entry', journal_entry) + self.assertEqual(jv.accounts[0].credit_in_account_currency, 16000) + self.assertEqual(jv.accounts[1].debit_in_account_currency, 16000) + self.assertEqual(jv.accounts[2].credit_in_account_currency, 4000) + self.assertEqual(jv.accounts[3].debit_in_account_currency, 4000) + + self.assertEqual(jv.accounts[0].reference_name, asset.name) + self.assertEqual(jv.accounts[1].reference_name, asset.name) + self.assertEqual(jv.accounts[2].reference_name, new_asset.name) + self.assertEqual(jv.accounts[3].reference_name, new_asset.name) + def test_expense_head(self): pr = make_purchase_receipt(item_code="Macbook Pro", qty=2, rate=200000.0, location="Test Location") @@ -822,8 +873,9 @@ class TestDepreciationBasics(AssetSetup): self.assertRaises(frappe.ValidationError, asset.save) def test_number_of_depreciations(self): - """Tests if an error is raised when number_of_depreciations_booked > total_number_of_depreciations.""" + """Tests if an error is raised when number_of_depreciations_booked >= total_number_of_depreciations.""" + # number_of_depreciations_booked > total_number_of_depreciations asset = create_asset( item_code = "Macbook Pro", calculate_depreciation = 1, @@ -838,6 +890,21 @@ class TestDepreciationBasics(AssetSetup): self.assertRaises(frappe.ValidationError, asset.save) + # number_of_depreciations_booked = total_number_of_depreciations + asset_2 = create_asset( + item_code = "Macbook Pro", + calculate_depreciation = 1, + available_for_use_date = "2019-12-31", + total_number_of_depreciations = 5, + expected_value_after_useful_life = 10000, + depreciation_start_date = "2020-07-01", + opening_accumulated_depreciation = 10000, + number_of_depreciations_booked = 5, + do_not_save = 1 + ) + + self.assertRaises(frappe.ValidationError, asset_2.save) + def test_depreciation_start_date_is_before_purchase_date(self): asset = create_asset( item_code = "Macbook Pro", @@ -1197,7 +1264,8 @@ def create_asset(**args): "available_for_use_date": args.available_for_use_date or "2020-06-06", "location": args.location or "Test Location", "asset_owner": args.asset_owner or "Company", - "is_existing_asset": args.is_existing_asset or 1 + "is_existing_asset": args.is_existing_asset or 1, + "asset_quantity": args.get("asset_quantity") or 1 }) if asset.calculate_depreciation: @@ -1212,7 +1280,7 @@ def create_asset(**args): if not args.do_not_save: try: - asset.save() + asset.insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass @@ -1253,7 +1321,7 @@ def create_fixed_asset_item(item_code=None, auto_create_assets=1, is_grouped_ass "is_grouped_asset": is_grouped_asset, "asset_naming_series": naming_series }) - item.insert() + item.insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass return item diff --git a/erpnext/assets/doctype/asset_category/test_asset_category.py b/erpnext/assets/doctype/asset_category/test_asset_category.py index 3d19fa39d1e..2f52248edb0 100644 --- a/erpnext/assets/doctype/asset_category/test_asset_category.py +++ b/erpnext/assets/doctype/asset_category/test_asset_category.py @@ -23,7 +23,7 @@ class TestAssetCategory(unittest.TestCase): }) try: - asset_category.insert() + asset_category.insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass diff --git a/erpnext/patches/v14_0/__init__.py b/erpnext/bulk_transaction/__init__.py similarity index 100% rename from erpnext/patches/v14_0/__init__.py rename to erpnext/bulk_transaction/__init__.py diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/bulk_transaction/doctype/__init__.py similarity index 100% rename from erpnext/portal/doctype/products_settings/__init__.py rename to erpnext/bulk_transaction/doctype/__init__.py diff --git a/erpnext/portal/product_configurator/__init__.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py similarity index 100% rename from erpnext/portal/product_configurator/__init__.py rename to erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js new file mode 100644 index 00000000000..a739cc37306 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js @@ -0,0 +1,34 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Bulk Transaction Log', { + + before_load: function(frm) { + query(frm); + }, + + refresh: function(frm) { + frm.disable_save(); + frm.add_custom_button(__('Retry Failed Transactions'), ()=>{ + frappe.confirm(__("Retry Failing Transactions ?"), ()=>{ + query(frm); + } + ); + }); + } +}); + +function query(frm) { + frappe.call({ + method: "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction", + args: { + log_date: frm.doc.log_date + } + }).then((r) => { + if (r.message) { + frm.remove_custom_button("Retry Failed Transactions"); + } else { + frappe.show_alert(__("Retrying Failed Transactions"), 5); + } + }); +} \ No newline at end of file diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json new file mode 100644 index 00000000000..da42cf1bd4b --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-11-30 13:41:16.343827", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "log_date", + "logger_data" + ], + "fields": [ + { + "fieldname": "log_date", + "fieldtype": "Date", + "label": "Log Date", + "read_only": 1 + }, + { + "fieldname": "logger_data", + "fieldtype": "Table", + "label": "Logger Data", + "options": "Bulk Transaction Log Detail" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2022-02-03 17:23:02.935325", + "modified_by": "Administrator", + "module": "Bulk Transaction", + "name": "Bulk Transaction Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py new file mode 100644 index 00000000000..de7cde5a6d3 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -0,0 +1,66 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import date + +import frappe +from frappe.model.document import Document + +from erpnext.utilities.bulk_transaction import task, update_logger + + +class BulkTransactionLog(Document): + pass + + +@frappe.whitelist() +def retry_failing_transaction(log_date=None): + btp = frappe.qb.DocType("Bulk Transaction Log Detail") + data = ( + frappe.qb.from_(btp) + .select(btp.transaction_name, btp.from_doctype, btp.to_doctype) + .distinct() + .where(btp.retried != 1) + .where(btp.transaction_status == "Failed") + .where(btp.date == log_date) + ).run(as_dict=True) + + if data: + if not log_date: + log_date = str(date.today()) + if len(data) > 10: + frappe.enqueue(job, queue="long", job_name="bulk_retry", data=data, log_date=log_date) + else: + job(data, log_date) + else: + return "No Failed Records" + +def job(data, log_date): + for d in data: + failed = [] + try: + frappe.db.savepoint("before_creation_of_record") + task(d.transaction_name, d.from_doctype, d.to_doctype) + except Exception as e: + frappe.db.rollback(save_point="before_creation_of_record") + failed.append(e) + update_logger( + d.transaction_name, + e, + d.from_doctype, + d.to_doctype, + status="Failed", + log_date=log_date, + restarted=1 + ) + + if not failed: + update_logger( + d.transaction_name, + None, + d.from_doctype, + d.to_doctype, + status="Success", + log_date=log_date, + restarted=1, + ) diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py new file mode 100644 index 00000000000..a78e697b6f9 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py @@ -0,0 +1,81 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest +from datetime import date + +import frappe + +from erpnext.utilities.bulk_transaction import transaction_processing + + +class TestBulkTransactionLog(unittest.TestCase): + + def setUp(self): + create_company() + create_customer() + create_item() + + def test_for_single_record(self): + so_name = create_so() + transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice") + data = frappe.db.get_list("Sales Invoice", filters = {"posting_date": date.today(), "customer": "Bulk Customer"}, fields=["*"]) + if not data: + self.fail("No Sales Invoice Created !") + + def test_entry_in_log(self): + so_name = create_so() + transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice") + doc = frappe.get_doc("Bulk Transaction Log", str(date.today())) + for d in doc.get("logger_data"): + if d.transaction_name == so_name: + self.assertEqual(d.transaction_name, so_name) + self.assertEqual(d.transaction_status, "Success") + self.assertEqual(d.from_doctype, "Sales Order") + self.assertEqual(d.to_doctype, "Sales Invoice") + self.assertEqual(d.retried, 0) + + + +def create_company(): + if not frappe.db.exists('Company', '_Test Company'): + frappe.get_doc({ + 'doctype': 'Company', + 'company_name': '_Test Company', + 'country': 'India', + 'default_currency': 'INR' + }).insert() + +def create_customer(): + if not frappe.db.exists('Customer', 'Bulk Customer'): + frappe.get_doc({ + 'doctype': 'Customer', + 'customer_name': 'Bulk Customer' + }).insert() + +def create_item(): + if not frappe.db.exists("Item", "MK"): + frappe.get_doc({ + "doctype": "Item", + "item_code": "MK", + "item_name": "Milk", + "description": "Milk", + "item_group": "Products" + }).insert() + +def create_so(intent=None): + so = frappe.new_doc("Sales Order") + so.customer = "Bulk Customer" + so.company = "_Test Company" + so.transaction_date = date.today() + + so.set_warehouse = "Finished Goods - _TC" + so.append("items", { + "item_code": "MK", + "delivery_date": date.today(), + "qty": 10, + "rate": 80, + }) + so.insert() + so.submit() + return so.name \ No newline at end of file diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/__init__.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py similarity index 100% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/__init__.py rename to erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json new file mode 100644 index 00000000000..8262caa0209 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json @@ -0,0 +1,86 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-11-30 13:38:30.926047", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "transaction_name", + "date", + "time", + "transaction_status", + "error_description", + "from_doctype", + "to_doctype", + "retried" + ], + "fields": [ + { + "fieldname": "transaction_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Name", + "options": "from_doctype" + }, + { + "fieldname": "transaction_status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status", + "read_only": 1 + }, + { + "fieldname": "error_description", + "fieldtype": "Long Text", + "label": "Error Description", + "read_only": 1 + }, + { + "fieldname": "from_doctype", + "fieldtype": "Link", + "label": "From Doctype", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "to_doctype", + "fieldtype": "Link", + "label": "To Doctype", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date ", + "read_only": 1 + }, + { + "fieldname": "time", + "fieldtype": "Time", + "label": "Time", + "read_only": 1 + }, + { + "fieldname": "retried", + "fieldtype": "Int", + "label": "Retried", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-02-03 19:57:31.650359", + "modified_by": "Administrator", + "module": "Bulk Transaction", + "name": "Bulk Transaction Log Detail", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py new file mode 100644 index 00000000000..67795b9d490 --- /dev/null +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BulkTransactionLogDetail(Document): + pass diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index b828a43d3cf..50321baa2e2 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -6,14 +6,17 @@ "document_type": "Other", "engine": "InnoDB", "field_order": [ + "supplier_and_price_defaults_section", "supp_master_name", "supplier_group", + "column_break_4", "buying_price_list", "maintain_same_rate_action", "role_to_override_stop_action", - "column_break_3", + "transaction_settings_section", "po_required", "pr_required", + "column_break_12", "maintain_same_rate", "allow_multiple_items", "bill_for_rejected_quantity_in_purchase_invoice", @@ -42,10 +45,6 @@ "label": "Default Buying Price List", "options": "Price List" }, - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, { "fieldname": "po_required", "fieldtype": "Select", @@ -73,7 +72,7 @@ { "fieldname": "subcontract", "fieldtype": "Section Break", - "label": "Subcontract" + "label": "Subcontracting Settings" }, { "default": "Material Transferred for Subcontract", @@ -116,6 +115,24 @@ "fieldname": "bill_for_rejected_quantity_in_purchase_invoice", "fieldtype": "Check", "label": "Bill for Rejected Quantity in Purchase Invoice" + }, + { + "fieldname": "supplier_and_price_defaults_section", + "fieldtype": "Section Break", + "label": "Supplier and Price Defaults" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "transaction_settings_section", + "fieldtype": "Section Break", + "label": "Transaction Settings" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" } ], "icon": "fa fa-cog", @@ -123,7 +140,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-08 19:26:23.548837", + "modified": "2022-01-27 17:57:58.367048", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", @@ -141,5 +158,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/buying/doctype/purchase_order/purchase_order_list.js b/erpnext/buying/doctype/purchase_order/purchase_order_list.js index 8413eb65c3f..d7907e4274b 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order_list.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order_list.js @@ -29,8 +29,22 @@ frappe.listview_settings['Purchase Order'] = { listview.call_for_selected_items(method, { "status": "Closed" }); }); - listview.page.add_menu_item(__("Re-open"), function () { + listview.page.add_menu_item(__("Reopen"), function () { listview.call_for_selected_items(method, { "status": "Submitted" }); }); + + + listview.page.add_action_item(__("Purchase Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Invoice"); + }); + + listview.page.add_action_item(__("Purchase Receipt"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Receipt"); + }); + + listview.page.add_action_item(__("Advance Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Advance Payment"); + }); + } }; diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 9a63afc1303..645e97ee7c8 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -682,17 +682,18 @@ class TestPurchaseOrder(unittest.TestCase): bin1 = frappe.db.get_value("Bin", filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, - fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1) + fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1) # Submit PO po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes") bin2 = frappe.db.get_value("Bin", filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"}, - fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1) + fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1) self.assertEqual(bin2.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10) self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10) + self.assertNotEqual(bin1.modified, bin2.modified) # Create stock transfer rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item", diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 14d2ccdb50d..4f9ff43cd43 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -131,28 +131,6 @@ class Supplier(TransactionBase): if frappe.defaults.get_global_default('supp_master_name') == 'Supplier Name': frappe.db.set(self, "supplier_name", newdn) - def create_onboarding_docs(self, args): - company = frappe.defaults.get_defaults().get('company') or \ - frappe.db.get_single_value('Global Defaults', 'default_company') - - for i in range(1, args.get('max_count')): - supplier = args.get('supplier_name_' + str(i)) - if supplier: - try: - doc = frappe.get_doc({ - 'doctype': self.doctype, - 'supplier_name': supplier, - 'supplier_group': _('Local'), - 'company': company - }).insert() - - if args.get('supplier_email_' + str(i)): - from erpnext.selling.doctype.customer.customer import create_contact - create_contact(supplier, 'Supplier', - doc.name, args.get('supplier_email_' + str(i))) - except frappe.NameError: - pass - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters): diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index 13fe9df13ee..0fb81b2578d 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -14,151 +14,150 @@ test_records = frappe.get_test_records('Supplier') class TestSupplier(unittest.TestCase): - def test_get_supplier_group_details(self): - doc = frappe.new_doc("Supplier Group") - doc.supplier_group_name = "_Testing Supplier Group" - doc.payment_terms = "_Test Payment Term Template 3" - doc.accounts = [] - test_account_details = { - "company": "_Test Company", - "account": "Creditors - _TC", - } - doc.append("accounts", test_account_details) - doc.save() - s_doc = frappe.new_doc("Supplier") - s_doc.supplier_name = "Testing Supplier" - s_doc.supplier_group = "_Testing Supplier Group" - s_doc.payment_terms = "" - s_doc.accounts = [] - s_doc.insert() - s_doc.get_supplier_group_details() - self.assertEqual(s_doc.payment_terms, "_Test Payment Term Template 3") - self.assertEqual(s_doc.accounts[0].company, "_Test Company") - self.assertEqual(s_doc.accounts[0].account, "Creditors - _TC") - s_doc.delete() - doc.delete() + def test_get_supplier_group_details(self): + doc = frappe.new_doc("Supplier Group") + doc.supplier_group_name = "_Testing Supplier Group" + doc.payment_terms = "_Test Payment Term Template 3" + doc.accounts = [] + test_account_details = { + "company": "_Test Company", + "account": "Creditors - _TC", + } + doc.append("accounts", test_account_details) + doc.save() + s_doc = frappe.new_doc("Supplier") + s_doc.supplier_name = "Testing Supplier" + s_doc.supplier_group = "_Testing Supplier Group" + s_doc.payment_terms = "" + s_doc.accounts = [] + s_doc.insert() + s_doc.get_supplier_group_details() + self.assertEqual(s_doc.payment_terms, "_Test Payment Term Template 3") + self.assertEqual(s_doc.accounts[0].company, "_Test Company") + self.assertEqual(s_doc.accounts[0].account, "Creditors - _TC") + s_doc.delete() + doc.delete() - def test_supplier_default_payment_terms(self): - # Payment Term based on Days after invoice date - frappe.db.set_value( - "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 3") + def test_supplier_default_payment_terms(self): + # Payment Term based on Days after invoice date + frappe.db.set_value( + "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 3") - due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") - self.assertEqual(due_date, "2016-02-21") + due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") + self.assertEqual(due_date, "2016-02-21") - due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1") - self.assertEqual(due_date, "2017-02-21") + due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1") + self.assertEqual(due_date, "2017-02-21") - # Payment Term based on last day of month - frappe.db.set_value( - "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 1") + # Payment Term based on last day of month + frappe.db.set_value( + "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 1") - due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") - self.assertEqual(due_date, "2016-02-29") + due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") + self.assertEqual(due_date, "2016-02-29") - due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1") - self.assertEqual(due_date, "2017-02-28") + due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1") + self.assertEqual(due_date, "2017-02-28") - frappe.db.set_value("Supplier", "_Test Supplier With Template 1", "payment_terms", "") + frappe.db.set_value("Supplier", "_Test Supplier With Template 1", "payment_terms", "") - # Set credit limit for the supplier group instead of supplier and evaluate the due date - frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 3") + # Set credit limit for the supplier group instead of supplier and evaluate the due date + frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 3") - due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") - self.assertEqual(due_date, "2016-02-21") + due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") + self.assertEqual(due_date, "2016-02-21") - # Payment terms for Supplier Group instead of supplier and evaluate the due date - frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 1") + # Payment terms for Supplier Group instead of supplier and evaluate the due date + frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 1") - # Leap year - due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") - self.assertEqual(due_date, "2016-02-29") - # # Non Leap year - due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1") - self.assertEqual(due_date, "2017-02-28") + # Leap year + due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1") + self.assertEqual(due_date, "2016-02-29") + # # Non Leap year + due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1") + self.assertEqual(due_date, "2017-02-28") - # Supplier with no default Payment Terms Template - frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "") - frappe.db.set_value("Supplier", "_Test Supplier", "payment_terms", "") + # Supplier with no default Payment Terms Template + frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "") + frappe.db.set_value("Supplier", "_Test Supplier", "payment_terms", "") - due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier") - self.assertEqual(due_date, "2016-01-22") - # # Non Leap year - due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier") - self.assertEqual(due_date, "2017-01-22") + due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier") + self.assertEqual(due_date, "2016-01-22") + # # Non Leap year + due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier") + self.assertEqual(due_date, "2017-01-22") - def test_supplier_disabled(self): - make_test_records("Item") + def test_supplier_disabled(self): + make_test_records("Item") - frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1) + frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1) - from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order - po = create_purchase_order(do_not_save=True) + po = create_purchase_order(do_not_save=True) - self.assertRaises(PartyDisabled, po.save) + self.assertRaises(PartyDisabled, po.save) - frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0) + frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0) - po.save() + po.save() - def test_supplier_country(self): - # Test that country field exists in Supplier DocType - supplier = frappe.get_doc('Supplier', '_Test Supplier with Country') - self.assertTrue('country' in supplier.as_dict()) + def test_supplier_country(self): + # Test that country field exists in Supplier DocType + supplier = frappe.get_doc('Supplier', '_Test Supplier with Country') + self.assertTrue('country' in supplier.as_dict()) - # Test if test supplier field record is 'Greece' - self.assertEqual(supplier.country, "Greece") + # Test if test supplier field record is 'Greece' + self.assertEqual(supplier.country, "Greece") - # Test update Supplier instance country value - supplier = frappe.get_doc('Supplier', '_Test Supplier') - supplier.country = 'Greece' - supplier.save() - self.assertEqual(supplier.country, "Greece") + # Test update Supplier instance country value + supplier = frappe.get_doc('Supplier', '_Test Supplier') + supplier.country = 'Greece' + supplier.save() + self.assertEqual(supplier.country, "Greece") - def test_party_details_tax_category(self): - from erpnext.accounts.party import get_party_details + def test_party_details_tax_category(self): + from erpnext.accounts.party import get_party_details - frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing") + frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing") - # Tax Category without Address - details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier") - self.assertEqual(details.tax_category, "_Test Tax Category 1") + # Tax Category without Address + details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier") + self.assertEqual(details.tax_category, "_Test Tax Category 1") - address = frappe.get_doc(dict( - doctype='Address', - address_title='_Test Address With Tax Category', - tax_category='_Test Tax Category 2', - address_type='Billing', - address_line1='Station Road', - city='_Test City', - country='India', - links=[dict( - link_doctype='Supplier', - link_name='_Test Supplier With Tax Category' - )] - )).insert() + address = frappe.get_doc(dict( + doctype='Address', + address_title='_Test Address With Tax Category', + tax_category='_Test Tax Category 2', + address_type='Billing', + address_line1='Station Road', + city='_Test City', + country='India', + links=[dict( + link_doctype='Supplier', + link_name='_Test Supplier With Tax Category' + )] + )).insert() - # Tax Category with Address - details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier") - self.assertEqual(details.tax_category, "_Test Tax Category 2") + # Tax Category with Address + details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier") + self.assertEqual(details.tax_category, "_Test Tax Category 2") - # Rollback - address.delete() + # Rollback + address.delete() def create_supplier(**args): - args = frappe._dict(args) + args = frappe._dict(args) - try: - doc = frappe.get_doc({ - "doctype": "Supplier", - "supplier_name": args.supplier_name, - "supplier_group": args.supplier_group or "Services", - "supplier_type": args.supplier_type or "Company", - "tax_withholding_category": args.tax_withholding_category - }).insert() + if frappe.db.exists("Supplier", args.supplier_name): + return frappe.get_doc("Supplier", args.supplier_name) - return doc + doc = frappe.get_doc({ + "doctype": "Supplier", + "supplier_name": args.supplier_name, + "supplier_group": args.supplier_group or "Services", + "supplier_type": args.supplier_type or "Company", + "tax_withholding_category": args.tax_withholding_category + }).insert() - except frappe.DuplicateEntryError: - return frappe.get_doc("Supplier", args.supplier_name) + return doc diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index d65ab94a6d3..171de7882dc 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -142,6 +142,26 @@ def make_purchase_order(source_name, target_doc=None): return doclist +@frappe.whitelist() +def make_purchase_invoice(source_name, target_doc=None): + doc = get_mapped_doc("Supplier Quotation", source_name, { + "Supplier Quotation": { + "doctype": "Purchase Invoice", + "validation": { + "docstatus": ["=", 1], + } + }, + "Supplier Quotation Item": { + "doctype": "Purchase Invoice Item" + }, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges" + } + }, target_doc) + + return doc + + @frappe.whitelist() def make_quotation(source_name, target_doc=None): doclist = get_mapped_doc("Supplier Quotation", source_name, { diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js index 5ab6c980d00..73685caa0b4 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js @@ -8,5 +8,15 @@ frappe.listview_settings['Supplier Quotation'] = { } else if(doc.status==="Expired") { return [__("Expired"), "gray", "status,=,Expired"]; } + }, + + onload: function(listview) { + listview.page.add_action_item(__("Purchase Order"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Order"); + }); + + listview.page.add_action_item(__("Purchase Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Invoice"); + }); } }; diff --git a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py index 49e33517e6f..7908c35cbbe 100644 --- a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py @@ -49,7 +49,7 @@ valid_scorecard = [ "min_grade":0.0,"name":"Very Poor", "prevent_rfqs":1, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":30.0, "prevent_pos":1, "warn_pos":0, @@ -65,7 +65,7 @@ valid_scorecard = [ "name":"Poor", "prevent_rfqs":1, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":50.0, "prevent_pos":0, "warn_pos":0, @@ -81,7 +81,7 @@ valid_scorecard = [ "name":"Average", "prevent_rfqs":0, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":80.0, "prevent_pos":0, "warn_pos":0, @@ -97,7 +97,7 @@ valid_scorecard = [ "name":"Excellent", "prevent_rfqs":0, "notify_supplier":0, - "doctype":"Supplier Scorecard Standing", + "doctype":"Supplier Scorecard Scoring Standing", "max_grade":100.0, "prevent_pos":0, "warn_pos":0, diff --git a/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json b/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json deleted file mode 100644 index ce3d8cfb7b2..00000000000 --- a/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "add_more_button": 1, - "app": "ERPNext", - "creation": "2019-11-15 14:45:32.626641", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [ - { - "label": "Learn More", - "video_id": "zsrrVDk6VBs" - } - ], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:54:18.452038", - "modified_by": "Administrator", - "name": "Add A Few Suppliers", - "owner": "Administrator", - "ref_doctype": "Supplier", - "slide_desc": "", - "slide_fields": [ - { - "align": "", - "fieldname": "supplier_name", - "fieldtype": "Data", - "label": "Supplier Name", - "placeholder": "", - "reqd": 1 - }, - { - "align": "", - "fieldtype": "Column Break", - "reqd": 0 - }, - { - "align": "", - "fieldname": "supplier_email", - "fieldtype": "Data", - "label": "Supplier Email", - "reqd": 1 - } - ], - "slide_order": 50, - "slide_title": "Add A Few Suppliers", - "slide_type": "Create" -} \ No newline at end of file diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 4775f56a01b..a94af10cde4 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -167,9 +167,14 @@ class AccountsController(TransactionBase): validate_regional(self) + validate_einvoice_fields(self) + if self.doctype != 'Material Request': apply_pricing_rule_on_transaction(self) + def before_cancel(self): + validate_einvoice_fields(self) + def on_trash(self): # delete sl and gl entries on deletion of transaction if frappe.db.get_single_value('Accounts Settings', 'delete_linked_ledger_entries'): @@ -402,6 +407,22 @@ class AccountsController(TransactionBase): if item_qty != len(get_serial_nos(item.get('serial_no'))): item.set(fieldname, value) + elif ( + ret.get("pricing_rule_removed") + and value is not None + and fieldname + in [ + "discount_percentage", + "discount_amount", + "rate", + "margin_rate_or_amount", + "margin_type", + "remove_free_item", + ] + ): + # reset pricing rule fields if pricing_rule_removed + item.set(fieldname, value) + if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'): item.set('is_fixed_asset', ret.get('is_fixed_asset', 0)) @@ -1313,6 +1334,9 @@ class AccountsController(TransactionBase): payment_schedule['discount_type'] = schedule.discount_type payment_schedule['discount'] = schedule.discount + if not schedule.invoice_portion: + payment_schedule['payment_amount'] = schedule.payment_amount + self.append("payment_schedule", payment_schedule) def set_due_date(self): @@ -1542,13 +1566,12 @@ def validate_taxes_and_charges(tax): tax.rate = None -def validate_account_head(tax, doc): - company = frappe.get_cached_value('Account', - tax.account_head, 'company') +def validate_account_head(idx, account, company): + account_company = frappe.get_cached_value('Account', account, 'company') - if company != doc.company: + if account_company != company: frappe.throw(_('Row {0}: Account {1} does not belong to Company {2}') - .format(tax.idx, frappe.bold(tax.account_head), frappe.bold(doc.company)), title=_('Invalid Account')) + .format(idx, frappe.bold(account), frappe.bold(company)), title=_('Invalid Account')) def validate_cost_center(tax, doc): @@ -1931,7 +1954,8 @@ def update_bin_on_delete(row, doctype): qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) - update_bin_qty(row.item_code, row.warehouse, qty_dict) + if row.warehouse: + update_bin_qty(row.item_code, row.warehouse, qty_dict) def validate_and_delete_children(parent, data): deleted_children = [] @@ -2151,3 +2175,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil @erpnext.allow_regional def validate_regional(doc): pass + +@erpnext.allow_regional +def validate_einvoice_fields(doc): + pass diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index f088b9ffc22..b740476481f 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -70,9 +70,18 @@ class BuyingController(StockController, Subcontracting): # set contact and address details for supplier, if they are not mentioned if getattr(self, "supplier", None): - self.update_if_missing(get_party_details(self.supplier, party_type="Supplier", ignore_permissions=self.flags.ignore_permissions, - doctype=self.doctype, company=self.company, party_address=self.supplier_address, shipping_address=self.get('shipping_address'), - fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'))) + self.update_if_missing( + get_party_details( + self.supplier, + party_type="Supplier", + doctype=self.doctype, + company=self.company, + party_address=self.get("supplier_address"), + shipping_address=self.get('shipping_address'), + fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'), + ignore_permissions=self.flags.ignore_permissions + ) + ) self.set_missing_item_details(for_validate) @@ -240,6 +249,7 @@ class BuyingController(StockController, Subcontracting): "posting_time": self.get('posting_time'), "qty": -1 * flt(d.get('stock_qty')), "serial_no": d.get('serial_no'), + "batch_no": d.get("batch_no"), "company": self.company, "voucher_type": self.doctype, "voucher_no": self.name, @@ -269,7 +279,8 @@ class BuyingController(StockController, Subcontracting): "posting_date": self.posting_date, "posting_time": self.posting_time, "qty": -1 * d.consumed_qty, - "serial_no": d.serial_no + "serial_no": d.serial_no, + "batch_no": d.batch_no, }) if rate > 0: diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py index b8dc92efdeb..dd02ce17487 100644 --- a/erpnext/controllers/employee_boarding_controller.py +++ b/erpnext/controllers/employee_boarding_controller.py @@ -104,11 +104,11 @@ class EmployeeBoardingController(Document): def get_task_dates(self, activity, holiday_list): start_date = end_date = None - if activity.begin_on: + if activity.begin_on is not None: start_date = add_days(self.boarding_begins_on, activity.begin_on) start_date = self.update_if_holiday(start_date, holiday_list) - if activity.duration: + if activity.duration is not None: end_date = add_days(self.boarding_begins_on, activity.begin_on + activity.duration) end_date = self.update_if_holiday(end_date, holiday_list) @@ -132,13 +132,17 @@ class EmployeeBoardingController(Document): def on_cancel(self): # delete task project - for task in frappe.get_all('Task', filters={'project': self.project}): + project = self.project + for task in frappe.get_all('Task', filters={'project': project}): frappe.delete_doc('Task', task.name, force=1) - frappe.delete_doc('Project', self.project, force=1) + frappe.delete_doc('Project', project, force=1) self.db_set('project', '') for activity in self.activities: activity.db_set('task', '') + frappe.msgprint(_('Linked Project {} and Tasks deleted.').format( + project), alert=True, indicator='blue') + @frappe.whitelist() def get_onboarding_details(parent, parenttype): diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 2bad6f8e9f4..68ad702b979 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -132,7 +132,7 @@ def find_variant(template, args, variant_item_code=None): conditions = " or ".join(conditions) - from erpnext.portal.product_configurator.utils import get_item_codes_by_attributes + from erpnext.e_commerce.variant_selector.utils import get_item_codes_by_attributes possible_variants = [i for i in get_item_codes_by_attributes(args, template) if i != variant_item_code] for variant in possible_variants: @@ -262,9 +262,8 @@ def generate_keyed_value_combinations(args): def copy_attributes_to_variant(item, variant): # copy non no-copy fields - exclude_fields = ["naming_series", "item_code", "item_name", "show_in_website", - "show_variant_in_website", "opening_stock", "variant_of", "valuation_rate", - "has_variants", "attributes"] + exclude_fields = ["naming_series", "item_code", "item_name", "published_in_website", + "opening_stock", "variant_of", "valuation_rate"] if item.variant_based_on=='Manufacturer': # don't copy manufacturer values if based on part no diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index dc04dab84c3..dd9b45cc3f9 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -249,6 +249,9 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals del filters['customer'] else: del filters['supplier'] + else: + filters.pop('customer', None) + filters.pop('supplier', None) description_cond = '' @@ -707,6 +710,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters): item_doc = frappe.get_cached_doc('Item', filters.get('item_code')) item_group = filters.get('item_group') + company = filters.get('company') taxes = item_doc.taxes or [] while item_group: @@ -715,7 +719,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters): item_group = item_group_doc.parent_item_group if not taxes: - return frappe.db.sql(""" SELECT name FROM `tabItem Tax Template` """) + return frappe.get_all('Item Tax Template', filters={'disabled': 0, 'company': company}, as_list=True) else: valid_from = filters.get('valid_from') valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from @@ -724,7 +728,7 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters): 'item_code': filters.get('item_code'), 'posting_date': valid_from, 'tax_category': filters.get('tax_category'), - 'company': filters.get('company') + 'company': company } taxes = _get_item_tax_template(args, taxes, for_validate=True) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index df3c5f10c1b..8c3aab442bb 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -420,6 +420,7 @@ def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None "posting_time": sle.get('posting_time'), "qty": sle.actual_qty, "serial_no": sle.get('serial_no'), + "batch_no": sle.get("batch_no"), "company": sle.company, "voucher_type": sle.voucher_type, "voucher_no": sle.voucher_no diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 4ff851d7f94..e918cde7c48 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -74,7 +74,8 @@ class SellingController(StockController): doctype=self.doctype, company=self.company, posting_date=self.get('posting_date'), fetch_payment_terms_template=fetch_payment_terms_template, - party_address=self.customer_address, shipping_address=self.shipping_address_name) + party_address=self.customer_address, shipping_address=self.shipping_address_name, + company_address=self.get('company_address')) if not self.meta.get_field("sales_team"): party_details.pop("sales_team") self.update_if_missing(party_details) @@ -204,7 +205,7 @@ class SellingController(StockController): valuation_rate_map = {} for item in self.items: - if not item.item_code: + if not item.item_code or item.is_free_item: continue last_purchase_rate, is_stock_item = frappe.get_cached_value( @@ -251,7 +252,7 @@ class SellingController(StockController): valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate for item in self.items: - if not item.item_code: + if not item.item_code or item.is_free_item: continue last_valuation_rate = valuation_rate_map.get( @@ -393,6 +394,7 @@ class SellingController(StockController): "posting_time": self.get('posting_time') or nowtime(), "qty": qty if cint(self.get("is_return")) else (-1 * qty), "serial_no": d.get('serial_no'), + "batch_no": d.get("batch_no"), "company": self.company, "voucher_type": self.doctype, "voucher_no": self.name, diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 76a7cdab516..affde4aa8ab 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -400,6 +400,16 @@ class StatusUpdater(Document): ref_doc = frappe.get_doc(ref_dt, ref_dn) ref_doc.db_set("per_billed", per_billed) + + # set billling status + if hasattr(ref_doc, 'billing_status'): + if ref_doc.per_billed < 0.001: + ref_doc.db_set("billing_status", "Not Billed") + elif ref_doc.per_billed > 99.999999: + ref_doc.db_set("billing_status", "Fully Billed") + else: + ref_doc.db_set("billing_status", "Partly Billed") + ref_doc.set_status(update=True) def get_allowance_for(item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"): diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index b97432e7485..c8e5eddfeac 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -3,6 +3,7 @@ import json from collections import defaultdict +from typing import List, Tuple import frappe from frappe import _ @@ -40,7 +41,10 @@ class StockController(AccountsController): if self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) - if cint(erpnext.is_perpetual_inventory_enabled(self.company)): + provisional_accounting_for_non_stock_items = \ + cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items')) + + if cint(erpnext.is_perpetual_inventory_enabled(self.company)) or provisional_accounting_for_non_stock_items: warehouse_account = get_warehouse_account_map(self.company) if self.docstatus==1: @@ -77,17 +81,17 @@ class StockController(AccountsController): .format(d.idx, get_link_to_form("Batch", d.get("batch_no")))) def clean_serial_nos(self): + from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string + for row in self.get("items"): if hasattr(row, "serial_no") and row.serial_no: - # replace commas by linefeed - row.serial_no = row.serial_no.replace(",", "\n") + # remove extra whitespace and store one serial no on each line + row.serial_no = clean_serial_no_string(row.serial_no) - # strip preceeding and succeeding spaces for each SN - # (SN could have valid spaces in between e.g. SN - 123 - 2021) - serial_no_list = row.serial_no.split("\n") - serial_no_list = [sn.strip() for sn in serial_no_list] - - row.serial_no = "\n".join(serial_no_list) + for row in self.get('packed_items') or []: + if hasattr(row, "serial_no") and row.serial_no: + # remove extra whitespace and store one serial no on each line + row.serial_no = clean_serial_no_string(row.serial_no) def get_gl_entries(self, warehouse_account=None, default_expense_account=None, default_cost_center=None): @@ -178,33 +182,28 @@ class StockController(AccountsController): return details - def get_items_and_warehouses(self): - items, warehouses = [], [] + def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]: + """Get list of items and warehouses affected by a transaction""" - if hasattr(self, "items"): - item_doclist = self.get("items") - elif self.doctype == "Stock Reconciliation": - item_doclist = [] - data = json.loads(self.reconciliation_json) - for row in data[data.index(self.head_row)+1:]: - d = frappe._dict(zip(["item_code", "warehouse", "qty", "valuation_rate"], row)) - item_doclist.append(d) + if not (hasattr(self, "items") or hasattr(self, "packed_items")): + return [], [] - if item_doclist: - for d in item_doclist: - if d.item_code and d.item_code not in items: - items.append(d.item_code) + item_rows = (self.get("items") or []) + (self.get("packed_items") or []) - if d.get("warehouse") and d.warehouse not in warehouses: - warehouses.append(d.warehouse) + items = {d.item_code for d in item_rows if d.item_code} - if self.doctype == "Stock Entry": - if d.get("s_warehouse") and d.s_warehouse not in warehouses: - warehouses.append(d.s_warehouse) - if d.get("t_warehouse") and d.t_warehouse not in warehouses: - warehouses.append(d.t_warehouse) + warehouses = set() + for d in item_rows: + if d.get("warehouse"): + warehouses.add(d.warehouse) - return items, warehouses + if self.doctype == "Stock Entry": + if d.get("s_warehouse"): + warehouses.add(d.s_warehouse) + if d.get("t_warehouse"): + warehouses.add(d.t_warehouse) + + return list(items), list(warehouses) def get_stock_ledger_details(self): stock_ledger = {} @@ -216,7 +215,7 @@ class StockController(AccountsController): from `tabStock Ledger Entry` where - voucher_type=%s and voucher_no=%s + voucher_type=%s and voucher_no=%s and is_cancelled = 0 """, (self.doctype, self.name), as_dict=True) for sle in stock_ledger_entries: diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 075e3e38fa5..27766282277 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -106,6 +106,9 @@ class calculate_taxes_and_totals(object): self.doc.conversion_rate = flt(self.doc.conversion_rate) def calculate_item_values(self): + if self.doc.get('is_consolidated'): + return + if not self.discount_amount_applied: for item in self.doc.get("items"): self.doc.round_floats_in(item) @@ -647,12 +650,12 @@ class calculate_taxes_and_totals(object): def calculate_change_amount(self): self.doc.change_amount = 0.0 self.doc.base_change_amount = 0.0 + grand_total = self.doc.rounded_total or self.doc.grand_total + base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total if self.doc.doctype == "Sales Invoice" \ - and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \ + and self.doc.paid_amount > grand_total and not self.doc.is_return \ and any(d.type == "Cash" for d in self.doc.payments): - grand_total = self.doc.rounded_total or self.doc.grand_total - base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total self.doc.change_amount = flt(self.doc.paid_amount - grand_total + self.doc.write_off_amount, self.doc.precision("change_amount")) diff --git a/erpnext/controllers/tests/test_queries.py b/erpnext/controllers/tests/test_queries.py index 908d78c15bf..60d1733021c 100644 --- a/erpnext/controllers/tests/test_queries.py +++ b/erpnext/controllers/tests/test_queries.py @@ -56,6 +56,12 @@ class TestQueries(unittest.TestCase): bundled_stock_items = query(txt="_test product bundle item 5", filters={"is_stock_item": 1}) self.assertEqual(len(bundled_stock_items), 0) + # empty customer/supplier should be stripped of instead of failure + query(txt="", filters={"customer": None}) + query(txt="", filters={"customer": ""}) + query(txt="", filters={"supplier": None}) + query(txt="", filters={"supplier": ""}) + def test_bom_qury(self): query = add_default_params(queries.bom, "BOM") diff --git a/erpnext/crm/doctype/campaign/campaign.js b/erpnext/crm/doctype/campaign/campaign.js index 11bfa74b29c..cac45c682cb 100644 --- a/erpnext/crm/doctype/campaign/campaign.js +++ b/erpnext/crm/doctype/campaign/campaign.js @@ -5,7 +5,7 @@ frappe.ui.form.on('Campaign', { refresh: function(frm) { erpnext.toggle_naming_series(); - if (frm.doc.__islocal) { + if (frm.is_new()) { frm.toggle_display("naming_series", frappe.boot.sysdefaults.campaign_naming_by=="Naming Series"); } else { cur_frm.add_custom_button(__("View Leads"), function() { diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index bde52547c95..98cf7d845e0 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -1,9 +1,10 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document class CRMSettings(Document): - pass + def validate(self): + frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", "")) diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py index 8fd4978715f..d2ac10adea2 100644 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py @@ -2,13 +2,14 @@ # For license information, please see license.txt +from urllib.parse import urlencode + import frappe import requests from frappe import _ from frappe.model.document import Document from frappe.utils import get_url_to_form from frappe.utils.file_manager import get_file_path -from six.moves.urllib.parse import urlencode class LinkedInSettings(Document): diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js index f8376e6ca94..8e7d67e0575 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.js +++ b/erpnext/crm/doctype/opportunity/opportunity.js @@ -24,6 +24,14 @@ frappe.ui.form.on("Opportunity", { frm.trigger('set_contact_link'); } }, + + validate: function(frm) { + if (frm.doc.status == "Lost" && !frm.doc.lost_reasons.length) { + frm.trigger('set_as_lost_dialog'); + frappe.throw(__("Lost Reasons are required in case opportunity is Lost.")); + } + }, + contact_date: function(frm) { if(frm.doc.contact_date < frappe.datetime.now_datetime()){ frm.set_value("contact_date", ""); @@ -82,7 +90,7 @@ frappe.ui.form.on("Opportunity", { frm.trigger('setup_opportunity_from'); erpnext.toggle_naming_series(); - if(!doc.__islocal && doc.status!=="Lost") { + if(!frm.is_new() && doc.status!=="Lost") { if(doc.with_items){ frm.add_custom_button(__('Supplier Quotation'), function() { @@ -187,11 +195,11 @@ frappe.ui.form.on("Opportunity", { change_form_labels: function(frm) { let company_currency = erpnext.get_currency(frm.doc.company); - frm.set_currency_labels(["base_opportunity_amount", "base_total", "base_grand_total"], company_currency); - frm.set_currency_labels(["opportunity_amount", "total", "grand_total"], frm.doc.currency); + frm.set_currency_labels(["base_opportunity_amount", "base_total"], company_currency); + frm.set_currency_labels(["opportunity_amount", "total"], frm.doc.currency); // toggle fields - frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total", "base_grand_total"], + frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total"], frm.doc.currency != company_currency); }, @@ -209,20 +217,15 @@ frappe.ui.form.on("Opportunity", { }, calculate_total: function(frm) { - let total = 0, base_total = 0, grand_total = 0, base_grand_total = 0; + let total = 0, base_total = 0; frm.doc.items.forEach(item => { total += item.amount; base_total += item.base_amount; }) - base_grand_total = base_total + frm.doc.base_opportunity_amount; - grand_total = total + frm.doc.opportunity_amount; - frm.set_value({ 'total': flt(total), - 'base_total': flt(base_total), - 'grand_total': flt(grand_total), - 'base_grand_total': flt(base_grand_total) + 'base_total': flt(base_total) }); } diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index dc32d9a4124..089f2d2faa8 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -42,10 +42,8 @@ "items", "section_break_32", "base_total", - "base_grand_total", "column_break_33", "total", - "grand_total", "contact_info", "customer_address", "address_display", @@ -475,21 +473,6 @@ "fieldname": "column_break_33", "fieldtype": "Column Break" }, - { - "fieldname": "base_grand_total", - "fieldtype": "Currency", - "label": "Grand Total (Company Currency)", - "options": "Company:company:default_currency", - "print_hide": 1, - "read_only": 1 - }, - { - "fieldname": "grand_total", - "fieldtype": "Currency", - "label": "Grand Total", - "options": "currency", - "read_only": 1 - }, { "fieldname": "lost_detail_section", "fieldtype": "Section Break", @@ -510,7 +493,7 @@ "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2021-10-21 12:04:30.151379", + "modified": "2022-01-29 19:32:26.382896", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", @@ -547,6 +530,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "subject_field": "title", "timeline_field": "party_name", "title_field": "title", diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index a4fd7658eed..2d538748ec2 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -69,8 +69,6 @@ class Opportunity(TransactionBase): self.total = flt(total) self.base_total = flt(base_total) - self.grand_total = flt(self.total) + flt(self.opportunity_amount) - self.base_grand_total = flt(self.base_total) + flt(self.base_opportunity_amount) def make_new_lead_if_required(self): """Set lead against new opportunity""" diff --git a/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json b/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json index 8a8d4252daa..0cfcf0e0ea4 100644 --- a/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json +++ b/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json @@ -3,7 +3,7 @@ "allow_events_in_timeline": 0, "allow_guest_to_view": 0, "allow_import": 0, - "allow_rename": 0, + "allow_rename": 1, "autoname": "field:lost_reason", "beta": 0, "creation": "2018-12-28 14:48:51.044975", @@ -57,7 +57,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2018-12-28 14:49:43.336437", + "modified": "2022-02-16 10:49:43.336437", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity Lost Reason", @@ -150,4 +150,4 @@ "track_changes": 0, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} diff --git a/erpnext/crm/doctype/prospect/prospect.js b/erpnext/crm/doctype/prospect/prospect.js index 67018e1ef9c..8721a5b42d3 100644 --- a/erpnext/crm/doctype/prospect/prospect.js +++ b/erpnext/crm/doctype/prospect/prospect.js @@ -3,6 +3,8 @@ frappe.ui.form.on('Prospect', { refresh (frm) { + frappe.dynamic_link = { doc: frm.doc, fieldname: "name", doctype: frm.doctype }; + if (!frm.is_new() && frappe.boot.user.can_create.includes("Customer")) { frm.add_custom_button(__("Customer"), function() { frappe.model.open_mapped_doc({ diff --git a/erpnext/crm/module_onboarding/crm/crm.json b/erpnext/crm/module_onboarding/crm/crm.json index 8315218c842..0faad1d3151 100644 --- a/erpnext/crm/module_onboarding/crm/crm.json +++ b/erpnext/crm/module_onboarding/crm/crm.json @@ -16,7 +16,7 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/CRM", "idx": 0, "is_complete": 0, - "modified": "2020-07-08 14:05:42.644448", + "modified": "2022-01-29 20:14:29.502145", "modified_by": "Administrator", "module": "CRM", "name": "CRM", @@ -33,6 +33,9 @@ }, { "step": "Create and Send Quotation" + }, + { + "step": "CRM Settings" } ], "subtitle": "Lead, Opportunity, Customer, and more.", diff --git a/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json b/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json index 78f7e4de9c7..f0f50de5d16 100644 --- a/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json +++ b/erpnext/crm/onboarding_step/create_and_send_quotation/create_and_send_quotation.json @@ -5,7 +5,6 @@ "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, "modified": "2020-05-28 21:07:11.461172", @@ -13,6 +12,7 @@ "name": "Create and Send Quotation", "owner": "Administrator", "reference_document": "Quotation", + "show_form_tour": 0, "show_full_form": 1, "title": "Create and Send Quotation", "validate_action": 1 diff --git a/erpnext/crm/onboarding_step/create_lead/create_lead.json b/erpnext/crm/onboarding_step/create_lead/create_lead.json index c45e8b036c5..cb5cce66a5f 100644 --- a/erpnext/crm/onboarding_step/create_lead/create_lead.json +++ b/erpnext/crm/onboarding_step/create_lead/create_lead.json @@ -5,7 +5,6 @@ "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, "modified": "2020-05-28 21:07:01.373403", @@ -13,6 +12,7 @@ "name": "Create Lead", "owner": "Administrator", "reference_document": "Lead", + "show_form_tour": 0, "show_full_form": 1, "title": "Create Lead", "validate_action": 1 diff --git a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json index 0ee9317c852..96e0256d700 100644 --- a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json +++ b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json @@ -5,7 +5,6 @@ "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, "modified": "2021-01-21 15:28:52.483839", @@ -13,6 +12,7 @@ "name": "Create Opportunity", "owner": "Administrator", "reference_document": "Opportunity", + "show_form_tour": 0, "show_full_form": 1, "title": "Create Opportunity", "validate_action": 1 diff --git a/erpnext/crm/onboarding_step/crm_settings/crm_settings.json b/erpnext/crm/onboarding_step/crm_settings/crm_settings.json new file mode 100644 index 00000000000..555d795987f --- /dev/null +++ b/erpnext/crm/onboarding_step/crm_settings/crm_settings.json @@ -0,0 +1,21 @@ +{ + "action": "Go to Page", + "creation": "2022-01-29 20:14:24.803844", + "description": "# CRM Settings\n\nCRM module\u2019s features are configurable as per your business needs. CRM Settings is the place where you can set your preferences for:\n- Campaign\n- Lead\n- Opportunity\n- Quotation", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 1, + "is_skipped": 0, + "modified": "2022-01-29 20:14:24.803844", + "modified_by": "Administrator", + "name": "CRM Settings", + "owner": "Administrator", + "path": "#crm-settings/CRM%20Settings", + "reference_document": "CRM Settings", + "show_form_tour": 0, + "show_full_form": 0, + "title": "CRM Settings", + "validate_action": 1 +} \ No newline at end of file diff --git a/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json b/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json index fa26921ae2c..88717530945 100644 --- a/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json +++ b/erpnext/crm/onboarding_step/introduction_to_crm/introduction_to_crm.json @@ -5,13 +5,13 @@ "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, "modified": "2020-05-14 17:28:16.448676", "modified_by": "Administrator", "name": "Introduction to CRM", "owner": "Administrator", + "show_form_tour": 0, "show_full_form": 0, "title": "Introduction to CRM", "validate_action": 1, diff --git a/erpnext/shopping_cart/web_template/hero_slider/__init__.py b/erpnext/e_commerce/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/hero_slider/__init__.py rename to erpnext/e_commerce/__init__.py diff --git a/erpnext/e_commerce/api.py b/erpnext/e_commerce/api.py new file mode 100644 index 00000000000..43cb36ca2e2 --- /dev/null +++ b/erpnext/e_commerce/api.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe.utils import cint + +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder +from erpnext.e_commerce.product_data_engine.query import ProductQuery +from erpnext.setup.doctype.item_group.item_group import get_child_groups_for_website + + +@frappe.whitelist(allow_guest=True) +def get_product_filter_data(query_args=None): + """ + Returns filtered products and discount filters. + :param query_args (dict): contains filters to get products list + + Query Args filters: + search (str): Search Term. + field_filters (dict): Keys include item_group, brand, etc. + attribute_filters(dict): Keys include Color, Size, etc. + start (int): Offset items by + item_group (str): Valid Item Group + from_filters (bool): Set as True to jump to page 1 + """ + if isinstance(query_args, str): + query_args = json.loads(query_args) + + query_args = frappe._dict(query_args) + if query_args: + search = query_args.get("search") + field_filters = query_args.get("field_filters", {}) + attribute_filters = query_args.get("attribute_filters", {}) + start = cint(query_args.start) if query_args.get("start") else 0 + item_group = query_args.get("item_group") + from_filters = query_args.get("from_filters") + else: + search, attribute_filters, item_group, from_filters = None, None, None, None + field_filters = {} + start = 0 + + # if new filter is checked, reset start to show filtered items from page 1 + if from_filters: + start = 0 + + sub_categories = [] + if item_group: + field_filters['item_group'] = item_group + sub_categories = get_child_groups_for_website(item_group, immediate=True) + + engine = ProductQuery() + try: + result = engine.query( + attribute_filters, + field_filters, + search_term=search, + start=start, + item_group=item_group + ) + except Exception: + traceback = frappe.get_traceback() + frappe.log_error(traceback, frappe._("Product Engine Error")) + return {"exc": "Something went wrong!"} + + # discount filter data + filters = {} + discounts = result["discounts"] + + if discounts: + filter_engine = ProductFiltersBuilder() + filters["discount_filters"] = filter_engine.get_discount_filters(discounts) + + return { + "items": result["items"] or [], + "filters": filters, + "settings": engine.settings, + "sub_categories": sub_categories, + "items_count": result["items_count"] + } + +@frappe.whitelist(allow_guest=True) +def get_guest_redirect_on_action(): + return frappe.db.get_single_value("E Commerce Settings", "redirect_on_action") \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/item_card_group/__init__.py b/erpnext/e_commerce/doctype/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/item_card_group/__init__.py rename to erpnext/e_commerce/doctype/__init__.py diff --git a/erpnext/shopping_cart/web_template/product_card/__init__.py b/erpnext/e_commerce/doctype/e_commerce_settings/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/product_card/__init__.py rename to erpnext/e_commerce/doctype/e_commerce_settings/__init__.py diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js similarity index 60% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js rename to erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js index b38828e0d75..6302d260e0a 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js @@ -1,7 +1,7 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt -frappe.ui.form.on("Shopping Cart Settings", { +frappe.ui.form.on("E Commerce Settings", { onload: function(frm) { if(frm.doc.__onload && frm.doc.__onload.quotation_series) { frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series; @@ -23,6 +23,21 @@ frappe.ui.form.on("Shopping Cart Settings", { ` ); } + + frappe.model.with_doctype("Item", () => { + const web_item_meta = frappe.get_meta('Website Item'); + + const valid_fields = web_item_meta.fields.filter( + df => ["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden + ).map(df => ({ label: df.label, value: df.fieldname })); + + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'fieldtype', 'Select' + ); + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'options', valid_fields + ); + }); }, enabled: function(frm) { if (frm.doc.enabled === 1) { diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json new file mode 100644 index 00000000000..d5fb9697f89 --- /dev/null +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json @@ -0,0 +1,393 @@ +{ + "actions": [], + "creation": "2021-02-10 17:13:39.139103", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "products_per_page", + "filter_categories_section", + "enable_field_filters", + "filter_fields", + "enable_attribute_filters", + "filter_attributes", + "display_settings_section", + "hide_variants", + "enable_variants", + "show_price", + "column_break_9", + "show_stock_availability", + "show_quantity_in_website", + "allow_items_not_in_stock", + "column_break_13", + "show_apply_coupon_code_in_website", + "show_contact_us_button", + "show_attachments", + "section_break_18", + "company", + "price_list", + "enabled", + "store_page_docs", + "column_break_21", + "default_customer_group", + "quotation_series", + "checkout_settings_section", + "enable_checkout", + "show_price_in_quotation", + "column_break_27", + "save_quotations_as_draft", + "payment_gateway_account", + "payment_success_url", + "add_ons_section", + "enable_wishlist", + "column_break_22", + "enable_reviews", + "column_break_23", + "enable_recommendations", + "item_search_settings_section", + "redisearch_warning", + "search_index_fields", + "show_categories_in_search_autocomplete", + "is_redisearch_loaded", + "shop_by_category_section", + "slideshow", + "guest_display_settings_section", + "hide_price_for_guest", + "redirect_on_action" + ], + "fields": [ + { + "default": "6", + "fieldname": "products_per_page", + "fieldtype": "Int", + "label": "Products per Page" + }, + { + "collapsible": 1, + "fieldname": "filter_categories_section", + "fieldtype": "Section Break", + "label": "Filters and Categories" + }, + { + "default": "0", + "fieldname": "hide_variants", + "fieldtype": "Check", + "label": "Hide Variants" + }, + { + "default": "0", + "description": "The field filters will also work as categories in the Shop by Category page.", + "fieldname": "enable_field_filters", + "fieldtype": "Check", + "label": "Enable Field Filters (Categories)" + }, + { + "default": "0", + "fieldname": "enable_attribute_filters", + "fieldtype": "Check", + "label": "Enable Attribute Filters" + }, + { + "depends_on": "enable_field_filters", + "fieldname": "filter_fields", + "fieldtype": "Table", + "label": "Website Item Fields", + "options": "Website Filter Field" + }, + { + "depends_on": "enable_attribute_filters", + "fieldname": "filter_attributes", + "fieldtype": "Table", + "label": "Attributes", + "options": "Website Attribute" + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enable Shopping Cart" + }, + { + "depends_on": "doc.enabled", + "fieldname": "store_page_docs", + "fieldtype": "HTML" + }, + { + "fieldname": "display_settings_section", + "fieldtype": "Section Break", + "label": "Display Settings" + }, + { + "default": "0", + "fieldname": "show_attachments", + "fieldtype": "Check", + "label": "Show Public Attachments" + }, + { + "default": "0", + "fieldname": "show_price", + "fieldtype": "Check", + "label": "Show Price" + }, + { + "default": "0", + "fieldname": "show_stock_availability", + "fieldtype": "Check", + "label": "Show Stock Availability" + }, + { + "default": "0", + "fieldname": "enable_variants", + "fieldtype": "Check", + "label": "Enable Variant Selection" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "show_contact_us_button", + "fieldtype": "Check", + "label": "Show Contact Us Button" + }, + { + "default": "0", + "depends_on": "show_stock_availability", + "fieldname": "show_quantity_in_website", + "fieldtype": "Check", + "label": "Show Stock Quantity" + }, + { + "default": "0", + "fieldname": "show_apply_coupon_code_in_website", + "fieldtype": "Check", + "label": "Show Apply Coupon Code" + }, + { + "default": "0", + "fieldname": "allow_items_not_in_stock", + "fieldtype": "Check", + "label": "Allow items not in stock to be added to cart" + }, + { + "fieldname": "section_break_18", + "fieldtype": "Section Break", + "label": "Shopping Cart" + }, + { + "depends_on": "enabled", + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "mandatory_depends_on": "eval: doc.enabled === 1", + "options": "Company", + "remember_last_selected_value": 1 + }, + { + "depends_on": "enabled", + "description": "Prices will not be shown if Price List is not set", + "fieldname": "price_list", + "fieldtype": "Link", + "label": "Price List", + "mandatory_depends_on": "eval: doc.enabled === 1", + "options": "Price List" + }, + { + "fieldname": "column_break_21", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "default_customer_group", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Default Customer Group", + "mandatory_depends_on": "eval: doc.enabled === 1", + "options": "Customer Group" + }, + { + "depends_on": "enabled", + "fieldname": "quotation_series", + "fieldtype": "Select", + "label": "Quotation Series", + "mandatory_depends_on": "eval: doc.enabled === 1" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval:doc.enable_checkout", + "depends_on": "enabled", + "fieldname": "checkout_settings_section", + "fieldtype": "Section Break", + "label": "Checkout Settings" + }, + { + "default": "0", + "fieldname": "enable_checkout", + "fieldtype": "Check", + "label": "Enable Checkout" + }, + { + "default": "Orders", + "depends_on": "enable_checkout", + "description": "After payment completion redirect user to selected page.", + "fieldname": "payment_success_url", + "fieldtype": "Select", + "label": "Payment Success Url", + "mandatory_depends_on": "enable_checkout", + "options": "\nOrders\nInvoices\nMy Account" + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval: doc.enable_checkout == 0", + "fieldname": "save_quotations_as_draft", + "fieldtype": "Check", + "label": "Save Quotations as Draft" + }, + { + "depends_on": "enable_checkout", + "fieldname": "payment_gateway_account", + "fieldtype": "Link", + "label": "Payment Gateway Account", + "mandatory_depends_on": "enable_checkout", + "options": "Payment Gateway Account" + }, + { + "collapsible": 1, + "depends_on": "enable_field_filters", + "fieldname": "shop_by_category_section", + "fieldtype": "Section Break", + "label": "Shop by Category" + }, + { + "fieldname": "slideshow", + "fieldtype": "Link", + "label": "Slideshow", + "options": "Website Slideshow" + }, + { + "collapsible": 1, + "fieldname": "add_ons_section", + "fieldtype": "Section Break", + "label": "Add-ons" + }, + { + "default": "0", + "fieldname": "enable_wishlist", + "fieldtype": "Check", + "label": "Enable Wishlist" + }, + { + "default": "0", + "fieldname": "enable_reviews", + "fieldtype": "Check", + "label": "Enable Reviews and Ratings" + }, + { + "fieldname": "search_index_fields", + "fieldtype": "Small Text", + "label": "Search Index Fields", + "read_only_depends_on": "eval:!doc.is_redisearch_loaded" + }, + { + "collapsible": 1, + "fieldname": "item_search_settings_section", + "fieldtype": "Section Break", + "label": "Item Search Settings" + }, + { + "default": "1", + "fieldname": "show_categories_in_search_autocomplete", + "fieldtype": "Check", + "label": "Show Categories in Search Autocomplete", + "read_only_depends_on": "eval:!doc.is_redisearch_loaded" + }, + { + "default": "0", + "fieldname": "is_redisearch_loaded", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Redisearch Loaded" + }, + { + "depends_on": "eval:!doc.is_redisearch_loaded", + "fieldname": "redisearch_warning", + "fieldtype": "HTML", + "label": "Redisearch Warning", + "options": "

Redisearch is not loaded. If you want to use the advanced product search feature, refer here.

" + }, + { + "default": "0", + "depends_on": "eval:doc.show_price", + "fieldname": "hide_price_for_guest", + "fieldtype": "Check", + "label": "Hide Price for Guest" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "collapsible": 1, + "fieldname": "guest_display_settings_section", + "fieldtype": "Section Break", + "label": "Guest Display Settings" + }, + { + "description": "Link to redirect Guest on actions that need login such as add to cart, wishlist, etc. E.g.: /login", + "fieldname": "redirect_on_action", + "fieldtype": "Data", + "label": "Redirect on Action" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enable_recommendations", + "fieldtype": "Check", + "label": "Enable Recommendations" + }, + { + "default": "0", + "depends_on": "eval: doc.enable_checkout == 0", + "fieldname": "show_price_in_quotation", + "fieldtype": "Check", + "label": "Show Price in Quotation" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2021-09-02 14:02:44.785824", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "E Commerce Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py similarity index 52% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py rename to erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py index 4a755998dd4..dd7b114289d 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py @@ -1,25 +1,81 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import flt +from frappe.utils import comma_and, flt, unique + +from erpnext.e_commerce.redisearch_utils import ( + create_website_items_index, + get_indexable_web_fields, + is_search_module_loaded, +) class ShoppingCartSetupError(frappe.ValidationError): pass -class ShoppingCartSettings(Document): +class ECommerceSettings(Document): def onload(self): self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series") + self.is_redisearch_loaded = is_search_module_loaded() def validate(self): + self.validate_field_filters() + self.validate_attribute_filters() + self.validate_checkout() + self.validate_search_index_fields() + if self.enabled: self.validate_price_list_exchange_rate() + frappe.clear_document_cache("E Commerce Settings", "E Commerce Settings") + + def validate_field_filters(self): + if not (self.enable_field_filters and self.filter_fields): + return + + item_meta = frappe.get_meta("Item") + valid_fields = [df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]] + + for f in self.filter_fields: + if f.fieldname not in valid_fields: + frappe.throw(_("Filter Fields Row #{0}: Fieldname {1} must be of type 'Link' or 'Table MultiSelect'").format(f.idx, f.fieldname)) + + def validate_attribute_filters(self): + if not (self.enable_attribute_filters and self.filter_attributes): + return + + # if attribute filters are enabled, hide_variants should be disabled + self.hide_variants = 0 + + def validate_checkout(self): + if self.enable_checkout and not self.payment_gateway_account: + self.enable_checkout = 0 + + def validate_search_index_fields(self): + if not self.search_index_fields: + return + + fields = self.search_index_fields.replace(' ', '') + fields = unique(fields.strip(',').split(',')) # Remove extra ',' and remove duplicates + + # All fields should be indexable + allowed_indexable_fields = get_indexable_web_fields() + + if not (set(fields).issubset(allowed_indexable_fields)): + invalid_fields = list(set(fields).difference(allowed_indexable_fields)) + num_invalid_fields = len(invalid_fields) + invalid_fields = comma_and(invalid_fields) + + if num_invalid_fields > 1: + frappe.throw(_("{0} are not valid options for Search Index Field.").format(frappe.bold(invalid_fields))) + else: + frappe.throw(_("{0} is not a valid option for Search Index Field.").format(frappe.bold(invalid_fields))) + + self.search_index_fields = ','.join(fields) + def validate_price_list_exchange_rate(self): "Check if exchange rate exists for Price List currency (to Company's currency)." from erpnext.setup.utils import get_exchange_rate @@ -60,12 +116,23 @@ class ShoppingCartSettings(Document): def get_shipping_rules(self, shipping_territory): return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule") + def on_change(self): + old_doc = self.get_doc_before_save() + + if old_doc: + old_fields = old_doc.search_index_fields + new_fields = self.search_index_fields + + # if search index fields get changed + if not (new_fields == old_fields): + create_website_items_index() + def validate_cart_settings(doc=None, method=None): - frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings").run_method("validate") + frappe.get_doc("E Commerce Settings", "E Commerce Settings").run_method("validate") def get_shopping_cart_settings(): if not getattr(frappe.local, "shopping_cart_settings", None): - frappe.local.shopping_cart_settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings") + frappe.local.shopping_cart_settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings") return frappe.local.shopping_cart_settings diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py similarity index 67% rename from erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py rename to erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py index c3809b30b0f..86cef30d985 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py +++ b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py @@ -1,24 +1,21 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -# For license information, please see license.txt - - +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt import unittest import frappe -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( ShoppingCartSetupError, ) -class TestShoppingCartSettings(unittest.TestCase): +class TestECommerceSettings(unittest.TestCase): def setUp(self): frappe.db.sql("""delete from `tabSingles` where doctype="Shipping Cart Settings" """) def get_cart_settings(self): - return frappe.get_doc({"doctype": "Shopping Cart Settings", + return frappe.get_doc({"doctype": "E Commerce Settings", "company": "_Test Company"}) # NOTE: Exchangrate API has all enabled currencies that ERPNext supports. @@ -34,15 +31,17 @@ class TestShoppingCartSettings(unittest.TestCase): # cart_settings = self.get_cart_settings() # cart_settings.price_list = "_Test Price List Rest of the World" - # self.assertRaises(ShoppingCartSetupError, cart_settings.validate_price_list_exchange_rate) + # self.assertRaises(ShoppingCartSetupError, cart_settings.validate_exchange_rates_exist) - # from erpnext.setup.doctype.currency_exchange.test_currency_exchange import test_records as \ - # currency_exchange_records + # from erpnext.setup.doctype.currency_exchange.test_currency_exchange import ( + # test_records as currency_exchange_records, + # ) # frappe.get_doc(currency_exchange_records[0]).insert() - # cart_settings.validate_price_list_exchange_rate() + # cart_settings.validate_exchange_rates_exist() def test_tax_rule_validation(self): frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0") + frappe.db.commit() # nosemgrep cart_settings = self.get_cart_settings() cart_settings.enabled = 1 @@ -51,4 +50,13 @@ class TestShoppingCartSettings(unittest.TestCase): frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1") +def setup_e_commerce_settings(values_dict): + "Accepts a dict of values that updates E Commerce Settings." + if not values_dict: + return + + doc = frappe.get_doc("E Commerce Settings", "E Commerce Settings") + doc.update(values_dict) + doc.save() + test_dependencies = ["Tax Rule"] diff --git a/erpnext/shopping_cart/web_template/product_category_cards/__init__.py b/erpnext/e_commerce/doctype/item_review/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/product_category_cards/__init__.py rename to erpnext/e_commerce/doctype/item_review/__init__.py diff --git a/erpnext/e_commerce/doctype/item_review/item_review.js b/erpnext/e_commerce/doctype/item_review/item_review.js new file mode 100644 index 00000000000..a57c370287b --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/item_review.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Item Review', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/e_commerce/doctype/item_review/item_review.json b/erpnext/e_commerce/doctype/item_review/item_review.json new file mode 100644 index 00000000000..57f719fc3c4 --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/item_review.json @@ -0,0 +1,134 @@ +{ + "actions": [], + "beta": 1, + "creation": "2021-03-23 16:47:26.542226", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "website_item", + "user", + "customer", + "column_break_3", + "item", + "published_on", + "reviews_section", + "review_title", + "rating", + "comment" + ], + "fields": [ + { + "fieldname": "website_item", + "fieldtype": "Link", + "label": "Website Item", + "options": "Website Item", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "website_item.item_code", + "fieldname": "item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "reviews_section", + "fieldtype": "Section Break", + "label": "Reviews" + }, + { + "fieldname": "rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Rating", + "read_only": 1 + }, + { + "fieldname": "comment", + "fieldtype": "Small Text", + "label": "Comment", + "read_only": 1 + }, + { + "fieldname": "review_title", + "fieldtype": "Data", + "label": "Review Title", + "read_only": 1 + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer", + "read_only": 1 + }, + { + "fieldname": "published_on", + "fieldtype": "Data", + "label": "Published on", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-08-10 12:08:58.119691", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Item Review", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "Customer", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/item_review/item_review.py b/erpnext/e_commerce/doctype/item_review/item_review.py new file mode 100644 index 00000000000..966ec350e75 --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/item_review.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import datetime + +import frappe +from frappe import _ +from frappe.contacts.doctype.contact.contact import get_contact_name +from frappe.model.document import Document +from frappe.utils import cint, flt + +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( + get_shopping_cart_settings, +) + + +class UnverifiedReviewer(frappe.ValidationError): + pass + +class ItemReview(Document): + def after_insert(self): + # regenerate cache on review creation + reviews_dict = get_queried_reviews(self.website_item) + set_reviews_in_cache(self.website_item, reviews_dict) + + def after_delete(self): + # regenerate cache on review deletion + reviews_dict = get_queried_reviews(self.website_item) + set_reviews_in_cache(self.website_item, reviews_dict) + + +@frappe.whitelist() +def get_item_reviews(web_item, start=0, end=10, data=None): + "Get Website Item Review Data." + start, end = cint(start), cint(end) + settings = get_shopping_cart_settings() + + # Get cached reviews for first page (start=0) + # avoid cache when page is different + from_cache = not bool(start) + + if not data: + data = frappe._dict() + + if settings and settings.get("enable_reviews"): + reviews_cache = frappe.cache().hget("item_reviews", web_item) + if from_cache and reviews_cache: + data = reviews_cache + else: + data = get_queried_reviews(web_item, start, end, data) + if from_cache: + set_reviews_in_cache(web_item, data) + + return data + +def get_queried_reviews(web_item, start=0, end=10, data=None): + """ + Query Website Item wise reviews and cache if needed. + Cache stores only first page of reviews i.e. 10 reviews maximum. + Returns: + dict: Containing reviews, average ratings, % of reviews per rating and total reviews. + """ + if not data: + data = frappe._dict() + + data.reviews = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item}, + fields=["*"], + limit_start=start, + limit_page_length=end + ) + + rating_data = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item}, + fields=["avg(rating) as average, count(*) as total"] + )[0] + + data.average_rating = flt(rating_data.average, 1) + data.average_whole_rating = flt(data.average_rating, 0) + + # get % of reviews per rating + reviews_per_rating = [] + for i in range(1,6): + count = frappe.db.get_all( + "Item Review", + filters={"website_item": web_item, "rating": i}, + fields=["count(*) as count"] + )[0].count + + percent = flt((count / rating_data.total or 1) * 100, 0) if count else 0 + reviews_per_rating.append(percent) + + data.reviews_per_rating = reviews_per_rating + data.total_reviews = rating_data.total + + return data + +def set_reviews_in_cache(web_item, reviews_dict): + frappe.cache().hset("item_reviews", web_item, reviews_dict) + +@frappe.whitelist() +def add_item_review(web_item, title, rating, comment=None): + """ Add an Item Review by a user if non-existent. """ + if frappe.session.user == "Guest": + # guest user should not reach here ideally in the case they do via an API, throw error + frappe.throw(_("You are not verified to write a review yet."), exc=UnverifiedReviewer) + + if not frappe.db.exists("Item Review", {"user": frappe.session.user, "website_item": web_item}): + doc = frappe.get_doc({ + "doctype": "Item Review", + "user": frappe.session.user, + "customer": get_customer(), + "website_item": web_item, + "item": frappe.db.get_value("Website Item", web_item, "item_code"), + "review_title": title, + "rating": rating, + "comment": comment + }) + doc.published_on = datetime.today().strftime("%d %B %Y") + doc.insert() + +def get_customer(silent=False): + """ + silent: Return customer if exists else return nothing. Dont throw error. + """ + user = frappe.session.user + contact_name = get_contact_name(user) + customer = None + + if contact_name: + contact = frappe.get_doc('Contact', contact_name) + for link in contact.links: + if link.link_doctype == "Customer": + customer = link.link_name + break + + if customer: + return frappe.db.get_value("Customer", customer) + elif silent: + return None + else: + # should not reach here unless via an API + frappe.throw(_("You are not a verified customer yet. Please contact us to proceed."), + exc=UnverifiedReviewer) diff --git a/erpnext/e_commerce/doctype/item_review/test_item_review.py b/erpnext/e_commerce/doctype/item_review/test_item_review.py new file mode 100644 index 00000000000..8a4befc800a --- /dev/null +++ b/erpnext/e_commerce/doctype/item_review/test_item_review.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +import unittest + +import frappe +from frappe.core.doctype.user_permission.test_user_permission import create_user + +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( + setup_e_commerce_settings, +) +from erpnext.e_commerce.doctype.item_review.item_review import ( + UnverifiedReviewer, + add_item_review, + get_item_reviews, +) +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.shopping_cart.cart import get_party +from erpnext.stock.doctype.item.test_item import make_item + + +class TestItemReview(unittest.TestCase): + def setUp(self): + item = make_item("Test Mobile Phone") + if not frappe.db.exists("Website Item", {"item_code": "Test Mobile Phone"}): + make_website_item(item, save=True) + + setup_e_commerce_settings({"enable_reviews": 1}) + frappe.local.shopping_cart_settings = None + + def tearDown(self): + frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete() + setup_e_commerce_settings({"enable_reviews": 0}) + + def test_add_and_get_item_reviews_from_customer(self): + "Add / Get Reviews from a User that is a valid customer (has added to cart or purchased in the past)" + # create user + web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) + test_user = create_user("test_reviewer@example.com", "Customer") + frappe.set_user(test_user.name) + + # create customer and contact against user + customer = get_party() + + # post review on "Test Mobile Phone" + try: + add_item_review(web_item, "Great Product", 3, "Would recommend this product") + review_name = frappe.db.get_value("Item Review", {"website_item": web_item}) + except Exception: + self.fail(f"Error while publishing review for {web_item}") + + review_data = get_item_reviews(web_item, 0, 10) + + self.assertEqual(len(review_data.reviews), 1) + self.assertEqual(review_data.average_rating, 3) + self.assertEqual(review_data.reviews_per_rating[2], 100) + + # tear down + frappe.set_user("Administrator") + frappe.delete_doc("Item Review", review_name) + customer.delete() + + def test_add_item_review_from_non_customer(self): + "Check if logged in user (who is not a customer yet) is blocked from posting reviews." + web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) + test_user = create_user("test_reviewer@example.com", "Customer") + frappe.set_user(test_user.name) + + with self.assertRaises(UnverifiedReviewer): + add_item_review(web_item, "Great Product", 3, "Would recommend this product") + + # tear down + frappe.set_user("Administrator") + + def test_add_item_reviews_from_guest_user(self): + "Check if Guest user is blocked from posting reviews." + web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"}) + frappe.set_user("Guest") + + with self.assertRaises(UnverifiedReviewer): + add_item_review(web_item, "Great Product", 3, "Would recommend this product") + + # tear down + frappe.set_user("Administrator") diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.html b/erpnext/e_commerce/doctype/recommended_items/__init__.py similarity index 100% rename from erpnext/shopping_cart/web_template/product_card/product_card.html rename to erpnext/e_commerce/doctype/recommended_items/__init__.py diff --git a/erpnext/e_commerce/doctype/recommended_items/recommended_items.json b/erpnext/e_commerce/doctype/recommended_items/recommended_items.json new file mode 100644 index 00000000000..06ac3dc03b7 --- /dev/null +++ b/erpnext/e_commerce/doctype/recommended_items/recommended_items.json @@ -0,0 +1,87 @@ +{ + "actions": [], + "creation": "2021-07-12 20:52:12.503470", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "website_item", + "website_item_name", + "column_break_2", + "item_code", + "more_information_section", + "route", + "column_break_6", + "website_item_image", + "website_item_thumbnail" + ], + "fields": [ + { + "fieldname": "website_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Website Item", + "options": "Website Item" + }, + { + "fetch_from": "website_item.web_item_name", + "fieldname": "website_item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Website Item Name", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "more_information_section", + "fieldtype": "Section Break", + "label": "More Information" + }, + { + "fetch_from": "website_item.route", + "fieldname": "route", + "fieldtype": "Small Text", + "label": "Route", + "read_only": 1 + }, + { + "fetch_from": "website_item.image", + "fieldname": "website_item_image", + "fieldtype": "Attach", + "label": "Website Item Image", + "read_only": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fetch_from": "website_item.thumbnail", + "fieldname": "website_item_thumbnail", + "fieldtype": "Data", + "label": "Website Item Thumbnail", + "read_only": 1 + }, + { + "fetch_from": "website_item.item_code", + "fieldname": "item_code", + "fieldtype": "Data", + "label": "Item Code" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-07-13 21:02:19.031652", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Recommended Items", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/recommended_items/recommended_items.py b/erpnext/e_commerce/doctype/recommended_items/recommended_items.py new file mode 100644 index 00000000000..16b6e52047f --- /dev/null +++ b/erpnext/e_commerce/doctype/recommended_items/recommended_items.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class RecommendedItems(Document): + pass diff --git a/erpnext/e_commerce/doctype/website_item/__init__.py b/erpnext/e_commerce/doctype/website_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/doctype/website_item/templates/website_item.html b/erpnext/e_commerce/doctype/website_item/templates/website_item.html new file mode 100644 index 00000000000..db123090aae --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/templates/website_item.html @@ -0,0 +1,7 @@ +{% extends "templates/web.html" %} + +{% block page_content %} +

{{ title }}

+{% endblock %} + + \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html b/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html new file mode 100644 index 00000000000..d7014b453ab --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html @@ -0,0 +1,4 @@ +
+ {{ doc.title or doc.name }} +
+ diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py new file mode 100644 index 00000000000..b39e4dfb514 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest + +import frappe + +from erpnext.controllers.item_variant import create_variant +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( + get_shopping_cart_settings, +) +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( + setup_e_commerce_settings, +) +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website +from erpnext.stock.doctype.item.item import DataValidationError +from erpnext.stock.doctype.item.test_item import make_item + +WEBITEM_DESK_TESTS = ("test_website_item_desk_item_sync", "test_publish_variant_and_template") +WEBITEM_PRICE_TESTS = ('test_website_item_price_for_logged_in_user', 'test_website_item_price_for_guest_user') + +class TestWebsiteItem(unittest.TestCase): + @classmethod + def setUpClass(cls): + setup_e_commerce_settings({ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India" + }) + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + def setUp(self): + if self._testMethodName in WEBITEM_DESK_TESTS: + make_item("Test Web Item", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + { + "attribute": "Test Size" + } + ] + }) + elif self._testMethodName in WEBITEM_PRICE_TESTS: + create_user_and_customer_if_not_exists("test_contact_customer@example.com", "_Test Contact For _Test Customer") + create_regular_web_item() + make_web_item_price(item_code="Test Mobile Phone") + + # Note: When testing web item pricing rule logged-in user pricing rule must differ from guest pricing rule or test will falsely pass. + # This is because make_web_pricing_rule creates a pricing rule "selling": 1, without specifying "applicable_for". Therefor, + # when testing for logged-in user the test will get the previous pricing rule because "selling" is still true. + # + # I've attempted to mitigate this by setting applicable_for=Customer, and customer=Guest however, this only results in PermissionError failing the test. + make_web_pricing_rule( + title="Test Pricing Rule for Test Mobile Phone", + item_code="Test Mobile Phone", + selling=1) + make_web_pricing_rule( + title="Test Pricing Rule for Test Mobile Phone (Customer)", + item_code="Test Mobile Phone", + selling=1, + discount_percentage="25", + applicable_for="Customer", + customer="_Test Customer") + + def test_index_creation(self): + "Check if index is getting created in db." + from erpnext.e_commerce.doctype.website_item.website_item import on_doctype_update + on_doctype_update() + + indices = frappe.db.sql("show index from `tabWebsite Item`", as_dict=1) + expected_columns = {"route", "item_group", "brand"} + for index in indices: + expected_columns.discard(index.get("Column_name")) + + if expected_columns: + self.fail(f"Expected db index on these columns: {', '.join(expected_columns)}") + + def test_website_item_desk_item_sync(self): + "Check creation/updation/deletion of Website Item and its impact on Item master." + web_item = None + item = make_item("Test Web Item") # will return item if exists + try: + web_item = make_website_item(item, save=False) + web_item.save() + except Exception: + self.fail(f"Error while creating website item for {item}") + + # check if website item was created + self.assertTrue(bool(web_item)) + self.assertTrue(bool(web_item.route)) + + item.reload() + self.assertEqual(web_item.published, 1) + self.assertEqual(item.published_in_website, 1) # check if item was back updated + self.assertEqual(web_item.item_group, item.item_group) + + # check if changing item data changes it in website item + item.item_name = "Test Web Item 1" + item.stock_uom = "Unit" + item.save() + web_item.reload() + self.assertEqual(web_item.item_name, item.item_name) + self.assertEqual(web_item.stock_uom, item.stock_uom) + + # check if disabling item unpublished website item + item.disabled = 1 + item.save() + web_item.reload() + self.assertEqual(web_item.published, 0) + + # check if website item deletion, unpublishes desk item + web_item.delete() + item.reload() + self.assertEqual(item.published_in_website, 0) + + item.delete() + + def test_publish_variant_and_template(self): + "Check if template is published on publishing variant." + # template "Test Web Item" created on setUp + variant = create_variant("Test Web Item", {"Test Size": "Large"}) + variant.save() + + # check if template is not published + self.assertIsNone(frappe.db.exists("Website Item", {"item_code": variant.variant_of})) + + variant_web_item = make_website_item(variant, save=False) + variant_web_item.save() + + # check if template is published + try: + template_web_item = frappe.get_doc("Website Item", {"item_code": variant.variant_of}) + except frappe.DoesNotExistError: + self.fail(f"Template of {variant.item_code}, {variant.variant_of} not published") + + # teardown + variant_web_item.delete() + template_web_item.delete() + variant.delete() + + def test_impact_on_merging_items(self): + "Check if merging items is blocked if old and new items both have website items" + first_item = make_item("Test First Item") + second_item = make_item("Test Second Item") + + first_web_item = make_website_item(first_item, save=False) + first_web_item.save() + second_web_item = make_website_item(second_item, save=False) + second_web_item.save() + + with self.assertRaises(DataValidationError): + frappe.rename_doc("Item", "Test First Item", "Test Second Item", merge=True) + + # tear down + second_web_item.delete() + first_web_item.delete() + second_item.delete() + first_item.delete() + + # Website Item Portal Tests Begin + + def test_website_item_breadcrumbs(self): + "Check if breadcrumbs include homepage, product listing navigation page, parent item group(s) and item group." + from erpnext.setup.doctype.item_group.item_group import get_parent_item_groups + + item_code = "Test Breadcrumb Item" + item = make_item(item_code, { + "item_group": "_Test Item Group B - 1", + }) + + if not frappe.db.exists("Website Item", {"item_code": item_code}): + web_item = make_website_item(item, save=False) + web_item.save() + else: + web_item = frappe.get_cached_doc("Website Item", {"item_code": item_code}) + + frappe.db.set_value("Item Group", "_Test Item Group B - 1", "show_in_website", 1) + frappe.db.set_value("Item Group", "_Test Item Group B", "show_in_website", 1) + + breadcrumbs = get_parent_item_groups(item.item_group) + + self.assertEqual(breadcrumbs[0]["name"], "Home") + self.assertEqual(breadcrumbs[1]["name"], "Shop by Category") + self.assertEqual(breadcrumbs[2]["name"], "_Test Item Group B") # parent item group + self.assertEqual(breadcrumbs[3]["name"], "_Test Item Group B - 1") + + # tear down + web_item.delete() + item.delete() + + def test_website_item_price_for_logged_in_user(self): + "Check if price details are fetched correctly while logged in." + item_code = "Test Mobile Phone" + + # show price in e commerce settings + setup_e_commerce_settings({"show_price": 1}) + + # price and pricing rule added via setUp + + # login as customer with pricing rule + frappe.set_user("test_contact_customer@example.com") + + # check if price and slashed price is fetched correctly + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertTrue(bool(data.product_info["price"])) + + price_object = data.product_info["price"] + self.assertEqual(price_object.get("discount_percent"), 25) + self.assertEqual(price_object.get("price_list_rate"), 750) + self.assertEqual(price_object.get("formatted_mrp"), "₹ 1,000.00") + self.assertEqual(price_object.get("formatted_price"), "₹ 750.00") + self.assertEqual(price_object.get("formatted_discount_percent"), "25%") + + # switch to admin and disable show price + frappe.set_user("Administrator") + setup_e_commerce_settings({"show_price": 0}) + + # price should not be fetched for logged in user. + frappe.set_user("test_contact_customer@example.com") + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["price"])) + + # tear down + frappe.set_user("Administrator") + + def test_website_item_price_for_guest_user(self): + "Check if price details are fetched correctly for guest user." + item_code = "Test Mobile Phone" + + # show price for guest user in e commerce settings + setup_e_commerce_settings({ + "show_price": 1, + "hide_price_for_guest": 0 + }) + + # price and pricing rule added via setUp + + # switch to guest user + frappe.set_user("Guest") + + # price should be fetched + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertTrue(bool(data.product_info["price"])) + + price_object = data.product_info["price"] + self.assertEqual(price_object.get("discount_percent"), 10) + self.assertEqual(price_object.get("price_list_rate"), 900) + + # hide price for guest user + frappe.set_user("Administrator") + setup_e_commerce_settings({"hide_price_for_guest": 1}) + frappe.set_user("Guest") + + # price should not be fetched + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["price"])) + + # tear down + frappe.set_user("Administrator") + + def test_website_item_stock_when_out_of_stock(self): + """ + Check if stock details are fetched correctly for empty inventory when: + 1) Showing stock availability enabled: + - Warehouse unset + - Warehouse set + 2) Showing stock availability disabled + """ + item_code = "Test Mobile Phone" + create_regular_web_item() + setup_e_commerce_settings({"show_stock_availability": 1}) + + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + + # check if stock details are fetched and item not in stock without warehouse set + self.assertFalse(bool(data.product_info["in_stock"])) + self.assertFalse(bool(data.product_info["stock_qty"])) + + # set warehouse + frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "_Test Warehouse - _TC") + + # check if stock details are fetched and item not in stock with warehouse set + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["in_stock"])) + self.assertEqual(data.product_info["stock_qty"][0][0], 0) + + # disable show stock availability + setup_e_commerce_settings({"show_stock_availability": 0}) + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + + # check if stock detail attributes are not fetched if stock availability is hidden + self.assertIsNone(data.product_info.get("in_stock")) + self.assertIsNone(data.product_info.get("stock_qty")) + self.assertIsNone(data.product_info.get("show_stock_qty")) + + # tear down + frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete() + + def test_website_item_stock_when_in_stock(self): + """ + Check if stock details are fetched correctly for available inventory when: + 1) Showing stock availability enabled: + - Warehouse set + - Warehouse unset + 2) Showing stock availability disabled + """ + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = "Test Mobile Phone" + create_regular_web_item() + setup_e_commerce_settings({"show_stock_availability": 1}) + frappe.local.shopping_cart_settings = None + + # set warehouse + frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "_Test Warehouse - _TC") + + # stock up item + stock_entry = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=2, rate=100) + + # check if stock details are fetched and item is in stock with warehouse set + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertTrue(bool(data.product_info["in_stock"])) + self.assertEqual(data.product_info["stock_qty"][0][0], 2) + + # unset warehouse + frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "") + + # check if stock details are fetched and item not in stock without warehouse set + # (even though it has stock in some warehouse) + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + self.assertFalse(bool(data.product_info["in_stock"])) + self.assertFalse(bool(data.product_info["stock_qty"])) + + # disable show stock availability + setup_e_commerce_settings({"show_stock_availability": 0}) + frappe.local.shopping_cart_settings = None + data = get_product_info_for_website(item_code, skip_quotation_creation=True) + + # check if stock detail attributes are not fetched if stock availability is hidden + self.assertIsNone(data.product_info.get("in_stock")) + self.assertIsNone(data.product_info.get("stock_qty")) + self.assertIsNone(data.product_info.get("show_stock_qty")) + + # tear down + stock_entry.cancel() + frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete() + + def test_recommended_item(self): + "Check if added recommended items are fetched correctly." + item_code = "Test Mobile Phone" + web_item = create_regular_web_item(item_code) + + setup_e_commerce_settings({ + "enable_recommendations": 1, + "show_price": 1 + }) + + # create recommended web item and price for it + recommended_web_item = create_regular_web_item("Test Mobile Phone 1") + make_web_item_price(item_code="Test Mobile Phone 1") + + # add recommended item to first web item + web_item.append("recommended_items", {"website_item": recommended_web_item.name}) + web_item.save() + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + # test results if show price is enabled + self.assertEqual(len(recommended_items), 1) + recomm_item = recommended_items[0] + self.assertEqual(recomm_item.get("website_item_name"), "Test Mobile Phone 1") + self.assertTrue(bool(recomm_item.get("price_info"))) # price fetched + + price_info = recomm_item.get("price_info") + self.assertEqual(price_info.get("price_list_rate"), 1000) + self.assertEqual(price_info.get("formatted_price"), "₹ 1,000.00") + + # test results if show price is disabled + setup_e_commerce_settings({"show_price": 0}) + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + self.assertEqual(len(recommended_items), 1) + self.assertFalse(bool(recommended_items[0].get("price_info"))) # price not fetched + + # tear down + web_item.delete() + recommended_web_item.delete() + frappe.get_cached_doc("Item", "Test Mobile Phone 1").delete() + + def test_recommended_item_for_guest_user(self): + "Check if added recommended items are fetched correctly for guest user." + item_code = "Test Mobile Phone" + web_item = create_regular_web_item(item_code) + + # price visible to guests + setup_e_commerce_settings({ + "enable_recommendations": 1, + "show_price": 1, + "hide_price_for_guest": 0 + }) + + # create recommended web item and price for it + recommended_web_item = create_regular_web_item("Test Mobile Phone 1") + make_web_item_price(item_code="Test Mobile Phone 1") + + # add recommended item to first web item + web_item.append("recommended_items", {"website_item": recommended_web_item.name}) + web_item.save() + + frappe.set_user("Guest") + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + # test results if show price is enabled + self.assertEqual(len(recommended_items), 1) + self.assertTrue(bool(recommended_items[0].get("price_info"))) # price fetched + + # price hidden from guests + frappe.set_user("Administrator") + setup_e_commerce_settings({"hide_price_for_guest": 1}) + frappe.set_user("Guest") + + frappe.local.shopping_cart_settings = None + e_commerce_settings = get_shopping_cart_settings() + recommended_items = web_item.get_recommended_items(e_commerce_settings) + + # test results if show price is enabled + self.assertEqual(len(recommended_items), 1) + self.assertFalse(bool(recommended_items[0].get("price_info"))) # price fetched + + # tear down + frappe.set_user("Administrator") + web_item.delete() + recommended_web_item.delete() + frappe.get_cached_doc("Item", "Test Mobile Phone 1").delete() + +def create_regular_web_item(item_code=None, item_args=None, web_args=None): + "Create Regular Item and Website Item." + item_code = item_code or "Test Mobile Phone" + item = make_item(item_code, properties=item_args) + + if not frappe.db.exists("Website Item", {"item_code": item_code}): + web_item = make_website_item(item, save=False) + if web_args: + web_item.update(web_args) + web_item.save() + else: + web_item = frappe.get_cached_doc("Website Item", {"item_code": item_code}) + + return web_item + +def make_web_item_price(**kwargs): + item_code = kwargs.get("item_code") + if not item_code: + return + + if not frappe.db.exists("Item Price", {"item_code": item_code}): + item_price = frappe.get_doc({ + "doctype": "Item Price", + "item_code": item_code, + "price_list": kwargs.get("price_list") or "_Test Price List India", + "price_list_rate": kwargs.get("price_list_rate") or 1000 + }) + item_price.insert() + else: + item_price = frappe.get_cached_doc("Item Price", {"item_code": item_code}) + + return item_price + +def make_web_pricing_rule(**kwargs): + title = kwargs.get("title") + if not title: + return + + if not frappe.db.exists("Pricing Rule", title): + pricing_rule = frappe.get_doc({ + "doctype": "Pricing Rule", + "title": title, + "apply_on": kwargs.get("apply_on") or "Item Code", + "items": [{ + "item_code": kwargs.get("item_code") + }], + "selling": kwargs.get("selling") or 0, + "buying": kwargs.get("buying") or 0, + "rate_or_discount": kwargs.get("rate_or_discount") or "Discount Percentage", + "discount_percentage": kwargs.get("discount_percentage") or 10, + "company": kwargs.get("company") or "_Test Company", + "currency": kwargs.get("currency") or "INR", + "for_price_list": kwargs.get("price_list") or "_Test Price List India", + "applicable_for": kwargs.get("applicable_for") or "", + "customer": kwargs.get("customer") or "", + }) + pricing_rule.insert() + else: + pricing_rule = frappe.get_doc("Pricing Rule", {"title": title}) + + return pricing_rule + + +def create_user_and_customer_if_not_exists(email, first_name = None): + if frappe.db.exists("User", email): + return + + frappe.get_doc({ + "doctype": "User", + "user_type": "Website User", + "email": email, + "send_welcome_email": 0, + "first_name": first_name or email.split("@")[0] + }).insert(ignore_permissions=True) + + contact = frappe.get_last_doc("Contact", filters={"email_id": email}) + link = contact.append('links', {}) + link.link_doctype = "Customer" + link.link_name = "_Test Customer" + link.link_title = "_Test Customer" + contact.save() + +test_dependencies = ["Price List", "Item Price", "Customer", "Contact", "Item"] diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js new file mode 100644 index 00000000000..741e78f4a55 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item.js @@ -0,0 +1,24 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Website Item', { + onload: function(frm) { + // should never check Private + frm.fields_dict["website_image"].df.is_private = 0; + }, + + image: function() { + refresh_field("image_view"); + }, + + copy_from_item_group: function(frm) { + return frm.call({ + doc: frm.doc, + method: "copy_specification_from_item_group" + }); + }, + + set_meta_tags(frm) { + frappe.utils.set_meta_tag(frm.doc.route); + } +}); diff --git a/erpnext/e_commerce/doctype/website_item/website_item.json b/erpnext/e_commerce/doctype/website_item/website_item.json new file mode 100644 index 00000000000..245042addb8 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item.json @@ -0,0 +1,415 @@ +{ + "actions": [], + "allow_guest_to_view": 1, + "allow_import": 1, + "autoname": "naming_series", + "creation": "2021-02-09 21:06:14.441698", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "web_item_name", + "route", + "has_variants", + "variant_of", + "published", + "column_break_3", + "item_code", + "item_name", + "item_group", + "stock_uom", + "column_break_11", + "description", + "brand", + "image", + "display_section", + "website_image", + "website_image_alt", + "column_break_13", + "slideshow", + "thumbnail", + "stock_information_section", + "website_warehouse", + "column_break_24", + "on_backorder", + "section_break_17", + "short_description", + "web_long_description", + "column_break_27", + "website_specifications", + "copy_from_item_group", + "display_additional_information_section", + "show_tabbed_section", + "tabs", + "recommended_items_section", + "recommended_items", + "offers_section", + "offers", + "section_break_6", + "ranking", + "set_meta_tags", + "column_break_22", + "website_item_groups", + "advanced_display_section", + "website_content" + ], + "fields": [ + { + "description": "Website display name", + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, + "fieldname": "web_item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Website Item Name", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "options": "Item", + "read_only_depends_on": "eval:!doc.__islocal", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_6", + "fieldtype": "Section Break", + "label": "Search and SEO" + }, + { + "fieldname": "route", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Route", + "no_copy": 1 + }, + { + "description": "Items with higher ranking will be shown higher", + "fieldname": "ranking", + "fieldtype": "Int", + "label": "Ranking" + }, + { + "description": "Show a slideshow at the top of the page", + "fieldname": "slideshow", + "fieldtype": "Link", + "label": "Slideshow", + "options": "Website Slideshow" + }, + { + "description": "Item Image (if not slideshow)", + "fieldname": "website_image", + "fieldtype": "Attach", + "label": "Website Image" + }, + { + "description": "Image Alternative Text", + "fieldname": "website_image_alt", + "fieldtype": "Data", + "label": "Image Description" + }, + { + "fieldname": "thumbnail", + "fieldtype": "Data", + "label": "Thumbnail", + "read_only": 1 + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "description": "Show Stock availability based on this warehouse.", + "fieldname": "website_warehouse", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Website Warehouse", + "options": "Warehouse" + }, + { + "description": "List this Item in multiple groups on the website.", + "fieldname": "website_item_groups", + "fieldtype": "Table", + "label": "Website Item Groups", + "options": "Website Item Group" + }, + { + "fieldname": "set_meta_tags", + "fieldtype": "Button", + "label": "Set Meta Tags" + }, + { + "fieldname": "section_break_17", + "fieldtype": "Section Break", + "label": "Display Information" + }, + { + "fieldname": "copy_from_item_group", + "fieldtype": "Button", + "label": "Copy From Item Group" + }, + { + "fieldname": "website_specifications", + "fieldtype": "Table", + "label": "Website Specifications", + "options": "Item Website Specification" + }, + { + "fieldname": "web_long_description", + "fieldtype": "Text Editor", + "label": "Website Description" + }, + { + "description": "You can use any valid Bootstrap 4 markup in this field. It will be shown on your Item Page.", + "fieldname": "website_content", + "fieldtype": "HTML Editor", + "label": "Website Content" + }, + { + "fetch_from": "item_code.item_group", + "fieldname": "item_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Group", + "options": "Item Group", + "read_only": 1 + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "hidden": 1, + "in_preview": 1, + "label": "Image", + "print_hide": 1 + }, + { + "default": "1", + "fieldname": "published", + "fieldtype": "Check", + "label": "Published" + }, + { + "default": "0", + "depends_on": "has_variants", + "fetch_from": "item_code.has_variants", + "fieldname": "has_variants", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Has Variants", + "no_copy": 1, + "read_only": 1 + }, + { + "depends_on": "variant_of", + "fetch_from": "item_code.variant_of", + "fieldname": "variant_of", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_standard_filter": 1, + "label": "Variant Of", + "options": "Item", + "read_only": 1, + "search_index": 1, + "set_only_once": 1 + }, + { + "fetch_from": "item_code.stock_uom", + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, + { + "depends_on": "brand", + "fetch_from": "item_code.brand", + "fieldname": "brand", + "fieldtype": "Link", + "label": "Brand", + "options": "Brand" + }, + { + "collapsible": 1, + "fieldname": "advanced_display_section", + "fieldtype": "Section Break", + "label": "Advanced Display Content" + }, + { + "fieldname": "display_section", + "fieldtype": "Section Break", + "label": "Display Images" + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fetch_from": "item_code.description", + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Item Description", + "read_only": 1 + }, + { + "default": "WEB-ITM-.####", + "fieldname": "naming_series", + "fieldtype": "Select", + "hidden": 1, + "label": "Naming Series", + "no_copy": 1, + "options": "WEB-ITM-.####", + "print_hide": 1 + }, + { + "fieldname": "display_additional_information_section", + "fieldtype": "Section Break", + "label": "Display Additional Information" + }, + { + "depends_on": "show_tabbed_section", + "fieldname": "tabs", + "fieldtype": "Table", + "label": "Tabs", + "options": "Website Item Tabbed Section" + }, + { + "default": "0", + "fieldname": "show_tabbed_section", + "fieldtype": "Check", + "label": "Add Section with Tabs" + }, + { + "collapsible": 1, + "fieldname": "offers_section", + "fieldtype": "Section Break", + "label": "Offers" + }, + { + "fieldname": "offers", + "fieldtype": "Table", + "label": "Offers to Display", + "options": "Website Offer" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "description": "Short Description for List View", + "fieldname": "short_description", + "fieldtype": "Small Text", + "label": "Short Website Description" + }, + { + "collapsible": 1, + "fieldname": "recommended_items_section", + "fieldtype": "Section Break", + "label": "Recommended Items" + }, + { + "fieldname": "recommended_items", + "fieldtype": "Table", + "label": "Recommended/Similar Items", + "options": "Recommended Items" + }, + { + "fieldname": "stock_information_section", + "fieldtype": "Section Break", + "label": "Stock Information" + }, + { + "fieldname": "column_break_24", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Indicate that Item is available on backorder and not usually pre-stocked", + "fieldname": "on_backorder", + "fieldtype": "Check", + "label": "On Backorder" + } + ], + "has_web_view": 1, + "image_field": "image", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-09-02 13:08:41.942726", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Website Item", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "web_item_name, item_code, item_group", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "web_item_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py new file mode 100644 index 00000000000..62f7f49b2ef --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt, random_string +from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow +from frappe.website.website_generator import WebsiteGenerator + +from erpnext.e_commerce.doctype.item_review.item_review import get_item_reviews +from erpnext.e_commerce.redisearch_utils import ( + delete_item_from_index, + insert_item_to_index, + update_index_for_item, +) +from erpnext.e_commerce.shopping_cart.cart import _set_price_list +from erpnext.setup.doctype.item_group.item_group import ( + get_parent_item_groups, + invalidate_cache_for, +) +from erpnext.utilities.product import get_price + + +class WebsiteItem(WebsiteGenerator): + website = frappe._dict( + page_title_field="web_item_name", + condition_field="published", + template="templates/generators/item/item.html", + no_cache=1 + ) + + def autoname(self): + # use naming series to accomodate items with same name (different item code) + from frappe.model.naming import make_autoname + + from erpnext.setup.doctype.naming_series.naming_series import get_default_naming_series + + naming_series = get_default_naming_series("Website Item") + if not self.name and naming_series: + self.name = make_autoname(naming_series, doc=self) + + def onload(self): + super(WebsiteItem, self).onload() + + def validate(self): + super(WebsiteItem, self).validate() + + if not self.item_code: + frappe.throw(_("Item Code is required"), title=_("Mandatory")) + + self.validate_duplicate_website_item() + self.validate_website_image() + self.make_thumbnail() + self.publish_unpublish_desk_item(publish=True) + + if not self.get("__islocal"): + wig = frappe.qb.DocType("Website Item Group") + query = ( + frappe.qb.from_(wig) + .select(wig.item_group) + .where( + (wig.parentfield == "website_item_groups") + & (wig.parenttype == "Website Item") + & (wig.parent == self.name) + ) + ) + result = query.run(as_list=True) + + self.old_website_item_groups = [x[0] for x in result] + + def on_update(self): + invalidate_cache_for_web_item(self) + self.update_template_item() + + def on_trash(self): + super(WebsiteItem, self).on_trash() + delete_item_from_index(self) + self.publish_unpublish_desk_item(publish=False) + + def validate_duplicate_website_item(self): + existing_web_item = frappe.db.exists("Website Item", {"item_code": self.item_code}) + if existing_web_item and existing_web_item != self.name: + message = _("Website Item already exists against Item {0}").format(frappe.bold(self.item_code)) + frappe.throw(message, title=_("Already Published")) + + def publish_unpublish_desk_item(self, publish=True): + if frappe.db.get_value("Item", self.item_code, "published_in_website") and publish: + return # if already published don't publish again + frappe.db.set_value("Item", self.item_code, "published_in_website", publish) + + def make_route(self): + """Called from set_route in WebsiteGenerator.""" + if not self.route: + return cstr(frappe.db.get_value('Item Group', self.item_group, + 'route')) + '/' + self.scrub((self.item_name if self.item_name else self.item_code) + '-' + random_string(5)) + + def update_template_item(self): + """Publish Template Item if Variant is published.""" + if self.variant_of: + if self.published: + # show template + template_item = frappe.get_doc("Item", self.variant_of) + + if not template_item.published_in_website: + template_item.flags.ignore_permissions = True + make_website_item(template_item) + + def validate_website_image(self): + if frappe.flags.in_import: + return + + """Validate if the website image is a public file""" + auto_set_website_image = False + if not self.website_image and self.image: + auto_set_website_image = True + self.website_image = self.image + + if not self.website_image: + return + + # find if website image url exists as public + file_doc = frappe.get_all( + "File", + filters={ + "file_url": self.website_image + }, + fields=["name", "is_private"], + order_by="is_private asc", + limit_page_length=1 + ) + + if file_doc: + file_doc = file_doc[0] + + if not file_doc: + if not auto_set_website_image: + frappe.msgprint(_("Website Image {0} attached to Item {1} cannot be found").format(self.website_image, self.name)) + + self.website_image = None + + elif file_doc.is_private: + if not auto_set_website_image: + frappe.msgprint(_("Website Image should be a public file or website URL")) + + self.website_image = None + + def make_thumbnail(self): + """Make a thumbnail of `website_image`""" + if frappe.flags.in_import or frappe.flags.in_migrate: + return + + import requests.exceptions + + if not self.is_new() and self.website_image != frappe.db.get_value(self.doctype, self.name, "website_image"): + self.thumbnail = None + + if self.website_image and not self.thumbnail: + file_doc = None + + try: + file_doc = frappe.get_doc("File", { + "file_url": self.website_image, + "attached_to_doctype": "Website Item", + "attached_to_name": self.name + }) + except frappe.DoesNotExistError: + pass + # cleanup + frappe.local.message_log.pop() + + except requests.exceptions.HTTPError: + frappe.msgprint(_("Warning: Invalid attachment {0}").format(self.website_image)) + self.website_image = None + + except requests.exceptions.SSLError: + frappe.msgprint( + _("Warning: Invalid SSL certificate on attachment {0}").format(self.website_image)) + self.website_image = None + + # for CSV import + if self.website_image and not file_doc: + try: + file_doc = frappe.get_doc({ + "doctype": "File", + "file_url": self.website_image, + "attached_to_doctype": "Website Item", + "attached_to_name": self.name + }).save() + + except IOError: + self.website_image = None + + if file_doc: + if not file_doc.thumbnail_url: + file_doc.make_thumbnail() + + self.thumbnail = file_doc.thumbnail_url + + def get_context(self, context): + context.show_search = True + context.search_link = "/search" + context.body_class = "product-page" + + context.parents = get_parent_item_groups(self.item_group, from_item=True) # breadcumbs + self.attributes = frappe.get_all( + "Item Variant Attribute", + fields=["attribute", "attribute_value"], + filters={"parent": self.item_code} + ) + + if self.slideshow: + context.update(get_slideshow(self)) + + self.set_metatags(context) + self.set_shopping_cart_data(context) + + settings = context.shopping_cart.cart_settings + + self.get_product_details_section(context) + + if settings.get("enable_reviews"): + reviews_data = get_item_reviews(self.name) + context.update(reviews_data) + context.reviews = context.reviews[:4] + + context.wished = False + if frappe.db.exists("Wishlist Item", {"item_code": self.item_code, "parent": frappe.session.user}): + context.wished = True + + context.user_is_customer = check_if_user_is_customer() + + context.recommended_items = None + if settings and settings.enable_recommendations: + context.recommended_items = self.get_recommended_items(settings) + + return context + + def set_selected_attributes(self, variants, context, attribute_values_available): + for variant in variants: + variant.attributes = frappe.get_all( + "Item Variant Attribute", + filters={"parent": variant.name}, + fields=["attribute", "attribute_value as value"]) + + # make an attribute-value map for easier access in templates + variant.attribute_map = frappe._dict( + {attr.attribute : attr.value for attr in variant.attributes} + ) + + for attr in variant.attributes: + values = attribute_values_available.setdefault(attr.attribute, []) + if attr.value not in values: + values.append(attr.value) + + if variant.name == context.variant.name: + context.selected_attributes[attr.attribute] = attr.value + + def set_attribute_values(self, attributes, context, attribute_values_available): + for attr in attributes: + values = context.attribute_values.setdefault(attr.attribute, []) + + if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")): + for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt): + values.append(val) + else: + # get list of values defined (for sequence) + for attr_value in frappe.db.get_all("Item Attribute Value", + fields=["attribute_value"], + filters={"parent": attr.attribute}, order_by="idx asc"): + + if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): + values.append(attr_value.attribute_value) + + def set_metatags(self, context): + context.metatags = frappe._dict({}) + + safe_description = frappe.utils.to_markdown(self.description) + + context.metatags.url = frappe.utils.get_url() + '/' + context.route + + if context.website_image: + if context.website_image.startswith('http'): + url = context.website_image + else: + url = frappe.utils.get_url() + context.website_image + context.metatags.image = url + + context.metatags.description = safe_description[:300] + + context.metatags.title = self.web_item_name or self.item_name or self.item_code + + context.metatags['og:type'] = 'product' + context.metatags['og:site_name'] = 'ERPNext' + + def set_shopping_cart_data(self, context): + from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website + context.shopping_cart = get_product_info_for_website(self.item_code, skip_quotation_creation=True) + + def copy_specification_from_item_group(self): + self.set("website_specifications", []) + if self.item_group: + for label, desc in frappe.db.get_values("Item Website Specification", + {"parent": self.item_group}, ["label", "description"]): + row = self.append("website_specifications") + row.label = label + row.description = desc + + def get_product_details_section(self, context): + """ Get section with tabs or website specifications. """ + context.show_tabs = self.show_tabbed_section + if self.show_tabbed_section and (self.tabs or self.website_specifications): + context.tabs = self.get_tabs() + else: + context.website_specifications = self.website_specifications + + def get_tabs(self): + tab_values = {} + tab_values["tab_1_title"] = "Product Details" + tab_values["tab_1_content"] = frappe.render_template( + "templates/generators/item/item_specifications.html", + { + "website_specifications": self.website_specifications, + "show_tabs": self.show_tabbed_section + }) + + for row in self.tabs: + tab_values[f"tab_{row.idx + 1}_title"] = _(row.label) + tab_values[f"tab_{row.idx + 1}_content"] = row.content + + return tab_values + + def get_recommended_items(self, settings): + ri = frappe.qb.DocType("Recommended Items") + wi = frappe.qb.DocType("Website Item") + + query = ( + frappe.qb.from_(ri) + .join(wi).on(ri.item_code == wi.item_code) + .select( + ri.item_code, ri.route, + ri.website_item_name, + ri.website_item_thumbnail + ).where( + (ri.parent == self.name) + & (wi.published == 1) + ).orderby(ri.idx) + ) + items = query.run(as_dict=True) + + if settings.show_price: + is_guest = frappe.session.user == "Guest" + # Show Price if logged in. + # If not logged in and price is hidden for guest, skip price fetch. + if is_guest and settings.hide_price_for_guest: + return items + + selling_price_list = _set_price_list(settings, None) + for item in items: + item.price_info = get_price( + item.item_code, + selling_price_list, + settings.default_customer_group, + settings.company + ) + + return items + +def invalidate_cache_for_web_item(doc): + """Invalidate Website Item Group cache and rebuild ItemVariantsCacheManager.""" + from erpnext.stock.doctype.item.item import invalidate_item_variants_cache_for_website + + invalidate_cache_for(doc, doc.item_group) + + website_item_groups = list(set((doc.get("old_website_item_groups") or []) + + [d.item_group for d in doc.get({"doctype": "Website Item Group"}) if d.item_group])) + + for item_group in website_item_groups: + invalidate_cache_for(doc, item_group) + + # Update Search Cache + update_index_for_item(doc) + + invalidate_item_variants_cache_for_website(doc) + +def on_doctype_update(): + # since route is a Text column, it needs a length for indexing + frappe.db.add_index("Website Item", ["route(500)"]) + + frappe.db.add_index("Website Item", ["item_group"]) + frappe.db.add_index("Website Item", ["brand"]) + +def check_if_user_is_customer(user=None): + from frappe.contacts.doctype.contact.contact import get_contact_name + + if not user: + user = frappe.session.user + + contact_name = get_contact_name(user) + customer = None + + if contact_name: + contact = frappe.get_doc('Contact', contact_name) + for link in contact.links: + if link.link_doctype == "Customer": + customer = link.link_name + break + + return True if customer else False + +@frappe.whitelist() +def make_website_item(doc, save=True): + if not doc: + return + + if isinstance(doc, str): + doc = json.loads(doc) + + if frappe.db.exists("Website Item", {"item_code": doc.get("item_code")}): + message = _("Website Item already exists against {0}").format(frappe.bold(doc.get("item_code"))) + frappe.throw(message, title=_("Already Published")) + + website_item = frappe.new_doc("Website Item") + website_item.web_item_name = doc.get("item_name") + + fields_to_map = ["item_code", "item_name", "item_group", "stock_uom", "brand", "image", + "has_variants", "variant_of", "description"] + for field in fields_to_map: + website_item.update({field: doc.get(field)}) + + if not save: + return website_item + + website_item.save() + + # Add to search cache + insert_item_to_index(website_item) + + return [website_item.name, website_item.web_item_name] \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item/website_item_list.js b/erpnext/e_commerce/doctype/website_item/website_item_list.js new file mode 100644 index 00000000000..21be9428eb6 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item/website_item_list.js @@ -0,0 +1,20 @@ +frappe.listview_settings['Website Item'] = { + add_fields: ["item_name", "web_item_name", "published", "image", "has_variants", "variant_of"], + filters: [["published", "=", "1"]], + + get_indicator: function(doc) { + if (doc.has_variants && doc.published) { + return [__("Template"), "orange", "has_variants,=,Yes|published,=,1"]; + } else if (doc.has_variants && !doc.published) { + return [__("Template"), "grey", "has_variants,=,Yes|published,=,0"]; + } else if (doc.variant_of && doc.published) { + return [__("Variant"), "blue", "published,=,1|variant_of,=," + doc.variant_of]; + } else if (doc.variant_of && !doc.published) { + return [__("Variant"), "grey", "published,=,0|variant_of,=," + doc.variant_of]; + } else if (doc.published) { + return [__("Published"), "green", "published,=,1"]; + } else { + return [__("Not Published"), "grey", "published,=,0"]; + } + } +}; \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item_tabbed_section/__init__.py b/erpnext/e_commerce/doctype/website_item_tabbed_section/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json new file mode 100644 index 00000000000..6601dd81f21 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json @@ -0,0 +1,37 @@ +{ + "actions": [], + "creation": "2021-03-18 20:32:15.321402", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "content" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "fieldname": "content", + "fieldtype": "HTML Editor", + "in_list_view": 1, + "label": "Content" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-03-18 20:35:26.991192", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Website Item Tabbed Section", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py new file mode 100644 index 00000000000..91148b8b048 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class WebsiteItemTabbedSection(Document): + pass diff --git a/erpnext/e_commerce/doctype/website_offer/__init__.py b/erpnext/e_commerce/doctype/website_offer/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/doctype/website_offer/website_offer.json b/erpnext/e_commerce/doctype/website_offer/website_offer.json new file mode 100644 index 00000000000..627d548146b --- /dev/null +++ b/erpnext/e_commerce/doctype/website_offer/website_offer.json @@ -0,0 +1,43 @@ +{ + "actions": [], + "creation": "2021-04-21 13:37:14.162162", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "offer_title", + "offer_subtitle", + "offer_details" + ], + "fields": [ + { + "fieldname": "offer_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Offer Title" + }, + { + "fieldname": "offer_subtitle", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Offer Subtitle" + }, + { + "fieldname": "offer_details", + "fieldtype": "Text Editor", + "label": "Offer Details" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-04-21 13:56:04.660331", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Website Offer", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/website_offer/website_offer.py b/erpnext/e_commerce/doctype/website_offer/website_offer.py new file mode 100644 index 00000000000..d73c132b0e9 --- /dev/null +++ b/erpnext/e_commerce/doctype/website_offer/website_offer.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class WebsiteOffer(Document): + pass + +@frappe.whitelist(allow_guest=True) +def get_offer_details(offer_id): + return frappe.db.get_value('Website Offer', {'name': offer_id}, ['offer_details']) diff --git a/erpnext/e_commerce/doctype/wishlist/__init__.py b/erpnext/e_commerce/doctype/wishlist/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/doctype/wishlist/test_wishlist.py b/erpnext/e_commerce/doctype/wishlist/test_wishlist.py new file mode 100644 index 00000000000..504bb658113 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/test_wishlist.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +import unittest + +import frappe +from frappe.core.doctype.user_permission.test_user_permission import create_user + +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.doctype.wishlist.wishlist import add_to_wishlist, remove_from_wishlist +from erpnext.stock.doctype.item.test_item import make_item + + +class TestWishlist(unittest.TestCase): + def setUp(self): + item = make_item("Test Phone Series X") + if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series X"}): + make_website_item(item, save=True) + + item = make_item("Test Phone Series Y") + if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series Y"}): + make_website_item(item, save=True) + + def tearDown(self): + frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series X"}).delete() + frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series Y"}).delete() + frappe.get_cached_doc("Item", "Test Phone Series X").delete() + frappe.get_cached_doc("Item", "Test Phone Series Y").delete() + + def test_add_remove_items_in_wishlist(self): + "Check if items are added and removed from user's wishlist." + # add first item + add_to_wishlist("Test Phone Series X") + + # check if wishlist was created and item was added + self.assertTrue(frappe.db.exists("Wishlist", {"user": frappe.session.user})) + self.assertTrue(frappe.db.exists("Wishlist Item", {"item_code": "Test Phone Series X", "parent": frappe.session.user})) + + # add second item to wishlist + add_to_wishlist("Test Phone Series Y") + wishlist_length = frappe.db.get_value( + "Wishlist Item", + {"parent": frappe.session.user}, + "count(*)" + ) + self.assertEqual(wishlist_length, 2) + + remove_from_wishlist("Test Phone Series X") + remove_from_wishlist("Test Phone Series Y") + + wishlist_length = frappe.db.get_value( + "Wishlist Item", + {"parent": frappe.session.user}, + "count(*)" + ) + self.assertIsNone(frappe.db.exists("Wishlist Item", {"parent": frappe.session.user})) + self.assertEqual(wishlist_length, 0) + + # tear down + frappe.get_doc("Wishlist", {"user": frappe.session.user}).delete() + + def test_add_remove_in_wishlist_multiple_users(self): + "Check if items are added and removed from the correct user's wishlist." + test_user = create_user("test_reviewer@example.com", "Customer") + test_user_1 = create_user("test_reviewer_1@example.com", "Customer") + + # add to wishlist for first user + frappe.set_user(test_user.name) + add_to_wishlist("Test Phone Series X") + + # add to wishlist for second user + frappe.set_user(test_user_1.name) + add_to_wishlist("Test Phone Series X") + + # check wishlist and its content for users + self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user.name})) + self.assertTrue(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user.name})) + + self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user_1.name})) + self.assertTrue(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user_1.name})) + + # remove item for second user + remove_from_wishlist("Test Phone Series X") + + # make sure item was removed for second user and not first + self.assertFalse(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user_1.name})) + self.assertTrue(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user.name})) + + # remove item for first user + frappe.set_user(test_user.name) + remove_from_wishlist("Test Phone Series X") + self.assertFalse(frappe.db.exists("Wishlist Item", + {"item_code": "Test Phone Series X", "parent": test_user.name})) + + # tear down + frappe.set_user("Administrator") + frappe.get_doc("Wishlist", {"user": test_user.name}).delete() + frappe.get_doc("Wishlist", {"user": test_user_1.name}).delete() \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.js b/erpnext/e_commerce/doctype/wishlist/wishlist.js new file mode 100644 index 00000000000..d96e552ecdb --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/wishlist.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Wishlist', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.json b/erpnext/e_commerce/doctype/wishlist/wishlist.json new file mode 100644 index 00000000000..922924e53b0 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/wishlist.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "autoname": "field:user", + "creation": "2021-03-10 18:52:28.769126", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "section_break_2", + "items" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Wishlist Item" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-07-08 13:11:21.693956", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Wishlist", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.py b/erpnext/e_commerce/doctype/wishlist/wishlist.py new file mode 100644 index 00000000000..50e3d3a3392 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist/wishlist.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class Wishlist(Document): + pass + +@frappe.whitelist() +def add_to_wishlist(item_code): + """Insert Item into wishlist.""" + + if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}): + return + + web_item_data = frappe.db.get_value( + "Website Item", + {"item_code": item_code}, + ["image", "website_warehouse", "name", "web_item_name", "item_name", "item_group", "route"], + as_dict=1) + + wished_item_dict = { + "item_code": item_code, + "item_name": web_item_data.get("item_name"), + "item_group": web_item_data.get("item_group"), + "website_item": web_item_data.get("name"), + "web_item_name": web_item_data.get("web_item_name"), + "image": web_item_data.get("image"), + "warehouse": web_item_data.get("website_warehouse"), + "route": web_item_data.get("route") + } + + if not frappe.db.exists("Wishlist", frappe.session.user): + # initialise wishlist + wishlist = frappe.get_doc({"doctype": "Wishlist"}) + wishlist.user = frappe.session.user + wishlist.append("items", wished_item_dict) + wishlist.save(ignore_permissions=True) + else: + wishlist = frappe.get_doc("Wishlist", frappe.session.user) + item = wishlist.append('items', wished_item_dict) + item.db_insert() + + if hasattr(frappe.local, "cookie_manager"): + frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist.items))) + +@frappe.whitelist() +def remove_from_wishlist(item_code): + if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}): + frappe.db.delete( + "Wishlist Item", + { + "item_code": item_code, + "parent": frappe.session.user + } + ) + frappe.db.commit() # nosemgrep + + wishlist_items = frappe.db.get_values( + "Wishlist Item", + filters={"parent": frappe.session.user} + ) + + if hasattr(frappe.local, "cookie_manager"): + frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist_items))) \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist_item/__init__.py b/erpnext/e_commerce/doctype/wishlist_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json new file mode 100644 index 00000000000..c0414a7f8ed --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json @@ -0,0 +1,147 @@ +{ + "actions": [], + "creation": "2021-03-10 19:03:00.662714", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "website_item", + "web_item_name", + "column_break_3", + "item_name", + "item_group", + "item_details_section", + "description", + "column_break_7", + "route", + "image", + "image_view", + "section_break_8", + "warehouse_section", + "warehouse" + ], + "fields": [ + { + "fetch_from": "website_item.item_code", + "fetch_if_empty": 1, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fieldname": "website_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Website Item", + "options": "Website Item", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "item_details_section", + "fieldtype": "Section Break", + "label": "Item Details", + "read_only": 1 + }, + { + "fetch_from": "item_code.description", + "fetch_if_empty": 1, + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "read_only": 1 + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "fetch_from": "item_code.image", + "fetch_if_empty": 1, + "fieldname": "image", + "fieldtype": "Attach", + "hidden": 1, + "label": "Image" + }, + { + "fetch_from": "item_code.image", + "fetch_if_empty": 1, + "fieldname": "image_view", + "fieldtype": "Image", + "hidden": 1, + "label": "Image View", + "options": "image", + "print_hide": 1 + }, + { + "fieldname": "warehouse_section", + "fieldtype": "Section Break", + "label": "Warehouse" + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse", + "read_only": 1 + }, + { + "fieldname": "section_break_8", + "fieldtype": "Section Break" + }, + { + "fetch_from": "item_code.item_group", + "fetch_if_empty": 1, + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "read_only": 1 + }, + { + "fetch_from": "website_item.route", + "fetch_if_empty": 1, + "fieldname": "route", + "fieldtype": "Small Text", + "label": "Route", + "read_only": 1 + }, + { + "fetch_from": "website_item.web_item_name", + "fetch_if_empty": 1, + "fieldname": "web_item_name", + "fieldtype": "Data", + "label": "Website Item Name", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-08-09 10:30:41.964802", + "modified_by": "Administrator", + "module": "E-commerce", + "name": "Wishlist Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py new file mode 100644 index 00000000000..75ebccbc1b7 --- /dev/null +++ b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class WishlistItem(Document): + pass diff --git a/erpnext/shopping_cart/search.py b/erpnext/e_commerce/legacy_search.py similarity index 96% rename from erpnext/shopping_cart/search.py rename to erpnext/e_commerce/legacy_search.py index 5d2de78f7ca..752c33e92ee 100644 --- a/erpnext/shopping_cart/search.py +++ b/erpnext/e_commerce/legacy_search.py @@ -6,6 +6,7 @@ from whoosh.fields import ID, KEYWORD, TEXT, Schema from whoosh.qparser import FieldsPlugin, MultifieldParser, WildcardPlugin from whoosh.query import Prefix +# TODO: Make obsolete INDEX_NAME = "products" class ProductSearch(FullTextSearch): @@ -111,7 +112,7 @@ class ProductSearch(FullTextSearch): ) def get_all_published_items(): - return frappe.get_all("Item", filters={"variant_of": "", "show_in_website": 1},pluck="name") + return frappe.get_all("Website Item", filters={"variant_of": "", "published": 1}, pluck="item_code") def update_index_for_path(path): search = ProductSearch(INDEX_NAME) diff --git a/erpnext/e_commerce/product_data_engine/filters.py b/erpnext/e_commerce/product_data_engine/filters.py new file mode 100644 index 00000000000..c4a3cb9fbef --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/filters.py @@ -0,0 +1,139 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt +import frappe +from frappe.utils import floor + + +class ProductFiltersBuilder: + def __init__(self, item_group=None): + if not item_group: + self.doc = frappe.get_doc("E Commerce Settings") + else: + self.doc = frappe.get_doc("Item Group", item_group) + + self.item_group = item_group + + def get_field_filters(self): + if not self.item_group and not self.doc.enable_field_filters: + return + + fields, filter_data = [], [] + filter_fields = [row.fieldname for row in self.doc.filter_fields] # fields in settings + + # filter valid field filters i.e. those that exist in Item + item_meta = frappe.get_meta('Item', cached=True) + fields = [item_meta.get_field(field) for field in filter_fields if item_meta.has_field(field)] + + for df in fields: + item_filters, item_or_filters = {}, [] + link_doctype_values = self.get_filtered_link_doctype_records(df) + + if df.fieldtype == "Link": + if self.item_group: + item_or_filters.extend([ + ["item_group", "=", self.item_group], + ["Website Item Group", "item_group", "=", self.item_group] # consider website item groups + ]) + + # Get link field values attached to published items + item_filters['published_in_website'] = 1 + item_values = frappe.get_all( + "Item", + fields=[df.fieldname], + filters=item_filters, + or_filters=item_or_filters, + distinct="True", + pluck=df.fieldname + ) + + values = list(set(item_values) & link_doctype_values) # intersection of both + else: + # table multiselect + values = list(link_doctype_values) + + # Remove None + if None in values: + values.remove(None) + + if values: + filter_data.append([df, values]) + + return filter_data + + def get_filtered_link_doctype_records(self, field): + """ + Get valid link doctype records depending on filters. + Apply enable/disable/show_in_website filter. + Returns: + set: A set containing valid record names + """ + link_doctype = field.get_link_doctype() + meta = frappe.get_meta(link_doctype, cached=True) if link_doctype else None + if meta: + filters = self.get_link_doctype_filters(meta) + link_doctype_values = set(d.name for d in frappe.get_all(link_doctype, filters)) + + return link_doctype_values if meta else set() + + def get_link_doctype_filters(self, meta): + "Filters for Link Doctype eg. 'show_in_website'." + filters = {} + if not meta: + return filters + + if meta.has_field('enabled'): + filters['enabled'] = 1 + if meta.has_field('disabled'): + filters['disabled'] = 0 + if meta.has_field('show_in_website'): + filters['show_in_website'] = 1 + + return filters + + def get_attribute_filters(self): + if not self.item_group and not self.doc.enable_attribute_filters: + return + + attributes = [row.attribute for row in self.doc.filter_attributes] + + if not attributes: + return [] + + result = frappe.get_all( + "Item Variant Attribute", + filters={ + "attribute": ["in", attributes], + "attribute_value": ["is", "set"] + }, + fields=["attribute", "attribute_value"], + distinct=True + ) + + attribute_value_map = {} + for d in result: + attribute_value_map.setdefault(d.attribute, []).append(d.attribute_value) + + out = [] + for name, values in attribute_value_map.items(): + out.append(frappe._dict(name=name, item_attribute_values=values)) + return out + + def get_discount_filters(self, discounts): + discount_filters = [] + + # [25.89, 60.5] min max + min_discount, max_discount = discounts[0], discounts[1] + # [25, 60] rounded min max + min_range_absolute, max_range_absolute = floor(min_discount), floor(max_discount) + + min_range = int(min_discount - (min_range_absolute % 10)) # 20 + max_range = int(max_discount - (max_range_absolute % 10)) # 60 + + min_range = (min_range + 10) if min_range != min_range_absolute else min_range # 30 (upper limit of 25.89 in range of 10) + max_range = (max_range + 10) if max_range != max_range_absolute else max_range # 60 + + for discount in range(min_range, (max_range + 1), 10): + label = f"{discount}% and below" + discount_filters.append([discount, label]) + + return discount_filters diff --git a/erpnext/e_commerce/product_data_engine/query.py b/erpnext/e_commerce/product_data_engine/query.py new file mode 100644 index 00000000000..cfc3c7b357c --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/query.py @@ -0,0 +1,301 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import flt + +from erpnext.e_commerce.doctype.item_review.item_review import get_customer +from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website +from erpnext.utilities.product import get_non_stock_item_status + + +class ProductQuery: + """Query engine for product listing + + Attributes: + fields (list): Fields to fetch in query + conditions (string): Conditions for query building + or_conditions (string): Search conditions + page_length (Int): Length of page for the query + settings (Document): E Commerce Settings DocType + """ + def __init__(self): + self.settings = frappe.get_doc("E Commerce Settings") + self.page_length = self.settings.products_per_page or 20 + + self.or_filters = [] + self.filters = [["published", "=", 1]] + self.fields = [ + "web_item_name", "name", "item_name", "item_code", "website_image", + "variant_of", "has_variants", "item_group", "image", "web_long_description", + "short_description", "route", "website_warehouse", "ranking", "on_backorder" + ] + + def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None): + """ + Args: + attributes (dict, optional): Item Attribute filters + fields (dict, optional): Field level filters + search_term (str, optional): Search term to lookup + start (int, optional): Page start + + Returns: + dict: Dict containing items, item count & discount range + """ + # track if discounts included in field filters + self.filter_with_discount = bool(fields.get("discount")) + result, discount_list, website_item_groups, cart_items, count = [], [], [], [], 0 + + website_item_groups = self.get_website_item_group_results(item_group, website_item_groups) + + if fields: + self.build_fields_filters(fields) + if search_term: + self.build_search_filters(search_term) + if self.settings.hide_variants: + self.filters.append(["variant_of", "is", "not set"]) + + # query results + if attributes: + result, count = self.query_items_with_attributes(attributes, start) + else: + result, count = self.query_items(start=start) + + result = self.combine_web_item_group_results(item_group, result, website_item_groups) + + # sort combined results by ranking + result = sorted(result, key=lambda x: x.get("ranking"), reverse=True) + + if self.settings.enabled: + cart_items = self.get_cart_items() + + result, discount_list = self.add_display_details(result, discount_list, cart_items) + + discounts = [] + if discount_list: + discounts = [min(discount_list), max(discount_list)] + + result = self.filter_results_by_discount(fields, result) + + return { + "items": result, + "items_count": count, + "discounts": discounts + } + + def query_items(self, start=0): + """Build a query to fetch Website Items based on field filters.""" + # MySQL does not support offset without limit, + # frappe does not accept two parameters for limit + # https://dev.mysql.com/doc/refman/8.0/en/select.html#id4651989 + count_items = frappe.db.get_all( + "Website Item", + filters=self.filters, + or_filters=self.or_filters, + limit_page_length=184467440737095516, + limit_start=start, # get all items from this offset for total count ahead + order_by="ranking desc") + count = len(count_items) + + # If discounts included, return all rows. + # Slice after filtering rows with discount (See `filter_results_by_discount`). + # Slicing before hand will miss discounted items on the 3rd or 4th page. + # Discounts are fetched on computing Pricing Rules so we cannot query them directly. + page_length = 184467440737095516 if self.filter_with_discount else self.page_length + + items = frappe.db.get_all( + "Website Item", + fields=self.fields, + filters=self.filters, + or_filters=self.or_filters, + limit_page_length=page_length, + limit_start=start, + order_by="ranking desc") + + return items, count + + def query_items_with_attributes(self, attributes, start=0): + """Build a query to fetch Website Items based on field & attribute filters.""" + item_codes = [] + + for attribute, values in attributes.items(): + if not isinstance(values, list): + values = [values] + + # get items that have selected attribute & value + item_code_list = frappe.db.get_all( + "Item", + fields=["item_code"], + filters=[ + ["published_in_website", "=", 1], + ["Item Variant Attribute", "attribute", "=", attribute], + ["Item Variant Attribute", "attribute_value", "in", values] + ]) + item_codes.append({x.item_code for x in item_code_list}) + + if item_codes: + item_codes = list(set.intersection(*item_codes)) + self.filters.append(["item_code", "in", item_codes]) + + items, count = self.query_items(start=start) + + return items, count + + def build_fields_filters(self, filters): + """Build filters for field values + + Args: + filters (dict): Filters + """ + for field, values in filters.items(): + if not values or field == "discount": + continue + + # handle multiselect fields in filter addition + meta = frappe.get_meta('Website Item', cached=True) + df = meta.get_field(field) + if df.fieldtype == 'Table MultiSelect': + child_doctype = df.options + child_meta = frappe.get_meta(child_doctype, cached=True) + fields = child_meta.get("fields") + if fields: + self.filters.append([child_doctype, fields[0].fieldname, 'IN', values]) + elif isinstance(values, list): + # If value is a list use `IN` query + self.filters.append([field, "in", values]) + else: + # `=` will be faster than `IN` for most cases + self.filters.append([field, "=", values]) + + def build_search_filters(self, search_term): + """Query search term in specified fields + + Args: + search_term (str): Search candidate + """ + # Default fields to search from + default_fields = {'item_code', 'item_name', 'web_long_description', 'item_group'} + + # Get meta search fields + meta = frappe.get_meta("Website Item") + meta_fields = set(meta.get_search_fields()) + + # Join the meta fields and default fields set + search_fields = default_fields.union(meta_fields) + if frappe.db.count('Website Item', cache=True) > 50000: + search_fields.discard('web_long_description') + + # Build or filters for query + search = '%{}%'.format(search_term) + for field in search_fields: + self.or_filters.append([field, "like", search]) + + def get_website_item_group_results(self, item_group, website_item_groups): + """Get Web Items for Item Group Page via Website Item Groups.""" + if item_group: + website_item_groups = frappe.db.get_all( + "Website Item", + fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"], + filters=[ + ["Website Item Group", "item_group", "=", item_group], + ["published", "=", 1] + ] + ) + return website_item_groups + + def add_display_details(self, result, discount_list, cart_items): + """Add price and availability details in result.""" + for item in result: + product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') + + if product_info and product_info['price']: + # update/mutate item and discount_list objects + self.get_price_discount_info(item, product_info['price'], discount_list) + + if self.settings.show_stock_availability: + self.get_stock_availability(item) + + item.in_cart = item.item_code in cart_items + + item.wished = False + if frappe.db.exists("Wishlist Item", {"item_code": item.item_code, "parent": frappe.session.user}): + item.wished = True + + return result, discount_list + + def get_price_discount_info(self, item, price_object, discount_list): + """Modify item object and add price details.""" + fields = ["formatted_mrp", "formatted_price", "price_list_rate"] + for field in fields: + item[field] = price_object.get(field) + + if price_object.get('discount_percent'): + item.discount_percent = flt(price_object.discount_percent) + discount_list.append(price_object.discount_percent) + + if item.formatted_mrp: + item.discount = price_object.get('formatted_discount_percent') or \ + price_object.get('formatted_discount_rate') + + def get_stock_availability(self, item): + """Modify item object and add stock details.""" + item.in_stock = False + warehouse = item.get("website_warehouse") + is_stock_item = frappe.get_cached_value("Item", item.item_code, "is_stock_item") + + if item.get("on_backorder"): + return + + if not is_stock_item: + if warehouse: + # product bundle case + item.in_stock = get_non_stock_item_status(item.item_code, "website_warehouse") + else: + item.in_stock = True + elif warehouse: + # stock item and has warehouse + actual_qty = frappe.db.get_value( + "Bin", + {"item_code": item.item_code,"warehouse": item.get("website_warehouse")}, + "actual_qty") + item.in_stock = bool(flt(actual_qty)) + + def get_cart_items(self): + customer = get_customer(silent=True) + if customer: + quotation = frappe.get_all("Quotation", fields=["name"], filters= + {"party_name": customer, "contact_email": frappe.session.user, "order_type": "Shopping Cart", "docstatus": 0}, + order_by="modified desc", limit_page_length=1) + if quotation: + items = frappe.get_all( + "Quotation Item", + fields=["item_code"], + filters={ + "parent": quotation[0].get("name") + }) + items = [row.item_code for row in items] + return items + + return [] + + def combine_web_item_group_results(self, item_group, result, website_item_groups): + """Combine results with context of website item groups into item results.""" + if item_group and website_item_groups: + items_list = {row.name for row in result} + for row in website_item_groups: + if row.wig_parent not in items_list: + result.append(row) + + return result + + def filter_results_by_discount(self, fields, result): + if fields and fields.get("discount"): + discount_percent = frappe.utils.flt(fields["discount"][0]) + result = [row for row in result if row.get("discount_percent") and row.discount_percent <= discount_percent] + + if self.filter_with_discount: + # no limit was added to results while querying + # slice results manually + result[:self.page_length] + + return result diff --git a/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py b/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py new file mode 100644 index 00000000000..f0f7918d00e --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py @@ -0,0 +1,117 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import unittest + +import frappe + +from erpnext.e_commerce.api import get_product_filter_data +from erpnext.e_commerce.doctype.website_item.test_website_item import create_regular_web_item + +test_dependencies = ["Item", "Item Group"] + +class TestItemGroupProductDataEngine(unittest.TestCase): + "Test Products & Sub-Category Querying for Product Listing on Item Group Page." + + @classmethod + def setUpClass(cls): + item_codes = [ + ("Test Mobile A", "_Test Item Group B"), + ("Test Mobile B", "_Test Item Group B"), + ("Test Mobile C", "_Test Item Group B - 1"), + ("Test Mobile D", "_Test Item Group B - 1"), + ("Test Mobile E", "_Test Item Group B - 2") + ] + for item in item_codes: + item_code = item[0] + item_args = {"item_group": item[1]} + if not frappe.db.exists("Website Item", {"item_code": item_code}): + create_regular_web_item(item_code, item_args=item_args) + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + def test_product_listing_in_item_group(self): + "Test if only products belonging to the Item Group are fetched." + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B" + }) + + items = result.get("items") + item_codes = [item.get("item_code") for item in items] + + self.assertEqual(len(items), 2) + self.assertIn("Test Mobile A", item_codes) + self.assertNotIn("Test Mobile C", item_codes) + + def test_products_in_multiple_item_groups(self): + """Test if product is visible on multiple item group pages barring its own.""" + website_item = frappe.get_doc("Website Item", {"item_code": "Test Mobile E"}) + + # show item belonging to '_Test Item Group B - 2' in '_Test Item Group B - 1' as well + website_item.append("website_item_groups", { + "item_group": "_Test Item Group B - 1" + }) + website_item.save() + + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B - 1" + }) + + items = result.get("items") + item_codes = [item.get("item_code") for item in items] + + self.assertEqual(len(items), 3) + self.assertIn("Test Mobile E", item_codes) # visible in other item groups + self.assertIn("Test Mobile C", item_codes) + self.assertIn("Test Mobile D", item_codes) + + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B - 2" + }) + + items = result.get("items") + + self.assertEqual(len(items), 1) + self.assertEqual(items[0].get("item_code"), "Test Mobile E") # visible in own item group + + def test_item_group_with_sub_groups(self): + "Test Valid Sub Item Groups in Item Group Page." + frappe.db.set_value("Item Group", "_Test Item Group B - 1", "show_in_website", 1) + frappe.db.set_value("Item Group", "_Test Item Group B - 2", "show_in_website", 0) + + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B" + }) + + self.assertTrue(bool(result.get("sub_categories"))) + + child_groups = [d.name for d in result.get("sub_categories")] + # check if child group is fetched if shown in website + self.assertIn("_Test Item Group B - 1", child_groups) + + frappe.db.set_value("Item Group", "_Test Item Group B - 2", "show_in_website", 1) + result = get_product_filter_data(query_args={ + "field_filters": {}, + "attribute_filters": {}, + "start": 0, + "item_group": "_Test Item Group B" + }) + child_groups = [d.name for d in result.get("sub_categories")] + + # check if child group is fetched if shown in website + self.assertIn("_Test Item Group B - 1", child_groups) + self.assertIn("_Test Item Group B - 2", child_groups) \ No newline at end of file diff --git a/erpnext/e_commerce/product_data_engine/test_product_data_engine.py b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py new file mode 100644 index 00000000000..9ec336d1566 --- /dev/null +++ b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py @@ -0,0 +1,350 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import unittest + +import frappe + +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( + setup_e_commerce_settings, +) +from erpnext.e_commerce.doctype.website_item.test_website_item import create_regular_web_item +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder +from erpnext.e_commerce.product_data_engine.query import ProductQuery + +test_dependencies = ["Item", "Item Group"] + +class TestProductDataEngine(unittest.TestCase): + "Test Products Querying and Filters for Product Listing." + + @classmethod + def setUpClass(cls): + item_codes = [ + ("Test 11I Laptop", "Products"), # rank 1 + ("Test 12I Laptop", "Products"), # rank 2 + ("Test 13I Laptop", "Products"), # rank 3 + ("Test 14I Laptop", "Raw Material"), # rank 4 + ("Test 15I Laptop", "Raw Material"), # rank 5 + ("Test 16I Laptop", "Raw Material"), # rank 6 + ("Test 17I Laptop", "Products") # rank 7 + ] + for index, item in enumerate(item_codes, start=1): + item_code = item[0] + item_args = {"item_group": item[1]} + web_args = {"ranking": index} + if not frappe.db.exists("Website Item", {"item_code": item_code}): + create_regular_web_item(item_code, item_args=item_args, web_args=web_args) + + setup_e_commerce_settings({ + "products_per_page": 4, + "enable_field_filters": 1, + "filter_fields": [{"fieldname": "item_group"}], + "enable_attribute_filters": 1, + "filter_attributes": [{"attribute": "Test Size"}], + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India" + }) + frappe.local.shopping_cart_settings = None + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + def test_product_list_ordering_and_paging(self): + "Test if website items appear by ranking on different pages." + engine = ProductQuery() + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + self.assertIsNotNone(items) + self.assertEqual(len(items), 4) + self.assertGreater(result.get("items_count"), 4) + + # check if items appear as per ranking set in setUpClass + self.assertEqual(items[0].get("item_code"), "Test 17I Laptop") + self.assertEqual(items[1].get("item_code"), "Test 16I Laptop") + self.assertEqual(items[2].get("item_code"), "Test 15I Laptop") + self.assertEqual(items[3].get("item_code"), "Test 14I Laptop") + + # check next page + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=4, + item_group=None + ) + items = result.get("items") + + # check if items appear as per ranking set in setUpClass on next page + self.assertEqual(items[0].get("item_code"), "Test 13I Laptop") + self.assertEqual(items[1].get("item_code"), "Test 12I Laptop") + self.assertEqual(items[2].get("item_code"), "Test 11I Laptop") + + def test_change_product_ranking(self): + "Test if item on second page appear on first if ranking is changed." + item_code = "Test 12I Laptop" + old_ranking = frappe.db.get_value("Website Item", {"item_code": item_code}, "ranking") + + # low rank, appears on second page + self.assertEqual(old_ranking, 2) + + # set ranking as highest rank + frappe.db.set_value("Website Item", {"item_code": item_code}, "ranking", 10) + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if item is the first item on the first page + self.assertEqual(items[0].get("item_code"), item_code) + self.assertEqual(items[1].get("item_code"), "Test 17I Laptop") + + # tear down + frappe.db.set_value("Website Item", {"item_code": item_code}, "ranking", old_ranking) + + def test_product_list_field_filter_builder(self): + "Test if field filters are fetched correctly." + frappe.db.set_value("Item Group", "Raw Material", "show_in_website", 0) + + filter_engine = ProductFiltersBuilder() + field_filters = filter_engine.get_field_filters() + + # Web Items belonging to 'Products' and 'Raw Material' are available + # but only 'Products' has 'show_in_website' enabled + item_group_filters = field_filters[0] + docfield = item_group_filters[0] + valid_item_groups = item_group_filters[1] + + self.assertEqual(docfield.options, "Item Group") + self.assertIn("Products", valid_item_groups) + self.assertNotIn("Raw Material", valid_item_groups) + + frappe.db.set_value("Item Group", "Raw Material", "show_in_website", 1) + field_filters = filter_engine.get_field_filters() + + #'Products' and 'Raw Materials' both have 'show_in_website' enabled + item_group_filters = field_filters[0] + docfield = item_group_filters[0] + valid_item_groups = item_group_filters[1] + + self.assertEqual(docfield.options, "Item Group") + self.assertIn("Products", valid_item_groups) + self.assertIn("Raw Material", valid_item_groups) + + def test_product_list_with_field_filter(self): + "Test if field filters are applied correctly." + field_filters = {"item_group": "Raw Material"} + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields=field_filters, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if only 'Raw Material' are fetched in the right order + self.assertEqual(len(items), 3) + self.assertEqual(items[0].get("item_code"), "Test 16I Laptop") + self.assertEqual(items[1].get("item_code"), "Test 15I Laptop") + + # def test_product_list_with_field_filter_table_multiselect(self): + # TODO + # pass + + def test_product_list_attribute_filter_builder(self): + "Test if attribute filters are fetched correctly." + create_variant_web_item() + + filter_engine = ProductFiltersBuilder() + attribute_filter = filter_engine.get_attribute_filters()[0] + attribute_values = attribute_filter.item_attribute_values + + self.assertEqual(attribute_filter.name, "Test Size") + self.assertGreater(len(attribute_values), 0) + self.assertIn("Large", attribute_values) + + def test_product_list_with_attribute_filter(self): + "Test if attribute filters are applied correctly." + create_variant_web_item() + + attribute_filters = {"Test Size": ["Large"]} + engine = ProductQuery() + result = engine.query( + attributes=attribute_filters, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if only items with Test Size 'Large' are fetched + self.assertEqual(len(items), 1) + self.assertEqual(items[0].get("item_code"), "Test Web Item-L") + + def test_product_list_discount_filter_builder(self): + "Test if discount filters are fetched correctly." + from erpnext.e_commerce.doctype.website_item.test_website_item import ( + make_web_item_price, + make_web_pricing_rule, + ) + + item_code = "Test 12I Laptop" + make_web_item_price(item_code=item_code) + make_web_pricing_rule( + title=f"Test Pricing Rule for {item_code}", + item_code=item_code, + selling=1 + ) + + setup_e_commerce_settings({"show_price": 1}) + frappe.local.shopping_cart_settings = None + + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields={}, + search_term=None, + start=4, + item_group=None + ) + self.assertTrue(bool(result.get("discounts"))) + + filter_engine = ProductFiltersBuilder() + discount_filters = filter_engine.get_discount_filters(result["discounts"]) + + self.assertEqual(len(discount_filters[0]), 2) + self.assertEqual(discount_filters[0][0], 10) + self.assertEqual(discount_filters[0][1], "10% and below") + + def test_product_list_with_discount_filters(self): + "Test if discount filters are applied correctly." + from erpnext.e_commerce.doctype.website_item.test_website_item import ( + make_web_item_price, + make_web_pricing_rule, + ) + + field_filters = {"discount": [10]} + + make_web_item_price(item_code="Test 12I Laptop") + make_web_pricing_rule( + title="Test Pricing Rule for Test 12I Laptop", # 10% discount + item_code="Test 12I Laptop", + selling=1 + ) + make_web_item_price(item_code="Test 13I Laptop") + make_web_pricing_rule( + title="Test Pricing Rule for Test 13I Laptop", # 15% discount + item_code="Test 13I Laptop", + discount_percentage=15, + selling=1 + ) + + setup_e_commerce_settings({"show_price": 1}) + frappe.local.shopping_cart_settings = None + + engine = ProductQuery() + result = engine.query( + attributes={}, + fields=field_filters, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if only product with 10% and below discount are fetched + self.assertEqual(len(items), 1) + self.assertEqual(items[0].get("item_code"), "Test 12I Laptop") + + def test_product_list_with_api(self): + "Test products listing using API." + from erpnext.e_commerce.api import get_product_filter_data + + create_variant_web_item() + + result = get_product_filter_data(query_args={ + "field_filters": { + "item_group": "Products" + }, + "attribute_filters": { + "Test Size": ["Large"] + }, + "start": 0 + }) + + items = result.get("items") + + self.assertEqual(len(items), 1) + self.assertEqual(items[0].get("item_code"), "Test Web Item-L") + + def test_product_list_with_variants(self): + "Test if variants are hideen on hiding variants in settings." + create_variant_web_item() + + setup_e_commerce_settings({ + "enable_attribute_filters": 0, + "hide_variants": 1 + }) + frappe.local.shopping_cart_settings = None + + attribute_filters = {"Test Size": ["Large"]} + engine = ProductQuery() + result = engine.query( + attributes=attribute_filters, + fields={}, + search_term=None, + start=0, + item_group=None + ) + items = result.get("items") + + # check if any variants are fetched even though published variant exists + self.assertEqual(len(items), 0) + + # tear down + setup_e_commerce_settings({ + "enable_attribute_filters": 1, + "hide_variants": 0 + }) + +def create_variant_web_item(): + "Create Variant and Template Website Items." + from erpnext.controllers.item_variant import create_variant + from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + from erpnext.stock.doctype.item.test_item import make_item + + make_item("Test Web Item", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + { + "attribute": "Test Size" + } + ] + }) + if not frappe.db.exists("Item", "Test Web Item-L"): + variant = create_variant("Test Web Item", {"Test Size": "Large"}) + variant.save() + + if not frappe.db.exists("Website Item", {"variant_of": "Test Web Item"}): + make_website_item(variant, save=True) \ No newline at end of file diff --git a/erpnext/e_commerce/product_ui/grid.js b/erpnext/e_commerce/product_ui/grid.js new file mode 100644 index 00000000000..9eb1d45d5f5 --- /dev/null +++ b/erpnext/e_commerce/product_ui/grid.js @@ -0,0 +1,201 @@ +erpnext.ProductGrid = class { + /* Options: + - items: Items + - settings: E Commerce Settings + - products_section: Products Wrapper + - preference: If preference is not grid view, render but hide + */ + constructor(options) { + Object.assign(this, options); + + if (this.preference !== "Grid View") { + this.products_section.addClass("hidden"); + } + + this.products_section.empty(); + this.make(); + } + + make() { + let me = this; + let html = ``; + + this.items.forEach(item => { + let title = item.web_item_name || item.item_name || item.item_code || ""; + title = title.length > 90 ? title.substr(0, 90) + "..." : title; + + html += `
`; + html += me.get_image_html(item, title); + html += me.get_card_body_html(item, title, me.settings); + html += `
`; + }); + + let $product_wrapper = this.products_section; + $product_wrapper.append(html); + } + + get_image_html(item, title) { + let image = item.website_image || item.image; + + if (image) { + return ` +
+ + ${ title } + +
+ `; + } else { + return ` +
+ +
+ ${ frappe.get_abbr(title) } +
+
+
+ `; + } + } + + get_card_body_html(item, title, settings) { + let body_html = ` +
+
+ `; + body_html += this.get_title(item, title); + + // get floating elements + if (!item.has_variants) { + if (settings.enable_wishlist) { + body_html += this.get_wishlist_icon(item); + } + if (settings.enabled) { + body_html += this.get_cart_indicator(item); + } + + } + + body_html += `
`; + body_html += `
${ item.item_group || '' }
`; + + if (item.formatted_price) { + body_html += this.get_price_html(item); + } + + body_html += this.get_stock_availability(item, settings); + body_html += this.get_primary_button(item, settings); + body_html += `
`; // close div on line 49 + + return body_html; + } + + get_title(item, title) { + let title_html = ` + +
+ ${ title || '' } +
+
+ `; + return title_html; + } + + get_wishlist_icon(item) { + let icon_class = item.wished ? "wished" : "not-wished"; + return ` +
+ + + +
+ `; + } + + get_cart_indicator(item) { + return ` +
+ 1 +
+ `; + } + + get_price_html(item) { + let price_html = ` +
+ ${ item.formatted_price || '' } + `; + + if (item.formatted_mrp) { + price_html += ` + + ${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" } + + + ${ item.discount } OFF + + `; + } + price_html += `
`; + return price_html; + } + + get_stock_availability(item, settings) { + if (settings.show_stock_availability && !item.has_variants) { + if (item.on_backorder) { + return ` + + ${ __("Available on backorder") } + + `; + } else if (!item.in_stock) { + return ` + + ${ __("Out of stock") } + + `; + } + } + + return ``; + } + + get_primary_button(item, settings) { + if (item.has_variants) { + return ` + +
+ ${ __('Explore') } +
+
+ `; + } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) { + return ` +
+ + + + + + ${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') } +
+ + +
+ ${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') } +
+
+ `; + } else { + return ``; + } + } +}; \ No newline at end of file diff --git a/erpnext/e_commerce/product_ui/list.js b/erpnext/e_commerce/product_ui/list.js new file mode 100644 index 00000000000..691cd4d9de1 --- /dev/null +++ b/erpnext/e_commerce/product_ui/list.js @@ -0,0 +1,204 @@ +erpnext.ProductList = class { + /* Options: + - items: Items + - settings: E Commerce Settings + - products_section: Products Wrapper + - preference: If preference is not list view, render but hide + */ + constructor(options) { + Object.assign(this, options); + + if (this.preference !== "List View") { + this.products_section.addClass("hidden"); + } + + this.products_section.empty(); + this.make(); + } + + make() { + let me = this; + let html = `

`; + + this.items.forEach(item => { + let title = item.web_item_name || item.item_name || item.item_code || ""; + title = title.length > 200 ? title.substr(0, 200) + "..." : title; + + html += `
`; + html += me.get_image_html(item, title, me.settings); + html += me.get_row_body_html(item, title, me.settings); + html += `
`; + }); + + let $product_wrapper = this.products_section; + $product_wrapper.append(html); + } + + get_image_html(item, title, settings) { + let image = item.website_image || item.image; + let wishlist_enabled = !item.has_variants && settings.enable_wishlist; + let image_html = ``; + + if (image) { + image_html += ` +
+ + ${ title } + + ${ wishlist_enabled ? this.get_wishlist_icon(item): '' } +
+ `; + } else { + image_html += ` +
+ +
+ ${ frappe.get_abbr(title) } +
+
+ ${ wishlist_enabled ? this.get_wishlist_icon(item): '' } +
+ `; + } + + return image_html; + } + + get_row_body_html(item, title, settings) { + let body_html = `
`; + body_html += this.get_title_html(item, title, settings); + body_html += this.get_item_details(item, settings); + body_html += `
`; + return body_html; + } + + get_title_html(item, title, settings) { + let title_html = `
`; + title_html += ` +
+ + ${ title } + +
+ `; + + if (settings.enabled) { + title_html += `
`; + title_html += this.get_primary_button(item, settings); + title_html += `
`; + } + title_html += `
`; + + return title_html; + } + + get_item_details(item, settings) { + let details = ` +

+ ${ item.item_group } | Item Code : ${ item.item_code } +

+
+ ${ item.short_description || '' } +
+
+ ${ item.formatted_price || '' } + `; + + if (item.formatted_mrp) { + details += ` + + ${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" } + + + ${ item.discount } OFF + + `; + } + + details += this.get_stock_availability(item, settings); + details += `
`; + + return details; + } + + get_stock_availability(item, settings) { + if (settings.show_stock_availability && !item.has_variants) { + if (item.on_backorder) { + return ` +
+ + ${ __("Available on backorder") } + + `; + } else if (!item.in_stock) { + return ` +
+ ${ __("Out of stock") } + `; + } + } + return ``; + } + + get_wishlist_icon(item) { + let icon_class = item.wished ? "wished" : "not-wished"; + + return ` +
+ + + +
+ `; + } + + get_primary_button(item, settings) { + if (item.has_variants) { + return ` + +
+ ${ __('Explore') } +
+
+ `; + } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) { + return ` +
+ + + + + + ${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') } +
+ +
+ 1 +
+ + +
+ ${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') } +
+
+ `; + } else { + return ``; + } + } + +}; \ No newline at end of file diff --git a/erpnext/e_commerce/product_ui/search.js b/erpnext/e_commerce/product_ui/search.js new file mode 100644 index 00000000000..61922459e56 --- /dev/null +++ b/erpnext/e_commerce/product_ui/search.js @@ -0,0 +1,244 @@ +erpnext.ProductSearch = class { + constructor(opts) { + /* Options: search_box_id (for custom search box) */ + $.extend(this, opts); + this.MAX_RECENT_SEARCHES = 4; + this.search_box_id = this.search_box_id || "#search-box"; + this.searchBox = $(this.search_box_id); + + this.setupSearchDropDown(); + this.bindSearchAction(); + } + + setupSearchDropDown() { + this.search_area = $("#dropdownMenuSearch"); + this.setupSearchResultContainer(); + this.populateRecentSearches(); + } + + bindSearchAction() { + let me = this; + + // Show Search dropdown + this.searchBox.on("focus", () => { + this.search_dropdown.removeClass("hidden"); + }); + + // If click occurs outside search input/results, hide results. + // Click can happen anywhere on the page + $("body").on("click", (e) => { + let searchEvent = $(e.target).closest(this.search_box_id).length; + let resultsEvent = $(e.target).closest('#search-results-container').length; + let isResultHidden = this.search_dropdown.hasClass("hidden"); + + if (!searchEvent && !resultsEvent && !isResultHidden) { + this.search_dropdown.addClass("hidden"); + } + }); + + // Process search input + this.searchBox.on("input", (e) => { + let query = e.target.value; + + if (query.length == 0) { + me.populateResults(null); + me.populateCategoriesList(null); + } + + if (query.length < 3 || !query.length) return; + + frappe.call({ + method: "erpnext.templates.pages.product_search.search", + args: { + query: query + }, + callback: (data) => { + let product_results = null, category_results = null; + + // Populate product results + product_results = data.message ? data.message.product_results : null; + me.populateResults(product_results); + + // Populate categories + if (me.category_container) { + category_results = data.message ? data.message.category_results : null; + me.populateCategoriesList(category_results); + } + + // Populate recent search chips only on successful queries + if (!$.isEmptyObject(product_results) || !$.isEmptyObject(category_results)) { + me.setRecentSearches(query); + } + } + }); + + this.search_dropdown.removeClass("hidden"); + }); + } + + setupSearchResultContainer() { + this.search_dropdown = this.search_area.append(` + + `).find("#search-results-container"); + + this.setupCategoryContainer(); + this.setupProductsContainer(); + this.setupRecentsContainer(); + } + + setupProductsContainer() { + this.products_container = this.search_dropdown.append(` +
+
+
+
+ `).find("#product-scroll"); + } + + setupCategoryContainer() { + this.category_container = this.search_dropdown.append(` +
+
+
+
+ `).find(".category-chips"); + } + + setupRecentsContainer() { + let $recents_section = this.search_dropdown.append(` +
+
+ ${ __("Recent") } +
+
+ `).find(".recent-searches"); + + this.recents_container = $recents_section.append(` +
+
+ `).find("#recents"); + } + + getRecentSearches() { + return JSON.parse(localStorage.getItem("recent_searches") || "[]"); + } + + attachEventListenersToChips() { + let me = this; + const chips = $(".recent-search"); + window.chips = chips; + + for (let chip of chips) { + chip.addEventListener("click", () => { + me.searchBox[0].value = chip.innerText.trim(); + + // Start search with `recent query` + me.searchBox.trigger("input"); + me.searchBox.focus(); + }); + } + } + + setRecentSearches(query) { + let recents = this.getRecentSearches(); + if (recents.length >= this.MAX_RECENT_SEARCHES) { + // Remove the `first` query + recents.splice(0, 1); + } + + if (recents.indexOf(query) >= 0) { + return; + } + + recents.push(query); + localStorage.setItem("recent_searches", JSON.stringify(recents)); + + this.populateRecentSearches(); + } + + populateRecentSearches() { + let recents = this.getRecentSearches(); + + if (!recents.length) { + this.recents_container.html(`No searches yet.`); + return; + } + + let html = ""; + recents.forEach((key) => { + html += ` + + `; + }); + + this.recents_container.html(html); + this.attachEventListenersToChips(); + } + + populateResults(product_results) { + if (!product_results || product_results.length === 0) { + let empty_html = ``; + this.products_container.html(empty_html); + return; + } + + let html = ""; + + product_results.forEach((res) => { + let thumbnail = res.thumbnail || '/assets/erpnext/images/ui-states/cart-empty-state.png'; + html += ` + + `; + }); + + this.products_container.html(html); + } + + populateCategoriesList(category_results) { + if (!category_results || category_results.length === 0) { + let empty_html = ` +
+
+
+
+ `; + this.category_container.html(empty_html); + return; + } + + let html = ` +
+ ${ __("Categories") } +
+ `; + + category_results.forEach((category) => { + html += ` + + ${ category.name } + + `; + }); + + this.category_container.html(html); + } +}; \ No newline at end of file diff --git a/erpnext/e_commerce/product_ui/views.js b/erpnext/e_commerce/product_ui/views.js new file mode 100644 index 00000000000..1b5c44038f3 --- /dev/null +++ b/erpnext/e_commerce/product_ui/views.js @@ -0,0 +1,532 @@ +erpnext.ProductView = class { + /* Options: + - View Type + - Products Section Wrapper, + - Item Group: If its an Item Group page + */ + constructor(options) { + Object.assign(this, options); + this.preference = this.view_type; + this.make(); + } + + make(from_filters=false) { + this.products_section.empty(); + this.prepare_toolbar(); + this.get_item_filter_data(from_filters); + } + + prepare_toolbar() { + this.products_section.append(` +
+
+ `); + this.prepare_search(); + this.prepare_view_toggler(); + + new erpnext.ProductSearch(); + } + + prepare_view_toggler() { + + if (!$("#list").length || !$("#image-view").length) { + this.render_view_toggler(); + this.bind_view_toggler_actions(); + this.set_view_state(); + } + } + + get_item_filter_data(from_filters=false) { + // Get and render all Product related views + let me = this; + this.from_filters = from_filters; + let args = this.get_query_filters(); + + this.disable_view_toggler(true); + + frappe.call({ + method: "erpnext.e_commerce.api.get_product_filter_data", + args: { + query_args: args + }, + callback: function(result) { + if (!result || result.exc || !result.message || result.message.exc) { + me.render_no_products_section(true); + } else { + // Sub Category results are independent of Items + if (me.item_group && result.message["sub_categories"].length) { + me.render_item_sub_categories(result.message["sub_categories"]); + } + + if (!result.message["items"].length) { + // if result has no items or result is empty + me.render_no_products_section(); + } else { + // Add discount filters + me.re_render_discount_filters(result.message["filters"].discount_filters); + + // Render views + me.render_list_view(result.message["items"], result.message["settings"]); + me.render_grid_view(result.message["items"], result.message["settings"]); + + me.products = result.message["items"]; + me.product_count = result.message["items_count"]; + } + + // Bind filter actions + if (!from_filters) { + // If `get_product_filter_data` was triggered after checking a filter, + // don't touch filters unnecessarily, only data must change + // filter persistence is handle on filter change event + me.bind_filters(); + me.restore_filters_state(); + } + + // Bottom paging + me.add_paging_section(result.message["settings"]); + } + + me.disable_view_toggler(false); + } + }); + } + + disable_view_toggler(disable=false) { + $('#list').prop('disabled', disable); + $('#image-view').prop('disabled', disable); + } + + render_grid_view(items, settings) { + // loop over data and add grid html to it + let me = this; + this.prepare_product_area_wrapper("grid"); + + new erpnext.ProductGrid({ + items: items, + products_section: $("#products-grid-area"), + settings: settings, + preference: me.preference + }); + } + + render_list_view(items, settings) { + let me = this; + this.prepare_product_area_wrapper("list"); + + new erpnext.ProductList({ + items: items, + products_section: $("#products-list-area"), + settings: settings, + preference: me.preference + }); + } + + prepare_product_area_wrapper(view) { + let left_margin = view == "list" ? "ml-2" : ""; + let top_margin = view == "list" ? "mt-6" : "mt-minus-1"; + return this.products_section.append(` +
+
+ `); + } + + get_query_filters() { + const filters = frappe.utils.get_query_params(); + let {field_filters, attribute_filters} = filters; + + field_filters = field_filters ? JSON.parse(field_filters) : {}; + attribute_filters = attribute_filters ? JSON.parse(attribute_filters) : {}; + + return { + field_filters: field_filters, + attribute_filters: attribute_filters, + item_group: this.item_group, + start: filters.start || null, + from_filters: this.from_filters || false + }; + } + + add_paging_section(settings) { + $(".product-paging-area").remove(); + + if (this.products) { + let paging_html = ` +
+
+
+
+ `; + let query_params = frappe.utils.get_query_params(); + let start = query_params.start ? cint(JSON.parse(query_params.start)) : 0; + let page_length = settings.products_per_page || 0; + + let prev_disable = start > 0 ? "" : "disabled"; + let next_disable = (this.product_count > page_length) ? "" : "disabled"; + + paging_html += ` + `; + + paging_html += ` + + `; + + paging_html += `
`; + + $(".page_content").append(paging_html); + this.bind_paging_action(); + } + } + + prepare_search() { + $(".toolbar").append(` +
+ +
+ `); + } + + render_view_toggler() { + $(".toolbar").append(`
`); + + ["btn-list-view", "btn-grid-view"].forEach(view => { + let icon = view === "btn-list-view" ? "list" : "image-view"; + $(".toggle-container").append(` +
+ +
+ `); + }); + } + + bind_view_toggler_actions() { + $("#list").click(function() { + let $btn = $(this); + $btn.removeClass('btn-primary'); + $btn.addClass('btn-primary'); + $(".btn-grid-view").removeClass('btn-primary'); + + $("#products-grid-area").addClass("hidden"); + $("#products-list-area").removeClass("hidden"); + localStorage.setItem("product_view", "List View"); + }); + + $("#image-view").click(function() { + let $btn = $(this); + $btn.removeClass('btn-primary'); + $btn.addClass('btn-primary'); + $(".btn-list-view").removeClass('btn-primary'); + + $("#products-list-area").addClass("hidden"); + $("#products-grid-area").removeClass("hidden"); + localStorage.setItem("product_view", "Grid View"); + }); + } + + set_view_state() { + if (this.preference === "List View") { + $("#list").addClass('btn-primary'); + $("#image-view").removeClass('btn-primary'); + } else { + $("#image-view").addClass('btn-primary'); + $("#list").removeClass('btn-primary'); + } + } + + bind_paging_action() { + let me = this; + $('.btn-prev, .btn-next').click((e) => { + const $btn = $(e.target); + me.from_filters = false; + + $btn.prop('disabled', true); + const start = $btn.data('start'); + + let query_params = frappe.utils.get_query_params(); + query_params.start = start; + let path = window.location.pathname + '?' + frappe.utils.get_url_from_dict(query_params); + window.location.href = path; + }); + } + + re_render_discount_filters(filter_data) { + this.get_discount_filter_html(filter_data); + if (this.from_filters) { + // Bind filter action if triggered via filters + // if not from filter action, page load will bind actions + this.bind_discount_filter_action(); + } + // discount filters are rendered with Items (later) + // unlike the other filters + this.restore_discount_filter(); + } + + get_discount_filter_html(filter_data) { + $("#discount-filters").remove(); + if (filter_data) { + $("#product-filters").append(` +
+
${ __("Discounts") }
+
+ `); + + let html = `
`; + filter_data.forEach(filter => { + html += ` +
+ +
+ `; + }); + html += `
`; + + $("#discount-filters").append(html); + } + } + + restore_discount_filter() { + const filters = frappe.utils.get_query_params(); + let field_filters = filters.field_filters; + if (!field_filters) return; + + field_filters = JSON.parse(field_filters); + + if (field_filters && field_filters["discount"]) { + const values = field_filters["discount"]; + const selector = values.map(value => { + return `input[data-filter-name="discount"][data-filter-value="${value}"]`; + }).join(','); + $(selector).prop('checked', true); + this.field_filters = field_filters; + } + } + + bind_discount_filter_action() { + let me = this; + $('.discount-filter').on('change', (e) => { + const $checkbox = $(e.target); + const is_checked = $checkbox.is(':checked'); + + const { + filterValue: filter_value + } = $checkbox.data(); + + delete this.field_filters["discount"]; + + if (is_checked) { + this.field_filters["discount"] = []; + this.field_filters["discount"].push(filter_value); + } + + if (this.field_filters["discount"].length === 0) { + delete this.field_filters["discount"]; + } + + me.change_route_with_filters(); + }); + } + + bind_filters() { + let me = this; + this.field_filters = {}; + this.attribute_filters = {}; + + $('.product-filter').on('change', (e) => { + me.from_filters = true; + + const $checkbox = $(e.target); + const is_checked = $checkbox.is(':checked'); + + if ($checkbox.is('.attribute-filter')) { + const { + attributeName: attribute_name, + attributeValue: attribute_value + } = $checkbox.data(); + + if (is_checked) { + this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || []; + this.attribute_filters[attribute_name].push(attribute_value); + } else { + this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || []; + this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name].filter(v => v !== attribute_value); + } + + if (this.attribute_filters[attribute_name].length === 0) { + delete this.attribute_filters[attribute_name]; + } + } else if ($checkbox.is('.field-filter') || $checkbox.is('.discount-filter')) { + const { + filterName: filter_name, + filterValue: filter_value + } = $checkbox.data(); + + if ($checkbox.is('.discount-filter')) { + // clear previous discount filter to accomodate new + delete this.field_filters["discount"]; + } + if (is_checked) { + this.field_filters[filter_name] = this.field_filters[filter_name] || []; + if (!in_list(this.field_filters[filter_name], filter_value)) { + this.field_filters[filter_name].push(filter_value); + } + } else { + this.field_filters[filter_name] = this.field_filters[filter_name] || []; + this.field_filters[filter_name] = this.field_filters[filter_name].filter(v => v !== filter_value); + } + + if (this.field_filters[filter_name].length === 0) { + delete this.field_filters[filter_name]; + } + } + + me.change_route_with_filters(); + }); + } + + change_route_with_filters() { + let route_params = frappe.utils.get_query_params(); + + let start = this.if_key_exists(route_params.start) || 0; + if (this.from_filters) { + start = 0; // show items from first page if new filters are triggered + } + + const query_string = this.get_query_string({ + start: start, + field_filters: JSON.stringify(this.if_key_exists(this.field_filters)), + attribute_filters: JSON.stringify(this.if_key_exists(this.attribute_filters)), + }); + window.history.pushState('filters', '', `${location.pathname}?` + query_string); + + $('.page_content input').prop('disabled', true); + + this.make(true); + $('.page_content input').prop('disabled', false); + } + + restore_filters_state() { + const filters = frappe.utils.get_query_params(); + let {field_filters, attribute_filters} = filters; + + if (field_filters) { + field_filters = JSON.parse(field_filters); + for (let fieldname in field_filters) { + const values = field_filters[fieldname]; + const selector = values.map(value => { + return `input[data-filter-name="${fieldname}"][data-filter-value="${value}"]`; + }).join(','); + $(selector).prop('checked', true); + } + this.field_filters = field_filters; + } + if (attribute_filters) { + attribute_filters = JSON.parse(attribute_filters); + for (let attribute in attribute_filters) { + const values = attribute_filters[attribute]; + const selector = values.map(value => { + return `input[data-attribute-name="${attribute}"][data-attribute-value="${value}"]`; + }).join(','); + $(selector).prop('checked', true); + } + this.attribute_filters = attribute_filters; + } + } + + render_no_products_section(error=false) { + let error_section = ` +
+ Something went wrong. Please refresh or contact us. +
+ `; + let no_results_section = ` +
+
+ Empty Cart +
+
${ __('No products found') }

+
+ `; + + this.products_section.append(error ? error_section : no_results_section); + } + + render_item_sub_categories(categories) { + if (categories && categories.length) { + let sub_group_html = ` +
+ `; + + categories.forEach(category => { + sub_group_html += ` + +
+ ${ category.name } +
+
+ `; + }); + sub_group_html += `
`; + + $("#product-listing").prepend(sub_group_html); + } + } + + get_query_string(object) { + const url = new URLSearchParams(); + for (let key in object) { + const value = object[key]; + if (value) { + url.append(key, value); + } + } + return url.toString(); + } + + if_key_exists(obj) { + let exists = false; + for (let key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key) && obj[key]) { + exists = true; + break; + } + } + return exists ? obj : undefined; + } +}; \ No newline at end of file diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py new file mode 100644 index 00000000000..59c7f32fd46 --- /dev/null +++ b/erpnext/e_commerce/redisearch_utils.py @@ -0,0 +1,210 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils.redis_wrapper import RedisWrapper +from redisearch import AutoCompleter, Client, IndexDefinition, Suggestion, TagField, TextField + +WEBSITE_ITEM_INDEX = 'website_items_index' +WEBSITE_ITEM_KEY_PREFIX = 'website_item:' +WEBSITE_ITEM_NAME_AUTOCOMPLETE = 'website_items_name_dict' +WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE = 'website_items_category_dict' + +def get_indexable_web_fields(): + "Return valid fields from Website Item that can be searched for." + web_item_meta = frappe.get_meta("Website Item", cached=True) + valid_fields = filter( + lambda df: df.fieldtype in ("Link", "Table MultiSelect", "Data", "Small Text", "Text Editor"), + web_item_meta.fields) + + return [df.fieldname for df in valid_fields] + +def is_search_module_loaded(): + try: + cache = frappe.cache() + out = cache.execute_command('MODULE LIST') + + parsed_output = " ".join( + (" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out) + ) + return "search" in parsed_output + except Exception: + return False + +def if_redisearch_loaded(function): + "Decorator to check if Redisearch is loaded." + def wrapper(*args, **kwargs): + if is_search_module_loaded(): + func = function(*args, **kwargs) + return func + return + + return wrapper + +def make_key(key): + return "{0}|{1}".format(frappe.conf.db_name, key).encode('utf-8') + +@if_redisearch_loaded +def create_website_items_index(): + "Creates Index Definition." + + # CREATE index + client = Client(make_key(WEBSITE_ITEM_INDEX), conn=frappe.cache()) + + # DROP if already exists + try: + client.drop_index() + except Exception: + pass + + idx_def = IndexDefinition([make_key(WEBSITE_ITEM_KEY_PREFIX)]) + + # Based on e-commerce settings + idx_fields = frappe.db.get_single_value( + 'E Commerce Settings', + 'search_index_fields' + ) + idx_fields = idx_fields.split(',') if idx_fields else [] + + if 'web_item_name' in idx_fields: + idx_fields.remove('web_item_name') + + idx_fields = list(map(to_search_field, idx_fields)) + + client.create_index( + [TextField("web_item_name", sortable=True)] + idx_fields, + definition=idx_def, + ) + + reindex_all_web_items() + define_autocomplete_dictionary() + +def to_search_field(field): + if field == "tags": + return TagField("tags", separator=",") + + return TextField(field) + +@if_redisearch_loaded +def insert_item_to_index(website_item_doc): + # Insert item to index + key = get_cache_key(website_item_doc.name) + cache = frappe.cache() + web_item = create_web_item_map(website_item_doc) + + for k, v in web_item.items(): + super(RedisWrapper, cache).hset(make_key(key), k, v) + + insert_to_name_ac(website_item_doc.web_item_name, website_item_doc.name) + +@if_redisearch_loaded +def insert_to_name_ac(web_name, doc_name): + ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=frappe.cache()) + ac.add_suggestions(Suggestion(web_name, payload=doc_name)) + +def create_web_item_map(website_item_doc): + fields_to_index = get_fields_indexed() + web_item = {} + + for f in fields_to_index: + web_item[f] = website_item_doc.get(f) or '' + + return web_item + +@if_redisearch_loaded +def update_index_for_item(website_item_doc): + # Reinsert to Cache + insert_item_to_index(website_item_doc) + define_autocomplete_dictionary() + +@if_redisearch_loaded +def delete_item_from_index(website_item_doc): + cache = frappe.cache() + key = get_cache_key(website_item_doc.name) + + try: + cache.delete(key) + except Exception: + return False + + delete_from_ac_dict(website_item_doc) + return True + +@if_redisearch_loaded +def delete_from_ac_dict(website_item_doc): + '''Removes this items's name from autocomplete dictionary''' + cache = frappe.cache() + name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache) + name_ac.delete(website_item_doc.web_item_name) + +@if_redisearch_loaded +def define_autocomplete_dictionary(): + """Creates an autocomplete search dictionary for `name`. + Also creats autocomplete dictionary for `categories` if + checked in E Commerce Settings""" + + cache = frappe.cache() + name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache) + cat_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache) + + ac_categories = frappe.db.get_single_value( + 'E Commerce Settings', + 'show_categories_in_search_autocomplete' + ) + + # Delete both autocomplete dicts + try: + cache.delete(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE)) + cache.delete(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE)) + except Exception: + return False + + items = frappe.get_all( + 'Website Item', + fields=['web_item_name', 'item_group'], + filters={"published": 1} + ) + + for item in items: + name_ac.add_suggestions(Suggestion(item.web_item_name)) + if ac_categories and item.item_group: + cat_ac.add_suggestions(Suggestion(item.item_group)) + + return True + +@if_redisearch_loaded +def reindex_all_web_items(): + items = frappe.get_all( + 'Website Item', + fields=get_fields_indexed(), + filters={"published": True} + ) + + cache = frappe.cache() + for item in items: + web_item = create_web_item_map(item) + key = make_key(get_cache_key(item.name)) + + for k, v in web_item.items(): + super(RedisWrapper, cache).hset(key, k, v) + +def get_cache_key(name): + name = frappe.scrub(name) + return f"{WEBSITE_ITEM_KEY_PREFIX}{name}" + +def get_fields_indexed(): + fields_to_index = frappe.db.get_single_value( + 'E Commerce Settings', + 'search_index_fields' + ) + fields_to_index = fields_to_index.split(',') if fields_to_index else [] + + mandatory_fields = ['name', 'web_item_name', 'route', 'thumbnail', 'ranking'] + fields_to_index = fields_to_index + mandatory_fields + + return fields_to_index + +# TODO: Remove later +# # Figure out a way to run this at startup +define_autocomplete_dictionary() +create_website_items_index() diff --git a/erpnext/e_commerce/shopping_cart/__init__.py b/erpnext/e_commerce/shopping_cart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py similarity index 87% rename from erpnext/shopping_cart/cart.py rename to erpnext/e_commerce/shopping_cart/cart.py index ebbe233ca3a..372aed0b95f 100644 --- a/erpnext/shopping_cart/cart.py +++ b/erpnext/e_commerce/shopping_cart/cart.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import frappe import frappe.defaults from frappe import _, throw @@ -11,20 +10,20 @@ from frappe.utils import cint, cstr, flt, get_fullname from frappe.utils.nestedset import get_root_of from erpnext.accounts.utils import get_account_name -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( get_shopping_cart_settings, ) -from erpnext.utilities.product import get_qty_in_stock +from erpnext.utilities.product import get_web_item_qty_in_stock class WebsitePriceListMissingError(frappe.ValidationError): pass def set_cart_count(quotation=None): - if cint(frappe.db.get_singles_value("Shopping Cart Settings", "enabled")): + if cint(frappe.db.get_singles_value("E Commerce Settings", "enabled")): if not quotation: quotation = _get_cart_quotation() - cart_count = cstr(len(quotation.get("items"))) + cart_count = cstr(cint(quotation.get("total_qty"))) if hasattr(frappe.local, "cookie_manager"): frappe.local.cookie_manager.set_cookie("cart_count", cart_count) @@ -48,7 +47,7 @@ def get_cart_quotation(doc=None): "shipping_addresses": get_shipping_addresses(party), "billing_addresses": get_billing_addresses(party), "shipping_rules": get_applicable_shipping_rules(party), - "cart_settings": frappe.get_cached_doc("Shopping Cart Settings") + "cart_settings": frappe.get_cached_doc("E Commerce Settings") } @frappe.whitelist() @@ -72,7 +71,7 @@ def get_billing_addresses(party=None): @frappe.whitelist() def place_order(): quotation = _get_cart_quotation() - cart_settings = frappe.db.get_value("Shopping Cart Settings", None, + cart_settings = frappe.db.get_value("E Commerce Settings", None, ["company", "allow_items_not_in_stock"], as_dict=1) quotation.company = cart_settings.company @@ -92,13 +91,19 @@ def place_order(): if not cint(cart_settings.allow_items_not_in_stock): for item in sales_order.get("items"): - item.reserved_warehouse, is_stock_item = frappe.db.get_value("Item", - item.item_code, ["website_warehouse", "is_stock_item"]) + item.warehouse = frappe.db.get_value( + "Website Item", + { + "item_code": item.item_code + }, + "website_warehouse" + ) + is_stock_item = frappe.db.get_value("Item", item.item_code, "is_stock_item") if is_stock_item: - item_stock = get_qty_in_stock(item.item_code, "website_warehouse") + item_stock = get_web_item_qty_in_stock(item.item_code, "website_warehouse") if not cint(item_stock.in_stock): - throw(_("{1} Not in Stock").format(item.item_code)) + throw(_("{0} Not in Stock").format(item.item_code)) if item.qty > item_stock.stock_qty[0][0]: throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty[0][0], item.item_code)) @@ -156,19 +161,19 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False): set_cart_count(quotation) - context = get_cart_quotation(quotation) - if cint(with_items): + context = get_cart_quotation(quotation) return { "items": frappe.render_template("templates/includes/cart/cart_items.html", context), - "taxes": frappe.render_template("templates/includes/order/order_taxes.html", + "total": frappe.render_template("templates/includes/cart/cart_items_total.html", context), + "taxes_and_totals": frappe.render_template("templates/includes/cart/cart_payment_summary.html", + context) } else: return { - 'name': quotation.name, - 'shopping_cart_menu': get_shopping_cart_menu(context) + 'name': quotation.name } @frappe.whitelist() @@ -265,13 +270,36 @@ def guess_territory(): territory = frappe.db.get_value("Territory", geoip_country) return territory or \ - frappe.db.get_value("Shopping Cart Settings", None, "territory") or \ + frappe.db.get_value("E Commerce Settings", None, "territory") or \ get_root_of("Territory") def decorate_quotation_doc(doc): for d in doc.get("items", []): - d.update(frappe.db.get_value("Item", d.item_code, - ["thumbnail", "website_image", "description", "route"], as_dict=True)) + item_code = d.item_code + fields = ["web_item_name", "thumbnail", "website_image", "description", "route"] + + # Variant Item + if not frappe.db.exists("Website Item", {"item_code": item_code}): + variant_data = frappe.db.get_values( + "Item", + filters={"item_code": item_code}, + fieldname=["variant_of", "item_name", "image"], + as_dict=True + )[0] + item_code = variant_data.variant_of + fields = fields[1:] + d.web_item_name = variant_data.item_name + + if variant_data.image: # get image from variant or template web item + d.thumbnail = variant_data.image + fields = fields[2:] + + d.update(frappe.db.get_value( + "Website Item", + {"item_code": item_code}, + fields, + as_dict=True) + ) return doc @@ -282,13 +310,13 @@ def _get_cart_quotation(party=None): party = get_party() quotation = frappe.get_all("Quotation", fields=["name"], filters= - {"party_name": party.name, "order_type": "Shopping Cart", "docstatus": 0}, + {"party_name": party.name, "contact_email": frappe.session.user, "order_type": "Shopping Cart", "docstatus": 0}, order_by="modified desc", limit_page_length=1) if quotation: qdoc = frappe.get_doc("Quotation", quotation[0].name) else: - company = frappe.db.get_value("Shopping Cart Settings", None, ["company"]) + company = frappe.db.get_value("E Commerce Settings", None, ["company"]) qdoc = frappe.get_doc({ "doctype": "Quotation", "naming_series": get_shopping_cart_settings().quotation_series or "QTN-CART-", @@ -343,7 +371,7 @@ def apply_cart_settings(party=None, quotation=None): if not quotation: quotation = _get_cart_quotation(party) - cart_settings = frappe.get_doc("Shopping Cart Settings") + cart_settings = frappe.get_doc("E Commerce Settings") set_price_list_and_rate(quotation, cart_settings) @@ -420,7 +448,7 @@ def get_party(user=None): party_doctype = contact.links[0].link_doctype party = contact.links[0].link_name - cart_settings = frappe.get_doc("Shopping Cart Settings") + cart_settings = frappe.get_doc("E Commerce Settings") debtors_account = '' @@ -557,10 +585,20 @@ def get_shipping_rules(quotation=None, cart_settings=None): if quotation.shipping_address_name: country = frappe.db.get_value("Address", quotation.shipping_address_name, "country") if country: - shipping_rules = frappe.db.sql_list("""select distinct sr.name - from `tabShipping Rule Country` src, `tabShipping Rule` sr - where src.country = %s and - sr.disabled != 1 and sr.name = src.parent""", country) + sr_country = frappe.qb.DocType("Shipping Rule Country") + sr = frappe.qb.DocType("Shipping Rule") + query = ( + frappe.qb.from_(sr_country) + .join(sr).on(sr.name == sr_country.parent) + .select(sr.name) + .distinct() + .where( + (sr_country.country == country) + & (sr.disabled != 1) + ) + ) + result = query.run(as_list=True) + shipping_rules = [x[0] for x in result] return shipping_rules diff --git a/erpnext/shopping_cart/product_info.py b/erpnext/e_commerce/shopping_cart/product_info.py similarity index 52% rename from erpnext/shopping_cart/product_info.py rename to erpnext/e_commerce/shopping_cart/product_info.py index 977f12fb9ef..595fed01d25 100644 --- a/erpnext/shopping_cart/product_info.py +++ b/erpnext/e_commerce/shopping_cart/product_info.py @@ -1,15 +1,18 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import frappe -from erpnext.shopping_cart.cart import _get_cart_quotation, _set_price_list -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( get_shopping_cart_settings, show_quantity_in_website, ) -from erpnext.utilities.product import get_non_stock_item_status, get_price, get_qty_in_stock +from erpnext.e_commerce.shopping_cart.cart import _get_cart_quotation, _set_price_list +from erpnext.utilities.product import ( + get_non_stock_item_status, + get_price, + get_web_item_qty_in_stock, +) @frappe.whitelist(allow_guest=True) @@ -18,7 +21,11 @@ def get_product_info_for_website(item_code, skip_quotation_creation=False): cart_settings = get_shopping_cart_settings() if not cart_settings.enabled: - return frappe._dict() + # return settings even if cart is disabled + return frappe._dict({ + "product_info": {}, + "cart_settings": cart_settings + }) cart_quotation = frappe._dict() if not skip_quotation_creation: @@ -26,25 +33,43 @@ def get_product_info_for_website(item_code, skip_quotation_creation=False): selling_price_list = cart_quotation.get("selling_price_list") if cart_quotation else _set_price_list(cart_settings, None) - price = get_price( - item_code, - selling_price_list, - cart_settings.default_customer_group, - cart_settings.company - ) + price = {} + if cart_settings.show_price: + is_guest = frappe.session.user == "Guest" + # Show Price if logged in. + # If not logged in, check if price is hidden for guest. + if not is_guest or not cart_settings.hide_price_for_guest: + price = get_price( + item_code, + selling_price_list, + cart_settings.default_customer_group, + cart_settings.company + ) - stock_status = get_qty_in_stock(item_code, "website_warehouse") + stock_status = None + + if cart_settings.show_stock_availability: + on_backorder = frappe.get_cached_value("Website Item", {"item_code": item_code}, "on_backorder") + if on_backorder: + stock_status = frappe._dict({"on_backorder": True}) + else: + stock_status = get_web_item_qty_in_stock(item_code, "website_warehouse") product_info = { "price": price, - "stock_qty": stock_status.stock_qty, - "in_stock": stock_status.in_stock if stock_status.is_stock_item else get_non_stock_item_status(item_code, "website_warehouse"), "qty": 0, "uom": frappe.db.get_value("Item", item_code, "stock_uom"), - "show_stock_qty": show_quantity_in_website(), "sales_uom": frappe.db.get_value("Item", item_code, "sales_uom") } + if stock_status: + if stock_status.on_backorder: + product_info["on_backorder"] = True + else: + product_info["stock_qty"] = stock_status.stock_qty + product_info["in_stock"] = stock_status.in_stock if stock_status.is_stock_item else get_non_stock_item_status(item_code, "website_warehouse") + product_info["show_stock_qty"] = show_quantity_in_website() + if product_info["price"]: if frappe.session.user != "Guest": item = cart_quotation.get({"item_code": item_code}) if cart_quotation else None diff --git a/erpnext/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py similarity index 70% rename from erpnext/shopping_cart/test_shopping_cart.py rename to erpnext/e_commerce/shopping_cart/test_shopping_cart.py index 60c220a0878..37b3672806d 100644 --- a/erpnext/shopping_cart/test_shopping_cart.py +++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py @@ -8,8 +8,14 @@ import frappe from frappe.utils import add_months, nowdate from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule -from erpnext.shopping_cart.cart import _get_cart_quotation, get_party, update_cart -from erpnext.tests.utils import create_test_contact_and_address +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.shopping_cart.cart import ( + _get_cart_quotation, + get_cart_quotation, + get_party, + update_cart, +) +from erpnext.tests.utils import change_settings, create_test_contact_and_address # test_dependencies = ['Payment Terms Template'] @@ -27,8 +33,14 @@ class TestShoppingCart(unittest.TestCase): frappe.set_user("Administrator") create_test_contact_and_address() self.enable_shopping_cart() + if not frappe.db.exists("Website Item", {"item_code": "_Test Item"}): + make_website_item(frappe.get_cached_doc("Item", "_Test Item")) + + if not frappe.db.exists("Website Item", {"item_code": "_Test Item 2"}): + make_website_item(frappe.get_cached_doc("Item", "_Test Item 2")) def tearDown(self): + frappe.db.rollback() frappe.set_user("Administrator") self.disable_shopping_cart() @@ -45,13 +57,19 @@ class TestShoppingCart(unittest.TestCase): return quotation def test_get_cart_customer(self): - self.login_as_customer() + def validate_quotation(): + # test if quotation with customer is fetched + quotation = _get_cart_quotation() + self.assertEqual(quotation.quotation_to, "Customer") + self.assertEqual(quotation.party_name, "_Test Customer") + self.assertEqual(quotation.contact_email, frappe.session.user) + return quotation - # test if quotation with customer is fetched - quotation = _get_cart_quotation() - self.assertEqual(quotation.quotation_to, "Customer") - self.assertEqual(quotation.party_name, "_Test Customer") - self.assertEqual(quotation.contact_email, frappe.session.user) + self.login_as_customer("test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer") + validate_quotation() + + self.login_as_customer() + quotation = validate_quotation() return quotation @@ -123,10 +141,47 @@ class TestShoppingCart(unittest.TestCase): self.remove_test_quotation(quotation) + @change_settings("E Commerce Settings",{ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India", + "show_price": 1 + }) + def test_add_item_variant_without_web_item_to_cart(self): + "Test adding Variants having no Website Items in cart via Template Web Item." + from erpnext.controllers.item_variant import create_variant + from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + from erpnext.stock.doctype.item.test_item import make_item + + template_item = make_item("Test-Tshirt-Temp", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + {"attribute": "Test Size"}, + {"attribute": "Test Colour"} + ] + }) + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": "Small", "Test Colour": "Red" + }) + variant.save() + make_website_item(template_item) # publish template not variant + + update_cart("Test-Tshirt-Temp-S-R", 1) + + cart = get_cart_quotation() # test if cart page gets data without errors + doc = cart.get("doc") + + self.assertEqual(doc.get("items")[0].item_name, "Test-Tshirt-Temp-S-R") + + # test if items are rendered without error + frappe.render_template("templates/includes/cart/cart_items.html", cart) + def create_tax_rule(self): tax_rule = frappe.get_test_records("Tax Rule")[0] try: - frappe.get_doc(tax_rule).insert() + frappe.get_doc(tax_rule).insert(ignore_if_duplicate=True) except (frappe.DuplicateEntryError, ConflictingTaxRule): pass @@ -166,7 +221,7 @@ class TestShoppingCart(unittest.TestCase): # helper functions def enable_shopping_cart(self): - settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings") + settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings") settings.update({ "enabled": 1, @@ -196,7 +251,7 @@ class TestShoppingCart(unittest.TestCase): frappe.local.shopping_cart_settings = None def disable_shopping_cart(self): - settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings") + settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings") settings.enabled = 0 settings.save() frappe.local.shopping_cart_settings = None @@ -205,10 +260,9 @@ class TestShoppingCart(unittest.TestCase): self.create_user_if_not_exists("test_cart_user@example.com") frappe.set_user("test_cart_user@example.com") - def login_as_customer(self): - self.create_user_if_not_exists("test_contact_customer@example.com", - "_Test Contact For _Test Customer") - frappe.set_user("test_contact_customer@example.com") + def login_as_customer(self, email="test_contact_customer@example.com", name="_Test Contact For _Test Customer"): + self.create_user_if_not_exists(email, name) + frappe.set_user(email) def clear_existing_quotations(self): quotations = frappe.get_all("Quotation", filters={ diff --git a/erpnext/shopping_cart/utils.py b/erpnext/e_commerce/shopping_cart/utils.py similarity index 84% rename from erpnext/shopping_cart/utils.py rename to erpnext/e_commerce/shopping_cart/utils.py index 5f0c7923814..e9745a44d72 100644 --- a/erpnext/shopping_cart/utils.py +++ b/erpnext/e_commerce/shopping_cart/utils.py @@ -1,10 +1,8 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt import frappe -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( - is_cart_enabled, -) +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import is_cart_enabled def show_cart_count(): @@ -23,7 +21,7 @@ def set_cart_count(login_manager): return if show_cart_count(): - from erpnext.shopping_cart.cart import set_cart_count + from erpnext.e_commerce.shopping_cart.cart import set_cart_count # set_cart_count will try to fetch existing cart quotation # or create one if non existent (and create a customer too) diff --git a/erpnext/e_commerce/variant_selector/__init__.py b/erpnext/e_commerce/variant_selector/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/portal/product_configurator/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py similarity index 77% rename from erpnext/portal/product_configurator/item_variants_cache.py rename to erpnext/e_commerce/variant_selector/item_variants_cache.py index 636ae8d4917..3107c019e62 100644 --- a/erpnext/portal/product_configurator/item_variants_cache.py +++ b/erpnext/e_commerce/variant_selector/item_variants_cache.py @@ -44,7 +44,7 @@ class ItemVariantsCacheManager: val = frappe.cache().get_value('ordered_attribute_values_map') if val: return val - all_attribute_values = frappe.db.get_all('Item Attribute Value', + all_attribute_values = frappe.get_all('Item Attribute Value', ['attribute_value', 'idx', 'parent'], order_by='idx asc') ordered_attribute_values_map = frappe._dict({}) @@ -57,22 +57,33 @@ class ItemVariantsCacheManager: def build_cache(self): parent_item_code = self.item_code - attributes = [a.attribute for a in frappe.db.get_all('Item Variant Attribute', - {'parent': parent_item_code}, ['attribute'], order_by='idx asc') + attributes = [ + a.attribute for a in frappe.get_all( + 'Item Variant Attribute', + {'parent': parent_item_code}, + ['attribute'], + order_by='idx asc' + ) ] - item_variants_data = frappe.db.get_all('Item Variant Attribute', - {'variant_of': parent_item_code}, ['parent', 'attribute', 'attribute_value'], - order_by='name', - as_list=1 + # Get Variants and tehir Attributes that are not disabled + iva = frappe.qb.DocType("Item Variant Attribute") + item = frappe.qb.DocType("Item") + query = ( + frappe.qb.from_(iva) + .join(item).on(item.name == iva.parent) + .select( + iva.parent, iva.attribute, iva.attribute_value + ).where( + (iva.variant_of == parent_item_code) + & (item.disabled == 0) + ).orderby(iva.name) ) + item_variants_data = query.run() - disabled_items = set([i.name for i in frappe.db.get_all('Item', {'disabled': 1})]) + attribute_value_item_map = frappe._dict() + item_attribute_value_map = frappe._dict() - attribute_value_item_map = frappe._dict({}) - item_attribute_value_map = frappe._dict({}) - - item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items] for row in item_variants_data: item_code, attribute, attribute_value = row # (attr, value) => [item1, item2] @@ -111,4 +122,7 @@ def build_cache(item_code): def enqueue_build_cache(item_code): if frappe.cache().hget('item_cache_build_in_progress', item_code): return - frappe.enqueue(build_cache, item_code=item_code, queue='long') + frappe.enqueue( + "erpnext.e_commerce.variant_selector.item_variants_cache.build_cache", + item_code=item_code, queue='long' + ) diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py new file mode 100644 index 00000000000..4d907c62216 --- /dev/null +++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py @@ -0,0 +1,119 @@ +import frappe + +from erpnext.controllers.item_variant import create_variant +from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import ( + setup_e_commerce_settings, +) +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item +from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.tests.utils import ERPNextTestCase + +test_dependencies = ["Item"] + +class TestVariantSelector(ERPNextTestCase): + + @classmethod + def setUpClass(cls): + template_item = make_item("Test-Tshirt-Temp", { + "has_variant": 1, + "variant_based_on": "Item Attribute", + "attributes": [ + {"attribute": "Test Size"}, + {"attribute": "Test Colour"} + ] + }) + + # create L-R, L-G, M-R, M-G and S-R + for size in ("Large", "Medium",): + for colour in ("Red", "Green",): + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": size, "Test Colour": colour + }) + variant.save() + + variant = create_variant("Test-Tshirt-Temp", { + "Test Size": "Small", "Test Colour": "Red" + }) + variant.save() + + make_website_item(template_item) # publish template not variants + + def test_item_attributes(self): + """ + Test if the right attributes are fetched in the popup. + (Attributes must only come from active items) + + Attribute selection must not be linked to Website Items. + """ + from erpnext.e_commerce.variant_selector.utils import get_attributes_and_values + + attr_data = get_attributes_and_values("Test-Tshirt-Temp") + + self.assertEqual(attr_data[0]["attribute"], "Test Size") + self.assertEqual(attr_data[1]["attribute"], "Test Colour") + self.assertEqual(len(attr_data[0]["values"]), 3) # ['Small', 'Medium', 'Large'] + self.assertEqual(len(attr_data[1]["values"]), 2) # ['Red', 'Green'] + + # disable small red tshirt, now there are no small tshirts. + # but there are some red tshirts + small_variant = frappe.get_doc("Item", "Test-Tshirt-Temp-S-R") + small_variant.disabled = 1 + small_variant.save() # trigger cache rebuild + + attr_data = get_attributes_and_values("Test-Tshirt-Temp") + + # Only L and M attribute values must be fetched since S is disabled + self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large'] + + # teardown + small_variant.disabled = 0 + small_variant.save() + + def test_next_item_variant_values(self): + """ + Test if on selecting an attribute value, the next possible values + are filtered accordingly. + Values that dont apply should not be fetched. + E.g. + There is a ** Small-Red ** Tshirt. No other colour in this size. + On selecting ** Small **, only ** Red ** should be selectable next. + """ + next_values = get_next_attribute_and_values("Test-Tshirt-Temp", selected_attributes={"Test Size": "Small"}) + next_colours = next_values["valid_options_for_attributes"]["Test Colour"] + filtered_items = next_values["filtered_items"] + + self.assertEqual(len(next_colours), 1) + self.assertEqual(next_colours.pop(), "Red") + self.assertEqual(len(filtered_items), 1) + self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R") + + def test_exact_match_with_price(self): + """ + Test price fetching and matching of variant without Website Item + """ + from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price + + frappe.set_user("Administrator") + setup_e_commerce_settings({ + "company": "_Test Company", + "enabled": 1, + "default_customer_group": "_Test Customer Group", + "price_list": "_Test Price List India", + "show_price": 1 + }) + + make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100) + + frappe.local.shopping_cart_settings = None # clear cached settings values + next_values = get_next_attribute_and_values( + "Test-Tshirt-Temp", + selected_attributes={"Test Size": "Small", "Test Colour": "Red"} + ) + print(">>>>", next_values) + price_info = next_values["product_info"]["price"] + + self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") + self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R") + self.assertEqual(price_info["price_list_rate"], 100.0) + self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00") \ No newline at end of file diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py new file mode 100644 index 00000000000..33802737efd --- /dev/null +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -0,0 +1,218 @@ +import frappe +from frappe.utils import cint + +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( + get_shopping_cart_settings, +) +from erpnext.e_commerce.shopping_cart.cart import _set_price_list +from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager +from erpnext.utilities.product import get_price + + +def get_item_codes_by_attributes(attribute_filters, template_item_code=None): + items = [] + + for attribute, values in attribute_filters.items(): + attribute_values = values + + if not isinstance(attribute_values, list): + attribute_values = [attribute_values] + + if not attribute_values: + continue + + wheres = [] + query_values = [] + for attribute_value in attribute_values: + wheres.append('( attribute = %s and attribute_value = %s )') + query_values += [attribute, attribute_value] + + attribute_query = ' or '.join(wheres) + + if template_item_code: + variant_of_query = 'AND t2.variant_of = %s' + query_values.append(template_item_code) + else: + variant_of_query = '' + + query = ''' + SELECT + t1.parent + FROM + `tabItem Variant Attribute` t1 + WHERE + 1 = 1 + AND ( + {attribute_query} + ) + AND EXISTS ( + SELECT + 1 + FROM + `tabItem` t2 + WHERE + t2.name = t1.parent + {variant_of_query} + ) + GROUP BY + t1.parent + ORDER BY + NULL + '''.format(attribute_query=attribute_query, variant_of_query=variant_of_query) + + item_codes = set([r[0] for r in frappe.db.sql(query, query_values)]) # nosemgrep + items.append(item_codes) + + res = list(set.intersection(*items)) + + return res + +@frappe.whitelist(allow_guest=True) +def get_attributes_and_values(item_code): + '''Build a list of attributes and their possible values. + This will ignore the values upon selection of which there cannot exist one item. + ''' + item_cache = ItemVariantsCacheManager(item_code) + item_variants_data = item_cache.get_item_variants_data() + + attributes = get_item_attributes(item_code) + attribute_list = [a.attribute for a in attributes] + + valid_options = {} + for item_code, attribute, attribute_value in item_variants_data: + if attribute in attribute_list: + valid_options.setdefault(attribute, set()).add(attribute_value) + + item_attribute_values = frappe.db.get_all('Item Attribute Value', + ['parent', 'attribute_value', 'idx'], order_by='parent asc, idx asc') + ordered_attribute_value_map = frappe._dict() + for iv in item_attribute_values: + ordered_attribute_value_map.setdefault(iv.parent, []).append(iv.attribute_value) + + # build attribute values in idx order + for attr in attributes: + valid_attribute_values = valid_options.get(attr.attribute, []) + ordered_values = ordered_attribute_value_map.get(attr.attribute, []) + attr['values'] = [v for v in ordered_values if v in valid_attribute_values] + + return attributes + + +@frappe.whitelist(allow_guest=True) +def get_next_attribute_and_values(item_code, selected_attributes): + '''Find the count of Items that match the selected attributes. + Also, find the attribute values that are not applicable for further searching. + If less than equal to 10 items are found, return item_codes of those items. + If one item is matched exactly, return item_code of that item. + ''' + selected_attributes = frappe.parse_json(selected_attributes) + + item_cache = ItemVariantsCacheManager(item_code) + item_variants_data = item_cache.get_item_variants_data() + + attributes = get_item_attributes(item_code) + attribute_list = [a.attribute for a in attributes] + filtered_items = get_items_with_selected_attributes(item_code, selected_attributes) + + next_attribute = None + + for attribute in attribute_list: + if attribute not in selected_attributes: + next_attribute = attribute + break + + valid_options_for_attributes = frappe._dict() + + for a in attribute_list: + valid_options_for_attributes[a] = set() + + selected_attribute = selected_attributes.get(a, None) + if selected_attribute: + # already selected attribute values are valid options + valid_options_for_attributes[a].add(selected_attribute) + + for row in item_variants_data: + item_code, attribute, attribute_value = row + if item_code in filtered_items and attribute not in selected_attributes and attribute in attribute_list: + valid_options_for_attributes[attribute].add(attribute_value) + + optional_attributes = item_cache.get_optional_attributes() + exact_match = [] + # search for exact match if all selected attributes are required attributes + if len(selected_attributes.keys()) >= (len(attribute_list) - len(optional_attributes)): + item_attribute_value_map = item_cache.get_item_attribute_value_map() + for item_code, attr_dict in item_attribute_value_map.items(): + if item_code in filtered_items and set(attr_dict.keys()) == set(selected_attributes.keys()): + exact_match.append(item_code) + + filtered_items_count = len(filtered_items) + + # get product info if exact match + # from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website + if exact_match: + cart_settings = get_shopping_cart_settings() + product_info = get_item_variant_price_dict(exact_match[0], cart_settings) + + if product_info: + product_info["allow_items_not_in_stock"] = cint(cart_settings.allow_items_not_in_stock) + else: + product_info = None + + return { + 'next_attribute': next_attribute, + 'valid_options_for_attributes': valid_options_for_attributes, + 'filtered_items_count': filtered_items_count, + 'filtered_items': filtered_items if filtered_items_count < 10 else [], + 'exact_match': exact_match, + 'product_info': product_info + } + + +def get_items_with_selected_attributes(item_code, selected_attributes): + item_cache = ItemVariantsCacheManager(item_code) + attribute_value_item_map = item_cache.get_attribute_value_item_map() + + items = [] + for attribute, value in selected_attributes.items(): + filtered_items = attribute_value_item_map.get((attribute, value), []) + items.append(set(filtered_items)) + + return set.intersection(*items) + +# utilities + +def get_item_attributes(item_code): + attributes = frappe.db.get_all('Item Variant Attribute', + fields=['attribute'], + filters={ + 'parenttype': 'Item', + 'parent': item_code + }, + order_by='idx asc' + ) + + optional_attributes = ItemVariantsCacheManager(item_code).get_optional_attributes() + + for a in attributes: + if a.attribute in optional_attributes: + a.optional = True + + return attributes + +def get_item_variant_price_dict(item_code, cart_settings): + if cart_settings.enabled and cart_settings.show_price: + is_guest = frappe.session.user == "Guest" + # Show Price if logged in. + # If not logged in, check if price is hidden for guest. + if not is_guest or not cart_settings.hide_price_for_guest: + price_list = _set_price_list(cart_settings, None) + price = get_price( + item_code, + price_list, + cart_settings.default_customer_group, + cart_settings.company + ) + return {"price": price} + + return None + diff --git a/erpnext/e_commerce/web_template/__init__.py b/erpnext/e_commerce/web_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/web_template/hero_slider/__init__.py b/erpnext/e_commerce/web_template/hero_slider/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html b/erpnext/e_commerce/web_template/hero_slider/hero_slider.html similarity index 100% rename from erpnext/shopping_cart/web_template/hero_slider/hero_slider.html rename to erpnext/e_commerce/web_template/hero_slider/hero_slider.html diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json b/erpnext/e_commerce/web_template/hero_slider/hero_slider.json similarity index 98% rename from erpnext/shopping_cart/web_template/hero_slider/hero_slider.json rename to erpnext/e_commerce/web_template/hero_slider/hero_slider.json index 04fb1d27059..2b1807c9651 100644 --- a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json +++ b/erpnext/e_commerce/web_template/hero_slider/hero_slider.json @@ -1,4 +1,5 @@ { + "__unsaved": 1, "creation": "2020-11-17 15:21:51.207221", "docstatus": 0, "doctype": "Web Template", @@ -273,9 +274,9 @@ } ], "idx": 2, - "modified": "2020-12-29 12:30:02.794994", + "modified": "2021-02-24 15:57:05.889709", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Hero Slider", "owner": "Administrator", "standard": 1, diff --git a/erpnext/e_commerce/web_template/item_card_group/__init__.py b/erpnext/e_commerce/web_template/item_card_group/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html b/erpnext/e_commerce/web_template/item_card_group/item_card_group.html similarity index 81% rename from erpnext/shopping_cart/web_template/item_card_group/item_card_group.html rename to erpnext/e_commerce/web_template/item_card_group/item_card_group.html index fe061d5f5f5..07952f056a5 100644 --- a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html +++ b/erpnext/e_commerce/web_template/item_card_group/item_card_group.html @@ -23,11 +23,10 @@ {%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%} {%- set item = values['card_' + index + '_item'] -%} {%- if item -%} - {%- set item = frappe.get_doc("Item", item) -%} + {%- set web_item = frappe.get_doc("Website Item", item) -%} {{ item_card( - item.item_name, item.image, item.route, item.description, - None, item.item_group, values['card_' + index + '_featured'], - True, "Center" + web_item, is_featured=values['card_' + index + '_featured'], + is_full_width=True, align="Center" ) }} {%- endif -%} {%- endfor -%} diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json b/erpnext/e_commerce/web_template/item_card_group/item_card_group.json similarity index 84% rename from erpnext/shopping_cart/web_template/item_card_group/item_card_group.json rename to erpnext/e_commerce/web_template/item_card_group/item_card_group.json index ad087b04704..ad9e2a7b243 100644 --- a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json +++ b/erpnext/e_commerce/web_template/item_card_group/item_card_group.json @@ -17,15 +17,12 @@ "reqd": 0 }, { - "__unsaved": 1, "fieldname": "primary_action_label", "fieldtype": "Data", "label": "Primary Action Label", "reqd": 0 }, { - "__islocal": 1, - "__unsaved": 1, "fieldname": "primary_action", "fieldtype": "Data", "label": "Primary Action", @@ -40,8 +37,8 @@ { "fieldname": "card_1_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -59,8 +56,8 @@ { "fieldname": "card_2_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -79,8 +76,8 @@ { "fieldname": "card_3_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -98,8 +95,8 @@ { "fieldname": "card_4_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -117,8 +114,8 @@ { "fieldname": "card_5_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -136,8 +133,8 @@ { "fieldname": "card_6_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -155,8 +152,8 @@ { "fieldname": "card_7_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -174,8 +171,8 @@ { "fieldname": "card_8_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -193,8 +190,8 @@ { "fieldname": "card_9_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -212,8 +209,8 @@ { "fieldname": "card_10_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -231,8 +228,8 @@ { "fieldname": "card_11_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -250,8 +247,8 @@ { "fieldname": "card_12_item", "fieldtype": "Link", - "label": "Item", - "options": "Item", + "label": "Website Item", + "options": "Website Item", "reqd": 0 }, { @@ -262,9 +259,9 @@ } ], "idx": 0, - "modified": "2020-11-19 18:48:52.633045", + "modified": "2021-12-21 14:44:59.821335", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Item Card Group", "owner": "Administrator", "standard": 1, diff --git a/erpnext/e_commerce/web_template/product_card/__init__.py b/erpnext/e_commerce/web_template/product_card/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/e_commerce/web_template/product_card/product_card.html b/erpnext/e_commerce/web_template/product_card/product_card.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.json b/erpnext/e_commerce/web_template/product_card/product_card.json similarity index 82% rename from erpnext/shopping_cart/web_template/product_card/product_card.json rename to erpnext/e_commerce/web_template/product_card/product_card.json index 1059c1b2519..2eb73741efb 100644 --- a/erpnext/shopping_cart/web_template/product_card/product_card.json +++ b/erpnext/e_commerce/web_template/product_card/product_card.json @@ -5,7 +5,6 @@ "doctype": "Web Template", "fields": [ { - "__unsaved": 1, "fieldname": "item", "fieldtype": "Link", "label": "Item", @@ -13,7 +12,6 @@ "reqd": 0 }, { - "__unsaved": 1, "fieldname": "featured", "fieldtype": "Check", "label": "Featured", @@ -22,9 +20,9 @@ } ], "idx": 0, - "modified": "2020-11-17 15:33:34.982515", + "modified": "2021-02-24 16:05:17.926610", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Product Card", "owner": "Administrator", "standard": 1, diff --git a/erpnext/e_commerce/web_template/product_category_cards/__init__.py b/erpnext/e_commerce/web_template/product_category_cards/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html similarity index 81% rename from erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html rename to erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html index 06b76af9018..6d75a8b1d5e 100644 --- a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html +++ b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html @@ -6,8 +6,15 @@ }) -%}
{% if image %} - {{ title }} + {{ title }} + {% else %} +
+ + {{ frappe.utils.get_abbr(title or '') }} + +
{% endif %} +
{{ title or '' }}
diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json similarity index 95% rename from erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json rename to erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json index ba5f63b48b2..0202165d08e 100644 --- a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json +++ b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json @@ -74,9 +74,9 @@ } ], "idx": 0, - "modified": "2020-11-18 17:26:28.726260", + "modified": "2021-02-24 16:03:33.835635", "modified_by": "Administrator", - "module": "Shopping Cart", + "module": "E-commerce", "name": "Product Category Cards", "owner": "Administrator", "standard": 1, diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.py b/erpnext/education/doctype/program_enrollment/program_enrollment.py index a23d49267e6..4d0f3a98011 100644 --- a/erpnext/education/doctype/program_enrollment/program_enrollment.py +++ b/erpnext/education/doctype/program_enrollment/program_enrollment.py @@ -6,6 +6,7 @@ import frappe from frappe import _, msgprint from frappe.desk.reportview import get_match_cond from frappe.model.document import Document +from frappe.query_builder.functions import Min from frappe.utils import comma_and, get_link_to_form, getdate @@ -60,8 +61,15 @@ class ProgramEnrollment(Document): frappe.throw(_("Student is already enrolled.")) def update_student_joining_date(self): - date = frappe.db.sql("select min(enrollment_date) from `tabProgram Enrollment` where student= %s", self.student) - frappe.db.set_value("Student", self.student, "joining_date", date) + table = frappe.qb.DocType('Program Enrollment') + date = ( + frappe.qb.from_(table) + .select(Min(table.enrollment_date).as_('enrollment_date')) + .where(table.student == self.student) + ).run(as_dict=True) + + if date: + frappe.db.set_value("Student", self.student, "joining_date", date[0].enrollment_date) def make_fee_records(self): from erpnext.education.api import get_fee_components diff --git a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js index 68e7780039b..45265851758 100644 --- a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js +++ b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js @@ -3,6 +3,10 @@ frappe.provide("education"); frappe.ui.form.on('Student Attendance Tool', { + setup: (frm) => { + frm.students_area = $('
') + .appendTo(frm.fields_dict.students_html.wrapper); + }, onload: function(frm) { frm.set_query("student_group", function() { return { @@ -34,6 +38,7 @@ frappe.ui.form.on('Student Attendance Tool', { student_group: function(frm) { if ((frm.doc.student_group && frm.doc.date) || frm.doc.course_schedule) { + frm.students_area.find('.student-attendance-checks').html(`
Fetching...
`); var method = "erpnext.education.doctype.student_attendance_tool.student_attendance_tool.get_student_attendance_records"; frappe.call({ @@ -62,10 +67,6 @@ frappe.ui.form.on('Student Attendance Tool', { }, get_students: function(frm, students) { - if (!frm.students_area) { - frm.students_area = $('
') - .appendTo(frm.fields_dict.students_html.wrapper); - } students = students || []; frm.students_editor = new education.StudentsEditor(frm, frm.students_area, students); } @@ -163,16 +164,26 @@ education.StudentsEditor = class StudentsEditor { ); }); - var htmls = students.map(function(student) { - return frappe.render_template("student_button", { - student: student.student, - student_name: student.student_name, - group_roll_number: student.group_roll_number, - status: student.status - }) - }); + // make html grid of students + let student_html = ''; + for (let student of students) { + student_html += `
+
+ +
+
`; + } - $(htmls.join("")).appendTo(me.wrapper); + $(`
${student_html}
`).appendTo(me.wrapper); } show_empty_state() { diff --git a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py index 7deb6b18da5..92bb20ca52b 100644 --- a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py +++ b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py @@ -24,24 +24,24 @@ def get_student_attendance_records(based_on, date=None, student_group=None, cour student_list = frappe.get_all("Student Group Student", fields=["student", "student_name", "group_roll_number"], filters={"parent": student_group, "active": 1}, order_by= "group_roll_number") - table = frappe.qb.DocType("Student Attendance") + StudentAttendance = frappe.qb.DocType("Student Attendance") if course_schedule: student_attendance_list = ( - frappe.qb.from_(table) - .select(table.student, table.status) + frappe.qb.from_(StudentAttendance) + .select(StudentAttendance.student, StudentAttendance.status) .where( - (table.course_schedule == course_schedule) + (StudentAttendance.course_schedule == course_schedule) ) ).run(as_dict=True) else: student_attendance_list = ( - frappe.qb.from_(table) - .select(table.student, table.status) + frappe.qb.from_(StudentAttendance) + .select(StudentAttendance.student, StudentAttendance.status) .where( - (table.student_group == student_group) - & (table.date == date) - & (table.course_schedule == "") | (table.course_schedule.isnull()) + (StudentAttendance.student_group == student_group) + & (StudentAttendance.date == date) + & ((StudentAttendance.course_schedule == "") | (StudentAttendance.course_schedule.isnull())) ) ).run(as_dict=True) diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py deleted file mode 100644 index 66826ba8d75..00000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py +++ /dev/null @@ -1,525 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt - - -import csv -import math -import time - -import dateutil -import frappe -from frappe import _ -from six import StringIO - -import erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_api as mws - - -#Get and Create Products -def get_products_details(): - products = get_products_instance() - reports = get_reports_instance() - - mws_settings = frappe.get_doc("Amazon MWS Settings") - market_place_list = return_as_list(mws_settings.market_place_id) - - for marketplace in market_place_list: - report_id = request_and_fetch_report_id("_GET_FLAT_FILE_OPEN_LISTINGS_DATA_", None, None, market_place_list) - - if report_id: - listings_response = reports.get_report(report_id=report_id) - - #Get ASIN Codes - string_io = StringIO(frappe.safe_decode(listings_response.original)) - csv_rows = list(csv.reader(string_io, delimiter='\t')) - asin_list = list(set([row[1] for row in csv_rows[1:]])) - #break into chunks of 10 - asin_chunked_list = list(chunks(asin_list, 10)) - - #Map ASIN Codes to SKUs - sku_asin = [{"asin":row[1],"sku":row[0]} for row in csv_rows[1:]] - - #Fetch Products List from ASIN - for asin_list in asin_chunked_list: - products_response = call_mws_method(products.get_matching_product,marketplaceid=marketplace, - asins=asin_list) - - matching_products_list = products_response.parsed - for product in matching_products_list: - skus = [row["sku"] for row in sku_asin if row["asin"]==product.ASIN] - for sku in skus: - create_item_code(product, sku) - -def get_products_instance(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - products = mws.Products( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region = mws_settings.region, - domain = mws_settings.domain - ) - - return products - -def get_reports_instance(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - reports = mws.Reports( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region = mws_settings.region, - domain = mws_settings.domain - ) - - return reports - -#returns list as expected by amazon API -def return_as_list(input_value): - if isinstance(input_value, list): - return input_value - else: - return [input_value] - -#function to chunk product data -def chunks(l, n): - for i in range(0, len(l), n): - yield l[i:i+n] - -def request_and_fetch_report_id(report_type, start_date=None, end_date=None, marketplaceids=None): - reports = get_reports_instance() - report_response = reports.request_report(report_type=report_type, - start_date=start_date, - end_date=end_date, - marketplaceids=marketplaceids) - - report_request_id = report_response.parsed["ReportRequestInfo"]["ReportRequestId"]["value"] - generated_report_id = None - #poll to get generated report - for x in range(1,10): - report_request_list_response = reports.get_report_request_list(requestids=[report_request_id]) - report_status = report_request_list_response.parsed["ReportRequestInfo"]["ReportProcessingStatus"]["value"] - - if report_status == "_SUBMITTED_" or report_status == "_IN_PROGRESS_": - #add time delay to wait for amazon to generate report - time.sleep(15) - continue - elif report_status == "_CANCELLED_": - break - elif report_status == "_DONE_NO_DATA_": - break - elif report_status == "_DONE_": - generated_report_id = report_request_list_response.parsed["ReportRequestInfo"]["GeneratedReportId"]["value"] - break - return generated_report_id - -def call_mws_method(mws_method, *args, **kwargs): - - mws_settings = frappe.get_doc("Amazon MWS Settings") - max_retries = mws_settings.max_retry_limit - - for x in range(0, max_retries): - try: - response = mws_method(*args, **kwargs) - return response - except Exception as e: - delay = math.pow(4, x) * 125 - frappe.log_error(message=e, title=f'Method "{mws_method.__name__}" failed') - time.sleep(delay) - continue - - mws_settings.enable_sync = 0 - mws_settings.save() - - frappe.throw(_("Sync has been temporarily disabled because maximum retries have been exceeded")) - -def create_item_code(amazon_item_json, sku): - if frappe.db.get_value("Item", sku): - return - - item = frappe.new_doc("Item") - - new_manufacturer = create_manufacturer(amazon_item_json) - new_brand = create_brand(amazon_item_json) - - mws_settings = frappe.get_doc("Amazon MWS Settings") - - item.item_code = sku - item.amazon_item_code = amazon_item_json.ASIN - item.item_group = mws_settings.item_group - item.description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title - item.brand = new_brand - item.manufacturer = new_manufacturer - item.web_long_description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title - - item.image = amazon_item_json.Product.AttributeSets.ItemAttributes.SmallImage.URL - - temp_item_group = amazon_item_json.Product.AttributeSets.ItemAttributes.ProductGroup - - item_group = frappe.db.get_value("Item Group",filters={"item_group_name": temp_item_group}) - - if not item_group: - igroup = frappe.new_doc("Item Group") - igroup.item_group_name = temp_item_group - igroup.parent_item_group = mws_settings.item_group - igroup.insert() - - item.append("item_defaults", {'company':mws_settings.company}) - - item.insert(ignore_permissions=True) - create_item_price(amazon_item_json, item.item_code) - - return item.name - -def create_manufacturer(amazon_item_json): - if not amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer: - return None - - existing_manufacturer = frappe.db.get_value("Manufacturer", - filters={"short_name":amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer}) - - if not existing_manufacturer: - manufacturer = frappe.new_doc("Manufacturer") - manufacturer.short_name = amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer - manufacturer.insert() - return manufacturer.short_name - else: - return existing_manufacturer - -def create_brand(amazon_item_json): - if not amazon_item_json.Product.AttributeSets.ItemAttributes.Brand: - return None - - existing_brand = frappe.db.get_value("Brand", - filters={"brand":amazon_item_json.Product.AttributeSets.ItemAttributes.Brand}) - if not existing_brand: - brand = frappe.new_doc("Brand") - brand.brand = amazon_item_json.Product.AttributeSets.ItemAttributes.Brand - brand.insert() - return brand.brand - else: - return existing_brand - -def create_item_price(amazon_item_json, item_code): - item_price = frappe.new_doc("Item Price") - item_price.price_list = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "price_list") - if not("ListPrice" in amazon_item_json.Product.AttributeSets.ItemAttributes): - item_price.price_list_rate = 0 - else: - item_price.price_list_rate = amazon_item_json.Product.AttributeSets.ItemAttributes.ListPrice.Amount - - item_price.item_code = item_code - item_price.insert() - -#Get and create Orders -def get_orders(after_date): - try: - orders = get_orders_instance() - statuses = ["PartiallyShipped", "Unshipped", "Shipped", "Canceled"] - mws_settings = frappe.get_doc("Amazon MWS Settings") - market_place_list = return_as_list(mws_settings.market_place_id) - - orders_response = call_mws_method(orders.list_orders, marketplaceids=market_place_list, - fulfillment_channels=["MFN", "AFN"], - lastupdatedafter=after_date, - orderstatus=statuses, - max_results='50') - - while True: - orders_list = [] - - if "Order" in orders_response.parsed.Orders: - orders_list = return_as_list(orders_response.parsed.Orders.Order) - - if len(orders_list) == 0: - break - - for order in orders_list: - create_sales_order(order, after_date) - - if not "NextToken" in orders_response.parsed: - break - - next_token = orders_response.parsed.NextToken - orders_response = call_mws_method(orders.list_orders_by_next_token, next_token) - - except Exception as e: - frappe.log_error(title="get_orders", message=e) - -def get_orders_instance(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - orders = mws.Orders( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region= mws_settings.region, - domain= mws_settings.domain, - version="2013-09-01" - ) - - return orders - -def create_sales_order(order_json,after_date): - customer_name = create_customer(order_json) - create_address(order_json, customer_name) - - market_place_order_id = order_json.AmazonOrderId - - so = frappe.db.get_value("Sales Order", - filters={"amazon_order_id": market_place_order_id}, - fieldname="name") - - taxes_and_charges = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "taxes_charges") - - if so: - return - - if not so: - items = get_order_items(market_place_order_id) - delivery_date = dateutil.parser.parse(order_json.LatestShipDate).strftime("%Y-%m-%d") - transaction_date = dateutil.parser.parse(order_json.PurchaseDate).strftime("%Y-%m-%d") - - so = frappe.get_doc({ - "doctype": "Sales Order", - "naming_series": "SO-", - "amazon_order_id": market_place_order_id, - "marketplace_id": order_json.MarketplaceId, - "customer": customer_name, - "delivery_date": delivery_date, - "transaction_date": transaction_date, - "items": items, - "company": frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "company") - }) - - try: - if taxes_and_charges: - charges_and_fees = get_charges_and_fees(market_place_order_id) - for charge in charges_and_fees.get("charges"): - so.append('taxes', charge) - - for fee in charges_and_fees.get("fees"): - so.append('taxes', fee) - - so.insert(ignore_permissions=True) - so.submit() - - except Exception as e: - import traceback - frappe.log_error(message=traceback.format_exc(), title="Create Sales Order") - -def create_customer(order_json): - order_customer_name = "" - - if not("BuyerName" in order_json): - order_customer_name = "Buyer - " + order_json.AmazonOrderId - else: - order_customer_name = order_json.BuyerName - - existing_customer_name = frappe.db.get_value("Customer", - filters={"name": order_customer_name}, fieldname="name") - - if existing_customer_name: - filters = [ - ["Dynamic Link", "link_doctype", "=", "Customer"], - ["Dynamic Link", "link_name", "=", existing_customer_name], - ["Dynamic Link", "parenttype", "=", "Contact"] - ] - - existing_contacts = frappe.get_list("Contact", filters) - - if existing_contacts: - pass - else: - new_contact = frappe.new_doc("Contact") - new_contact.first_name = order_customer_name - new_contact.append('links', { - "link_doctype": "Customer", - "link_name": existing_customer_name - }) - new_contact.insert() - - return existing_customer_name - else: - mws_customer_settings = frappe.get_doc("Amazon MWS Settings") - new_customer = frappe.new_doc("Customer") - new_customer.customer_name = order_customer_name - new_customer.customer_group = mws_customer_settings.customer_group - new_customer.territory = mws_customer_settings.territory - new_customer.customer_type = mws_customer_settings.customer_type - new_customer.save() - - new_contact = frappe.new_doc("Contact") - new_contact.first_name = order_customer_name - new_contact.append('links', { - "link_doctype": "Customer", - "link_name": new_customer.name - }) - - new_contact.insert() - - return new_customer.name - -def create_address(amazon_order_item_json, customer_name): - - filters = [ - ["Dynamic Link", "link_doctype", "=", "Customer"], - ["Dynamic Link", "link_name", "=", customer_name], - ["Dynamic Link", "parenttype", "=", "Address"] - ] - - existing_address = frappe.get_list("Address", filters) - - if not("ShippingAddress" in amazon_order_item_json): - return None - else: - make_address = frappe.new_doc("Address") - - if "AddressLine1" in amazon_order_item_json.ShippingAddress: - make_address.address_line1 = amazon_order_item_json.ShippingAddress.AddressLine1 - else: - make_address.address_line1 = "Not Provided" - - if "City" in amazon_order_item_json.ShippingAddress: - make_address.city = amazon_order_item_json.ShippingAddress.City - else: - make_address.city = "Not Provided" - - if "StateOrRegion" in amazon_order_item_json.ShippingAddress: - make_address.state = amazon_order_item_json.ShippingAddress.StateOrRegion - - if "PostalCode" in amazon_order_item_json.ShippingAddress: - make_address.pincode = amazon_order_item_json.ShippingAddress.PostalCode - - for address in existing_address: - address_doc = frappe.get_doc("Address", address["name"]) - if (address_doc.address_line1 == make_address.address_line1 and - address_doc.pincode == make_address.pincode): - return address - - make_address.append("links", { - "link_doctype": "Customer", - "link_name": customer_name - }) - make_address.address_type = "Shipping" - make_address.insert() - -def get_order_items(market_place_order_id): - mws_orders = get_orders_instance() - - order_items_response = call_mws_method(mws_orders.list_order_items, amazon_order_id=market_place_order_id) - final_order_items = [] - - order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem) - - warehouse = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "warehouse") - - while True: - for order_item in order_items_list: - - if not "ItemPrice" in order_item: - price = 0 - else: - price = order_item.ItemPrice.Amount - - final_order_items.append({ - "item_code": get_item_code(order_item), - "item_name": order_item.SellerSKU, - "description": order_item.Title, - "rate": price, - "qty": order_item.QuantityOrdered, - "stock_uom": "Nos", - "warehouse": warehouse, - "conversion_factor": "1.0" - }) - - if not "NextToken" in order_items_response.parsed: - break - - next_token = order_items_response.parsed.NextToken - - order_items_response = call_mws_method(mws_orders.list_order_items_by_next_token, next_token) - order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem) - - return final_order_items - -def get_item_code(order_item): - sku = order_item.SellerSKU - item_code = frappe.db.get_value("Item", {"item_code": sku}, "item_code") - if item_code: - return item_code - -def get_charges_and_fees(market_place_order_id): - finances = get_finances_instance() - - charges_fees = {"charges":[], "fees":[]} - - response = call_mws_method(finances.list_financial_events, amazon_order_id=market_place_order_id) - - shipment_event_list = return_as_list(response.parsed.FinancialEvents.ShipmentEventList) - - for shipment_event in shipment_event_list: - if shipment_event: - shipment_item_list = return_as_list(shipment_event.ShipmentEvent.ShipmentItemList.ShipmentItem) - - for shipment_item in shipment_item_list: - charges, fees = [], [] - - if 'ItemChargeList' in shipment_item.keys(): - charges = return_as_list(shipment_item.ItemChargeList.ChargeComponent) - - if 'ItemFeeList' in shipment_item.keys(): - fees = return_as_list(shipment_item.ItemFeeList.FeeComponent) - - for charge in charges: - if(charge.ChargeType != "Principal") and float(charge.ChargeAmount.CurrencyAmount) != 0: - charge_account = get_account(charge.ChargeType) - charges_fees.get("charges").append({ - "charge_type":"Actual", - "account_head": charge_account, - "tax_amount": charge.ChargeAmount.CurrencyAmount, - "description": charge.ChargeType + " for " + shipment_item.SellerSKU - }) - - for fee in fees: - if float(fee.FeeAmount.CurrencyAmount) != 0: - fee_account = get_account(fee.FeeType) - charges_fees.get("fees").append({ - "charge_type":"Actual", - "account_head": fee_account, - "tax_amount": fee.FeeAmount.CurrencyAmount, - "description": fee.FeeType + " for " + shipment_item.SellerSKU - }) - - return charges_fees - -def get_finances_instance(): - - mws_settings = frappe.get_doc("Amazon MWS Settings") - - finances = mws.Finances( - account_id = mws_settings.seller_id, - access_key = mws_settings.aws_access_key_id, - secret_key = mws_settings.secret_key, - region= mws_settings.region, - domain= mws_settings.domain, - version="2015-05-01" - ) - - return finances - -def get_account(name): - existing_account = frappe.db.get_value("Account", {"account_name": "Amazon {0}".format(name)}) - account_name = existing_account - mws_settings = frappe.get_doc("Amazon MWS Settings") - - if not existing_account: - try: - new_account = frappe.new_doc("Account") - new_account.account_name = "Amazon {0}".format(name) - new_account.company = mws_settings.company - new_account.parent_account = mws_settings.market_place_account_group - new_account.insert(ignore_permissions=True) - account_name = new_account.name - except Exception as e: - frappe.log_error(message=e, title="Create Account") - - return account_name diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py deleted file mode 100755 index 4caf137455a..00000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py +++ /dev/null @@ -1,651 +0,0 @@ -#!/usr/bin/env python -# -# Basic interface to Amazon MWS -# Based on http://code.google.com/p/amazon-mws-python -# Extended to include finances object - -import base64 -import hashlib -import hmac -import re -from urllib.parse import quote - -from erpnext.erpnext_integrations.doctype.amazon_mws_settings import xml_utils - -try: - from xml.etree.ElementTree import ParseError as XMLError -except ImportError: - from xml.parsers.expat import ExpatError as XMLError - -from time import gmtime, strftime - -from requests import request -from requests.exceptions import HTTPError - -__all__ = [ - 'Feeds', - 'Inventory', - 'MWSError', - 'Reports', - 'Orders', - 'Products', - 'Recommendations', - 'Sellers', - 'Finances' -] - -# See https://images-na.ssl-images-amazon.com/images/G/01/mwsportal/doc/en_US/bde/MWSDeveloperGuide._V357736853_.pdf page 8 -# for a list of the end points and marketplace IDs - -MARKETPLACES = { - "CA": "https://mws.amazonservices.ca", #A2EUQ1WTGCTBG2 - "US": "https://mws.amazonservices.com", #ATVPDKIKX0DER", - "DE": "https://mws-eu.amazonservices.com", #A1PA6795UKMFR9 - "ES": "https://mws-eu.amazonservices.com", #A1RKKUPIHCS9HS - "FR": "https://mws-eu.amazonservices.com", #A13V1IB3VIYZZH - "IN": "https://mws.amazonservices.in", #A21TJRUUN4KGV - "IT": "https://mws-eu.amazonservices.com", #APJ6JRA9NG5V4 - "UK": "https://mws-eu.amazonservices.com", #A1F83G8C2ARO7P - "JP": "https://mws.amazonservices.jp", #A1VC38T7YXB528 - "CN": "https://mws.amazonservices.com.cn", #AAHKV2X7AFYLW - "AE": " https://mws.amazonservices.ae", #A2VIGQ35RCS4UG - "MX": "https://mws.amazonservices.com.mx", #A1AM78C64UM0Y8 - "BR": "https://mws.amazonservices.com", #A2Q3Y263D00KWC -} - - -class MWSError(Exception): - """ - Main MWS Exception class - """ - # Allows quick access to the response object. - # Do not rely on this attribute, always check if its not None. - response = None - -def calc_md5(string): - """Calculates the MD5 encryption for the given string - """ - md = hashlib.md5() - md.update(string) - return base64.encodebytes(md.digest()).decode().strip() - - - -def remove_empty(d): - """ - Helper function that removes all keys from a dictionary (d), - that have an empty value. - """ - for key in list(d): - if not d[key]: - del d[key] - return d - -def remove_namespace(xml): - xml = xml.decode('utf-8') - regex = re.compile(' xmlns(:ns2)?="[^"]+"|(ns2:)|(xml:)') - return regex.sub('', xml) - -class DictWrapper(object): - def __init__(self, xml, rootkey=None): - self.original = xml - self._rootkey = rootkey - self._mydict = xml_utils.xml2dict().fromstring(remove_namespace(xml)) - self._response_dict = self._mydict.get(list(self._mydict)[0], self._mydict) - - @property - def parsed(self): - if self._rootkey: - return self._response_dict.get(self._rootkey) - else: - return self._response_dict - -class DataWrapper(object): - """ - Text wrapper in charge of validating the hash sent by Amazon. - """ - def __init__(self, data, header): - self.original = data - if 'content-md5' in header: - hash_ = calc_md5(self.original) - if header['content-md5'] != hash_: - raise MWSError("Wrong Contentlength, maybe amazon error...") - - @property - def parsed(self): - return self.original - -class MWS(object): - """ Base Amazon API class """ - - # This is used to post/get to the different uris used by amazon per api - # ie. /Orders/2011-01-01 - # All subclasses must define their own URI only if needed - URI = "/" - - # The API version varies in most amazon APIs - VERSION = "2009-01-01" - - # There seem to be some xml namespace issues. therefore every api subclass - # is recommended to define its namespace, so that it can be referenced - # like so AmazonAPISubclass.NS. - # For more information see http://stackoverflow.com/a/8719461/389453 - NS = '' - - # Some APIs are available only to either a "Merchant" or "Seller" - # the type of account needs to be sent in every call to the amazon MWS. - # This constant defines the exact name of the parameter Amazon expects - # for the specific API being used. - # All subclasses need to define this if they require another account type - # like "Merchant" in which case you define it like so. - # ACCOUNT_TYPE = "Merchant" - # Which is the name of the parameter for that specific account type. - ACCOUNT_TYPE = "SellerId" - - def __init__(self, access_key, secret_key, account_id, region='US', domain='', uri="", version=""): - self.access_key = access_key - self.secret_key = secret_key - self.account_id = account_id - self.version = version or self.VERSION - self.uri = uri or self.URI - - if domain: - self.domain = domain - elif region in MARKETPLACES: - self.domain = MARKETPLACES[region] - else: - error_msg = "Incorrect region supplied ('%(region)s'). Must be one of the following: %(marketplaces)s" % { - "marketplaces" : ', '.join(MARKETPLACES.keys()), - "region" : region, - } - raise MWSError(error_msg) - - def make_request(self, extra_data, method="GET", **kwargs): - """Make request to Amazon MWS API with these parameters - """ - - # Remove all keys with an empty value because - # Amazon's MWS does not allow such a thing. - extra_data = remove_empty(extra_data) - - params = { - 'AWSAccessKeyId': self.access_key, - self.ACCOUNT_TYPE: self.account_id, - 'SignatureVersion': '2', - 'Timestamp': self.get_timestamp(), - 'Version': self.version, - 'SignatureMethod': 'HmacSHA256', - } - params.update(extra_data) - request_description = '&'.join(['%s=%s' % (k, quote(params[k], safe='-_.~')) for k in sorted(params)]) - signature = self.calc_signature(method, request_description) - url = '%s%s?%s&Signature=%s' % (self.domain, self.uri, request_description, quote(signature)) - headers = {'User-Agent': 'python-amazon-mws/0.0.1 (Language=Python)'} - headers.update(kwargs.get('extra_headers', {})) - - try: - # Some might wonder as to why i don't pass the params dict as the params argument to request. - # My answer is, here i have to get the url parsed string of params in order to sign it, so - # if i pass the params dict as params to request, request will repeat that step because it will need - # to convert the dict to a url parsed string, so why do it twice if i can just pass the full url :). - response = request(method, url, data=kwargs.get('body', ''), headers=headers) - response.raise_for_status() - # When retrieving data from the response object, - # be aware that response.content returns the content in bytes while response.text calls - # response.content and converts it to unicode. - data = response.content - - # I do not check the headers to decide which content structure to server simply because sometimes - # Amazon's MWS API returns XML error responses with "text/plain" as the Content-Type. - try: - parsed_response = DictWrapper(data, extra_data.get("Action") + "Result") - except XMLError: - parsed_response = DataWrapper(data, response.headers) - - except HTTPError as e: - error = MWSError(str(e)) - error.response = e.response - raise error - - # Store the response object in the parsed_response for quick access - parsed_response.response = response - return parsed_response - - def get_service_status(self): - """ - Returns a GREEN, GREEN_I, YELLOW or RED status. - Depending on the status/availability of the API its being called from. - """ - - return self.make_request(extra_data=dict(Action='GetServiceStatus')) - - def calc_signature(self, method, request_description): - """Calculate MWS signature to interface with Amazon - """ - sig_data = method + '\n' + self.domain.replace('https://', '').lower() + '\n' + self.uri + '\n' + request_description - sig_data = sig_data.encode('utf-8') - secret_key = self.secret_key.encode('utf-8') - digest = hmac.new(secret_key, sig_data, hashlib.sha256).digest() - return base64.b64encode(digest).decode('utf-8') - - def get_timestamp(self): - """ - Returns the current timestamp in proper format. - """ - return strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) - - def enumerate_param(self, param, values): - """ - Builds a dictionary of an enumerated parameter. - Takes any iterable and returns a dictionary. - ie. - enumerate_param('MarketplaceIdList.Id', (123, 345, 4343)) - returns - { - MarketplaceIdList.Id.1: 123, - MarketplaceIdList.Id.2: 345, - MarketplaceIdList.Id.3: 4343 - } - """ - params = {} - if values is not None: - if not param.endswith('.'): - param = "%s." % param - for num, value in enumerate(values): - params['%s%d' % (param, (num + 1))] = value - return params - - -class Feeds(MWS): - """ Amazon MWS Feeds API """ - - ACCOUNT_TYPE = "Merchant" - - def submit_feed(self, feed, feed_type, marketplaceids=None, - content_type="text/xml", purge='false'): - """ - Uploads a feed ( xml or .tsv ) to the seller's inventory. - Can be used for creating/updating products on Amazon. - """ - data = dict(Action='SubmitFeed', - FeedType=feed_type, - PurgeAndReplace=purge) - data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) - md = calc_md5(feed) - return self.make_request(data, method="POST", body=feed, - extra_headers={'Content-MD5': md, 'Content-Type': content_type}) - - def get_feed_submission_list(self, feedids=None, max_count=None, feedtypes=None, - processingstatuses=None, fromdate=None, todate=None): - """ - Returns a list of all feed submissions submitted in the previous 90 days. - That match the query parameters. - """ - - data = dict(Action='GetFeedSubmissionList', - MaxCount=max_count, - SubmittedFromDate=fromdate, - SubmittedToDate=todate,) - data.update(self.enumerate_param('FeedSubmissionIdList.Id', feedids)) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def get_submission_list_by_next_token(self, token): - data = dict(Action='GetFeedSubmissionListByNextToken', NextToken=token) - return self.make_request(data) - - def get_feed_submission_count(self, feedtypes=None, processingstatuses=None, fromdate=None, todate=None): - data = dict(Action='GetFeedSubmissionCount', - SubmittedFromDate=fromdate, - SubmittedToDate=todate) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def cancel_feed_submissions(self, feedids=None, feedtypes=None, fromdate=None, todate=None): - data = dict(Action='CancelFeedSubmissions', - SubmittedFromDate=fromdate, - SubmittedToDate=todate) - data.update(self.enumerate_param('FeedSubmissionIdList.Id.', feedids)) - data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes)) - return self.make_request(data) - - def get_feed_submission_result(self, feedid): - data = dict(Action='GetFeedSubmissionResult', FeedSubmissionId=feedid) - return self.make_request(data) - -class Reports(MWS): - """ Amazon MWS Reports API """ - - ACCOUNT_TYPE = "Merchant" - - ## REPORTS ### - - def get_report(self, report_id): - data = dict(Action='GetReport', ReportId=report_id) - return self.make_request(data) - - def get_report_count(self, report_types=(), acknowledged=None, fromdate=None, todate=None): - data = dict(Action='GetReportCount', - Acknowledged=acknowledged, - AvailableFromDate=fromdate, - AvailableToDate=todate) - data.update(self.enumerate_param('ReportTypeList.Type.', report_types)) - return self.make_request(data) - - def get_report_list(self, requestids=(), max_count=None, types=(), acknowledged=None, - fromdate=None, todate=None): - data = dict(Action='GetReportList', - Acknowledged=acknowledged, - AvailableFromDate=fromdate, - AvailableToDate=todate, - MaxCount=max_count) - data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids)) - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - return self.make_request(data) - - def get_report_list_by_next_token(self, token): - data = dict(Action='GetReportListByNextToken', NextToken=token) - return self.make_request(data) - - def get_report_request_count(self, report_types=(), processingstatuses=(), fromdate=None, todate=None): - data = dict(Action='GetReportRequestCount', - RequestedFromDate=fromdate, - RequestedToDate=todate) - data.update(self.enumerate_param('ReportTypeList.Type.', report_types)) - data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def get_report_request_list(self, requestids=(), types=(), processingstatuses=(), - max_count=None, fromdate=None, todate=None): - data = dict(Action='GetReportRequestList', - MaxCount=max_count, - RequestedFromDate=fromdate, - RequestedToDate=todate) - data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids)) - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses)) - return self.make_request(data) - - def get_report_request_list_by_next_token(self, token): - data = dict(Action='GetReportRequestListByNextToken', NextToken=token) - return self.make_request(data) - - def request_report(self, report_type, start_date=None, end_date=None, marketplaceids=()): - data = dict(Action='RequestReport', - ReportType=report_type, - StartDate=start_date, - EndDate=end_date) - data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids)) - return self.make_request(data) - - ### ReportSchedule ### - - def get_report_schedule_list(self, types=()): - data = dict(Action='GetReportScheduleList') - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - return self.make_request(data) - - def get_report_schedule_count(self, types=()): - data = dict(Action='GetReportScheduleCount') - data.update(self.enumerate_param('ReportTypeList.Type.', types)) - return self.make_request(data) - - -class Orders(MWS): - """ Amazon Orders API """ - - URI = "/Orders/2013-09-01" - VERSION = "2013-09-01" - NS = '{https://mws.amazonservices.com/Orders/2011-01-01}' - - def list_orders(self, marketplaceids, created_after=None, created_before=None, lastupdatedafter=None, - lastupdatedbefore=None, orderstatus=(), fulfillment_channels=(), - payment_methods=(), buyer_email=None, seller_orderid=None, max_results='100'): - - data = dict(Action='ListOrders', - CreatedAfter=created_after, - CreatedBefore=created_before, - LastUpdatedAfter=lastupdatedafter, - LastUpdatedBefore=lastupdatedbefore, - BuyerEmail=buyer_email, - SellerOrderId=seller_orderid, - MaxResultsPerPage=max_results, - ) - data.update(self.enumerate_param('OrderStatus.Status.', orderstatus)) - data.update(self.enumerate_param('MarketplaceId.Id.', marketplaceids)) - data.update(self.enumerate_param('FulfillmentChannel.Channel.', fulfillment_channels)) - data.update(self.enumerate_param('PaymentMethod.Method.', payment_methods)) - return self.make_request(data) - - def list_orders_by_next_token(self, token): - data = dict(Action='ListOrdersByNextToken', NextToken=token) - return self.make_request(data) - - def get_order(self, amazon_order_ids): - data = dict(Action='GetOrder') - data.update(self.enumerate_param('AmazonOrderId.Id.', amazon_order_ids)) - return self.make_request(data) - - def list_order_items(self, amazon_order_id): - data = dict(Action='ListOrderItems', AmazonOrderId=amazon_order_id) - return self.make_request(data) - - def list_order_items_by_next_token(self, token): - data = dict(Action='ListOrderItemsByNextToken', NextToken=token) - return self.make_request(data) - - -class Products(MWS): - """ Amazon MWS Products API """ - - URI = '/Products/2011-10-01' - VERSION = '2011-10-01' - NS = '{http://mws.amazonservices.com/schema/Products/2011-10-01}' - - def list_matching_products(self, marketplaceid, query, contextid=None): - """ Returns a list of products and their attributes, ordered by - relevancy, based on a search query that you specify. - Your search query can be a phrase that describes the product - or it can be a product identifier such as a UPC, EAN, ISBN, or JAN. - """ - data = dict(Action='ListMatchingProducts', - MarketplaceId=marketplaceid, - Query=query, - QueryContextId=contextid) - return self.make_request(data) - - def get_matching_product(self, marketplaceid, asins): - """ Returns a list of products and their attributes, based on a list of - ASIN values that you specify. - """ - data = dict(Action='GetMatchingProduct', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - def get_matching_product_for_id(self, marketplaceid, type, id): - """ Returns a list of products and their attributes, based on a list of - product identifier values (asin, sellersku, upc, ean, isbn and JAN) - Added in Fourth Release, API version 2011-10-01 - """ - data = dict(Action='GetMatchingProductForId', - MarketplaceId=marketplaceid, - IdType=type) - data.update(self.enumerate_param('IdList.Id', id)) - return self.make_request(data) - - def get_competitive_pricing_for_sku(self, marketplaceid, skus): - """ Returns the current competitive pricing of a product, - based on the SellerSKU and MarketplaceId that you specify. - """ - data = dict(Action='GetCompetitivePricingForSKU', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) - return self.make_request(data) - - def get_competitive_pricing_for_asin(self, marketplaceid, asins): - """ Returns the current competitive pricing of a product, - based on the ASIN and MarketplaceId that you specify. - """ - data = dict(Action='GetCompetitivePricingForASIN', MarketplaceId=marketplaceid) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - def get_lowest_offer_listings_for_sku(self, marketplaceid, skus, condition="Any", excludeme="False"): - data = dict(Action='GetLowestOfferListingsForSKU', - MarketplaceId=marketplaceid, - ItemCondition=condition, - ExcludeMe=excludeme) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) - return self.make_request(data) - - def get_lowest_offer_listings_for_asin(self, marketplaceid, asins, condition="Any", excludeme="False"): - data = dict(Action='GetLowestOfferListingsForASIN', - MarketplaceId=marketplaceid, - ItemCondition=condition, - ExcludeMe=excludeme) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - def get_product_categories_for_sku(self, marketplaceid, sku): - data = dict(Action='GetProductCategoriesForSKU', - MarketplaceId=marketplaceid, - SellerSKU=sku) - return self.make_request(data) - - def get_product_categories_for_asin(self, marketplaceid, asin): - data = dict(Action='GetProductCategoriesForASIN', - MarketplaceId=marketplaceid, - ASIN=asin) - return self.make_request(data) - - def get_my_price_for_sku(self, marketplaceid, skus, condition=None): - data = dict(Action='GetMyPriceForSKU', - MarketplaceId=marketplaceid, - ItemCondition=condition) - data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus)) - return self.make_request(data) - - def get_my_price_for_asin(self, marketplaceid, asins, condition=None): - data = dict(Action='GetMyPriceForASIN', - MarketplaceId=marketplaceid, - ItemCondition=condition) - data.update(self.enumerate_param('ASINList.ASIN.', asins)) - return self.make_request(data) - - -class Sellers(MWS): - """ Amazon MWS Sellers API """ - - URI = '/Sellers/2011-07-01' - VERSION = '2011-07-01' - NS = '{http://mws.amazonservices.com/schema/Sellers/2011-07-01}' - - def list_marketplace_participations(self): - """ - Returns a list of marketplaces a seller can participate in and - a list of participations that include seller-specific information in that marketplace. - The operation returns only those marketplaces where the seller's account is in an active state. - """ - - data = dict(Action='ListMarketplaceParticipations') - return self.make_request(data) - - def list_marketplace_participations_by_next_token(self, token): - """ - Takes a "NextToken" and returns the same information as "list_marketplace_participations". - Based on the "NextToken". - """ - data = dict(Action='ListMarketplaceParticipations', NextToken=token) - return self.make_request(data) - -#### Fulfillment APIs #### - -class InboundShipments(MWS): - URI = "/FulfillmentInboundShipment/2010-10-01" - VERSION = '2010-10-01' - - # To be completed - - -class Inventory(MWS): - """ Amazon MWS Inventory Fulfillment API """ - - URI = '/FulfillmentInventory/2010-10-01' - VERSION = '2010-10-01' - NS = "{http://mws.amazonaws.com/FulfillmentInventory/2010-10-01}" - - def list_inventory_supply(self, skus=(), datetime=None, response_group='Basic'): - """ Returns information on available inventory """ - - data = dict(Action='ListInventorySupply', - QueryStartDateTime=datetime, - ResponseGroup=response_group, - ) - data.update(self.enumerate_param('SellerSkus.member.', skus)) - return self.make_request(data, "POST") - - def list_inventory_supply_by_next_token(self, token): - data = dict(Action='ListInventorySupplyByNextToken', NextToken=token) - return self.make_request(data, "POST") - - -class OutboundShipments(MWS): - URI = "/FulfillmentOutboundShipment/2010-10-01" - VERSION = "2010-10-01" - # To be completed - - -class Recommendations(MWS): - - """ Amazon MWS Recommendations API """ - - URI = '/Recommendations/2013-04-01' - VERSION = '2013-04-01' - NS = "{https://mws.amazonservices.com/Recommendations/2013-04-01}" - - def get_last_updated_time_for_recommendations(self, marketplaceid): - """ - Checks whether there are active recommendations for each category for the given marketplace, and if there are, - returns the time when recommendations were last updated for each category. - """ - - data = dict(Action='GetLastUpdatedTimeForRecommendations', - MarketplaceId=marketplaceid) - return self.make_request(data, "POST") - - def list_recommendations(self, marketplaceid, recommendationcategory=None): - """ - Returns your active recommendations for a specific category or for all categories for a specific marketplace. - """ - - data = dict(Action="ListRecommendations", - MarketplaceId=marketplaceid, - RecommendationCategory=recommendationcategory) - return self.make_request(data, "POST") - - def list_recommendations_by_next_token(self, token): - """ - Returns the next page of recommendations using the NextToken parameter. - """ - - data = dict(Action="ListRecommendationsByNextToken", - NextToken=token) - return self.make_request(data, "POST") - -class Finances(MWS): - """ Amazon Finances API""" - URI = '/Finances/2015-05-01' - VERSION = '2015-05-01' - NS = "{https://mws.amazonservices.com/Finances/2015-05-01}" - - def list_financial_events(self , posted_after=None, posted_before=None, - amazon_order_id=None, max_results='100'): - - data = dict(Action='ListFinancialEvents', - PostedAfter=posted_after, - PostedBefore=posted_before, - AmazonOrderId=amazon_order_id, - MaxResultsPerPage=max_results, - ) - return self.make_request(data) diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js deleted file mode 100644 index f5ea8047c6a..00000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js +++ /dev/null @@ -1,2 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json deleted file mode 100644 index 5a678e77d16..00000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json +++ /dev/null @@ -1,237 +0,0 @@ -{ - "actions": [], - "creation": "2018-07-31 05:51:41.357047", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enable_amazon", - "mws_credentials", - "seller_id", - "aws_access_key_id", - "mws_auth_token", - "secret_key", - "column_break_4", - "market_place_id", - "region", - "domain", - "section_break_13", - "company", - "warehouse", - "item_group", - "price_list", - "column_break_17", - "customer_group", - "territory", - "customer_type", - "market_place_account_group", - "section_break_12", - "after_date", - "taxes_charges", - "sync_products", - "sync_orders", - "column_break_10", - "enable_sync", - "max_retry_limit" - ], - "fields": [ - { - "default": "0", - "fieldname": "enable_amazon", - "fieldtype": "Check", - "label": "Enable Amazon" - }, - { - "fieldname": "mws_credentials", - "fieldtype": "Section Break", - "label": "MWS Credentials" - }, - { - "fieldname": "seller_id", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Seller ID", - "reqd": 1 - }, - { - "fieldname": "aws_access_key_id", - "fieldtype": "Data", - "in_list_view": 1, - "label": "AWS Access Key ID", - "reqd": 1 - }, - { - "fieldname": "mws_auth_token", - "fieldtype": "Data", - "in_list_view": 1, - "label": "MWS Auth Token", - "reqd": 1 - }, - { - "fieldname": "secret_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Secret Key", - "reqd": 1 - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "fieldname": "market_place_id", - "fieldtype": "Data", - "label": "Market Place ID", - "reqd": 1 - }, - { - "fieldname": "region", - "fieldtype": "Select", - "label": "Region", - "options": "\nAE\nAU\nBR\nCA\nCN\nDE\nES\nFR\nIN\nJP\nIT\nMX\nUK\nUS", - "reqd": 1 - }, - { - "fieldname": "domain", - "fieldtype": "Data", - "label": "Domain", - "reqd": 1 - }, - { - "fieldname": "section_break_13", - "fieldtype": "Section Break" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "options": "Company", - "reqd": 1 - }, - { - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Warehouse", - "options": "Warehouse", - "reqd": 1 - }, - { - "fieldname": "item_group", - "fieldtype": "Link", - "label": "Item Group", - "options": "Item Group", - "reqd": 1 - }, - { - "fieldname": "price_list", - "fieldtype": "Link", - "label": "Price List", - "options": "Price List", - "reqd": 1 - }, - { - "fieldname": "column_break_17", - "fieldtype": "Column Break" - }, - { - "fieldname": "customer_group", - "fieldtype": "Link", - "label": "Customer Group", - "options": "Customer Group", - "reqd": 1 - }, - { - "fieldname": "territory", - "fieldtype": "Link", - "label": "Territory", - "options": "Territory", - "reqd": 1 - }, - { - "fieldname": "customer_type", - "fieldtype": "Select", - "label": "Customer Type", - "options": "Individual\nCompany", - "reqd": 1 - }, - { - "fieldname": "market_place_account_group", - "fieldtype": "Link", - "label": "Market Place Account Group", - "options": "Account", - "reqd": 1 - }, - { - "fieldname": "section_break_12", - "fieldtype": "Section Break" - }, - { - "description": "Amazon will synch data updated after this date", - "fieldname": "after_date", - "fieldtype": "Datetime", - "label": "After Date", - "reqd": 1 - }, - { - "default": "0", - "description": "Get financial breakup of Taxes and charges data by Amazon ", - "fieldname": "taxes_charges", - "fieldtype": "Check", - "label": "Sync Taxes and Charges" - }, - { - "fieldname": "column_break_10", - "fieldtype": "Column Break" - }, - { - "default": "3", - "fieldname": "max_retry_limit", - "fieldtype": "Int", - "label": "Max Retry Limit" - }, - { - "description": "Always sync your products from Amazon MWS before synching the Orders details", - "fieldname": "sync_products", - "fieldtype": "Button", - "label": "Sync Products", - "options": "get_products_details" - }, - { - "description": "Click this button to pull your Sales Order data from Amazon MWS.", - "fieldname": "sync_orders", - "fieldtype": "Button", - "label": "Sync Orders", - "options": "get_order_details" - }, - { - "default": "0", - "description": "Check this to enable a scheduled Daily synchronization routine via scheduler", - "fieldname": "enable_sync", - "fieldtype": "Check", - "label": "Enable Scheduled Sync" - } - ], - "issingle": 1, - "links": [], - "modified": "2020-04-07 14:26:20.174848", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "Amazon MWS Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py deleted file mode 100644 index c1f460f49b6..00000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt - - -import dateutil -import frappe -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -from frappe.model.document import Document - -from erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods import get_orders - - -class AmazonMWSSettings(Document): - def validate(self): - if self.enable_amazon == 1: - self.enable_sync = 1 - setup_custom_fields() - else: - self.enable_sync = 0 - - @frappe.whitelist() - def get_products_details(self): - if self.enable_amazon == 1: - frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_products_details') - - @frappe.whitelist() - def get_order_details(self): - if self.enable_amazon == 1: - after_date = dateutil.parser.parse(self.after_date).strftime("%Y-%m-%d") - frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_orders', after_date=after_date) - -def schedule_get_order_details(): - mws_settings = frappe.get_doc("Amazon MWS Settings") - if mws_settings.enable_sync and mws_settings.enable_amazon: - after_date = dateutil.parser.parse(mws_settings.after_date).strftime("%Y-%m-%d") - get_orders(after_date = after_date) - -def setup_custom_fields(): - custom_fields = { - "Item": [dict(fieldname='amazon_item_code', label='Amazon Item Code', - fieldtype='Data', insert_after='series', read_only=1, print_hide=1)], - "Sales Order": [dict(fieldname='amazon_order_id', label='Amazon Order ID', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1)] - } - - create_custom_fields(custom_fields) diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py deleted file mode 100644 index 4be7960deda..00000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestAmazonMWSSettings(unittest.TestCase): - pass diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py deleted file mode 100644 index d9dfc6f72d4..00000000000 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Created on Tue Jun 26 15:42:07 2012 - -Borrowed from https://github.com/timotheus/ebaysdk-python - -@author: pierre -""" - -import re -import xml.etree.ElementTree as ET - - -class object_dict(dict): - """object view of dict, you can - >>> a = object_dict() - >>> a.fish = 'fish' - >>> a['fish'] - 'fish' - >>> a['water'] = 'water' - >>> a.water - 'water' - >>> a.test = {'value': 1} - >>> a.test2 = object_dict({'name': 'test2', 'value': 2}) - >>> a.test, a.test2.name, a.test2.value - (1, 'test2', 2) - """ - def __init__(self, initd=None): - if initd is None: - initd = {} - dict.__init__(self, initd) - - def __getattr__(self, item): - - try: - d = self.__getitem__(item) - except KeyError: - return None - - if isinstance(d, dict) and 'value' in d and len(d) == 1: - return d['value'] - else: - return d - - # if value is the only key in object, you can omit it - def __setstate__(self, item): - return False - - def __setattr__(self, item, value): - self.__setitem__(item, value) - - def getvalue(self, item, value=None): - return self.get(item, {}).get('value', value) - - -class xml2dict(object): - - def __init__(self): - pass - - def _parse_node(self, node): - node_tree = object_dict() - # Save attrs and text, hope there will not be a child with same name - if node.text: - node_tree.value = node.text - for (k, v) in node.attrib.items(): - k, v = self._namespace_split(k, object_dict({'value':v})) - node_tree[k] = v - #Save childrens - for child in node.getchildren(): - tag, tree = self._namespace_split(child.tag, - self._parse_node(child)) - if tag not in node_tree: # the first time, so store it in dict - node_tree[tag] = tree - continue - old = node_tree[tag] - if not isinstance(old, list): - node_tree.pop(tag) - node_tree[tag] = [old] # multi times, so change old dict to a list - node_tree[tag].append(tree) # add the new one - - return node_tree - - def _namespace_split(self, tag, value): - """ - Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients' - ns = http://cs.sfsu.edu/csc867/myscheduler - name = patients - """ - result = re.compile(r"\{(.*)\}(.*)").search(tag) - if result: - value.namespace, tag = result.groups() - - return (tag, value) - - def parse(self, file): - """parse a xml file to a dict""" - f = open(file, 'r') - return self.fromstring(f.read()) - - def fromstring(self, s): - """parse a string""" - t = ET.fromstring(s) - root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t)) - return object_dict({root_tag: root_tree}) diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py index e242ace60f7..f02f76e18b5 100644 --- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py @@ -2,17 +2,18 @@ # For license information, please see license.txt +from urllib.parse import urlencode + import frappe import gocardless_pro from frappe import _ from frappe.integrations.utils import create_payment_gateway, create_request_log from frappe.model.document import Document from frappe.utils import call_hook_method, cint, flt, get_url -from six.moves.urllib.parse import urlencode class GoCardlessSettings(Document): - supported_currencies = ["EUR", "DKK", "GBP", "SEK"] + supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"] def validate(self): self.initialize_client() @@ -79,7 +80,7 @@ class GoCardlessSettings(Document): def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: - frappe.throw(_("Please select another payment method. Stripe does not support transactions in currency '{0}'").format(currency)) + frappe.throw(_("Please select another payment method. Go Cardless does not support transactions in currency '{0}'").format(currency)) def get_payment_url(self, **kwargs): return get_url("./integrations/gocardless_checkout?{0}".format(urlencode(kwargs))) diff --git a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py index 54ed6f7d115..26bd19f0107 100644 --- a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py +++ b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py @@ -82,7 +82,7 @@ class TallyMigration(Document): "is_private": True }) try: - f.insert() + f.insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass setattr(self, key, f.file_url) diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py index 8da52f49f1c..309d2cb0e37 100644 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py +++ b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py @@ -2,12 +2,13 @@ # For license information, please see license.txt +from urllib.parse import urlparse + import frappe from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.model.document import Document from frappe.utils.nestedset import get_root_of -from six.moves.urllib.parse import urlparse class WoocommerceSettings(Document): diff --git a/erpnext/erpnext_integrations/taxjar_integration.py b/erpnext/erpnext_integrations/taxjar_integration.py index a4e21579e32..14c86d56328 100644 --- a/erpnext/erpnext_integrations/taxjar_integration.py +++ b/erpnext/erpnext_integrations/taxjar_integration.py @@ -8,10 +8,6 @@ from frappe.utils import cint, flt from erpnext import get_default_company, get_region -TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") -SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head") -TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") -TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax") SUPPORTED_COUNTRY_CODES = ["AT", "AU", "BE", "BG", "CA", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GB", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK", "US"] @@ -35,12 +31,14 @@ def get_client(): if api_key and api_url: client = taxjar.Client(api_key=api_key, api_url=api_url) client.set_api_config('headers', { - 'x-api-version': '2020-08-07' + 'x-api-version': '2022-01-24' }) return client def create_transaction(doc, method): + TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") + """Create an order transaction in TaxJar""" if not TAXJAR_CREATE_TRANSACTIONS: @@ -51,6 +49,7 @@ def create_transaction(doc, method): if not client: return + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") sales_tax = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == TAX_ACCOUNT_HEAD]) if not sales_tax: @@ -79,6 +78,7 @@ def create_transaction(doc, method): def delete_transaction(doc, method): """Delete an existing TaxJar order transaction""" + TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") if not TAXJAR_CREATE_TRANSACTIONS: return @@ -92,6 +92,8 @@ def delete_transaction(doc, method): def get_tax_data(doc): + SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head") + from_address = get_company_address_details(doc) from_shipping_state = from_address.get("state") from_country_code = frappe.db.get_value("Country", from_address.country, "code") @@ -113,20 +115,20 @@ def get_tax_data(doc): to_shipping_state = get_state_code(to_address, 'Shipping') tax_dict = { - 'from_country': from_country_code, - 'from_zip': from_address.pincode, - 'from_state': from_shipping_state, - 'from_city': from_address.city, - 'from_street': from_address.address_line1, - 'to_country': to_country_code, - 'to_zip': to_address.pincode, - 'to_city': to_address.city, - 'to_street': to_address.address_line1, - 'to_state': to_shipping_state, - 'shipping': shipping, - 'amount': doc.net_total, - 'plugin': 'erpnext', - 'line_items': line_items + "from_country": from_country_code, + "from_zip": from_address.pincode, + "from_state": from_shipping_state, + "from_city": from_address.city, + "from_street": from_address.address_line1, + "to_country": to_country_code, + "to_zip": to_address.pincode, + "to_city": to_address.city, + "to_street": to_address.address_line1, + "to_state": to_shipping_state, + "shipping": shipping, + "amount": doc.net_total, + "plugin": "erpnext", + "line_items": line_items } return tax_dict @@ -156,6 +158,9 @@ def get_line_item_dict(item, docstatus): return tax_dict def set_sales_tax(doc, method): + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") + TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax") + if not TAXJAR_CALCULATE_TAX: return @@ -206,6 +211,7 @@ def set_sales_tax(doc, method): doc.run_method("calculate_taxes_and_totals") def check_for_nexus(doc, tax_dict): + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") if not frappe.db.get_value('TaxJar Nexus', {'region_code': tax_dict["to_state"]}): for item in doc.get("items"): item.tax_collectable = flt(0) @@ -218,6 +224,8 @@ def check_for_nexus(doc, tax_dict): def check_sales_tax_exemption(doc): # if the party is exempt from sales tax, then set all tax account heads to zero + TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") + sales_tax_exempted = hasattr(doc, "exempt_from_sales_tax") and doc.exempt_from_sales_tax \ or frappe.db.has_column("Customer", "exempt_from_sales_tax") \ and frappe.db.get_value("Customer", doc.customer, "exempt_from_sales_tax") diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index d922d875fdd..30d3948fee7 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -1,10 +1,10 @@ import base64 import hashlib import hmac +from urllib.parse import urlparse import frappe from frappe import _ -from six.moves.urllib.parse import urlparse from erpnext import get_default_company diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json index 45077aa66c3..1f2619b9a6e 100644 --- a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json +++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json @@ -29,17 +29,6 @@ "onboard": 0, "type": "Link" }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Amazon MWS Settings", - "link_count": 0, - "link_to": "Amazon MWS Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "hidden": 0, "is_query_report": 0, diff --git a/erpnext/hooks.py b/erpnext/hooks.py index d172da3f1fa..38fa6916a51 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -51,15 +51,15 @@ additional_print_settings = "erpnext.controllers.print_settings.get_print_settin on_session_creation = [ "erpnext.portal.utils.create_customer_or_supplier", - "erpnext.shopping_cart.utils.set_cart_count" + "erpnext.e_commerce.shopping_cart.utils.set_cart_count" ] -on_logout = "erpnext.shopping_cart.utils.clear_cart_count" +on_logout = "erpnext.e_commerce.shopping_cart.utils.clear_cart_count" treeviews = ['Account', 'Cost Center', 'Warehouse', 'Item Group', 'Customer Group', 'Sales Person', 'Territory', 'Assessment Group', 'Department'] # website -update_website_context = ["erpnext.shopping_cart.utils.update_website_context", "erpnext.education.doctype.education_settings.education_settings.update_website_context"] -my_account_context = "erpnext.shopping_cart.utils.update_my_account_context" +update_website_context = ["erpnext.e_commerce.shopping_cart.utils.update_website_context", "erpnext.education.doctype.education_settings.education_settings.update_website_context"] +my_account_context = "erpnext.e_commerce.shopping_cart.utils.update_my_account_context" webform_list_context = "erpnext.controllers.website_list_for_contact.get_webform_list_context" calendars = ["Task", "Work Order", "Leave Application", "Sales Order", "Holiday List", "Course Schedule"] @@ -73,7 +73,7 @@ domains = { 'Services': 'erpnext.domains.services', } -website_generators = ["Item Group", "Item", "BOM", "Sales Partner", +website_generators = ["Item Group", "Website Item", "BOM", "Sales Partner", "Job Opening", "Student Admission"] website_context = { @@ -237,10 +237,7 @@ doc_events = { ] }, "Sales Taxes and Charges Template": { - "on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings" - }, - "Website Settings": { - "validate": "erpnext.portal.doctype.products_settings.products_settings.home_page_is_products" + "on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings" }, "Tax Category": { "validate": "erpnext.regional.india.utils.validate_tax_category" @@ -336,7 +333,6 @@ scheduler_events = { "hourly": [ 'erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails', "erpnext.accounts.doctype.subscription.subscription.process_all", - "erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_settings.schedule_get_order_details", "erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs", "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.projects.doctype.project.project.hourly_reminder", @@ -344,7 +340,8 @@ scheduler_events = { "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts" ], "hourly_long": [ - "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries" + "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", + "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction" ], "daily": [ "erpnext.stock.reorder_item.reorder_item", @@ -443,6 +440,7 @@ regional_overrides = { 'erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts': 'erpnext.regional.india.utils.get_regional_round_off_accounts', 'erpnext.hr.utils.calculate_annual_eligible_hra_exemption': 'erpnext.regional.india.utils.calculate_annual_eligible_hra_exemption', 'erpnext.hr.utils.calculate_hra_exemption_for_period': 'erpnext.regional.india.utils.calculate_hra_exemption_for_period', + 'erpnext.controllers.accounts_controller.validate_einvoice_fields': 'erpnext.regional.india.e_invoice.utils.validate_einvoice_fields', 'erpnext.assets.doctype.asset.asset.get_depreciation_amount': 'erpnext.regional.india.utils.get_depreciation_amount', 'erpnext.stock.doctype.item.item.set_item_tax_from_hsn_code': 'erpnext.regional.india.utils.set_item_tax_from_hsn_code' }, diff --git a/erpnext/hr/doctype/department/department.py b/erpnext/hr/doctype/department/department.py index ed0bfcf0d5a..71300c40b0a 100644 --- a/erpnext/hr/doctype/department/department.py +++ b/erpnext/hr/doctype/department/department.py @@ -32,7 +32,7 @@ class Department(NestedSet): return new def on_update(self): - if not frappe.local.flags.ignore_update_nsm: + if not (frappe.local.flags.ignore_update_nsm or frappe.flags.in_setup_wizard): super(Department, self).on_update() def on_trash(self): diff --git a/erpnext/hr/doctype/department/test_records.json b/erpnext/hr/doctype/department/test_records.json index 654925ef93f..e3421f28b8d 100644 --- a/erpnext/hr/doctype/department/test_records.json +++ b/erpnext/hr/doctype/department/test_records.json @@ -1,4 +1,4 @@ [ - {"doctype":"Department", "department_name":"_Test Department", "company": "_Test Company"}, - {"doctype":"Department", "department_name":"_Test Department 1", "company": "_Test Company"} + {"doctype":"Department", "department_name":"_Test Department", "company": "_Test Company", "parent_department": "All Departments"}, + {"doctype":"Department", "department_name":"_Test Department 1", "company": "_Test Company", "parent_department": "All Departments"} ] \ No newline at end of file diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index a2df26c3e2a..6e52eb97caa 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -142,7 +142,7 @@ class Employee(NestedSet): "file_url": self.image, "attached_to_doctype": "User", "attached_to_name": self.user_id - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: # already exists pass diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py index a1247d9eb17..fb62b0414d0 100644 --- a/erpnext/hr/doctype/employee/employee_dashboard.py +++ b/erpnext/hr/doctype/employee/employee_dashboard.py @@ -21,7 +21,7 @@ def get_data(): }, { 'label': _('Lifecycle'), - 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Grievance'] + 'items': ['Employee Onboarding', 'Employee Transfer', 'Employee Promotion', 'Employee Grievance'] }, { 'label': _('Exit'), diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py index 559bd393e62..0bb66374d1e 100644 --- a/erpnext/hr/doctype/employee/employee_reminders.py +++ b/erpnext/hr/doctype/employee/employee_reminders.py @@ -20,6 +20,7 @@ def send_reminders_in_advance_weekly(): send_advance_holiday_reminders("Weekly") + def send_reminders_in_advance_monthly(): to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders")) frequency = frappe.db.get_single_value("HR Settings", "frequency") @@ -28,6 +29,7 @@ def send_reminders_in_advance_monthly(): send_advance_holiday_reminders("Monthly") + def send_advance_holiday_reminders(frequency): """Send Holiday Reminders in Advance to Employees `frequency` (str): 'Weekly' or 'Monthly' @@ -42,7 +44,7 @@ def send_advance_holiday_reminders(frequency): else: return - employees = frappe.db.get_all('Employee', pluck='name') + employees = frappe.db.get_all('Employee', filters={'status': 'Active'}, pluck='name') for employee in employees: holidays = get_holidays_for_employee( employee, @@ -51,10 +53,13 @@ def send_advance_holiday_reminders(frequency): raise_exception=False ) - if not (holidays is None): - send_holidays_reminder_in_advance(employee, holidays) + send_holidays_reminder_in_advance(employee, holidays) + def send_holidays_reminder_in_advance(employee, holidays): + if not holidays: + return + employee_doc = frappe.get_doc('Employee', employee) employee_email = get_employee_email(employee_doc) frequency = frappe.db.get_single_value("HR Settings", "frequency") @@ -101,6 +106,7 @@ def send_birthday_reminders(): reminder_text, message = get_birthday_reminder_text_and_message(others) send_birthday_reminder(person_email, reminder_text, others, message) + def get_birthday_reminder_text_and_message(birthday_persons): if len(birthday_persons) == 1: birthday_person_text = birthday_persons[0]['name'] @@ -116,6 +122,7 @@ def get_birthday_reminder_text_and_message(birthday_persons): return reminder_text, message + def send_birthday_reminder(recipients, reminder_text, birthday_persons, message): frappe.sendmail( recipients=recipients, @@ -129,10 +136,12 @@ def send_birthday_reminder(recipients, reminder_text, birthday_persons, message) header=_("Birthday Reminder 🎂") ) + def get_employees_who_are_born_today(): """Get all employee born today & group them based on their company""" return get_employees_having_an_event_today("birthday") + def get_employees_having_an_event_today(event_type): """Get all employee who have `event_type` today & group them based on their company. `event_type` @@ -210,13 +219,14 @@ def send_work_anniversary_reminders(): reminder_text, message = get_work_anniversary_reminder_text_and_message(others) send_work_anniversary_reminder(person_email, reminder_text, others, message) + def get_work_anniversary_reminder_text_and_message(anniversary_persons): if len(anniversary_persons) == 1: anniversary_person = anniversary_persons[0]['name'] persons_name = anniversary_person # Number of years completed at the company completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year - anniversary_person += f" completed {completed_years} years" + anniversary_person += f" completed {completed_years} year(s)" else: person_names_with_years = [] names = [] @@ -225,7 +235,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons): names.append(person_text) # Number of years completed at the company completed_years = getdate().year - person['date_of_joining'].year - person_text += f" completed {completed_years} years" + person_text += f" completed {completed_years} year(s)" person_names_with_years.append(person_text) # converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim @@ -239,6 +249,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons): return reminder_text, message + def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message): frappe.sendmail( recipients=recipients, @@ -249,5 +260,5 @@ def send_work_anniversary_reminder(recipients, reminder_text, anniversary_person anniversary_persons=anniversary_persons, message=message, ), - header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️") + header=_("Work Anniversary Reminder") ) diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py index 8a2da0866e9..67cbea67e1f 100644 --- a/erpnext/hr/doctype/employee/test_employee.py +++ b/erpnext/hr/doctype/employee/test_employee.py @@ -36,7 +36,7 @@ class TestEmployee(unittest.TestCase): employee_doc.reload() make_holiday_list() - frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List") + frappe.db.set_value("Company", employee_doc.company, "default_holiday_list", "Salary Slip Test Holiday List") frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""") salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly", diff --git a/erpnext/hr/doctype/employee/test_employee_reminders.py b/erpnext/hr/doctype/employee/test_employee_reminders.py index 52c00982443..a4097ab9d19 100644 --- a/erpnext/hr/doctype/employee/test_employee_reminders.py +++ b/erpnext/hr/doctype/employee/test_employee_reminders.py @@ -5,10 +5,12 @@ import unittest from datetime import timedelta import frappe -from frappe.utils import getdate +from frappe.utils import add_months, getdate +from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change +from erpnext.hr.utils import get_holidays_for_employee class TestEmployeeReminders(unittest.TestCase): @@ -46,6 +48,24 @@ class TestEmployeeReminders(unittest.TestCase): cls.test_employee = test_employee cls.test_holiday_dates = test_holiday_dates + # Employee without holidays in this month/week + test_employee_2 = make_employee('test@empwithoutholiday.io', company="_Test Company") + test_employee_2 = frappe.get_doc('Employee', test_employee_2) + + test_holiday_list = make_holiday_list( + 'TestHolidayRemindersList2', + holiday_dates=[ + {'holiday_date': add_months(getdate(), 1), 'description': 'test holiday1'}, + ], + from_date=add_months(getdate(), -2), + to_date=add_months(getdate(), 2) + ) + test_employee_2.holiday_list = test_holiday_list.name + test_employee_2.save() + + cls.test_employee_2 = test_employee_2 + cls.holiday_list_2 = test_holiday_list + @classmethod def get_test_holiday_dates(cls): today_date = getdate() @@ -61,6 +81,7 @@ class TestEmployeeReminders(unittest.TestCase): def setUp(self): # Clear Email Queue frappe.db.sql("delete from `tabEmail Queue`") + frappe.db.sql("delete from `tabEmail Queue Recipient`") def test_is_holiday(self): from erpnext.hr.doctype.employee.employee import is_holiday @@ -103,11 +124,10 @@ class TestEmployeeReminders(unittest.TestCase): self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message) def test_work_anniversary_reminders(self): - employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) - employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:] - employee.company_email = "test@example.com" - employee.company = "_Test Company" - employee.save() + make_employee("test_work_anniversary@gmail.com", + date_of_joining="1998" + frappe.utils.nowdate()[4:], + company="_Test Company", + ) from erpnext.hr.doctype.employee.employee_reminders import ( get_employees_having_an_event_today, @@ -115,7 +135,12 @@ class TestEmployeeReminders(unittest.TestCase): ) employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary') - self.assertTrue(employees_having_work_anniversary.get("_Test Company")) + employees = employees_having_work_anniversary.get("_Test Company") or [] + user_ids = [] + for entry in employees: + user_ids.append(entry.user_id) + + self.assertTrue("test_work_anniversary@gmail.com" in user_ids) hr_settings = frappe.get_doc("HR Settings", "HR Settings") hr_settings.send_work_anniversary_reminders = 1 @@ -126,16 +151,24 @@ class TestEmployeeReminders(unittest.TestCase): email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message) - def test_send_holidays_reminder_in_advance(self): - from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance - from erpnext.hr.utils import get_holidays_for_employee + def test_work_anniversary_reminder_not_sent_for_0_years(self): + make_employee("test_work_anniversary_2@gmail.com", + date_of_joining=getdate(), + company="_Test Company", + ) - # Get HR settings and enable advance holiday reminders - hr_settings = frappe.get_doc("HR Settings", "HR Settings") - hr_settings.send_holiday_reminders = 1 - set_proceed_with_frequency_change() - hr_settings.frequency = 'Weekly' - hr_settings.save() + from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today + + employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary') + employees = employees_having_work_anniversary.get("_Test Company") or [] + user_ids = [] + for entry in employees: + user_ids.append(entry.user_id) + + self.assertTrue("test_work_anniversary_2@gmail.com" not in user_ids) + + def test_send_holidays_reminder_in_advance(self): + setup_hr_settings('Weekly') holidays = get_holidays_for_employee( self.test_employee.get('name'), @@ -151,32 +184,80 @@ class TestEmployeeReminders(unittest.TestCase): email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) self.assertEqual(len(email_queue), 1) + self.assertTrue("Holidays this Week." in email_queue[0].message) def test_advance_holiday_reminders_monthly(self): from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly - # Get HR settings and enable advance holiday reminders - hr_settings = frappe.get_doc("HR Settings", "HR Settings") - hr_settings.send_holiday_reminders = 1 - set_proceed_with_frequency_change() - hr_settings.frequency = 'Monthly' - hr_settings.save() + setup_hr_settings('Monthly') + + # disable emp 2, set same holiday list + frappe.db.set_value('Employee', self.test_employee_2.name, { + 'status': 'Left', + 'holiday_list': self.test_employee.holiday_list + }) send_reminders_in_advance_monthly() - email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) self.assertTrue(len(email_queue) > 0) + # even though emp 2 has holiday, non-active employees should not be recipients + recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient') + self.assertTrue(self.test_employee_2.user_id not in recipients) + + # teardown: enable emp 2 + frappe.db.set_value('Employee', self.test_employee_2.name, { + 'status': 'Active', + 'holiday_list': self.holiday_list_2.name + }) + def test_advance_holiday_reminders_weekly(self): from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly - # Get HR settings and enable advance holiday reminders - hr_settings = frappe.get_doc("HR Settings", "HR Settings") - hr_settings.send_holiday_reminders = 1 - hr_settings.frequency = 'Weekly' - hr_settings.save() + setup_hr_settings('Weekly') + + # disable emp 2, set same holiday list + frappe.db.set_value('Employee', self.test_employee_2.name, { + 'status': 'Left', + 'holiday_list': self.test_employee.holiday_list + }) send_reminders_in_advance_weekly() - email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) self.assertTrue(len(email_queue) > 0) + + # even though emp 2 has holiday, non-active employees should not be recipients + recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient') + self.assertTrue(self.test_employee_2.user_id not in recipients) + + # teardown: enable emp 2 + frappe.db.set_value('Employee', self.test_employee_2.name, { + 'status': 'Active', + 'holiday_list': self.holiday_list_2.name + }) + + def test_reminder_not_sent_if_no_holdays(self): + setup_hr_settings('Monthly') + + # reminder not sent if there are no holidays + holidays = get_holidays_for_employee( + self.test_employee_2.get('name'), + getdate(), getdate() + timedelta(days=3), + only_non_weekly=True, + raise_exception=False + ) + send_holidays_reminder_in_advance( + self.test_employee_2.get('name'), + holidays + ) + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertEqual(len(email_queue), 0) + + +def setup_hr_settings(frequency=None): + # Get HR settings and enable advance holiday reminders + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_holiday_reminders = 1 + set_proceed_with_frequency_change() + hr_settings.frequency = frequency or 'Weekly' + hr_settings.save() \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json index 044a5a9886b..8474bd09d51 100644 --- a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json +++ b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json @@ -62,6 +62,7 @@ }, { "default": "0", + "depends_on": "eval:['Employee Onboarding', 'Employee Onboarding Template'].includes(doc.parenttype)", "description": "Applicable in the case of Employee Onboarding", "fieldname": "required_for_employee_creation", "fieldtype": "Check", @@ -93,7 +94,7 @@ ], "istable": 1, "links": [], - "modified": "2021-07-30 15:55:22.470102", + "modified": "2022-01-29 14:05:00.543122", "modified_by": "Administrator", "module": "HR", "name": "Employee Boarding Activity", @@ -102,5 +103,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_group_table/employee_group_table.json b/erpnext/hr/doctype/employee_group_table/employee_group_table.json index 4e0045cdeb8..54eb8c6da91 100644 --- a/erpnext/hr/doctype/employee_group_table/employee_group_table.json +++ b/erpnext/hr/doctype/employee_group_table/employee_group_table.json @@ -27,12 +27,13 @@ "fetch_from": "employee.user_id", "fieldname": "user_id", "fieldtype": "Data", + "in_list_view": 1, "label": "ERPNext User ID", "read_only": 1 } ], "istable": 1, - "modified": "2019-06-06 10:41:20.313756", + "modified": "2022-02-13 19:44:21.302938", "modified_by": "Administrator", "module": "HR", "name": "Employee Group Table", @@ -42,4 +43,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js index 5d1a024ebb3..6fbb54d002a 100644 --- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js +++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js @@ -3,12 +3,6 @@ frappe.ui.form.on('Employee Onboarding', { setup: function(frm) { - frm.add_fetch("employee_onboarding_template", "company", "company"); - frm.add_fetch("employee_onboarding_template", "department", "department"); - frm.add_fetch("employee_onboarding_template", "designation", "designation"); - frm.add_fetch("employee_onboarding_template", "employee_grade", "employee_grade"); - - frm.set_query("job_applicant", function () { return { filters:{ @@ -71,5 +65,19 @@ frappe.ui.form.on('Employee Onboarding', { } }); } + }, + + job_applicant: function(frm) { + if (frm.doc.job_applicant) { + frappe.db.get_value('Employee', {'job_applicant': frm.doc.job_applicant}, 'name', (r) => { + if (r.name) { + frm.set_value('employee', r.name); + } else { + frm.set_value('employee', ''); + } + }); + } else { + frm.set_value('employee', ''); + } } }); diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json index fd877a68d85..1d2ea0c669a 100644 --- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json +++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json @@ -92,6 +92,7 @@ "options": "Employee Onboarding Template" }, { + "fetch_from": "employee_onboarding_template.company", "fieldname": "company", "fieldtype": "Link", "label": "Company", @@ -99,6 +100,7 @@ "reqd": 1 }, { + "fetch_from": "employee_onboarding_template.department", "fieldname": "department", "fieldtype": "Link", "in_list_view": 1, @@ -106,6 +108,7 @@ "options": "Department" }, { + "fetch_from": "employee_onboarding_template.designation", "fieldname": "designation", "fieldtype": "Link", "in_list_view": 1, @@ -113,6 +116,7 @@ "options": "Designation" }, { + "fetch_from": "employee_onboarding_template.employee_grade", "fieldname": "employee_grade", "fieldtype": "Link", "label": "Employee Grade", @@ -170,10 +174,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-07-30 14:55:04.560683", + "modified": "2022-01-29 12:33:57.120384", "modified_by": "Administrator", "module": "HR", "name": "Employee Onboarding", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -194,6 +199,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "employee_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py index eba2a03193c..a0939a847b2 100644 --- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py +++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py @@ -14,10 +14,15 @@ class IncompleteTaskError(frappe.ValidationError): pass class EmployeeOnboarding(EmployeeBoardingController): def validate(self): super(EmployeeOnboarding, self).validate() + self.set_employee() self.validate_duplicate_employee_onboarding() + def set_employee(self): + if not self.employee: + self.employee = frappe.db.get_value('Employee', {'job_applicant': self.job_applicant}, 'name') + def validate_duplicate_employee_onboarding(self): - emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant}) + emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant, "docstatus": ("!=", 2)}) if emp_onboarding and emp_onboarding != self.name: frappe.throw(_("Employee Onboarding: {0} already exists for Job Applicant: {1}").format(frappe.bold(emp_onboarding), frappe.bold(self.job_applicant))) diff --git a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py index 2d129c8acfc..0fb821ddb2b 100644 --- a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py +++ b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import getdate +from frappe.utils import add_days, getdate from erpnext.hr.doctype.employee_onboarding.employee_onboarding import ( IncompleteTaskError, @@ -35,6 +35,15 @@ class TestEmployeeOnboarding(unittest.TestCase): # boarding status self.assertEqual(onboarding.boarding_status, 'Pending') + # start and end dates + start_date, end_date = frappe.db.get_value('Task', onboarding.activities[0].task, ['exp_start_date', 'exp_end_date']) + self.assertEqual(getdate(start_date), getdate(onboarding.boarding_begins_on)) + self.assertEqual(getdate(end_date), add_days(start_date, onboarding.activities[0].duration)) + + start_date, end_date = frappe.db.get_value('Task', onboarding.activities[1].task, ['exp_start_date', 'exp_end_date']) + self.assertEqual(getdate(start_date), add_days(onboarding.boarding_begins_on, onboarding.activities[0].duration)) + self.assertEqual(getdate(end_date), add_days(start_date, onboarding.activities[1].duration)) + # complete the task project = frappe.get_doc('Project', onboarding.project) for task in frappe.get_all('Task', dict(project=project.name)): @@ -57,10 +66,7 @@ class TestEmployeeOnboarding(unittest.TestCase): self.assertEqual(employee.employee_name, 'Test Researcher') def tearDown(self): - for entry in frappe.get_all('Employee Onboarding'): - doc = frappe.get_doc('Employee Onboarding', entry.name) - doc.cancel() - doc.delete() + frappe.db.rollback() def get_job_applicant(): @@ -87,23 +93,31 @@ def get_job_offer(applicant_name): def create_employee_onboarding(): applicant = get_job_applicant() job_offer = get_job_offer(applicant.name) - holiday_list = make_holiday_list() + + holiday_list = make_holiday_list('_Test Employee Boarding') + holiday_list = frappe.get_doc('Holiday List', holiday_list) + holiday_list.holidays = [] + holiday_list.save() onboarding = frappe.new_doc('Employee Onboarding') onboarding.job_applicant = applicant.name onboarding.job_offer = job_offer.name onboarding.date_of_joining = onboarding.boarding_begins_on = getdate() onboarding.company = '_Test Company' - onboarding.holiday_list = holiday_list + onboarding.holiday_list = holiday_list.name onboarding.designation = 'Researcher' onboarding.append('activities', { 'activity_name': 'Assign ID Card', 'role': 'HR User', - 'required_for_employee_creation': 1 + 'required_for_employee_creation': 1, + 'begin_on': 0, + 'duration': 1 }) onboarding.append('activities', { 'activity_name': 'Assign a laptop', - 'role': 'HR User' + 'role': 'HR User', + 'begin_on': 1, + 'duration': 1 }) onboarding.status = 'Pending' onboarding.insert() diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index 30e19f1c9bb..59fb2fd9ca5 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -128,4 +128,4 @@ def show_email_summary(email_success, email_failure): message += _('{0} due to missing email information for employee(s): {1}').format( frappe.bold('Sending Failed'), ', '.join(email_failure)) - frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True) \ No newline at end of file + frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True) diff --git a/erpnext/hr/doctype/interview/interview.py b/erpnext/hr/doctype/interview/interview.py index 4bb003ded18..a3b111ccb15 100644 --- a/erpnext/hr/doctype/interview/interview.py +++ b/erpnext/hr/doctype/interview/interview.py @@ -7,7 +7,7 @@ import datetime import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr, get_datetime, get_link_to_form +from frappe.utils import cstr, flt, get_datetime, get_link_to_form class DuplicateInterviewRoundError(frappe.ValidationError): @@ -18,6 +18,7 @@ class Interview(Document): self.validate_duplicate_interview() self.validate_designation() self.validate_overlap() + self.set_average_rating() def on_submit(self): if self.status not in ['Cleared', 'Rejected']: @@ -67,6 +68,13 @@ class Interview(Document): overlapping_details = _('Interview overlaps with {0}').format(get_link_to_form('Interview', overlaps[0][0])) frappe.throw(overlapping_details, title=_('Overlap')) + def set_average_rating(self): + total_rating = 0 + for entry in self.interview_details: + if entry.average_rating: + total_rating += entry.average_rating + + self.average_rating = flt(total_rating / len(self.interview_details) if len(self.interview_details) else 0) @frappe.whitelist() def reschedule_interview(self, scheduled_on, from_time, to_time): diff --git a/erpnext/hr/doctype/interview/test_interview.py b/erpnext/hr/doctype/interview/test_interview.py index 1a2257a6d90..fdb11afe822 100644 --- a/erpnext/hr/doctype/interview/test_interview.py +++ b/erpnext/hr/doctype/interview/test_interview.py @@ -12,6 +12,7 @@ from frappe.utils import add_days, getdate, nowtime from erpnext.hr.doctype.designation.test_designation import create_designation from erpnext.hr.doctype.interview.interview import DuplicateInterviewRoundError +from erpnext.hr.doctype.job_applicant.job_applicant import get_interview_details from erpnext.hr.doctype.job_applicant.test_job_applicant import create_job_applicant @@ -70,6 +71,20 @@ class TestInterview(unittest.TestCase): email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) self.assertTrue("Subject: Interview Feedback Reminder" in email_queue[0].message) + def test_get_interview_details_for_applicant_dashboard(self): + job_applicant = create_job_applicant() + interview = create_interview_and_dependencies(job_applicant.name) + + details = get_interview_details(job_applicant.name) + self.assertEqual(details.get('stars'), 5) + self.assertEqual(details.get('interviews').get(interview.name), { + 'name': interview.name, + 'interview_round': interview.interview_round, + 'expected_average_rating': interview.expected_average_rating * 5, + 'average_rating': interview.average_rating * 5, + 'status': 'Pending' + }) + def tearDown(self): frappe.db.rollback() @@ -106,7 +121,8 @@ def create_interview_round(name, skill_set, interviewers=[], designation=None, s interview_round = frappe.new_doc("Interview Round") interview_round.round_name = name interview_round.interview_type = create_interview_type() - interview_round.expected_average_rating = 4 + # average rating = 4 + interview_round.expected_average_rating = 0.8 if designation: interview_round.designation = designation diff --git a/erpnext/hr/doctype/interview_feedback/interview_feedback.py b/erpnext/hr/doctype/interview_feedback/interview_feedback.py index d046458f196..2ff00c1cac7 100644 --- a/erpnext/hr/doctype/interview_feedback/interview_feedback.py +++ b/erpnext/hr/doctype/interview_feedback/interview_feedback.py @@ -57,7 +57,6 @@ class InterviewFeedback(Document): def update_interview_details(self): doc = frappe.get_doc('Interview', self.interview) - total_rating = 0 if self.docstatus == 2: for entry in doc.interview_details: @@ -72,10 +71,6 @@ class InterviewFeedback(Document): entry.comments = self.feedback entry.result = self.result - if entry.average_rating: - total_rating += entry.average_rating - - doc.average_rating = flt(total_rating / len(doc.interview_details) if len(doc.interview_details) else 0) doc.save() doc.notify_update() diff --git a/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py index d2ec5b9438e..19c464296ac 100644 --- a/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py +++ b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py @@ -24,7 +24,7 @@ class TestInterviewFeedback(unittest.TestCase): create_skill_set(['Leadership']) interview_feedback = create_interview_feedback(interview.name, interviewer, skill_ratings) - interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 4}) + interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 0.8}) frappe.set_user(interviewer) self.assertRaises(frappe.ValidationError, interview_feedback.save) @@ -50,7 +50,7 @@ class TestInterviewFeedback(unittest.TestCase): avg_rating = flt(total_rating / len(feedback_1.skill_assessment) if len(feedback_1.skill_assessment) else 0) - self.assertEqual(flt(avg_rating, 3), feedback_1.average_rating) + self.assertEqual(flt(avg_rating, 2), flt(feedback_1.average_rating, 2)) avg_on_interview_detail = frappe.db.get_value('Interview Detail', { 'parent': feedback_1.interview, @@ -59,7 +59,7 @@ class TestInterviewFeedback(unittest.TestCase): }, 'average_rating') # 1. average should be reflected in Interview Detail. - self.assertEqual(avg_on_interview_detail, feedback_1.average_rating) + self.assertEqual(flt(avg_on_interview_detail, 2), flt(feedback_1.average_rating, 2)) '''For Second Interviewer Feedback''' interviewer = interview.interview_details[1].interviewer @@ -97,5 +97,5 @@ def get_skills_rating(interview_round): skills = frappe.get_all("Expected Skill Set", filters={"parent": interview_round}, fields = ["skill"]) for d in skills: - d["rating"] = random.randint(1, 5) + d["rating"] = random.random() return skills diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.js b/erpnext/hr/doctype/job_applicant/job_applicant.js index d7b1c6c9df3..c1e82571683 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.js +++ b/erpnext/hr/doctype/job_applicant/job_applicant.js @@ -21,9 +21,9 @@ frappe.ui.form.on("Job Applicant", { create_custom_buttons: function(frm) { if (!frm.doc.__islocal && frm.doc.status !== "Rejected" && frm.doc.status !== "Accepted") { - frm.add_custom_button(__("Create Interview"), function() { + frm.add_custom_button(__("Interview"), function() { frm.events.create_dialog(frm); - }); + }, __("Create")); } if (!frm.doc.__islocal) { @@ -40,10 +40,10 @@ frappe.ui.form.on("Job Applicant", { frappe.route_options = { "job_applicant": frm.doc.name, "applicant_name": frm.doc.applicant_name, - "designation": frm.doc.job_opening, + "designation": frm.doc.job_opening || frm.doc.designation, }; frappe.new_doc("Job Offer"); - }); + }, __("Create")); } } }, @@ -55,13 +55,16 @@ frappe.ui.form.on("Job Applicant", { job_applicant: frm.doc.name }, callback: function(r) { - $("div").remove(".form-dashboard-section.custom"); - frm.dashboard.add_section( - frappe.render_template('job_applicant_dashboard', { - data: r.message - }), - __("Interview Summary") - ); + if (r.message) { + $("div").remove(".form-dashboard-section.custom"); + frm.dashboard.add_section( + frappe.render_template("job_applicant_dashboard", { + data: r.message.interviews, + number_of_stars: r.message.stars + }), + __("Interview Summary") + ); + } } }); }, diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.py b/erpnext/hr/doctype/job_applicant/job_applicant.py index 5b3d9bfb4ff..ccc21ced2cc 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.py +++ b/erpnext/hr/doctype/job_applicant/job_applicant.py @@ -81,8 +81,13 @@ def get_interview_details(job_applicant): fields=["name", "interview_round", "expected_average_rating", "average_rating", "status"] ) interview_detail_map = {} + meta = frappe.get_meta("Interview") + number_of_stars = meta.get_options("expected_average_rating") or 5 for detail in interview_details: + detail.expected_average_rating = detail.expected_average_rating * number_of_stars if detail.expected_average_rating else 0 + detail.average_rating = detail.average_rating * number_of_stars if detail.average_rating else 0 + interview_detail_map[detail.name] = detail - return interview_detail_map + return {"interviews": interview_detail_map, "stars": number_of_stars} diff --git a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html index c286787a556..734b2fe5e8b 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html +++ b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html @@ -17,24 +17,33 @@ {%= key %} {%= value["interview_round"] %} {%= value["status"] %} - - {% for (i = 0; i < value["expected_average_rating"]; i++) { %} - - {% } %} - {% for (i = 0; i < (5-value["expected_average_rating"]); i++) { %} - - {% } %} - - - {% if(value["average_rating"]){ %} - {% for (i = 0; i < value["average_rating"]; i++) { %} - - {% } %} - {% for (i = 0; i < (5-value["average_rating"]); i++) { %} - - {% } %} - {% } %} - + {% let right_class = ''; %} + {% let left_class = ''; %} + + {% $.each([value["expected_average_rating"], value["average_rating"]], (_, val) => { %} + +
+ {% for (let i = 1; i <= number_of_stars; i++) { %} + {% if (i <= val) { %} + {% right_class = 'star-click'; %} + {% } else { %} + {% right_class = ''; %} + {% } %} + + {% if ((i <= val) || ((i - 0.5) == val)) { %} + {% left_class = 'star-click'; %} + {% } else { %} + {% left_class = ''; %} + {% } %} + + + + + + {% } %} +
+ + {% }); %} {% } %} diff --git a/erpnext/hr/doctype/job_offer/job_offer.py b/erpnext/hr/doctype/job_offer/job_offer.py index 39f471929b4..072fc73271f 100644 --- a/erpnext/hr/doctype/job_offer/job_offer.py +++ b/erpnext/hr/doctype/job_offer/job_offer.py @@ -78,6 +78,7 @@ def make_employee(source_name, target_doc=None): "doctype": "Employee", "field_map": { "applicant_name": "employee_name", + "offer_date": "scheduled_confirmation_date" }} }, target_doc, set_missing_values) return doc diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 3e739768af6..22c7a8f5995 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -75,10 +75,8 @@ class TestLeaveApplication(unittest.TestCase): frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec frappe.set_user("Administrator") - - @classmethod - def setUpClass(cls): set_leave_approver() + frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'") def tearDown(self): @@ -134,10 +132,11 @@ class TestLeaveApplication(unittest.TestCase): make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date)) holiday_list = make_holiday_list() - frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list) + employee = get_employee() + frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list) first_sunday = get_first_sunday(holiday_list) - leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name) + leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name) leave_application.reload() self.assertEqual(leave_application.total_leave_days, 4) self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4) @@ -157,25 +156,28 @@ class TestLeaveApplication(unittest.TestCase): make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date)) holiday_list = make_holiday_list() - frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list) + employee = get_employee() + frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list) first_sunday = get_first_sunday(holiday_list) # already marked attendance on a holiday should be deleted in this case config = { "doctype": "Attendance", - "employee": "_T-Employee-00001", + "employee": employee.name, "status": "Present" } attendance_on_holiday = frappe.get_doc(config) attendance_on_holiday.attendance_date = first_sunday + attendance_on_holiday.flags.ignore_validate = True attendance_on_holiday.save() # already marked attendance on a non-holiday should be updated attendance = frappe.get_doc(config) attendance.attendance_date = add_days(first_sunday, 3) + attendance.flags.ignore_validate = True attendance.save() - leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name) + leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name) leave_application.reload() # holiday should be excluded while marking attendance self.assertEqual(leave_application.total_leave_days, 3) @@ -325,7 +327,7 @@ class TestLeaveApplication(unittest.TestCase): employee = get_employee() default_holiday_list = make_holiday_list() - frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list) + frappe.db.set_value("Company", employee.company, "default_holiday_list", default_holiday_list) first_sunday = get_first_sunday(default_holiday_list) optional_leave_date = add_days(first_sunday, 1) @@ -544,7 +546,7 @@ class TestLeaveApplication(unittest.TestCase): from erpnext.hr.utils import allocate_earned_leaves i = 0 while(i<14): - allocate_earned_leaves() + allocate_earned_leaves(ignore_duplicates=True) i += 1 self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6) @@ -552,7 +554,7 @@ class TestLeaveApplication(unittest.TestCase): frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0) i = 0 while(i<6): - allocate_earned_leaves() + allocate_earned_leaves(ignore_duplicates=True) i += 1 self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 355370f3a4f..c11a821738f 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -8,11 +8,10 @@ from math import ceil import frappe from frappe import _, bold from frappe.model.document import Document -from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate +from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate class LeavePolicyAssignment(Document): - def validate(self): self.validate_policy_assignment_overlap() self.set_dates() @@ -94,10 +93,12 @@ class LeavePolicyAssignment(Document): new_leaves_allocated = 0 elif leave_type_details.get(leave_type).is_earned_leave == 1: - if self.assignment_based_on == "Leave Period": - new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining) - else: + if not self.assignment_based_on: new_leaves_allocated = 0 + else: + # get leaves for past months if assignment is based on Leave Period / Joining Date + new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining) + # Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period elif getdate(date_of_joining) > getdate(self.effective_from): remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1)) @@ -108,21 +109,24 @@ class LeavePolicyAssignment(Document): def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining): from erpnext.hr.utils import get_monthly_earned_leave - current_month = get_datetime().month - current_year = get_datetime().year + current_date = frappe.flags.current_date or getdate() + if current_date > getdate(self.effective_to): + current_date = getdate(self.effective_to) - from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date") - if getdate(date_of_joining) > getdate(from_date): - from_date = date_of_joining - - from_date_month = get_datetime(from_date).month - from_date_year = get_datetime(from_date).year + from_date = getdate(self.effective_from) + if getdate(date_of_joining) > from_date: + from_date = getdate(date_of_joining) months_passed = 0 - if current_year == from_date_year and current_month > from_date_month: - months_passed = current_month - from_date_month - elif current_year > from_date_year: - months_passed = (12 - from_date_month) + current_month + based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining + + if current_date.year == from_date.year and current_date.month >= from_date.month: + months_passed = current_date.month - from_date.month + months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj) + + elif current_date.year > from_date.year: + months_passed = (12 - from_date.month) + current_date.month + months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj) if months_passed > 0: monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated, @@ -134,6 +138,23 @@ class LeavePolicyAssignment(Document): return new_leaves_allocated +def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj): + date = getdate(frappe.flags.current_date) or getdate() + + if based_on_doj: + # if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ, + # then the month should be considered + if date.day == date_of_joining.day: + months_passed += 1 + else: + last_day_of_month = get_last_day(date) + # if its the last day of the month, then that month should be considered + if last_day_of_month == date: + months_passed += 1 + + return months_passed + + @frappe.whitelist() def create_assignment_for_multiple_employees(employees, data): @@ -168,7 +189,7 @@ def create_assignment_for_multiple_employees(employees, data): def get_leave_type_details(): leave_type_details = frappe._dict() leave_types = frappe.get_all("Leave Type", - fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", + fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "based_on_date_of_joining", "is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"]) for d in leave_types: leave_type_details.setdefault(d.name, d) diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index 3b7f8ec822c..a19ddce7c09 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import add_months, get_first_day, getdate +from frappe.utils import add_months, get_first_day, get_last_day, getdate from erpnext.hr.doctype.leave_application.test_leave_application import ( get_employee, @@ -20,36 +20,31 @@ test_dependencies = ["Employee"] class TestLeavePolicyAssignment(unittest.TestCase): def setUp(self): for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]: - frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec + frappe.db.delete(doctype) + + employee = get_employee() + self.original_doj = employee.date_of_joining + self.employee = employee def test_grant_leaves(self): leave_period = get_leave_period() - employee = get_employee() - - # create the leave policy with leave type "_Test Leave Type", allocation = 10 + # allocation = 10 leave_policy = create_leave_policy() leave_policy.submit() - data = { "assignment_based_on": "Leave Period", "leave_policy": leave_policy.name, "leave_period": leave_period.name } - - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) - - leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]) - leave_policy_assignment_doc.reload() - - self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1) + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1) leave_allocation = frappe.get_list("Leave Allocation", filters={ - "employee": employee.name, + "employee": self.employee.name, "leave_policy":leave_policy.name, "leave_policy_assignment": leave_policy_assignments[0], "docstatus": 1})[0] - leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10) @@ -61,63 +56,46 @@ class TestLeavePolicyAssignment(unittest.TestCase): def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self): leave_period = get_leave_period() - employee = get_employee() - # create the leave policy with leave type "_Test Leave Type", allocation = 10 leave_policy = create_leave_policy() leave_policy.submit() - data = { "assignment_based_on": "Leave Period", "leave_policy": leave_policy.name, "leave_period": leave_period.name } - - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) - - leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]) - leave_policy_assignment_doc.reload() - + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) # every leave is allocated no more leave can be granted now - self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1) - + self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1) leave_allocation = frappe.get_list("Leave Allocation", filters={ - "employee": employee.name, + "employee": self.employee.name, "leave_policy":leave_policy.name, "leave_policy_assignment": leave_policy_assignments[0], "docstatus": 1})[0] leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) - - # User all allowed to grant leave when there is no allocation against assignment leave_alloc_doc.cancel() leave_alloc_doc.delete() - - leave_policy_assignment_doc.reload() - - - # User are now allowed to grant leave - self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0) + self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0) def test_earned_leave_allocation(self): leave_period = create_leave_period("Test Earned Leave Period") - employee = get_employee() leave_type = create_earned_leave_type("Test Earned Leave") leave_policy = frappe.get_doc({ "doctype": "Leave Policy", "title": "Test Leave Policy", "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}] - }).insert() + }).submit() data = { "assignment_based_on": "Leave Period", "leave_policy": leave_policy.name, "leave_period": leave_period.name } - leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) # leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency leaves_allocated = frappe.db.get_value("Leave Allocation", { @@ -125,11 +103,200 @@ class TestLeavePolicyAssignment(unittest.TestCase): }, "total_leaves_allocated") self.assertEqual(leaves_allocated, 0) + def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self): + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -1))) + + # Case 1: assignment created one month after the leave period, should allocate 1 leave + frappe.flags.current_date = get_first_day(getdate()) + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 1) + + def test_earned_leave_alloc_for_passed_months_on_month_end_based_on_leave_period(self): + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2))) + # Case 2: assignment created on the last day of the leave period's latter month + # should allocate 1 leave for current month even though the month has not ended + # since the daily job might have already executed + frappe.flags.current_date = get_last_day(getdate()) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + def test_earned_leave_alloc_for_passed_months_with_cf_leaves_based_on_leave_period(self): + from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation + + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2))) + # initial leave allocation = 5 + leave_allocation = create_leave_allocation(employee=self.employee.name, employee_name=self.employee.employee_name, leave_type="Test Earned Leave", + from_date=add_months(getdate(), -12), to_date=add_months(getdate(), -3), new_leaves_allocated=5, carry_forward=0) + leave_allocation.submit() + + # Case 3: assignment created on the last day of the leave period's latter month with carry forwarding + frappe.flags.current_date = get_last_day(add_months(getdate(), -1)) + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name, + "carry_forward": 1 + } + # carry forwarded leaves = 5, 3 leaves allocated for passed months + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + + details = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True) + self.assertEqual(details.new_leaves_allocated, 2) + self.assertEqual(details.unused_leaves, 5) + self.assertEqual(details.total_leaves_allocated, 7) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import is_earned_leave_already_allocated + frappe.flags.current_date = get_last_day(getdate()) + + allocation = frappe.get_doc("Leave Allocation", details.name) + # 1 leave is still pending to be allocated, irrespective of carry forwarded leaves + self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation)) + + def test_earned_leave_alloc_for_passed_months_based_on_joining_date(self): + # tests leave alloc for earned leaves for assignment based on joining date in policy assignment + leave_type = create_earned_leave_type("Test Earned Leave") + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).submit() + + # joining date set to 2 months back + self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) + self.employee.save() + + # assignment created on the last day of the current month + frappe.flags.current_date = get_last_day(getdate()) + data = { + "assignment_based_on": "Joining Date", + "leave_policy": leave_policy.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from") + self.assertEqual(effective_from, self.employee.date_of_joining) + self.assertEqual(leaves_allocated, 3) + + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + frappe.flags.current_date = get_last_day(getdate()) + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self): + # tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type + leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)), based_on_doj=True) + + # joining date set to 2 months back + self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) + self.employee.save() + + # assignment created on the same day of the current month, should allocate leaves including the current month + frappe.flags.current_date = get_first_day(getdate()) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + # if the daily job is not completed yet, there is another check present + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + frappe.flags.current_date = get_first_day(getdate()) + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", { + "leave_policy_assignment": leave_policy_assignments[0] + }, "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + + def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self): + # tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type + leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).submit() + + # joining date set to 2 months back + # leave should be allocated for current month too since this day is same as the joining day + self.employee.date_of_joining = get_first_day(add_months(getdate(), -2)) + self.employee.save() + + # assignment created on the first day of the current month + frappe.flags.current_date = get_first_day(getdate()) + data = { + "assignment_based_on": "Joining Date", + "leave_policy": leave_policy.name + } + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data)) + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from") + self.assertEqual(effective_from, self.employee.date_of_joining) + self.assertEqual(leaves_allocated, 3) + + # to ensure leave is not already allocated to avoid duplication + from erpnext.hr.utils import allocate_earned_leaves + frappe.flags.current_date = get_first_day(getdate()) + allocate_earned_leaves() + + leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]}, + "total_leaves_allocated") + self.assertEqual(leaves_allocated, 3) + def tearDown(self): frappe.db.rollback() + frappe.db.set_value("Employee", self.employee.name, "date_of_joining", self.original_doj) + frappe.flags.current_date = None -def create_earned_leave_type(leave_type): +def create_earned_leave_type(leave_type, based_on_doj=False): frappe.delete_doc_if_exists("Leave Type", leave_type, force=1) return frappe.get_doc(dict( @@ -138,13 +305,15 @@ def create_earned_leave_type(leave_type): is_earned_leave=1, earned_leave_frequency="Monthly", rounding=0.5, - max_leaves_allowed=6 + is_carry_forward=1, + based_on_date_of_joining=based_on_doj )).insert() -def create_leave_period(name): +def create_leave_period(name, start_date=None): frappe.delete_doc_if_exists("Leave Period", name, force=1) - start_date = get_first_day(getdate()) + if not start_date: + start_date = get_first_day(getdate()) return frappe.get_doc(dict( name=name, @@ -153,4 +322,17 @@ def create_leave_period(name): to_date=add_months(start_date, 12), company="_Test Company", is_active=1 - )).insert() \ No newline at end of file + )).insert() + + +def setup_leave_period_and_policy(start_date, based_on_doj=False): + leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj) + leave_period = create_leave_period("Test Earned Leave Period", + start_date=start_date) + leave_policy = frappe.get_doc({ + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}] + }).insert() + + return leave_period, leave_policy \ No newline at end of file diff --git a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py index acd50f278cd..abb288723c4 100644 --- a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py +++ b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py @@ -82,7 +82,7 @@ def get_vehicle(employee_id): "vehicle_value": flt(500000) }) try: - vehicle.insert() + vehicle.insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass return license_plate diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 0febce1610a..c1740471e2c 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -237,7 +237,7 @@ def generate_leave_encashment(): create_leave_encashment(leave_allocation=leave_allocation) -def allocate_earned_leaves(): +def allocate_earned_leaves(ignore_duplicates=False): '''Allocate earned leaves to Employees''' e_leave_types = get_earned_leaves() today = getdate() @@ -261,13 +261,13 @@ def allocate_earned_leaves(): from_date=allocation.from_date - if e_leave_type.based_on_date_of_joining_date: + if e_leave_type.based_on_date_of_joining: from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") - if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date): - update_previous_leave_allocation(allocation, annual_allocation, e_leave_type) + if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining): + update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates) -def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type): +def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False): earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding) allocation = frappe.get_doc('Leave Allocation', allocation.name) @@ -277,9 +277,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type new_allocation = e_leave_type.max_leaves_allowed if new_allocation != allocation.total_leaves_allocated: - allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) today_date = today() - create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + + if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation): + allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) + create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + def get_monthly_earned_leave(annual_leaves, frequency, rounding): earned_leaves = 0.0 @@ -297,6 +300,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding): return earned_leaves +def is_earned_leave_already_allocated(allocation, annual_allocation): + from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( + get_leave_type_details, + ) + + leave_type_details = get_leave_type_details() + date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") + + assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment) + leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type, + annual_allocation, leave_type_details, date_of_joining) + + # exclude carry-forwarded leaves while checking for leave allocation for passed months + num_allocations = allocation.total_leaves_allocated + if allocation.unused_leaves: + num_allocations -= allocation.unused_leaves + + if num_allocations >= leaves_for_passed_months: + return True + return False + + def get_leave_allocations(date, leave_type): return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy from `tabLeave Allocation` @@ -318,7 +343,7 @@ def create_additional_leave_ledger_entry(allocation, leaves, date): allocation.unused_leaves = 0 allocation.create_leave_ledger_entry() -def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date): +def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining): import calendar from dateutil import relativedelta @@ -329,7 +354,7 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining #last day of month last_day = calendar.monthrange(to_date.year, to_date.month)[1] - if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day): + if (from_date.day == to_date.day and based_on_date_of_joining) or (not based_on_date_of_joining and to_date.day == last_day): if frequency == "Monthly": return True elif frequency == "Quarterly" and rd.months % 3: diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js index f9c201ab603..940a1bbc000 100644 --- a/erpnext/loan_management/doctype/loan/loan.js +++ b/erpnext/loan_management/doctype/loan/loan.js @@ -46,7 +46,7 @@ frappe.ui.form.on('Loan', { }); }); - $.each(["payment_account", "loan_account"], function (i, field) { + $.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) { frm.set_query(field, function () { return { "filters": { @@ -88,6 +88,10 @@ frappe.ui.form.on('Loan', { frm.add_custom_button(__('Loan Write Off'), function() { frm.trigger("make_loan_write_off_entry"); },__('Create')); + + frm.add_custom_button(__('Loan Refund'), function() { + frm.trigger("make_loan_refund"); + },__('Create')); } } frm.trigger("toggle_fields"); @@ -155,6 +159,21 @@ frappe.ui.form.on('Loan', { }) }, + make_loan_refund: function(frm) { + frappe.call({ + args: { + "loan": frm.doc.name + }, + method: "erpnext.loan_management.doctype.loan.loan.make_refund_jv", + callback: function (r) { + if (r.message) { + let doc = frappe.model.sync(r.message)[0]; + frappe.set_route("Form", doc.doctype, doc.name); + } + } + }) + }, + request_loan_closure: function(frm) { frappe.confirm(__("Do you really want to close this loan"), function() { diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json index af26f7bc5c4..196f36f0f46 100644 --- a/erpnext/loan_management/doctype/loan/loan.json +++ b/erpnext/loan_management/doctype/loan/loan.json @@ -2,7 +2,7 @@ "actions": [], "allow_import": 1, "autoname": "ACC-LOAN-.YYYY.-.#####", - "creation": "2019-08-29 17:29:18.176786", + "creation": "2022-01-25 10:30:02.294967", "doctype": "DocType", "document_type": "Document", "editable_grid": 1, @@ -34,6 +34,7 @@ "is_term_loan", "account_info", "mode_of_payment", + "disbursement_account", "payment_account", "column_break_9", "loan_account", @@ -356,12 +357,21 @@ "fieldtype": "Date", "label": "Closure Date", "read_only": 1 + }, + { + "fetch_from": "loan_type.disbursement_account", + "fieldname": "disbursement_account", + "fieldtype": "Link", + "label": "Disbursement Account", + "options": "Account", + "read_only": 1, + "reqd": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-10-12 18:10:32.360818", + "modified": "2022-01-25 16:29:16.325501", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", @@ -391,5 +401,6 @@ "search_fields": "posting_date", "sort_field": "creation", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index f660a24a6d5..b798e088b4f 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -10,6 +10,7 @@ from frappe import _ from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, nowdate import erpnext +from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry from erpnext.controllers.accounts_controller import AccountsController from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts from erpnext.loan_management.doctype.loan_security_unpledge.loan_security_unpledge import ( @@ -233,17 +234,15 @@ def request_loan_closure(loan, posting_date=None): loan_type = frappe.get_value('Loan', loan, 'loan_type') write_off_limit = frappe.get_value('Loan Type', loan_type, 'write_off_amount') - # checking greater than 0 as there may be some minor precision error - if not pending_amount: - frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') - elif pending_amount < write_off_limit: + if pending_amount and abs(pending_amount) < write_off_limit: # Auto create loan write off and update status as loan closure requested write_off = make_loan_write_off(loan) write_off.submit() - frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') - else: + elif pending_amount > 0: frappe.throw(_("Cannot close loan as there is an outstanding of {0}").format(pending_amount)) + frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') + @frappe.whitelist() def get_loan_application(loan_application): loan = frappe.get_doc("Loan Application", loan_application) @@ -400,4 +399,39 @@ def add_single_month(date): if getdate(date) == get_last_day(date): return get_last_day(add_months(date, 1)) else: - return add_months(date, 1) \ No newline at end of file + return add_months(date, 1) + +@frappe.whitelist() +def make_refund_jv(loan, amount=0, reference_number=None, reference_date=None, submit=0): + loan_details = frappe.db.get_value('Loan', loan, ['applicant_type', 'applicant', + 'loan_account', 'payment_account', 'posting_date', 'company', 'name', + 'total_payment', 'total_principal_paid'], as_dict=1) + + loan_details.doctype = 'Loan' + loan_details[loan_details.applicant_type.lower()] = loan_details.applicant + + if not amount: + amount = flt(loan_details.total_principal_paid - loan_details.total_payment) + + if amount < 0: + frappe.throw(_('No excess amount pending for refund')) + + refund_jv = get_payment_entry(loan_details, { + "party_type": loan_details.applicant_type, + "party_account": loan_details.loan_account, + "amount_field_party": 'debit_in_account_currency', + "amount_field_bank": 'credit_in_account_currency', + "amount": amount, + "bank_account": loan_details.payment_account + }) + + if reference_number: + refund_jv.cheque_no = reference_number + + if reference_date: + refund_jv.cheque_date = reference_date + + if submit: + refund_jv.submit() + + return refund_jv \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py index 1676c218c87..5ebb2e1bdce 100644 --- a/erpnext/loan_management/doctype/loan/test_loan.py +++ b/erpnext/loan_management/doctype/loan/test_loan.py @@ -42,16 +42,17 @@ class TestLoan(unittest.TestCase): create_loan_type("Personal Loan", 500000, 8.4, is_term_loan=1, mode_of_payment='Cash', + disbursement_account='Disbursement Account - _TC', payment_account='Payment Account - _TC', loan_account='Loan Account - _TC', interest_income_account='Interest Income Account - _TC', penalty_income_account='Penalty Income Account - _TC') - create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') - create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') create_loan_security_type() create_loan_security() @@ -679,6 +680,29 @@ class TestLoan(unittest.TestCase): loan.load_from_db() self.assertEqual(loan.status, "Loan Closure Requested") + def test_loan_repayment_against_partially_disbursed_loan(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + first_date = '2019-10-01' + last_date = '2019-10-30' + + make_loan_disbursement_entry(loan.name, loan.loan_amount/2, disbursement_date=first_date) + + loan.load_from_db() + + self.assertEqual(loan.status, "Partially Disbursed") + create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), + flt(loan.loan_amount/3)) + def test_loan_amount_write_off(self): pledge = [{ "loan_security": "Test Security 1", @@ -790,6 +814,18 @@ def create_loan_accounts(): "account_type": "Bank", }).insert(ignore_permissions=True) + if not frappe.db.exists("Account", "Disbursement Account - _TC"): + frappe.get_doc({ + "doctype": "Account", + "company": "_Test Company", + "account_name": "Disbursement Account", + "root_type": "Asset", + "report_type": "Balance Sheet", + "currency": "INR", + "parent_account": "Bank Accounts - _TC", + "account_type": "Bank", + }).insert(ignore_permissions=True) + if not frappe.db.exists("Account", "Interest Income Account - _TC"): frappe.get_doc({ "doctype": "Account", @@ -815,7 +851,7 @@ def create_loan_accounts(): }).insert(ignore_permissions=True) def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_interest_rate=None, is_term_loan=None, grace_period_in_days=None, - mode_of_payment=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None, + mode_of_payment=None, disbursement_account=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None, repayment_method=None, repayment_periods=None): if not frappe.db.exists("Loan Type", loan_name): @@ -829,6 +865,7 @@ def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_i "penalty_interest_rate": penalty_interest_rate, "grace_period_in_days": grace_period_in_days, "mode_of_payment": mode_of_payment, + "disbursement_account": disbursement_account, "payment_account": payment_account, "loan_account": loan_account, "interest_income_account": interest_income_account, diff --git a/erpnext/loan_management/doctype/loan_application/test_loan_application.py b/erpnext/loan_management/doctype/loan_application/test_loan_application.py index d367e92ac49..640709c095f 100644 --- a/erpnext/loan_management/doctype/loan_application/test_loan_application.py +++ b/erpnext/loan_management/doctype/loan_application/test_loan_application.py @@ -15,7 +15,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( class TestLoanApplication(unittest.TestCase): def setUp(self): create_loan_accounts() - create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', + create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Disbursement Account - _TC', 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC', 'Repay Over Number of Periods', 18) self.applicant = make_employee("kate_loan@loan.com", "_Test Company") make_salary_structure("Test Salary Structure Loan", "Monthly", employee=self.applicant, currency='INR') diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json index 7811d56a758..50926d77268 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json @@ -14,11 +14,15 @@ "applicant", "section_break_7", "disbursement_date", + "clearance_date", "column_break_8", "disbursed_amount", "accounting_dimensions_section", "cost_center", - "customer_details_section", + "accounting_details", + "disbursement_account", + "column_break_16", + "loan_account", "bank_account", "disbursement_references_section", "reference_date", @@ -106,11 +110,6 @@ "fieldtype": "Section Break", "label": "Disbursement Details" }, - { - "fieldname": "customer_details_section", - "fieldtype": "Section Break", - "label": "Customer Details" - }, { "fetch_from": "against_loan.applicant_type", "fieldname": "applicant_type", @@ -149,15 +148,48 @@ "fieldname": "reference_number", "fieldtype": "Data", "label": "Reference Number" + }, + { + "fieldname": "clearance_date", + "fieldtype": "Date", + "label": "Clearance Date", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "accounting_details", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fetch_from": "against_loan.disbursement_account", + "fieldname": "disbursement_account", + "fieldtype": "Link", + "label": "Disbursement Account", + "options": "Account", + "read_only": 1 + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fetch_from": "against_loan.loan_account", + "fieldname": "loan_account", + "fieldtype": "Link", + "label": "Loan Account", + "options": "Account", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:09:32.175355", + "modified": "2022-02-17 18:23:44.157598", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Disbursement", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -194,5 +226,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py index e2d758b1b90..54a03b92b5e 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -42,9 +42,6 @@ class LoanDisbursement(AccountsController): if not self.posting_date: self.posting_date = self.disbursement_date or nowdate() - if not self.bank_account and self.applicant_type == "Customer": - self.bank_account = frappe.db.get_value("Customer", self.applicant, "default_bank_account") - def validate_disbursal_amount(self): possible_disbursal_amount = get_disbursal_amount(self.against_loan) @@ -117,12 +114,11 @@ class LoanDisbursement(AccountsController): def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] - loan_details = frappe.get_doc("Loan", self.against_loan) gle_map.append( self.get_gl_dict({ - "account": loan_details.loan_account, - "against": loan_details.payment_account, + "account": self.loan_account, + "against": self.disbursement_account, "debit": self.disbursed_amount, "debit_in_account_currency": self.disbursed_amount, "against_voucher_type": "Loan", @@ -137,8 +133,8 @@ class LoanDisbursement(AccountsController): gle_map.append( self.get_gl_dict({ - "account": loan_details.payment_account, - "against": loan_details.loan_account, + "account": self.disbursement_account, + "against": self.loan_account, "credit": self.disbursed_amount, "credit_in_account_currency": self.disbursed_amount, "against_voucher_type": "Loan", diff --git a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py index 94ec84ea5db..10be750b449 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py @@ -44,8 +44,8 @@ class TestLoanDisbursement(unittest.TestCase): def setUp(self): create_loan_accounts() - create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') create_loan_security_type() create_loan_security() diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py index 0de073f85da..1c800a06da0 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py @@ -74,39 +74,6 @@ class LoanInterestAccrual(AccountsController): }) ) - if self.payable_principal_amount: - gle_map.append( - self.get_gl_dict({ - "account": self.loan_account, - "party_type": self.applicant_type, - "party": self.applicant, - "against": self.interest_income_account, - "debit": self.payable_principal_amount, - "debit_in_account_currency": self.interest_amount, - "against_voucher_type": "Loan", - "against_voucher": self.loan, - "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format( - self.last_accrual_date, self.posting_date, self.loan), - "cost_center": erpnext.get_default_cost_center(self.company), - "posting_date": self.posting_date - }) - ) - - gle_map.append( - self.get_gl_dict({ - "account": self.interest_income_account, - "against": self.loan_account, - "credit": self.payable_principal_amount, - "credit_in_account_currency": self.interest_amount, - "against_voucher_type": "Loan", - "against_voucher": self.loan, - "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format( - self.last_accrual_date, self.posting_date, self.loan), - "cost_center": erpnext.get_default_cost_center(self.company), - "posting_date": self.posting_date - }) - ) - if gle_map: make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj) diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py index 46aaaad9fd2..e8c77506fcb 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py @@ -30,8 +30,8 @@ class TestLoanInterestAccrual(unittest.TestCase): def setUp(self): create_loan_accounts() - create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', - 'Interest Income Account - _TC', 'Penalty Income Account - _TC') + create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC', + 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC') create_loan_security_type() create_loan_security() diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json index 93ef2170420..480e010b49a 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json @@ -1,7 +1,7 @@ { "actions": [], "autoname": "LM-REP-.####", - "creation": "2019-09-03 14:44:39.977266", + "creation": "2022-01-25 10:30:02.767941", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -13,6 +13,7 @@ "column_break_3", "company", "posting_date", + "clearance_date", "rate_of_interest", "payroll_payable_account", "is_term_loan", @@ -37,7 +38,12 @@ "total_penalty_paid", "total_interest_paid", "repayment_details", - "amended_from" + "amended_from", + "accounting_details_section", + "payment_account", + "penalty_income_account", + "column_break_36", + "loan_account" ], "fields": [ { @@ -260,12 +266,52 @@ "fieldname": "repay_from_salary", "fieldtype": "Check", "label": "Repay From Salary" + }, + { + "fieldname": "clearance_date", + "fieldtype": "Date", + "label": "Clearance Date", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "accounting_details_section", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fetch_from": "against_loan.payment_account", + "fieldname": "payment_account", + "fieldtype": "Link", + "label": "Repayment Account", + "options": "Account", + "read_only": 1 + }, + { + "fieldname": "column_break_36", + "fieldtype": "Column Break" + }, + { + "fetch_from": "against_loan.loan_account", + "fieldname": "loan_account", + "fieldtype": "Link", + "label": "Loan Account", + "options": "Account", + "read_only": 1 + }, + { + "fetch_from": "against_loan.penalty_income_account", + "fieldname": "penalty_income_account", + "fieldtype": "Link", + "hidden": 1, + "label": "Penalty Income Account", + "options": "Account" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-01-06 01:51:06.707782", + "modified": "2022-02-18 19:10:07.742298", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 7e997e87c32..67c2b1ee14d 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -125,7 +125,7 @@ class LoanRepayment(AccountsController): def update_paid_amount(self): loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid', - 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable', + 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable', 'written_off_amount'], as_dict=1) loan.update({ @@ -153,7 +153,7 @@ class LoanRepayment(AccountsController): def mark_as_unpaid(self): loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid', - 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable', + 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable', 'written_off_amount'], as_dict=1) no_of_repayments = len(self.repayment_details) @@ -310,7 +310,6 @@ class LoanRepayment(AccountsController): def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] - loan_details = frappe.get_doc("Loan", self.against_loan) if self.shortfall_amount and self.amount_paid > self.shortfall_amount: remarks = _("Shortfall Repayment of {0}.\nRepayment against Loan: {1}").format(self.shortfall_amount, @@ -323,13 +322,13 @@ class LoanRepayment(AccountsController): if self.repay_from_salary: payment_account = self.payroll_payable_account else: - payment_account = loan_details.payment_account + payment_account = self.payment_account if self.total_penalty_paid: gle_map.append( self.get_gl_dict({ - "account": loan_details.loan_account, - "against": loan_details.payment_account, + "account": self.loan_account, + "against": payment_account, "debit": self.total_penalty_paid, "debit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", @@ -344,8 +343,8 @@ class LoanRepayment(AccountsController): gle_map.append( self.get_gl_dict({ - "account": loan_details.penalty_income_account, - "against": payment_account, + "account": self.penalty_income_account, + "against": self.loan_account, "credit": self.total_penalty_paid, "credit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", @@ -359,23 +358,24 @@ class LoanRepayment(AccountsController): gle_map.append( self.get_gl_dict({ "account": payment_account, - "against": loan_details.loan_account + ", " + loan_details.interest_income_account - + ", " + loan_details.penalty_income_account, + "against": self.loan_account + ", " + self.penalty_income_account, "debit": self.amount_paid, "debit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, "remarks": remarks, "cost_center": self.cost_center, - "posting_date": getdate(self.posting_date) + "posting_date": getdate(self.posting_date), + "party_type": self.applicant_type if self.repay_from_salary else '', + "party": self.applicant if self.repay_from_salary else '' }) ) gle_map.append( self.get_gl_dict({ - "account": loan_details.loan_account, - "party_type": loan_details.applicant_type, - "party": loan_details.applicant, + "account": self.loan_account, + "party_type": self.applicant_type, + "party": self.applicant, "against": payment_account, "credit": self.amount_paid, "credit_in_account_currency": self.amount_paid, diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.js b/erpnext/loan_management/doctype/loan_type/loan_type.js index 04c89c45499..9f9137cfbcd 100644 --- a/erpnext/loan_management/doctype/loan_type/loan_type.js +++ b/erpnext/loan_management/doctype/loan_type/loan_type.js @@ -15,7 +15,7 @@ frappe.ui.form.on('Loan Type', { }); }); - $.each(["payment_account", "loan_account"], function (i, field) { + $.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) { frm.set_query(field, function () { return { "filters": { diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.json b/erpnext/loan_management/doctype/loan_type/loan_type.json index c0a5d2cda12..00337e4b4c3 100644 --- a/erpnext/loan_management/doctype/loan_type/loan_type.json +++ b/erpnext/loan_management/doctype/loan_type/loan_type.json @@ -19,9 +19,10 @@ "description", "account_details_section", "mode_of_payment", + "disbursement_account", "payment_account", - "loan_account", "column_break_12", + "loan_account", "interest_income_account", "penalty_income_account", "amended_from" @@ -79,7 +80,7 @@ { "fieldname": "payment_account", "fieldtype": "Link", - "label": "Payment Account", + "label": "Repayment Account", "options": "Account", "reqd": 1 }, @@ -149,15 +150,23 @@ "fieldtype": "Currency", "label": "Auto Write Off Amount ", "options": "Company:company:default_currency" + }, + { + "fieldname": "disbursement_account", + "fieldtype": "Link", + "label": "Disbursement Account", + "options": "Account", + "reqd": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:10:57.368490", + "modified": "2022-01-25 16:23:57.009349", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Type", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -181,5 +190,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json index 3d070812152..b7b20d945d6 100644 --- a/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json +++ b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json @@ -70,7 +70,6 @@ { "fieldname": "loan_repayment_entry", "fieldtype": "Link", - "hidden": 1, "label": "Loan Repayment Entry", "no_copy": 1, "options": "Loan Repayment", @@ -88,7 +87,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-03-14 20:47:11.725818", + "modified": "2022-01-31 14:50:14.823213", "modified_by": "Administrator", "module": "Loan Management", "name": "Salary Slip Loan", @@ -97,5 +96,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 6d35d65bea9..8a7634e24ec 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -93,7 +93,7 @@ frappe.ui.form.on("BOM", { }); } - if(frm.doc.docstatus!=0) { + if(frm.doc.docstatus==1) { frm.add_custom_button(__("Work Order"), function() { frm.trigger("make_work_order"); }, __("Create")); @@ -331,7 +331,7 @@ frappe.ui.form.on("BOM", { }); }); - if (has_template_rm) { + if (has_template_rm && has_template_rm.length) { dialog.fields_dict.items.grid.refresh(); } }, @@ -467,7 +467,8 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) { "uom": d.uom, "stock_uom": d.stock_uom, "conversion_factor": d.conversion_factor, - "sourced_by_supplier": d.sourced_by_supplier + "sourced_by_supplier": d.sourced_by_supplier, + "do_not_explode": d.do_not_explode }, callback: function(r) { d = locals[cdt][cdn]; @@ -640,6 +641,13 @@ frappe.ui.form.on("BOM Operation", "workstation", function(frm, cdt, cdn) { }); }); +frappe.ui.form.on("BOM Item", { + do_not_explode: function(frm, cdt, cdn) { + get_bom_material_detail(frm.doc, cdt, cdn, false); + } +}) + + frappe.ui.form.on("BOM Item", "qty", function(frm, cdt, cdn) { var d = locals[cdt][cdn]; d.stock_qty = d.qty * d.conversion_factor; diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 218ac64d8da..0b441969400 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -37,7 +37,6 @@ "inspection_required", "quality_inspection_template", "column_break_31", - "bom_level", "section_break_33", "items", "scrap_section", @@ -522,13 +521,6 @@ "fieldname": "column_break_31", "fieldtype": "Column Break" }, - { - "default": "0", - "fieldname": "bom_level", - "fieldtype": "Int", - "label": "BOM Level", - "read_only": 1 - }, { "fieldname": "section_break_33", "fieldtype": "Section Break", @@ -540,7 +532,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2021-11-18 13:04:16.271975", + "modified": "2022-01-30 21:27:54.727298", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", @@ -577,5 +569,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 5a60fb751d1..d640f3fda7e 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -149,12 +149,12 @@ class BOM(WebsiteGenerator): self.set_bom_material_details() self.set_bom_scrap_items_detail() self.validate_materials() + self.validate_transfer_against() self.set_routing_operations() self.validate_operations() self.calculate_cost() self.update_stock_qty() self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate = False, save=False) - self.set_bom_level() self.validate_scrap_items() def get_context(self, context): @@ -203,6 +203,10 @@ class BOM(WebsiteGenerator): for item in self.get("items"): self.validate_bom_currency(item) + item.bom_no = '' + if not item.do_not_explode: + item.bom_no = item.bom_no + ret = self.get_bom_material_detail({ "company": self.company, "item_code": item.item_code, @@ -214,8 +218,10 @@ class BOM(WebsiteGenerator): "uom": item.uom, "stock_uom": item.stock_uom, "conversion_factor": item.conversion_factor, - "sourced_by_supplier": item.sourced_by_supplier + "sourced_by_supplier": item.sourced_by_supplier, + "do_not_explode": item.do_not_explode }) + for r in ret: if not item.get(r): item.set(r, ret[r]) @@ -267,6 +273,9 @@ class BOM(WebsiteGenerator): 'sourced_by_supplier' : args.get('sourced_by_supplier', 0) } + if args.get('do_not_explode'): + ret_item['bom_no'] = '' + return ret_item def validate_bom_currency(self, item): @@ -681,6 +690,12 @@ class BOM(WebsiteGenerator): if act_pbom and act_pbom[0][0]: frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs")) + def validate_transfer_against(self): + if not self.with_operations: + self.transfer_material_against = "Work Order" + if not self.transfer_material_against and not self.is_new(): + frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value")) + def set_routing_operations(self): if self.routing and self.with_operations and not self.operations: self.get_routing() @@ -700,20 +715,6 @@ class BOM(WebsiteGenerator): """Get a complete tree representation preserving order of child items.""" return BOMTree(self.name) - def set_bom_level(self, update=False): - levels = [] - - self.bom_level = 0 - for row in self.items: - if row.bom_no: - levels.append(frappe.get_cached_value("BOM", row.bom_no, "bom_level") or 0) - - if levels: - self.bom_level = max(levels) + 1 - - if update: - self.db_set("bom_level", self.bom_level) - def validate_scrap_items(self): for item in self.scrap_items: msg = "" diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 178d92c26c3..53437c80127 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -385,6 +385,53 @@ class TestBOM(ERPNextTestCase): self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results") self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") + def test_exclude_exploded_items_from_bom(self): + bom_no = get_default_bom() + new_bom = frappe.copy_doc(frappe.get_doc('BOM', bom_no)) + for row in new_bom.items: + if row.item_code == '_Test Item Home Desktop Manufactured': + self.assertTrue(row.bom_no) + row.do_not_explode = True + + new_bom.docstatus = 0 + new_bom.save() + new_bom.load_from_db() + + for row in new_bom.items: + if row.item_code == '_Test Item Home Desktop Manufactured' and row.do_not_explode: + self.assertFalse(row.bom_no) + + new_bom.delete() + + def test_valid_transfer_defaults(self): + bom_with_op = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1}) + bom = frappe.copy_doc(frappe.get_doc("BOM", bom_with_op), ignore_no_copy=False) + + # test defaults + bom.docstatus = 0 + bom.transfer_material_against = None + bom.insert() + self.assertEqual(bom.transfer_material_against, "Work Order") + + bom.reload() + bom.transfer_material_against = None + with self.assertRaises(frappe.ValidationError): + bom.save() + bom.reload() + + # test saner default + bom.transfer_material_against = "Job Card" + bom.with_operations = 0 + bom.save() + self.assertEqual(bom.transfer_material_against, "Work Order") + + # test no value on existing doc + bom.transfer_material_against = None + bom.with_operations = 0 + bom.save() + self.assertEqual(bom.transfer_material_against, "Work Order") + bom.delete() + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 4c9877f52b2..3406215cbbb 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -10,6 +10,7 @@ "item_name", "operation", "column_break_3", + "do_not_explode", "bom_no", "source_warehouse", "allow_alternative_item", @@ -73,6 +74,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.do_not_explode", "fieldname": "bom_no", "fieldtype": "Link", "in_filter": 1, @@ -284,18 +286,25 @@ "fieldname": "sourced_by_supplier", "fieldtype": "Check", "label": "Sourced by Supplier" + }, + { + "default": "0", + "fieldname": "do_not_explode", + "fieldtype": "Check", + "label": "Do Not Explode" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-08 14:19:37.563300", + "modified": "2022-01-24 16:57:57.020232", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 8d00019b7d6..9f4ace296e8 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -62,7 +62,7 @@ class JobCard(Document): if self.get('time_logs'): for d in self.get('time_logs'): - if get_datetime(d.from_time) > get_datetime(d.to_time): + if d.to_time and get_datetime(d.from_time) > get_datetime(d.to_time): frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx)) data = self.get_overlap_for(d) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 8b1dbd0b901..80003dab786 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -28,9 +28,24 @@ from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults class ProductionPlan(Document): def validate(self): + self.set_pending_qty_in_row_without_reference() self.calculate_total_planned_qty() self.set_status() + def set_pending_qty_in_row_without_reference(self): + "Set Pending Qty in independent rows (not from SO or MR)." + if self.docstatus > 0: # set only to initialise value before submit + return + + for item in self.po_items: + if not item.get("sales_order") or not item.get("material_request"): + item.pending_qty = item.planned_qty + + def calculate_total_planned_qty(self): + self.total_planned_qty = 0 + for d in self.po_items: + self.total_planned_qty += flt(d.planned_qty) + def validate_data(self): for d in self.get('po_items'): if not d.bom_no: @@ -263,11 +278,6 @@ class ProductionPlan(Document): 'qty': so_detail['qty'] }) - def calculate_total_planned_qty(self): - self.total_planned_qty = 0 - for d in self.po_items: - self.total_planned_qty += flt(d.planned_qty) - def calculate_total_produced_qty(self): self.total_produced_qty = 0 for d in self.po_items: @@ -275,10 +285,11 @@ class ProductionPlan(Document): self.db_set("total_produced_qty", self.total_produced_qty, update_modified=False) - def update_produced_qty(self, produced_qty, production_plan_item): + def update_produced_pending_qty(self, produced_qty, production_plan_item): for data in self.po_items: if data.name == production_plan_item: data.produced_qty = produced_qty + data.pending_qty = flt(data.planned_qty - produced_qty) data.db_update() self.calculate_total_produced_qty() @@ -308,7 +319,7 @@ class ProductionPlan(Document): if self.total_produced_qty > 0: self.status = "In Process" - if self.check_have_work_orders_completed(): + if self.all_items_completed(): self.status = "Completed" if self.status != 'Completed': @@ -341,6 +352,7 @@ class ProductionPlan(Document): def get_production_items(self): item_dict = {} + for d in self.po_items: item_details = { "production_item" : d.item_code, @@ -357,12 +369,12 @@ class ProductionPlan(Document): "production_plan" : self.name, "production_plan_item" : d.name, "product_bundle_item" : d.product_bundle_item, - "planned_start_date" : d.planned_start_date + "planned_start_date" : d.planned_start_date, + "project" : self.project } - item_details.update({ - "project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project") - }) + if not item_details['project'] and d.sales_order: + item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project") if self.get_items_from == "Material Request": item_details.update({ @@ -380,39 +392,59 @@ class ProductionPlan(Document): @frappe.whitelist() def make_work_order(self): + from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse + wo_list, po_list = [], [] subcontracted_po = {} + default_warehouses = get_default_warehouse() - self.validate_data() - self.make_work_order_for_finished_goods(wo_list) - self.make_work_order_for_subassembly_items(wo_list, subcontracted_po) + self.make_work_order_for_finished_goods(wo_list, default_warehouses) + self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses) self.make_subcontracted_purchase_order(subcontracted_po, po_list) self.show_list_created_message('Work Order', wo_list) self.show_list_created_message('Purchase Order', po_list) - def make_work_order_for_finished_goods(self, wo_list): + def make_work_order_for_finished_goods(self, wo_list, default_warehouses): items_data = self.get_production_items() for key, item in items_data.items(): if self.sub_assembly_items: item['use_multi_level_bom'] = 0 + set_default_warehouses(item, default_warehouses) work_order = self.create_work_order(item) if work_order: wo_list.append(work_order) - def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po): + def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses): for row in self.sub_assembly_items: if row.type_of_manufacturing == 'Subcontract': subcontracted_po.setdefault(row.supplier, []).append(row) continue - args = {} - self.prepare_args_for_sub_assembly_items(row, args) - work_order = self.create_work_order(args) + work_order_data = { + 'wip_warehouse': default_warehouses.get('wip_warehouse'), + 'fg_warehouse': default_warehouses.get('fg_warehouse') + } + + self.prepare_data_for_sub_assembly_items(row, work_order_data) + work_order = self.create_work_order(work_order_data) if work_order: wo_list.append(work_order) + def prepare_data_for_sub_assembly_items(self, row, wo_data): + for field in ["production_item", "item_name", "qty", "fg_warehouse", + "description", "bom_no", "stock_uom", "bom_level", + "production_plan_item", "schedule_date"]: + if row.get(field): + wo_data[field] = row.get(field) + + wo_data.update({ + "use_multi_level_bom": 0, + "production_plan": self.name, + "production_plan_sub_assembly_item": row.name + }) + def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders): if not subcontracted_po: return @@ -423,7 +455,7 @@ class ProductionPlan(Document): po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate() po.is_subcontracted = 'Yes' for row in po_list: - args = { + po_data = { 'item_code': row.production_item, 'warehouse': row.fg_warehouse, 'production_plan_sub_assembly_item': row.name, @@ -433,9 +465,9 @@ class ProductionPlan(Document): for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name', 'description', 'production_plan_item']: - args[field] = row.get(field) + po_data[field] = row.get(field) - po.append('items', args) + po.append('items', po_data) po.set_missing_values() po.flags.ignore_mandatory = True @@ -452,24 +484,9 @@ class ProductionPlan(Document): doc_list = [get_link_to_form(doctype, p) for p in doc_list] msgprint(_("{0} created").format(comma_and(doc_list))) - def prepare_args_for_sub_assembly_items(self, row, args): - for field in ["production_item", "item_name", "qty", "fg_warehouse", - "description", "bom_no", "stock_uom", "bom_level", - "production_plan_item", "schedule_date"]: - args[field] = row.get(field) - - args.update({ - "use_multi_level_bom": 0, - "production_plan": self.name, - "production_plan_sub_assembly_item": row.name - }) - def create_work_order(self, item): - from erpnext.manufacturing.doctype.work_order.work_order import ( - OverProductionError, - get_default_warehouse, - ) - warehouse = get_default_warehouse() + from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError + wo = frappe.new_doc("Work Order") wo.update(item) wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date') @@ -478,11 +495,11 @@ class ProductionPlan(Document): wo.fg_warehouse = item.get("warehouse") wo.set_work_order_operations() + wo.set_required_items() - if not wo.fg_warehouse: - wo.fg_warehouse = warehouse.get('fg_warehouse') try: wo.flags.ignore_mandatory = True + wo.flags.ignore_validate = True wo.insert() return wo.name except OverProductionError: @@ -559,9 +576,11 @@ class ProductionPlan(Document): get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty) self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type) - def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None): - bom_data = sorted(bom_data, key = lambda i: i.bom_level) + self.sub_assembly_items.sort(key= lambda d: d.bom_level, reverse=True) + for idx, row in enumerate(self.sub_assembly_items, start=1): + row.idx = idx + def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None): for data in bom_data: data.qty = data.stock_qty data.production_plan_item = row.name @@ -572,21 +591,32 @@ class ProductionPlan(Document): self.append("sub_assembly_items", data) - def check_have_work_orders_completed(self): - wo_status = frappe.db.get_list( + def all_items_completed(self): + all_items_produced = all(flt(d.planned_qty) - flt(d.produced_qty) < 0.000001 + for d in self.po_items) + if not all_items_produced: + return False + + wo_status = frappe.get_all( "Work Order", - filters={"production_plan": self.name}, + filters={ + "production_plan": self.name, + "status": ("not in", ["Closed", "Stopped"]), + "docstatus": ("<", 2), + }, fields="status", - pluck="status" + pluck="status", ) - return all(s == "Completed" for s in wo_status) + all_work_orders_completed = all(s == "Completed" for s in wo_status) + return all_work_orders_completed @frappe.whitelist() def download_raw_materials(doc, warehouses=None): if isinstance(doc, str): doc = frappe._dict(json.loads(doc)) - item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM', + item_list = [['Item Code', 'Item Name', 'Description', + 'Stock UOM', 'Warehouse', 'Required Qty as per BOM', 'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty', 'Reserved Qty for Production', 'Safety Stock', 'Required Qty']] @@ -595,7 +625,8 @@ def download_raw_materials(doc, warehouses=None): items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True) for d in items: - item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'), + item_list.append([d.get('item_code'), d.get('item_name'), + d.get('description'), d.get('stock_uom'), d.get('warehouse'), d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'), d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')]) @@ -1004,9 +1035,6 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0): for d in data: if d.expandable: parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") - bom_level = (frappe.get_cached_value("BOM", d.value, "bom_level") - if d.value else 0) - stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) bom_data.append(frappe._dict({ 'parent_item_code': parent_item_code, @@ -1017,10 +1045,15 @@ def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0): 'uom': d.stock_uom, 'bom_no': d.value, 'is_sub_contracted_item': d.is_sub_contracted_item, - 'bom_level': bom_level, + 'bom_level': indent, 'indent': indent, 'stock_qty': stock_qty })) if d.value: get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1) + +def set_default_warehouses(row, default_warehouses): + for field in ['wip_warehouse', 'fg_warehouse']: + if not row.get(field): + row[field] = default_warehouses.get(field) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 2febc1e23c0..2359815813d 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -9,8 +9,10 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_sales_orders, get_warehouse_list, ) +from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, ) @@ -36,15 +38,21 @@ class TestProductionPlan(ERPNextTestCase): if not frappe.db.get_value('BOM', {'item': item}): make_bom(item = item, raw_materials = raw_materials) - def test_production_plan(self): + def test_production_plan_mr_creation(self): + "Test if MRs are created for unavailable raw materials." pln = create_production_plan(item_code='Test Production Item 1') self.assertTrue(len(pln.mr_items), 2) - pln.make_material_request() - pln = frappe.get_doc('Production Plan', pln.name) + pln.make_material_request() + pln.reload() self.assertTrue(pln.status, 'Material Requested') - material_requests = frappe.get_all('Material Request Item', fields = ['distinct parent'], - filters = {'production_plan': pln.name}, as_list=1) + + material_requests = frappe.get_all( + 'Material Request Item', + fields = ['distinct parent'], + filters = {'production_plan': pln.name}, + as_list=1 + ) self.assertTrue(len(material_requests), 2) @@ -66,27 +74,42 @@ class TestProductionPlan(ERPNextTestCase): pln.cancel() def test_production_plan_start_date(self): + "Test if Work Order has same Planned Start Date as Prod Plan." planned_date = add_to_date(date=None, days=3) - plan = create_production_plan(item_code='Test Production Item 1', planned_start_date=planned_date) + plan = create_production_plan( + item_code='Test Production Item 1', + planned_start_date=planned_date + ) plan.make_work_order() - work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'], - filters = {'production_plan': plan.name}) + work_orders = frappe.get_all( + 'Work Order', + fields = ['name', 'planned_start_date'], + filters = {'production_plan': plan.name} + ) self.assertEqual(work_orders[0].planned_start_date, planned_date) for wo in work_orders: frappe.delete_doc('Work Order', wo.name) - frappe.get_doc('Production Plan', plan.name).cancel() + plan.reload() + plan.cancel() def test_production_plan_for_existing_ordered_qty(self): + """ + - Enable 'ignore_existing_ordered_qty'. + - Test if MR Planning table pulls Raw Material Qty even if it is in stock. + """ sr1 = create_stock_reconciliation(item_code="Raw Material Item 1", target="_Test Warehouse - _TC", qty=1, rate=110) sr2 = create_stock_reconciliation(item_code="Raw Material Item 2", target="_Test Warehouse - _TC", qty=1, rate=120) - pln = create_production_plan(item_code='Test Production Item 1', ignore_existing_ordered_qty=0) + pln = create_production_plan( + item_code='Test Production Item 1', + ignore_existing_ordered_qty=1 + ) self.assertTrue(len(pln.mr_items), 1) self.assertTrue(flt(pln.mr_items[0].quantity), 1.0) @@ -95,23 +118,39 @@ class TestProductionPlan(ERPNextTestCase): pln.cancel() def test_production_plan_with_non_stock_item(self): - pln = create_production_plan(item_code='Test Production Item 1', include_non_stock_items=0) + "Test if MR Planning table includes Non Stock RM." + pln = create_production_plan( + item_code='Test Production Item 1', + include_non_stock_items=1 + ) self.assertTrue(len(pln.mr_items), 3) pln.cancel() def test_production_plan_without_multi_level(self): - pln = create_production_plan(item_code='Test Production Item 1', use_multi_level_bom=0) + "Test MR Planning table for non exploded BOM." + pln = create_production_plan( + item_code='Test Production Item 1', + use_multi_level_bom=0 + ) self.assertTrue(len(pln.mr_items), 2) pln.cancel() def test_production_plan_without_multi_level_for_existing_ordered_qty(self): + """ + - Disable 'ignore_existing_ordered_qty'. + - Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for + non exploded BOM. + """ sr1 = create_stock_reconciliation(item_code="Raw Material Item 1", target="_Test Warehouse - _TC", qty=1, rate=130) sr2 = create_stock_reconciliation(item_code="Subassembly Item 1", target="_Test Warehouse - _TC", qty=1, rate=140) - pln = create_production_plan(item_code='Test Production Item 1', - use_multi_level_bom=0, ignore_existing_ordered_qty=0) + pln = create_production_plan( + item_code='Test Production Item 1', + use_multi_level_bom=0, + ignore_existing_ordered_qty=0 + ) self.assertTrue(len(pln.mr_items), 0) sr1.cancel() @@ -119,6 +158,7 @@ class TestProductionPlan(ERPNextTestCase): pln.cancel() def test_production_plan_sales_orders(self): + "Test if previously fulfilled SO (with WO) is pulled into Prod Plan." item = 'Test Production Item 1' so = make_sales_order(item_code=item, qty=1) sales_order = so.name @@ -166,24 +206,25 @@ class TestProductionPlan(ERPNextTestCase): self.assertEqual(sales_orders, []) def test_production_plan_combine_items(self): + "Test combining FG items in Production Plan." item = 'Test Production Item 1' - so = make_sales_order(item_code=item, qty=1) + so1 = make_sales_order(item_code=item, qty=1) pln = frappe.new_doc('Production Plan') - pln.company = so.company + pln.company = so1.company pln.get_items_from = 'Sales Order' pln.append('sales_orders', { - 'sales_order': so.name, - 'sales_order_date': so.transaction_date, - 'customer': so.customer, - 'grand_total': so.grand_total + 'sales_order': so1.name, + 'sales_order_date': so1.transaction_date, + 'customer': so1.customer, + 'grand_total': so1.grand_total }) - so = make_sales_order(item_code=item, qty=2) + so2 = make_sales_order(item_code=item, qty=2) pln.append('sales_orders', { - 'sales_order': so.name, - 'sales_order_date': so.transaction_date, - 'customer': so.customer, - 'grand_total': so.grand_total + 'sales_order': so2.name, + 'sales_order_date': so2.transaction_date, + 'customer': so2.customer, + 'grand_total': so2.grand_total }) pln.combine_items = 1 pln.get_items() @@ -214,28 +255,37 @@ class TestProductionPlan(ERPNextTestCase): so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty') self.assertEqual(so_wo_qty, 0.0) - latest_plan = frappe.get_doc('Production Plan', pln.name) - latest_plan.cancel() + pln.reload() + pln.cancel() def test_pp_to_mr_customer_provided(self): - #Material Request from Production Plan for Customer Provided + " Test Material Request from Production Plan for Customer Provided Item." create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0) create_item('Production Item CUST') + for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items(): if not frappe.db.get_value('BOM', {'item': item}): make_bom(item = item, raw_materials = raw_materials) production_plan = create_production_plan(item_code = 'Production Item CUST') production_plan.make_material_request() - material_request = frappe.db.get_value('Material Request Item', {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, 'parent') + + material_request = frappe.db.get_value( + 'Material Request Item', + {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, + 'parent' + ) mr = frappe.get_doc('Material Request', material_request) + self.assertTrue(mr.material_request_type, 'Customer Provided') self.assertTrue(mr.customer, '_Test Customer') def test_production_plan_with_multi_level_bom(self): - #|Item Code | Qty | - #|Test BOM 1 | 1 | - #| Test BOM 2 | 2 | - #| Test BOM 3 | 3 | + """ + Item Code | Qty | + |Test BOM 1 | 1 | + |Test BOM 2 | 2 | + |Test BOM 3 | 3 | + """ for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]: create_item(item_code, is_stock_item=1) @@ -264,15 +314,18 @@ class TestProductionPlan(ERPNextTestCase): pln.make_work_order() #last level sub-assembly work order produce qty - to_produce_qty = frappe.db.get_value("Work Order", - {"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty") + to_produce_qty = frappe.db.get_value( + "Work Order", + {"production_plan": pln.name, "production_item": "Test BOM 3"}, + "qty" + ) self.assertEqual(to_produce_qty, 18.0) pln.cancel() frappe.delete_doc("Production Plan", pln.name) def test_get_warehouse_list_group(self): - """Check if required warehouses are returned""" + "Check if required child warehouses are returned." warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]' warehouses = set(get_warehouse_list(warehouse_json)) @@ -284,6 +337,7 @@ class TestProductionPlan(ERPNextTestCase): msg=f"Following warehouses were expected {', '.join(missing_warehouse)}") def test_get_warehouse_list_single(self): + "Check if same warehouse is returned in absence of child warehouses." warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]' warehouses = set(get_warehouse_list(warehouse_json)) @@ -292,6 +346,7 @@ class TestProductionPlan(ERPNextTestCase): self.assertEqual(warehouses, expected_warehouses) def test_get_sales_order_with_variant(self): + "Check if Template BOM is fetched in absence of Variant BOM." rm_item = create_item('PIV_RM', valuation_rate = 100) if not frappe.db.exists('Item', {"item_code": 'PIV'}): item = create_item('PIV', valuation_rate = 100) @@ -347,7 +402,216 @@ class TestProductionPlan(ERPNextTestCase): frappe.db.rollback() + def test_subassmebly_sorting(self): + "Test subassembly sorting in case of multiple items with nested BOMs." + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + prefix = "_TestLevel_" + boms = { + "Assembly": { + "SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},}, + "ChildPart6": {}, + "SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}}, + }, + "MegaDeepAssy": { + "SecretSubassy": {"SecretPart": {"VerySecret" : { "SuperSecret": {"Classified": {}}}},}, + # ^ assert that this is + # first item in subassy table + } + } + create_nested_bom(boms, prefix=prefix) + + items = [prefix + item_code for item_code in boms.keys()] + plan = create_production_plan(item_code=items[0], do_not_save=True) + plan.append("po_items", { + 'use_multi_level_bom': 1, + 'item_code': items[1], + 'bom_no': frappe.db.get_value('Item', items[1], 'default_bom'), + 'planned_qty': 1, + 'planned_start_date': now_datetime() + }) + plan.get_sub_assembly_items() + + bom_level_order = [d.bom_level for d in plan.sub_assembly_items] + self.assertEqual(bom_level_order, sorted(bom_level_order, reverse=True)) + # lowest most level of subassembly should be first + self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item) + + def test_multiple_work_order_for_production_plan_item(self): + "Test producing Prod Plan (making WO) in parts." + def create_work_order(item, pln, qty): + # Get Production Items + items_data = pln.get_production_items() + + # Update qty + items_data[(item, None, None)]["qty"] = qty + + # Create and Submit Work Order for each item in items_data + for key, item in items_data.items(): + if pln.sub_assembly_items: + item['use_multi_level_bom'] = 0 + + wo_name = pln.create_work_order(item) + wo_doc = frappe.get_doc("Work Order", wo_name) + wo_doc.update({ + 'wip_warehouse': 'Work In Progress - _TC', + 'fg_warehouse': 'Finished Goods - _TC' + }) + wo_doc.submit() + wo_list.append(wo_name) + + item = "Test Production Item 1" + raw_materials = ["Raw Material Item 1", "Raw Material Item 2"] + + # Create BOM + bom = make_bom(item=item, raw_materials=raw_materials) + + # Create Production Plan + pln = create_production_plan(item_code=bom.item, planned_qty=5) + + # All the created Work Orders + wo_list = [] + + # Create and Submit 1st Work Order for 3 qty + create_work_order(item, pln, 3) + pln.reload() + self.assertEqual(pln.po_items[0].ordered_qty, 3) + + # Create and Submit 2nd Work Order for 2 qty + create_work_order(item, pln, 2) + pln.reload() + self.assertEqual(pln.po_items[0].ordered_qty, 5) + + # Overproduction + self.assertRaises(OverProductionError, create_work_order, item=item, pln=pln, qty=2) + + # Cancel 1st Work Order + wo1 = frappe.get_doc("Work Order", wo_list[0]) + wo1.cancel() + pln.reload() + self.assertEqual(pln.po_items[0].ordered_qty, 2) + + # Cancel 2nd Work Order + wo2 = frappe.get_doc("Work Order", wo_list[1]) + wo2.cancel() + pln.reload() + self.assertEqual(pln.po_items[0].ordered_qty, 0) + + def test_production_plan_pending_qty_with_sales_order(self): + """ + Test Prod Plan impact via: SO -> Prod Plan -> WO -> SE -> SE (cancel) + """ + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_se_from_wo, + ) + + make_stock_entry(item_code="Raw Material Item 1", + target="Work In Progress - _TC", + qty=2, basic_rate=100 + ) + make_stock_entry(item_code="Raw Material Item 2", + target="Work In Progress - _TC", + qty=2, basic_rate=100 + ) + + item = 'Test Production Item 1' + so = make_sales_order(item_code=item, qty=1) + + pln = create_production_plan( + company=so.company, + get_items_from="Sales Order", + sales_order=so, + skip_getting_mr_items=True + ) + self.assertEqual(pln.po_items[0].pending_qty, 1) + + wo = make_wo_order_test_record( + item_code=item, qty=1, + company=so.company, + wip_warehouse='Work In Progress - _TC', + fg_warehouse='Finished Goods - _TC', + skip_transfer=1, + do_not_submit=True + ) + wo.production_plan = pln.name + wo.production_plan_item = pln.po_items[0].name + wo.submit() + + se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1)) + se.submit() + + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 0) + + se.cancel() + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 1) + + def test_production_plan_pending_qty_independent_items(self): + "Test Prod Plan impact if items are added independently (no from SO or MR)." + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_se_from_wo, + ) + + make_stock_entry(item_code="Raw Material Item 1", + target="Work In Progress - _TC", + qty=2, basic_rate=100 + ) + make_stock_entry(item_code="Raw Material Item 2", + target="Work In Progress - _TC", + qty=2, basic_rate=100 + ) + + pln = create_production_plan( + item_code='Test Production Item 1', + skip_getting_mr_items=True + ) + self.assertEqual(pln.po_items[0].pending_qty, 1) + + wo = make_wo_order_test_record( + item_code='Test Production Item 1', qty=1, + company=pln.company, + wip_warehouse='Work In Progress - _TC', + fg_warehouse='Finished Goods - _TC', + skip_transfer=1, + do_not_submit=True + ) + wo.production_plan = pln.name + wo.production_plan_item = pln.po_items[0].name + wo.submit() + + se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1)) + se.submit() + + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 0) + + se.cancel() + pln.reload() + self.assertEqual(pln.po_items[0].pending_qty, 1) + + def test_qty_based_status(self): + pp = frappe.new_doc("Production Plan") + pp.po_items = [ + frappe._dict(planned_qty=5, produce_qty=4) + ] + self.assertFalse(pp.all_items_completed()) + + pp.po_items = [ + frappe._dict(planned_qty=5, produce_qty=10), + frappe._dict(planned_qty=5, produce_qty=4) + ] + self.assertFalse(pp.all_items_completed()) + + def create_production_plan(**args): + """ + sales_order (obj): Sales Order Doc Object + get_items_from (str): Sales Order/Material Request + skip_getting_mr_items (bool): Whether or not to plan for new MRs + """ args = frappe._dict(args) pln = frappe.get_doc({ @@ -355,20 +619,35 @@ def create_production_plan(**args): 'company': args.company or '_Test Company', 'customer': args.customer or '_Test Customer', 'posting_date': nowdate(), - 'include_non_stock_items': args.include_non_stock_items or 1, - 'include_subcontracted_items': args.include_subcontracted_items or 1, - 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1, - 'po_items': [{ + 'include_non_stock_items': args.include_non_stock_items or 0, + 'include_subcontracted_items': args.include_subcontracted_items or 0, + 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0, + 'get_items_from': 'Sales Order' + }) + + if not args.get("sales_order"): + pln.append('po_items', { 'use_multi_level_bom': args.use_multi_level_bom or 1, 'item_code': args.item_code, 'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'), 'planned_qty': args.planned_qty or 1, 'planned_start_date': args.planned_start_date or now_datetime() - }] - }) - mr_items = get_items_for_material_requests(pln.as_dict()) - for d in mr_items: - pln.append('mr_items', d) + }) + + if args.get("get_items_from") == "Sales Order" and args.get("sales_order"): + so = args.get("sales_order") + pln.append('sales_orders', { + 'sales_order': so.name, + 'sales_order_date': so.transaction_date, + 'customer': so.customer, + 'grand_total': so.grand_total + }) + pln.get_items() + + if not args.get("skip_getting_mr_items"): + mr_items = get_items_for_material_requests(pln.as_dict()) + for d in mr_items: + pln.append('mr_items', d) if not args.do_not_save: pln.insert() diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json index 657ee35a852..45ea26c3a8a 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -102,7 +102,6 @@ }, { "columns": 1, - "fetch_from": "bom_no.bom_level", "fieldname": "bom_level", "fieldtype": "Int", "in_list_view": 1, @@ -189,7 +188,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-06-28 20:10:56.296410", + "modified": "2022-01-30 21:31:10.527559", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", @@ -198,5 +197,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index a399edda707..67c47efb5b5 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -201,6 +201,21 @@ class TestWorkOrder(ERPNextTestCase): self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), cint(bin1_on_start_production.reserved_qty_for_production)) + def test_reserved_qty_for_production_closed(self): + + wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2, + source_warehouse=self.warehouse) + item = wo1.required_items[0].item_code + bin_before = get_bin(item, self.warehouse) + bin_before.update_reserved_qty_for_production() + + make_wo_order_test_record(item="_Test FG Item", qty=2, + source_warehouse=self.warehouse) + close_work_order(wo1.name, "Closed") + + bin_after = get_bin(item, self.warehouse) + self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production) + def test_backflush_qty_for_overpduction_manufacture(self): cancel_stock_entry = [] allow_overproduction("overproduction_percentage_for_work_order", 30) @@ -703,7 +718,8 @@ class TestWorkOrder(ERPNextTestCase): wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse, company=company) - self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture') + stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture')) + self.assertRaises(frappe.ValidationError, stock_entry.save) def test_wo_completion_with_pl_bom(self): from erpnext.manufacturing.doctype.bom.test_bom import ( diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 12cd58f418b..9452a63d70b 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -333,12 +333,13 @@ "options": "fa fa-wrench" }, { - "default": "Work Order", "depends_on": "operations", + "fetch_from": "bom_no.transfer_material_against", + "fetch_if_empty": 1, "fieldname": "transfer_material_against", "fieldtype": "Select", "label": "Transfer Material Against", - "options": "Work Order\nJob Card" + "options": "\nWork Order\nJob Card" }, { "fieldname": "operations", @@ -574,7 +575,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2021-11-08 17:36:07.016300", + "modified": "2022-01-24 21:18:12.160114", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -607,6 +608,7 @@ ], "sort_field": "modified", "sort_order": "ASC", + "states": [], "title_field": "production_item", "track_changes": 1, "track_seen": 1 diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 170454c8238..e1120c723d5 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -8,6 +8,8 @@ from dateutil.relativedelta import relativedelta from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Case +from frappe.query_builder.functions import Sum from frappe.utils import ( cint, date_diff, @@ -31,6 +33,7 @@ from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life from erpnext.stock.doctype.serial_no.serial_no import ( auto_make_serial_nos, + clean_serial_no_string, get_auto_serial_nos, get_serial_nos, ) @@ -65,6 +68,7 @@ class WorkOrder(Document): self.validate_warehouse_belongs_to_company() self.calculate_operating_cost() self.validate_qty() + self.validate_transfer_against() self.validate_operation_time() self.status = self.get_status() @@ -268,7 +272,7 @@ class WorkOrder(Document): produced_qty = total_qty[0][0] if total_qty else 0 - production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item) + production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item) def before_submit(self): self.create_serial_no_batch_no() @@ -356,6 +360,7 @@ class WorkOrder(Document): frappe.delete_doc("Batch", row.name) def make_serial_nos(self, args): + self.serial_no = clean_serial_no_string(self.serial_no) serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series") if serial_no_series: self.serial_no = get_auto_serial_nos(serial_no_series, self.qty) @@ -445,7 +450,13 @@ class WorkOrder(Document): def update_ordered_qty(self): if self.production_plan and self.production_plan_item: - qty = self.qty if self.docstatus == 1 else 0 + qty = frappe.get_value("Production Plan Item", self.production_plan_item, "ordered_qty") or 0.0 + + if self.docstatus == 1: + qty += self.qty + elif self.docstatus == 2: + qty -= self.qty + frappe.db.set_value('Production Plan Item', self.production_plan_item, 'ordered_qty', qty) @@ -534,7 +545,7 @@ class WorkOrder(Document): if node.is_bom: operations.extend(_get_operations(node.name, qty=node.exploded_qty)) - bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") + bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity") operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty)) for correct_index, operation in enumerate(operations, start=1): @@ -615,7 +626,7 @@ class WorkOrder(Document): frappe.delete_doc("Job Card", d.name) def validate_production_item(self): - if frappe.db.get_value("Item", self.production_item, "has_variants"): + if frappe.get_cached_value("Item", self.production_item, "has_variants"): frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError) if self.production_item: @@ -625,6 +636,31 @@ class WorkOrder(Document): if not self.qty > 0: frappe.throw(_("Quantity to Manufacture must be greater than 0.")) + if self.production_plan and self.production_plan_item: + qty_dict = frappe.db.get_value("Production Plan Item", self.production_plan_item, ["planned_qty", "ordered_qty"], as_dict=1) + + allowance_qty =flt(frappe.db.get_single_value("Manufacturing Settings", + "overproduction_percentage_for_work_order"))/100 * qty_dict.get("planned_qty", 0) + + max_qty = qty_dict.get("planned_qty", 0) + allowance_qty - qty_dict.get("ordered_qty", 0) + + if max_qty < 1: + frappe.throw(_("Cannot produce more item for {0}") + .format(self.production_item), OverProductionError) + elif self.qty > max_qty: + frappe.throw(_("Cannot produce more than {0} items for {1}") + .format(max_qty, self.production_item), OverProductionError) + + def validate_transfer_against(self): + if not self.docstatus == 1: + # let user configure operations until they're ready to submit + return + if not self.operations: + self.transfer_material_against = "Work Order" + if not self.transfer_material_against: + frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value")) + + def validate_operation_time(self): for d in self.operations: if not d.time_in_mins > 0: @@ -1155,3 +1191,27 @@ def create_pick_list(source_name, target_doc=None, for_qty=None): doc.set_item_locations() return doc + +def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float: + """Get total reserved quantity for any item in specified warehouse""" + wo = frappe.qb.DocType("Work Order") + wo_item = frappe.qb.DocType("Work Order Item") + + return ( + frappe.qb + .from_(wo) + .from_(wo_item) + .select(Sum(Case() + .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) + .else_(wo_item.required_qty - wo_item.consumed_qty)) + ) + .where( + (wo_item.item_code == item_code) + & (wo_item.parent == wo.name) + & (wo.docstatus == 1) + & (wo_item.source_warehouse == warehouse) + & (wo.status.notin(["Stopped", "Completed", "Closed"])) + & ((wo_item.required_qty > wo_item.transferred_qty) + | (wo_item.required_qty > wo_item.consumed_qty)) + ) + ).run()[0][0] or 0.0 diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index 25de2e03797..19a80ab4076 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -26,8 +26,7 @@ def get_exploded_items(bom, data, indent=0, qty=1): 'item_code': item.item_code, 'item_name': item.item_name, 'indent': indent, - 'bom_level': (frappe.get_cached_value("BOM", item.bom_no, "bom_level") - if item.bom_no else ""), + 'bom_level': indent, 'bom': item.bom_no, 'qty': item.qty * qty, 'uom': item.uom, @@ -73,7 +72,7 @@ def get_columns(): }, { "label": "BOM Level", - "fieldtype": "Data", + "fieldtype": "Int", "fieldname": "bom_level", "width": 100 }, diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py index 090a3e74fc8..26933523246 100644 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py +++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py @@ -89,10 +89,10 @@ def get_bom_stock(filters): GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1) def get_manufacturer_records(): - details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"]) + details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "item_code"]) manufacture_details = frappe._dict() for detail in details: - dic = manufacture_details.setdefault(detail.get('parent'), {}) + dic = manufacture_details.setdefault(detail.get('item_code'), {}) dic.setdefault('manufacturer', []).append(detail.get('manufacturer')) dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no')) diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js index 97e7e0a7d20..72eed5e0d7c 100644 --- a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js @@ -17,14 +17,12 @@ frappe.query_reports["Cost of Poor Quality Report"] = { fieldname:"from_date", fieldtype: "Datetime", default: frappe.datetime.convert_to_system_tz(frappe.datetime.add_months(frappe.datetime.now_datetime(), -1)), - reqd: 1 }, { label: __("To Date"), fieldname:"to_date", fieldtype: "Datetime", default: frappe.datetime.now_datetime(), - reqd: 1, }, { label: __("Job Card"), diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py index 77418235b07..88b21170e8b 100644 --- a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py @@ -3,46 +3,65 @@ import frappe from frappe import _ -from frappe.utils import flt def execute(filters=None): - columns, data = [], [] + return get_columns(filters), get_data(filters) - columns = get_columns(filters) - data = get_data(filters) - - return columns, data def get_data(report_filters): data = [] operations = frappe.get_all("Operation", filters = {"is_corrective_operation": 1}) if operations: - operations = [d.name for d in operations] - fields = ["production_item as item_code", "item_name", "work_order", "operation", - "workstation", "total_time_in_mins", "name", "hour_rate", "serial_no", "batch_no"] + if report_filters.get('operation'): + operations = [report_filters.get('operation')] + else: + operations = [d.name for d in operations] - filters = get_filters(report_filters, operations) + job_card = frappe.qb.DocType("Job Card") - job_cards = frappe.get_all("Job Card", fields = fields, - filters = filters) + operating_cost = ((job_card.hour_rate) * (job_card.total_time_in_mins) / 60.0).as_('operating_cost') + item_code = (job_card.production_item).as_('item_code') - for row in job_cards: - row.operating_cost = flt(row.hour_rate) * (flt(row.total_time_in_mins) / 60.0) - data.append(row) + query = (frappe.qb + .from_(job_card) + .select(job_card.name, job_card.work_order, item_code, job_card.item_name, + job_card.operation, job_card.serial_no, job_card.batch_no, + job_card.workstation, job_card.total_time_in_mins, job_card.hour_rate, + operating_cost) + .where( + (job_card.docstatus == 1) + & (job_card.is_corrective_job_card == 1)) + .groupby(job_card.name) + ) + query = append_filters(query, report_filters, operations, job_card) + data = query.run(as_dict=True) return data -def get_filters(report_filters, operations): - filters = {"docstatus": 1, "operation": ("in", operations), "is_corrective_job_card": 1} - for field in ["name", "work_order", "operation", "workstation", "company", "serial_no", "batch_no", "production_item"]: - if report_filters.get(field): - if field != 'serial_no': - filters[field] = report_filters.get(field) - else: - filters[field] = ('like', '% {} %'.format(report_filters.get(field))) +def append_filters(query, report_filters, operations, job_card): + """Append optional filters to query builder. """ - return filters + for field in ("name", "work_order", "operation", "workstation", + "company", "serial_no", "batch_no", "production_item"): + if report_filters.get(field): + if field == 'serial_no': + query = query.where(job_card[field].like('%{}%'.format(report_filters.get(field)))) + elif field == 'operation': + query = query.where(job_card[field].isin(operations)) + else: + query = query.where(job_card[field] == report_filters.get(field)) + + if report_filters.get('from_date') or report_filters.get('to_date'): + job_card_time_log = frappe.qb.DocType("Job Card Time Log") + + query = query.join(job_card_time_log).on(job_card.name == job_card_time_log.parent) + if report_filters.get('from_date'): + query = query.where(job_card_time_log.from_time >= report_filters.get('from_date')) + if report_filters.get('to_date'): + query = query.where(job_card_time_log.to_time <= report_filters.get('to_date')) + + return query def get_columns(filters): return [ diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py index 55b1a3f2f9a..aaa231466fd 100644 --- a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py @@ -48,7 +48,7 @@ def get_production_plan_item_details(filters, data, order_details): "qty": row.planned_qty, "document_type": "Work Order", "document_name": work_order or "", - "bom_level": frappe.get_cached_value("BOM", row.bom_no, "bom_level"), + "bom_level": 0, "produced_qty": order_details.get((work_order, row.item_code), {}).get("produced_qty", 0), "pending_qty": flt(row.planned_qty) - flt(order_details.get((work_order, row.item_code), {}).get("produced_qty", 0)) }) diff --git a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py index 8368db6374b..e1e7225e057 100644 --- a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py +++ b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py @@ -172,10 +172,15 @@ class ProductionPlanReport(object): self.purchase_details = {} - for d in frappe.get_all("Purchase Order Item", + purchased_items = frappe.get_all("Purchase Order Item", fields=["item_code", "min(schedule_date) as arrival_date", "qty as arrival_qty", "warehouse"], - filters = {"item_code": ("in", self.item_codes), "warehouse": ("in", self.warehouses)}, - group_by = "item_code, warehouse"): + filters={ + "item_code": ("in", self.item_codes), + "warehouse": ("in", self.warehouses), + "docstatus": 1, + }, + group_by = "item_code, warehouse") + for d in purchased_items: key = (d.item_code, d.warehouse) if key not in self.purchase_details: self.purchase_details.setdefault(key, d) diff --git a/erpnext/manufacturing/report/test_reports.py b/erpnext/manufacturing/report/test_reports.py index 1de472659eb..e436fdca646 100644 --- a/erpnext/manufacturing/report/test_reports.py +++ b/erpnext/manufacturing/report/test_reports.py @@ -18,7 +18,7 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ ("BOM Operations Time", {}), ("BOM Stock Calculated", {"bom": frappe.get_last_doc("BOM").name, "qty_to_make": 2}), ("BOM Stock Report", {"bom": frappe.get_last_doc("BOM").name, "qty_to_produce": 2}), - ("Cost of Poor Quality Report", {}), + ("Cost of Poor Quality Report", {"item": "_Test Item", "serial_no": "00"}), ("Downtime Analysis", {}), ( "Exponential Smoothing Forecasting", @@ -55,10 +55,11 @@ class TestManufacturingReports(unittest.TestCase): def test_execute_all_manufacturing_reports(self): """Test that all script report in manufacturing modules are executable with supported filters""" for report, filter in REPORT_FILTER_TEST_CASES: - execute_script_report( - report_name=report, - module="Manufacturing", - filters=filter, - default_filters=DEFAULT_FILTERS, - optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, - ) + with self.subTest(report=report): + execute_script_report( + report_name=report, + module="Manufacturing", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) diff --git a/erpnext/modules.txt b/erpnext/modules.txt index e62e2bcfab0..8c79ee5c9a8 100644 --- a/erpnext/modules.txt +++ b/erpnext/modules.txt @@ -9,7 +9,6 @@ Manufacturing Stock Support Utilities -Shopping Cart Assets Portal Maintenance @@ -21,4 +20,6 @@ Quality Management Communication Loan Management Payroll -Telephony \ No newline at end of file +Telephony +Bulk Transaction +E-commerce diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 83bab06b71e..7560f2f599e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -1,4 +1,6 @@ +[pre_model_sync] erpnext.patches.v12_0.update_is_cancelled_field +erpnext.patches.v13_0.add_bin_unique_constraint erpnext.patches.v11_0.rename_production_order_to_work_order erpnext.patches.v11_0.refactor_naming_series erpnext.patches.v11_0.refactor_autoname_naming @@ -202,6 +204,7 @@ execute:frappe.delete_doc_if_exists("DocType", "Bank Reconciliation") erpnext.patches.v13_0.move_doctype_reports_and_notification_from_hr_to_payroll #22-06-2020 erpnext.patches.v13_0.move_payroll_setting_separately_from_hr_settings #22-06-2020 erpnext.patches.v12_0.create_itc_reversal_custom_fields +execute:frappe.reload_doc("regional", "doctype", "e_invoice_settings") erpnext.patches.v13_0.check_is_income_tax_component #22-06-2020 erpnext.patches.v13_0.loyalty_points_entry_for_pos_invoice #22-07-2020 erpnext.patches.v12_0.add_taxjar_integration_field @@ -222,11 +225,11 @@ erpnext.patches.v13_0.set_youtube_video_id erpnext.patches.v13_0.set_app_name erpnext.patches.v13_0.print_uom_after_quantity_patch erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account +erpnext.patches.v12_0.setup_einvoice_fields #2020-12-02 erpnext.patches.v13_0.updates_for_multi_currency_payroll erpnext.patches.v13_0.update_reason_for_resignation_in_employee execute:frappe.delete_doc("Report", "Quoted Item Comparison") erpnext.patches.v13_0.update_member_email_address -erpnext.patches.v13_0.updates_for_multi_currency_payroll erpnext.patches.v13_0.create_leave_policy_assignment_based_on_employee_current_leave_policy erpnext.patches.v13_0.update_pos_closing_entry_in_merge_log erpnext.patches.v13_0.add_po_to_global_search @@ -242,6 +245,8 @@ erpnext.patches.v12_0.add_state_code_for_ladakh erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes erpnext.patches.v13_0.update_vehicle_no_reqd_condition +erpnext.patches.v12_0.add_einvoice_status_field #2021-03-17 +erpnext.patches.v12_0.add_einvoice_summary_report_permissions erpnext.patches.v13_0.setup_fields_for_80g_certificate_and_donation erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae @@ -252,9 +257,10 @@ erpnext.patches.v12_0.add_gst_category_in_delivery_note erpnext.patches.v12_0.purchase_receipt_status erpnext.patches.v13_0.fix_non_unique_represents_company erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing -erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 +erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022 erpnext.patches.v13_0.update_shipment_status erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting +erpnext.patches.v12_0.add_ewaybill_validity_field erpnext.patches.v13_0.germany_make_custom_fields erpnext.patches.v13_0.germany_fill_debtor_creditor_number erpnext.patches.v13_0.set_pos_closing_as_failed @@ -266,12 +272,11 @@ erpnext.patches.v13_0.set_training_event_attendance erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice erpnext.patches.v13_0.rename_issue_status_hold_to_on_hold erpnext.patches.v13_0.update_response_by_variance -erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice erpnext.patches.v13_0.update_job_card_details -erpnext.patches.v13_0.update_level_in_bom #1234sswef erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry erpnext.patches.v13_0.update_subscription_status_in_memberships erpnext.patches.v13_0.update_amt_in_work_order_required_items +erpnext.patches.v12_0.show_einvoice_irn_cancelled_field erpnext.patches.v13_0.delete_orphaned_tables erpnext.patches.v13_0.update_export_type_for_gst #2021-08-16 erpnext.patches.v13_0.update_tds_check_field #3 @@ -282,15 +287,15 @@ erpnext.patches.v13_0.remove_bad_selling_defaults erpnext.patches.v13_0.trim_whitespace_from_serial_nos # 16-01-2022 erpnext.patches.v13_0.migrate_stripe_api erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries -erpnext.patches.v13_0.einvoicing_deprecation_warning execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings") execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category") -erpnext.patches.v14_0.delete_einvoicing_doctypes erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021 erpnext.patches.v13_0.set_operation_time_based_on_operating_cost erpnext.patches.v13_0.create_gst_payment_entry_fields #27-11-2021 -erpnext.patches.v14_0.delete_shopify_doctypes erpnext.patches.v13_0.fix_invoice_statuses +erpnext.patches.v13_0.create_website_items #30-09-2021 +erpnext.patches.v13_0.populate_e_commerce_settings +erpnext.patches.v13_0.make_homepage_products_website_items erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item erpnext.patches.v13_0.update_dates_in_tax_withholding_category erpnext.patches.v14_0.update_opportunity_currency_fields @@ -312,24 +317,41 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory erpnext.patches.v14_0.delete_healthcare_doctypes erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.create_pan_field_for_india #2 -erpnext.patches.v14_0.delete_hub_doctypes +erpnext.patches.v13_0.fetch_thumbnail_in_website_items erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit erpnext.patches.v13_0.create_ksa_vat_custom_fields # 07-01-2022 -erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents erpnext.patches.v14_0.migrate_crm_settings erpnext.patches.v13_0.rename_ksa_qr_field erpnext.patches.v13_0.wipe_serial_no_field_for_0_qty erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021 -erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template erpnext.patches.v13_0.update_tax_category_for_rcm execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') erpnext.patches.v14_0.set_payroll_cost_centers erpnext.patches.v13_0.agriculture_deprecation_warning -erpnext.patches.v14_0.delete_agriculture_doctypes erpnext.patches.v13_0.hospitality_deprecation_warning -erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022 -erpnext.patches.v13_0.update_exchange_rate_settings -erpnext.patches.v14_0.rearrange_company_fields -erpnext.patches.v14_0.update_leave_notification_template erpnext.patches.v13_0.update_asset_quantity_field erpnext.patches.v13_0.delete_bank_reconciliation_detail +erpnext.patches.v13_0.enable_provisional_accounting + +[post_model_sync] +erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents +erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template +erpnext.patches.v14_0.delete_shopify_doctypes +erpnext.patches.v14_0.delete_hub_doctypes +erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022 +erpnext.patches.v14_0.delete_agriculture_doctypes +erpnext.patches.v14_0.rearrange_company_fields +erpnext.patches.v14_0.update_leave_notification_template +erpnext.patches.v14_0.restore_einvoice_fields +erpnext.patches.v13_0.update_sane_transfer_against +erpnext.patches.v12_0.add_company_link_to_einvoice_settings +erpnext.patches.v14_0.migrate_cost_center_allocations +erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template +erpnext.patches.v13_0.shopping_cart_to_ecommerce +erpnext.patches.v13_0.update_disbursement_account +erpnext.patches.v13_0.update_reserved_qty_closed_wo +erpnext.patches.v13_0.update_exchange_rate_settings +erpnext.patches.v14_0.delete_amazon_mws_doctype +erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr +erpnext.patches.v13_0.update_accounts_in_loan_docs +erpnext.patches.v14_0.update_batch_valuation_flag diff --git a/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py new file mode 100644 index 00000000000..e498b673fbf --- /dev/null +++ b/erpnext/patches/v12_0/add_company_link_to_einvoice_settings.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals + +import frappe + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company or not frappe.db.count('E Invoice User'): + return + + frappe.reload_doc("regional", "doctype", "e_invoice_user") + for creds in frappe.db.get_all('E Invoice User', fields=['name', 'gstin']): + company_name = frappe.db.sql(""" + select dl.link_name from `tabAddress` a, `tabDynamic Link` dl + where a.gstin = %s and dl.parent = a.name and dl.link_doctype = 'Company' + """, (creds.get('gstin'))) + if company_name and len(company_name) > 0: + frappe.db.set_value('E Invoice User', creds.get('name'), 'company', company_name[0][0]) diff --git a/erpnext/patches/v12_0/add_einvoice_status_field.py b/erpnext/patches/v12_0/add_einvoice_status_field.py new file mode 100644 index 00000000000..aeff9ca8413 --- /dev/null +++ b/erpnext/patches/v12_0/add_einvoice_status_field.py @@ -0,0 +1,72 @@ +from __future__ import unicode_literals + +import json + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + # move hidden einvoice fields to a different section + custom_fields = { + 'Sales Invoice': [ + dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type', + print_hide=1, hidden=1), + + dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section', + no_copy=1, print_hide=1), + + dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), + + dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date', + no_copy=1, print_hide=1), + + dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image', + options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON', + hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1) + ] + } + create_custom_fields(custom_fields, update=True) + + if frappe.db.exists('E Invoice Settings') and frappe.db.get_single_value('E Invoice Settings', 'enable'): + frappe.db.sql(''' + UPDATE `tabSales Invoice` SET einvoice_status = 'Pending' + WHERE + posting_date >= '2021-04-01' + AND ifnull(irn, '') = '' + AND ifnull(`billing_address_gstin`, '') != ifnull(`company_gstin`, '') + AND ifnull(gst_category, '') in ('Registered Regular', 'SEZ', 'Overseas', 'Deemed Export') + ''') + + # set appropriate statuses + frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Generated' + WHERE ifnull(irn, '') != '' AND ifnull(irn_cancelled, 0) = 0''') + + frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Cancelled' + WHERE ifnull(irn_cancelled, 0) = 1''') + + # set correct acknowledgement in e-invoices + einvoices = frappe.get_all('Sales Invoice', {'irn': ['is', 'set']}, ['name', 'signed_einvoice']) + + if einvoices: + for inv in einvoices: + signed_einvoice = inv.get('signed_einvoice') + if signed_einvoice: + signed_einvoice = json.loads(signed_einvoice) + frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_no', signed_einvoice.get('AckNo'), update_modified=False) + frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_date', signed_einvoice.get('AckDt'), update_modified=False) diff --git a/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py new file mode 100644 index 00000000000..e837786138f --- /dev/null +++ b/erpnext/patches/v12_0/add_einvoice_summary_report_permissions.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals + +import frappe + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + if frappe.db.exists('Report', 'E-Invoice Summary') and \ + not frappe.db.get_value('Custom Role', dict(report='E-Invoice Summary')): + frappe.get_doc(dict( + doctype='Custom Role', + report='E-Invoice Summary', + roles= [ + dict(role='Accounts User'), + dict(role='Accounts Manager') + ] + )).insert() diff --git a/erpnext/patches/v12_0/add_ewaybill_validity_field.py b/erpnext/patches/v12_0/add_ewaybill_validity_field.py new file mode 100644 index 00000000000..247140d21d0 --- /dev/null +++ b/erpnext/patches/v12_0/add_ewaybill_validity_field.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = { + 'Sales Invoice': [ + dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1, + depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill') + ] + } + create_custom_fields(custom_fields, update=True) diff --git a/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py b/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py index 9b083cafb3a..8dec9ff381f 100644 --- a/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py +++ b/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py @@ -9,6 +9,8 @@ def execute(): FROM `tabBin`""",as_dict=1) for entry in bin_details: + if not (entry.item_code and entry.warehouse): + continue update_bin_qty(entry.get("item_code"), entry.get("warehouse"), { "indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse")) }) diff --git a/erpnext/patches/v12_0/rename_mws_settings_fields.py b/erpnext/patches/v12_0/rename_mws_settings_fields.py deleted file mode 100644 index d5bf38d204d..00000000000 --- a/erpnext/patches/v12_0/rename_mws_settings_fields.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) 2020, Frappe and Contributors -# License: GNU General Public License v3. See license.txt - -import frappe - - -def execute(): - count = frappe.db.sql("SELECT COUNT(*) FROM `tabSingles` WHERE doctype='Amazon MWS Settings' AND field='enable_sync';")[0][0] - if count == 0: - frappe.db.sql("UPDATE `tabSingles` SET field='enable_sync' WHERE doctype='Amazon MWS Settings' AND field='enable_synch';") - - frappe.reload_doc("ERPNext Integrations", "doctype", "Amazon MWS Settings") diff --git a/erpnext/patches/v12_0/setup_einvoice_fields.py b/erpnext/patches/v12_0/setup_einvoice_fields.py new file mode 100644 index 00000000000..c17666add18 --- /dev/null +++ b/erpnext/patches/v12_0/setup_einvoice_fields.py @@ -0,0 +1,59 @@ +from __future__ import unicode_literals + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +from erpnext.regional.india.setup import add_permissions, add_print_formats + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + frappe.reload_doc("custom", "doctype", "custom_field") + frappe.reload_doc("regional", "doctype", "e_invoice_settings") + custom_fields = { + 'Sales Invoice': [ + dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1, + depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'), + + dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='irn', no_copy=1, print_hide=1), + + dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), + + dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='signed_einvoice', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='signed_qr_code', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, no_copy=1, print_hide=1, read_only=1) + ] + } + create_custom_fields(custom_fields, update=True) + add_permissions() + add_print_formats() + + einvoice_cond = 'in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category)' + t = { + 'mode_of_transport': [{'default': None}], + 'distance': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.transporter'}], + 'gst_vehicle_type': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}], + 'lr_date': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}], + 'lr_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}], + 'vehicle_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}], + 'ewaybill': [ + {'read_only_depends_on': 'eval:doc.irn && doc.ewaybill'}, + {'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)'} + ] + } + + for field, conditions in t.items(): + for c in conditions: + [(prop, value)] = c.items() + frappe.db.set_value('Custom Field', { 'fieldname': field }, prop, value) diff --git a/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py b/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py new file mode 100644 index 00000000000..3f90a03020f --- /dev/null +++ b/erpnext/patches/v12_0/show_einvoice_irn_cancelled_field.py @@ -0,0 +1,14 @@ +from __future__ import unicode_literals + +import frappe + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + irn_cancelled_field = frappe.db.exists('Custom Field', {'dt': 'Sales Invoice', 'fieldname': 'irn_cancelled'}) + if irn_cancelled_field: + frappe.db.set_value('Custom Field', irn_cancelled_field, 'depends_on', 'eval: doc.irn') + frappe.db.set_value('Custom Field', irn_cancelled_field, 'read_only', 0) diff --git a/erpnext/patches/v12_0/update_is_cancelled_field.py b/erpnext/patches/v12_0/update_is_cancelled_field.py index df7875079bc..04010348742 100644 --- a/erpnext/patches/v12_0/update_is_cancelled_field.py +++ b/erpnext/patches/v12_0/update_is_cancelled_field.py @@ -2,14 +2,28 @@ import frappe def execute(): - try: - frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')") - frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')") + #handle type casting for is_cancelled field + module_doctypes = ( + ('stock', 'Stock Ledger Entry'), + ('stock', 'Serial No'), + ('accounts', 'GL Entry') + ) - frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 1 where is_cancelled = 'Yes'") - frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 1 where is_cancelled = 'Yes'") + for module, doctype in module_doctypes: + if (not frappe.db.has_column(doctype, "is_cancelled") + or frappe.db.get_column_type(doctype, "is_cancelled").lower() == "int(1)" + ): + continue - frappe.reload_doc("stock", "doctype", "stock_ledger_entry") - frappe.reload_doc("stock", "doctype", "serial_no") - except Exception: - pass + frappe.db.sql(""" + UPDATE `tab{doctype}` + SET is_cancelled = 0 + where is_cancelled in ('', NULL, 'No')""" + .format(doctype=doctype)) + frappe.db.sql(""" + UPDATE `tab{doctype}` + SET is_cancelled = 1 + where is_cancelled = 'Yes'""" + .format(doctype=doctype)) + + frappe.reload_doc(module, "doctype", frappe.scrub(doctype)) diff --git a/erpnext/patches/v13_0/add_bin_unique_constraint.py b/erpnext/patches/v13_0/add_bin_unique_constraint.py new file mode 100644 index 00000000000..57fbaae9d8d --- /dev/null +++ b/erpnext/patches/v13_0/add_bin_unique_constraint.py @@ -0,0 +1,63 @@ +import frappe + +from erpnext.stock.stock_balance import ( + get_balance_qty_from_sle, + get_indented_qty, + get_ordered_qty, + get_planned_qty, + get_reserved_qty, +) +from erpnext.stock.utils import get_bin + + +def execute(): + delete_broken_bins() + delete_and_patch_duplicate_bins() + +def delete_broken_bins(): + # delete useless bins + frappe.db.sql("delete from `tabBin` where item_code is null or warehouse is null") + +def delete_and_patch_duplicate_bins(): + + duplicate_bins = frappe.db.sql(""" + SELECT + item_code, warehouse, count(*) as bin_count + FROM + tabBin + GROUP BY + item_code, warehouse + HAVING + bin_count > 1 + """, as_dict=1) + + for duplicate_bin in duplicate_bins: + item_code = duplicate_bin.item_code + warehouse = duplicate_bin.warehouse + existing_bins = frappe.get_list("Bin", + filters={ + "item_code": item_code, + "warehouse": warehouse + }, + fields=["name"], + order_by="creation",) + + # keep last one + existing_bins.pop() + + for broken_bin in existing_bins: + frappe.delete_doc("Bin", broken_bin.name) + + qty_dict = { + "reserved_qty": get_reserved_qty(item_code, warehouse), + "indented_qty": get_indented_qty(item_code, warehouse), + "ordered_qty": get_ordered_qty(item_code, warehouse), + "planned_qty": get_planned_qty(item_code, warehouse), + "actual_qty": get_balance_qty_from_sle(item_code, warehouse) + } + + bin = get_bin(item_code, warehouse) + bin.update(qty_dict) + bin.update_reserved_qty_for_production() + bin.update_reserved_qty_for_sub_contracting() + bin.db_update() diff --git a/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py b/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py new file mode 100644 index 00000000000..d3ee3f8276c --- /dev/null +++ b/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py @@ -0,0 +1,57 @@ +import json +from typing import List, Union + +import frappe + +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + + +def execute(): + """ + Convert all Item links to Website Item link values in + exisitng 'Item Card Group' Web Page Block data. + """ + frappe.reload_doc("e_commerce", "web_template", "item_card_group") + + blocks = frappe.db.get_all( + "Web Page Block", + filters={"web_template": "Item Card Group"}, + fields=["parent", "web_template_values", "name"] + ) + + fields = generate_fields_to_edit() + + for block in blocks: + web_template_value = json.loads(block.get('web_template_values')) + + for field in fields: + item = web_template_value.get(field) + if not item: + continue + + if frappe.db.exists("Website Item", {"item_code": item}): + website_item = frappe.db.get_value("Website Item", {"item_code": item}) + else: + website_item = make_new_website_item(item) + + if website_item: + web_template_value[field] = website_item + + frappe.db.set_value("Web Page Block", block.name, "web_template_values", json.dumps(web_template_value)) + +def generate_fields_to_edit() -> List: + fields = [] + for i in range(1, 13): + fields.append(f"card_{i}_item") # fields like 'card_1_item', etc. + + return fields + +def make_new_website_item(item: str) -> Union[str, None]: + try: + doc = frappe.get_doc("Item", item) + web_item = make_website_item(doc) # returns [website_item.name, item_name] + return web_item[0] + except Exception: + title = f"{item}: Error while converting to Website Item " + frappe.log_error(title + "for Item Card Group Template" + "\n\n" + frappe.get_traceback(), title=title) + return None diff --git a/erpnext/patches/v13_0/create_website_items.py b/erpnext/patches/v13_0/create_website_items.py new file mode 100644 index 00000000000..da162a3ab11 --- /dev/null +++ b/erpnext/patches/v13_0/create_website_items.py @@ -0,0 +1,72 @@ +import frappe + +from erpnext.e_commerce.doctype.website_item.website_item import make_website_item + + +def execute(): + frappe.reload_doc("e_commerce", "doctype", "website_item") + frappe.reload_doc("e_commerce", "doctype", "website_item_tabbed_section") + frappe.reload_doc("e_commerce", "doctype", "website_offer") + frappe.reload_doc("e_commerce", "doctype", "recommended_items") + frappe.reload_doc("e_commerce", "doctype", "e_commerce_settings") + frappe.reload_doc("stock", "doctype", "item") + + item_fields = ["item_code", "item_name", "item_group", "stock_uom", "brand", "image", + "has_variants", "variant_of", "description", "weightage"] + web_fields_to_map = ["route", "slideshow", "website_image_alt", + "website_warehouse", "web_long_description", "website_content", "thumbnail"] + + # get all valid columns (fields) from Item master DB schema + item_table_fields = frappe.db.sql("desc `tabItem`", as_dict=1) # nosemgrep + item_table_fields = [d.get('Field') for d in item_table_fields] + + # prepare fields to query from Item, check if the web field exists in Item master + web_query_fields = [] + for web_field in web_fields_to_map: + if web_field in item_table_fields: + web_query_fields.append(web_field) + item_fields.append(web_field) + + # check if the filter fields exist in Item master + or_filters = {} + for field in ["show_in_website", "show_variant_in_website"]: + if field in item_table_fields: + or_filters[field] = 1 + + if not web_query_fields or not or_filters: + # web fields to map are not present in Item master schema + # most likely a fresh installation that doesnt need this patch + return + + items = frappe.db.get_all( + "Item", + fields=item_fields, + or_filters=or_filters + ) + total_count = len(items) + + for count, item in enumerate(items, start=1): + if frappe.db.exists("Website Item", {"item_code": item.item_code}): + continue + + # make new website item from item (publish item) + website_item = make_website_item(item, save=False) + website_item.ranking = item.get("weightage") + + for field in web_fields_to_map: + website_item.update({field: item.get(field)}) + + website_item.save() + + # move Website Item Group & Website Specification table to Website Item + for doctype in ("Website Item Group", "Item Website Specification"): + frappe.db.set_value( + doctype, + {"parenttype": "Item", "parent": item.item_code}, # filters + {"parenttype": "Website Item", "parent": website_item.name} # value dict + ) + + if count % 20 == 0: # commit after every 20 items + frappe.db.commit() + + frappe.utils.update_progress_bar('Creating Website Items', count, total_count) diff --git a/erpnext/patches/v13_0/delete_old_sales_reports.py b/erpnext/patches/v13_0/delete_old_sales_reports.py index c597fe86457..e6eba0a6085 100644 --- a/erpnext/patches/v13_0/delete_old_sales_reports.py +++ b/erpnext/patches/v13_0/delete_old_sales_reports.py @@ -12,6 +12,7 @@ def execute(): for report in reports_to_delete: if frappe.db.exists("Report", report): + delete_links_from_desktop_icons(report) delete_auto_email_reports(report) check_and_delete_linked_reports(report) @@ -22,3 +23,9 @@ def delete_auto_email_reports(report): auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"]) for auto_email_report in auto_email_reports: frappe.delete_doc("Auto Email Report", auto_email_report[0]) + +def delete_links_from_desktop_icons(report): + """ Check for one or multiple Desktop Icons and delete """ + desktop_icons = frappe.db.get_values("Desktop Icon", {"_report": report}, ["name"]) + for desktop_icon in desktop_icons: + frappe.delete_doc("Desktop Icon", desktop_icon[0]) \ No newline at end of file diff --git a/erpnext/patches/v13_0/einvoicing_deprecation_warning.py b/erpnext/patches/v13_0/einvoicing_deprecation_warning.py deleted file mode 100644 index e123a55f5ab..00000000000 --- a/erpnext/patches/v13_0/einvoicing_deprecation_warning.py +++ /dev/null @@ -1,9 +0,0 @@ -import click - - -def execute(): - click.secho( - "Indian E-Invoicing integration is moved to a separate app and will be removed from ERPNext in version-14.\n" - "Please install the app to continue using the integration: https://github.com/frappe/erpnext_gst_compliance", - fg="yellow", - ) diff --git a/erpnext/patches/v13_0/enable_provisional_accounting.py b/erpnext/patches/v13_0/enable_provisional_accounting.py new file mode 100644 index 00000000000..8e222700f86 --- /dev/null +++ b/erpnext/patches/v13_0/enable_provisional_accounting.py @@ -0,0 +1,19 @@ +import frappe + + +def execute(): + frappe.reload_doc("setup", "doctype", "company") + + company = frappe.qb.DocType("Company") + + frappe.qb.update( + company + ).set( + company.enable_provisional_accounting_for_non_stock_items, company.enable_perpetual_inventory_for_non_stock_items + ).set( + company.default_provisional_account, company.service_received_but_not_billed + ).where( + company.enable_perpetual_inventory_for_non_stock_items == 1 + ).where( + company.service_received_but_not_billed.isnotnull() + ).run() \ No newline at end of file diff --git a/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py new file mode 100644 index 00000000000..32ad542cf88 --- /dev/null +++ b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + if frappe.db.has_column("Item", "thumbnail"): + website_item = frappe.qb.DocType("Website Item").as_("wi") + item = frappe.qb.DocType("Item") + + frappe.qb.update(website_item).inner_join(item).on( + website_item.item_code == item.item_code + ).set( + website_item.thumbnail, item.thumbnail + ).where( + website_item.website_image.notnull() + & website_item.thumbnail.isnull() + ).run() diff --git a/erpnext/patches/v13_0/make_homepage_products_website_items.py b/erpnext/patches/v13_0/make_homepage_products_website_items.py new file mode 100644 index 00000000000..7a7ddba12d6 --- /dev/null +++ b/erpnext/patches/v13_0/make_homepage_products_website_items.py @@ -0,0 +1,15 @@ +import frappe + + +def execute(): + homepage = frappe.get_doc("Homepage") + + for row in homepage.products: + web_item = frappe.db.get_value("Website Item", {"item_code": row.item_code}, "name") + if not web_item: + continue + + row.item_code = web_item + + homepage.flags.ignore_mandatory = True + homepage.save() \ No newline at end of file diff --git a/erpnext/patches/v13_0/make_non_standard_user_type.py b/erpnext/patches/v13_0/make_non_standard_user_type.py index a7bdf93332a..ff241a3fd93 100644 --- a/erpnext/patches/v13_0/make_non_standard_user_type.py +++ b/erpnext/patches/v13_0/make_non_standard_user_type.py @@ -10,8 +10,15 @@ from erpnext.setup.install import add_non_standard_user_types def execute(): doctype_dict = { 'projects': ['Timesheet'], - 'payroll': ['Salary Slip', 'Employee Tax Exemption Declaration', 'Employee Tax Exemption Proof Submission'], - 'hr': ['Employee', 'Expense Claim', 'Leave Application', 'Attendance Request', 'Compensatory Leave Request'] + 'payroll': [ + 'Salary Slip', 'Employee Tax Exemption Declaration', 'Employee Tax Exemption Proof Submission', + 'Employee Benefit Application', 'Employee Benefit Claim' + ], + 'hr': [ + 'Employee', 'Expense Claim', 'Leave Application', 'Attendance Request', 'Compensatory Leave Request', + 'Holiday List', 'Employee Advance', 'Training Program', 'Training Feedback', + 'Shift Request', 'Employee Grievance', 'Employee Referral', 'Travel Request' + ] } for module, doctypes in doctype_dict.items(): diff --git a/erpnext/patches/v13_0/populate_e_commerce_settings.py b/erpnext/patches/v13_0/populate_e_commerce_settings.py new file mode 100644 index 00000000000..8f9ee512fde --- /dev/null +++ b/erpnext/patches/v13_0/populate_e_commerce_settings.py @@ -0,0 +1,62 @@ +import frappe +from frappe.utils import cint + + +def execute(): + frappe.reload_doc("e_commerce", "doctype", "e_commerce_settings") + frappe.reload_doc("portal", "doctype", "website_filter_field") + frappe.reload_doc("portal", "doctype", "website_attribute") + + products_settings_fields = [ + "hide_variants", "products_per_page", + "enable_attribute_filters", "enable_field_filters" + ] + + shopping_cart_settings_fields = [ + "enabled", "show_attachments", "show_price", + "show_stock_availability", "enable_variants", "show_contact_us_button", + "show_quantity_in_website", "show_apply_coupon_code_in_website", + "allow_items_not_in_stock", "company", "price_list", "default_customer_group", + "quotation_series", "enable_checkout", "payment_success_url", + "payment_gateway_account", "save_quotations_as_draft" + ] + + settings = frappe.get_doc("E Commerce Settings") + + def map_into_e_commerce_settings(doctype, fields): + singles = frappe.qb.DocType("Singles") + query = ( + frappe.qb.from_(singles) + .select( + singles["field"], singles.value + ).where( + (singles.doctype == doctype) + & (singles["field"].isin(fields)) + ) + ) + data = query.run(as_dict=True) + + # {'enable_attribute_filters': '1', ...} + mapper = {row.field: row.value for row in data} + + for key, value in mapper.items(): + value = cint(value) if (value and value.isdigit()) else value + settings.update({key: value}) + + settings.save() + + # shift data to E Commerce Settings + map_into_e_commerce_settings("Products Settings", products_settings_fields) + map_into_e_commerce_settings("Shopping Cart Settings", shopping_cart_settings_fields) + + # move filters and attributes tables to E Commerce Settings from Products Settings + for doctype in ("Website Filter Field", "Website Attribute"): + frappe.db.set_value( + doctype, + {"parent": "Products Settings"}, + { + "parenttype": "E Commerce Settings", + "parent": "E Commerce Settings" + }, + update_modified=False + ) diff --git a/erpnext/patches/v13_0/remove_bad_selling_defaults.py b/erpnext/patches/v13_0/remove_bad_selling_defaults.py index 5487a6c60cc..02625396dd1 100644 --- a/erpnext/patches/v13_0/remove_bad_selling_defaults.py +++ b/erpnext/patches/v13_0/remove_bad_selling_defaults.py @@ -3,6 +3,7 @@ from frappe import _ def execute(): + frappe.reload_doctype('Selling Settings') selling_settings = frappe.get_single("Selling Settings") if selling_settings.customer_group in (_("All Customer Groups"), "All Customer Groups"): diff --git a/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py b/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py new file mode 100644 index 00000000000..f097ab9297f --- /dev/null +++ b/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py @@ -0,0 +1,36 @@ +import frappe + + +def execute(): + """ + 1. Get submitted Work Orders with MR, MR Item and SO set + 2. Get SO Item detail from MR Item detail in WO, and set in WO + 3. Update work_order_qty in SO + """ + work_order = frappe.qb.DocType("Work Order") + query = ( + frappe.qb.from_(work_order) + .select( + work_order.name, work_order.produced_qty, + work_order.material_request, + work_order.material_request_item, + work_order.sales_order + ).where( + (work_order.material_request.isnotnull()) + & (work_order.material_request_item.isnotnull()) + & (work_order.sales_order.isnotnull()) + & (work_order.docstatus == 1) + & (work_order.produced_qty > 0) + ) + ) + results = query.run(as_dict=True) + + for row in results: + so_item = frappe.get_value( + "Material Request Item", row.material_request_item, "sales_order_item" + ) + frappe.db.set_value("Work Order", row.name, "sales_order_item", so_item) + + if so_item: + wo = frappe.get_doc("Work Order", row.name) + wo.update_work_order_qty_in_so() diff --git a/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py index 7a2a2539670..2d35ea34587 100644 --- a/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py +++ b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py @@ -5,6 +5,9 @@ from erpnext.regional.india.setup import make_custom_fields def execute(): if frappe.get_all('Company', filters = {'country': 'India'}): + frappe.reload_doc('accounts', 'doctype', 'POS Invoice') + frappe.reload_doc('accounts', 'doctype', 'POS Invoice Item') + make_custom_fields() if not frappe.db.exists('Party Type', 'Donor'): diff --git a/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py new file mode 100644 index 00000000000..35710a9bb4a --- /dev/null +++ b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py @@ -0,0 +1,29 @@ +import click +import frappe + + +def execute(): + + frappe.delete_doc("DocType", "Shopping Cart Settings", ignore_missing=True) + frappe.delete_doc("DocType", "Products Settings", ignore_missing=True) + frappe.delete_doc("DocType", "Supplier Item Group", ignore_missing=True) + + if frappe.db.get_single_value("E Commerce Settings", "enabled"): + notify_users() + + +def notify_users(): + + click.secho( + "Shopping cart and Product settings are merged into E-commerce settings.\n" + "Checkout the documentation to learn more:" + "https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce", + fg="yellow", + ) + + note = frappe.new_doc("Note") + note.title = "New E-Commerce Module" + note.public = 1 + note.notify_on_login = 1 + note.content = """

You are seeing this message because Shopping Cart is enabled on your site.


Shopping Cart Settings and Products settings are now merged into "E Commerce Settings".


You can learn about new and improved E-Commerce features in the official documentation.

  1. https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce


""" + note.insert(ignore_mandatory=True) diff --git a/erpnext/patches/v13_0/update_accounts_in_loan_docs.py b/erpnext/patches/v13_0/update_accounts_in_loan_docs.py new file mode 100644 index 00000000000..440f912be21 --- /dev/null +++ b/erpnext/patches/v13_0/update_accounts_in_loan_docs.py @@ -0,0 +1,37 @@ +import frappe + + +def execute(): + ld = frappe.qb.DocType("Loan Disbursement").as_("ld") + lr = frappe.qb.DocType("Loan Repayment").as_("lr") + loan = frappe.qb.DocType("Loan") + + frappe.qb.update( + ld + ).inner_join( + loan + ).on( + loan.name == ld.against_loan + ).set( + ld.disbursement_account, loan.disbursement_account + ).set( + ld.loan_account, loan.loan_account + ).where( + ld.docstatus < 2 + ).run() + + frappe.qb.update( + lr + ).inner_join( + loan + ).on( + loan.name == lr.against_loan + ).set( + lr.payment_account, loan.payment_account + ).set( + lr.loan_account, loan.loan_account + ).set( + lr.penalty_income_account, loan.penalty_income_account + ).where( + lr.docstatus < 2 + ).run() diff --git a/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py b/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py index 55fd465b204..60466eb86a4 100644 --- a/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py +++ b/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py @@ -37,4 +37,4 @@ def execute(): jc.production_item = wo.production_item, jc.item_name = wo.item_name WHERE jc.work_order = wo.name and IFNULL(jc.production_item, "") = "" - """) + """) \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_disbursement_account.py b/erpnext/patches/v13_0/update_disbursement_account.py new file mode 100644 index 00000000000..c56fa8fdc62 --- /dev/null +++ b/erpnext/patches/v13_0/update_disbursement_account.py @@ -0,0 +1,22 @@ +import frappe + + +def execute(): + + frappe.reload_doc("loan_management", "doctype", "loan_type") + frappe.reload_doc("loan_management", "doctype", "loan") + + loan_type = frappe.qb.DocType("Loan Type") + loan = frappe.qb.DocType("Loan") + + frappe.qb.update( + loan_type + ).set( + loan_type.disbursement_account, loan_type.payment_account + ).run() + + frappe.qb.update( + loan + ).set( + loan.disbursement_account, loan.payment_account + ).run() \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_level_in_bom.py b/erpnext/patches/v13_0/update_level_in_bom.py deleted file mode 100644 index 499412ee270..00000000000 --- a/erpnext/patches/v13_0/update_level_in_bom.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) 2020, Frappe and Contributors -# License: GNU General Public License v3. See license.txt - - -import frappe - - -def execute(): - for document in ["bom", "bom_item", "bom_explosion_item"]: - frappe.reload_doc('manufacturing', 'doctype', document) - - frappe.db.sql(" update `tabBOM` set bom_level = 0 where docstatus = 1") - - bom_list = frappe.db.sql_list("""select name from `tabBOM` bom - where docstatus=1 and is_active=1 and not exists(select bom_no from `tabBOM Item` - where parent=bom.name and ifnull(bom_no, '')!='')""") - - count = 0 - while(count < len(bom_list)): - for parent_bom in get_parent_boms(bom_list[count]): - bom_doc = frappe.get_cached_doc("BOM", parent_bom) - bom_doc.set_bom_level(update=True) - bom_list.append(parent_bom) - count += 1 - -def get_parent_boms(bom_no): - return frappe.db.sql_list(""" - select distinct bom_item.parent from `tabBOM Item` bom_item - where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM' - and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1) - """, bom_no) diff --git a/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py b/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py index 450c00e4212..7a8c1c61356 100644 --- a/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py +++ b/erpnext/patches/v13_0/update_maintenance_schedule_field_in_visit.py @@ -3,6 +3,9 @@ import frappe def execute(): + frappe.reload_doctype('Maintenance Visit') + frappe.reload_doctype('Maintenance Visit Purpose') + # Updates the Maintenance Schedule link to fetch serial nos from frappe.query_builder.functions import Coalesce mvp = frappe.qb.DocType('Maintenance Visit Purpose') diff --git a/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py new file mode 100644 index 00000000000..00926b09241 --- /dev/null +++ b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py @@ -0,0 +1,28 @@ +import frappe + +from erpnext.stock.utils import get_bin + + +def execute(): + + wo = frappe.qb.DocType("Work Order") + wo_item = frappe.qb.DocType("Work Order Item") + + incorrect_item_wh = ( + frappe.qb + .from_(wo) + .join(wo_item).on(wo.name == wo_item.parent) + .select(wo_item.item_code, wo.source_warehouse).distinct() + .where( + (wo.status == "Closed") + & (wo.docstatus == 1) + & (wo.source_warehouse.notnull()) + ) + ).run() + + for item_code, warehouse in incorrect_item_wh: + if not (item_code and warehouse): + continue + + bin = get_bin(item_code, warehouse) + bin.update_reserved_qty_for_production() diff --git a/erpnext/patches/v13_0/update_sane_transfer_against.py b/erpnext/patches/v13_0/update_sane_transfer_against.py new file mode 100644 index 00000000000..a163d385843 --- /dev/null +++ b/erpnext/patches/v13_0/update_sane_transfer_against.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + bom = frappe.qb.DocType("BOM") + + (frappe.qb + .update(bom) + .set(bom.transfer_material_against, "Work Order") + .where(bom.with_operations == 0) + ).run() diff --git a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py index 120182a80e3..2a8b6ef746a 100644 --- a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py +++ b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py @@ -5,9 +5,6 @@ from frappe import _ def execute(): - frappe.reload_doc("email", "doctype", "email_template") - frappe.reload_doc("hr", "doctype", "hr_settings") - template = frappe.db.exists("Email Template", _("Exit Questionnaire Notification")) if not template: base_path = frappe.get_app_path("erpnext", "hr", "doctype") diff --git a/erpnext/patches/v14_0/delete_amazon_mws_doctype.py b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py new file mode 100644 index 00000000000..525da6cbe5a --- /dev/null +++ b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py @@ -0,0 +1,5 @@ +import frappe + + +def execute(): + frappe.delete_doc("DocType", "Amazon MWS Settings", ignore_missing=True) \ No newline at end of file diff --git a/erpnext/patches/v14_0/delete_einvoicing_doctypes.py b/erpnext/patches/v14_0/delete_einvoicing_doctypes.py deleted file mode 100644 index a3a8149be32..00000000000 --- a/erpnext/patches/v14_0/delete_einvoicing_doctypes.py +++ /dev/null @@ -1,10 +0,0 @@ -import frappe - - -def execute(): - frappe.delete_doc('DocType', 'E Invoice Settings', ignore_missing=True) - frappe.delete_doc('DocType', 'E Invoice User', ignore_missing=True) - frappe.delete_doc('Report', 'E-Invoice Summary', ignore_missing=True) - frappe.delete_doc('Print Format', 'GST E-Invoice', ignore_missing=True) - frappe.delete_doc('Custom Field', 'Sales Invoice-eway_bill_cancelled', ignore_missing=True) - frappe.delete_doc('Custom Field', 'Sales Invoice-irn_cancelled', ignore_missing=True) diff --git a/erpnext/patches/v14_0/migrate_cost_center_allocations.py b/erpnext/patches/v14_0/migrate_cost_center_allocations.py new file mode 100644 index 00000000000..c4f097fdd92 --- /dev/null +++ b/erpnext/patches/v14_0/migrate_cost_center_allocations.py @@ -0,0 +1,48 @@ +import frappe +from frappe.utils import today + + +def execute(): + for dt in ("cost_center_allocation", "cost_center_allocation_percentage"): + frappe.reload_doc('accounts', 'doctype', dt) + + cc_allocations = get_existing_cost_center_allocations() + if cc_allocations: + create_new_cost_center_allocation_records(cc_allocations) + + frappe.delete_doc('DocType', 'Distributed Cost Center', ignore_missing=True) + +def create_new_cost_center_allocation_records(cc_allocations): + for main_cc, allocations in cc_allocations.items(): + cca = frappe.new_doc("Cost Center Allocation") + cca.main_cost_center = main_cc + cca.valid_from = today() + + for child_cc, percentage in allocations.items(): + cca.append("allocation_percentages", ({ + "cost_center": child_cc, + "percentage": percentage + })) + cca.save() + cca.submit() + +def get_existing_cost_center_allocations(): + if not frappe.db.exists("DocType", "Distributed Cost Center"): + return + + par = frappe.qb.DocType("Cost Center") + child = frappe.qb.DocType("Distributed Cost Center") + + records = ( + frappe.qb.from_(par) + .inner_join(child).on(par.name == child.parent) + .select(par.name, child.cost_center, child.percentage_allocation) + .where(par.enable_distributed_cost_center == 1) + ).run(as_dict=True) + + cc_allocations = frappe._dict() + for d in records: + cc_allocations.setdefault(d.name, frappe._dict())\ + .setdefault(d.cost_center, d.percentage_allocation) + + return cc_allocations \ No newline at end of file diff --git a/erpnext/patches/v14_0/migrate_crm_settings.py b/erpnext/patches/v14_0/migrate_crm_settings.py index 30d3ea0cb1f..0c7785367c3 100644 --- a/erpnext/patches/v14_0/migrate_crm_settings.py +++ b/erpnext/patches/v14_0/migrate_crm_settings.py @@ -9,8 +9,9 @@ def execute(): ], as_dict=True) frappe.reload_doc('crm', 'doctype', 'crm_settings') - frappe.db.set_value('CRM Settings', 'CRM Settings', { - 'campaign_naming_by': settings.campaign_naming_by, - 'close_opportunity_after_days': settings.close_opportunity_after_days, - 'default_valid_till': settings.default_valid_till - }) + if settings: + frappe.db.set_value('CRM Settings', 'CRM Settings', { + 'campaign_naming_by': settings.campaign_naming_by, + 'close_opportunity_after_days': settings.close_opportunity_after_days, + 'default_valid_till': settings.default_valid_till + }) diff --git a/erpnext/patches/v14_0/rearrange_company_fields.py b/erpnext/patches/v14_0/rearrange_company_fields.py index dd953ffb0f5..fd7eb7f750a 100644 --- a/erpnext/patches/v14_0/rearrange_company_fields.py +++ b/erpnext/patches/v14_0/rearrange_company_fields.py @@ -1,10 +1,7 @@ -import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def execute(): - frappe.reload_doc('setup', 'doctype', 'company') - custom_fields = { 'Company': [ dict(fieldname='hra_section', label='HRA Settings', @@ -28,4 +25,4 @@ def execute(): ] } - create_custom_fields(custom_fields, update=True) \ No newline at end of file + create_custom_fields(custom_fields, update=True) diff --git a/erpnext/patches/v14_0/restore_einvoice_fields.py b/erpnext/patches/v14_0/restore_einvoice_fields.py new file mode 100644 index 00000000000..c4431fb9dbe --- /dev/null +++ b/erpnext/patches/v14_0/restore_einvoice_fields.py @@ -0,0 +1,24 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +from erpnext.regional.india.setup import add_permissions, add_print_formats + + +def execute(): + # restores back the 2 custom fields that was deleted while removing e-invoicing from v14 + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = { + 'Sales Invoice': [ + dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + ] + } + create_custom_fields(custom_fields, update=True) + add_permissions() + add_print_formats() diff --git a/erpnext/patches/v14_0/update_batch_valuation_flag.py b/erpnext/patches/v14_0/update_batch_valuation_flag.py new file mode 100644 index 00000000000..55c8c48aa21 --- /dev/null +++ b/erpnext/patches/v14_0/update_batch_valuation_flag.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + """ + - Don't use batchwise valuation for existing batches. + - Only batches created after this patch shoule use it. + """ + + batch = frappe.qb.DocType("Batch") + frappe.qb.update(batch).set(batch.use_batchwise_valuation, 0).run() diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py index 13071478c86..75049a6e8a5 100644 --- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py +++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py @@ -6,9 +6,6 @@ from erpnext.setup.utils import get_exchange_rate def execute(): - frappe.reload_doc('crm', 'doctype', 'opportunity') - frappe.reload_doc('crm', 'doctype', 'opportunity_item') - opportunities = frappe.db.get_list('Opportunity', filters={ 'opportunity_amount': ['>', 0] }, fields=['name', 'company', 'currency', 'opportunity_amount']) @@ -20,15 +17,11 @@ def execute(): if opportunity.currency != company_currency: conversion_rate = get_exchange_rate(opportunity.currency, company_currency) base_opportunity_amount = flt(conversion_rate) * flt(opportunity.opportunity_amount) - grand_total = flt(opportunity.opportunity_amount) - base_grand_total = flt(conversion_rate) * flt(opportunity.opportunity_amount) else: conversion_rate = 1 - base_opportunity_amount = grand_total = base_grand_total = flt(opportunity.opportunity_amount) + base_opportunity_amount = flt(opportunity.opportunity_amount) frappe.db.set_value('Opportunity', opportunity.name, { 'conversion_rate': conversion_rate, - 'base_opportunity_amount': base_opportunity_amount, - 'grand_total': grand_total, - 'base_grand_total': base_grand_total + 'base_opportunity_amount': base_opportunity_amount }, update_modified=False) diff --git a/erpnext/patches/v4_2/repost_reserved_qty.py b/erpnext/patches/v4_2/repost_reserved_qty.py index c2ca9be64aa..ed4b19d07d3 100644 --- a/erpnext/patches/v4_2/repost_reserved_qty.py +++ b/erpnext/patches/v4_2/repost_reserved_qty.py @@ -29,9 +29,11 @@ def execute(): """) for item_code, warehouse in repost_for: - update_bin_qty(item_code, warehouse, { - "reserved_qty": get_reserved_qty(item_code, warehouse) - }) + if not (item_code and warehouse): + continue + update_bin_qty(item_code, warehouse, { + "reserved_qty": get_reserved_qty(item_code, warehouse) + }) frappe.db.sql("""delete from tabBin where exists( diff --git a/erpnext/patches/v4_2/update_requested_and_ordered_qty.py b/erpnext/patches/v4_2/update_requested_and_ordered_qty.py index 42b0b04076f..dd79410ba58 100644 --- a/erpnext/patches/v4_2/update_requested_and_ordered_qty.py +++ b/erpnext/patches/v4_2/update_requested_and_ordered_qty.py @@ -14,6 +14,8 @@ def execute(): union select item_code, warehouse from `tabStock Ledger Entry`) a"""): try: + if not (item_code and warehouse): + continue count += 1 update_bin_qty(item_code, warehouse, { "indented_qty": get_indented_qty(item_code, warehouse), diff --git a/erpnext/payroll/doctype/gratuity/gratuity.js b/erpnext/payroll/doctype/gratuity/gratuity.js index d4f7c9ca091..3d69c46e55a 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity.js +++ b/erpnext/payroll/doctype/gratuity/gratuity.js @@ -3,6 +3,14 @@ frappe.ui.form.on('Gratuity', { setup: function (frm) { + frm.set_query("salary_component", function () { + return { + filters: { + type: "Earning" + } + }; + }); + frm.set_query("expense_account", function () { return { filters: { @@ -24,7 +32,7 @@ frappe.ui.form.on('Gratuity', { }); }, refresh: function (frm) { - if (frm.doc.docstatus == 1 && frm.doc.status == "Unpaid") { + if (frm.doc.docstatus == 1 && !frm.doc.pay_via_salary_slip && frm.doc.status == "Unpaid") { frm.add_custom_button(__("Create Payment Entry"), function () { return frappe.call({ method: 'erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry', diff --git a/erpnext/payroll/doctype/gratuity/gratuity.json b/erpnext/payroll/doctype/gratuity/gratuity.json index 197089567df..1fd1cecaaaa 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity.json +++ b/erpnext/payroll/doctype/gratuity/gratuity.json @@ -1,7 +1,7 @@ { "actions": [], "autoname": "HR-GRA-PAY-.#####", - "creation": "2020-08-05 20:52:13.024683", + "creation": "2022-01-27 16:24:28.200061", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -16,6 +16,9 @@ "company", "gratuity_rule", "section_break_5", + "pay_via_salary_slip", + "payroll_date", + "salary_component", "payable_account", "expense_account", "mode_of_payment", @@ -78,18 +81,20 @@ "reqd": 1 }, { + "depends_on": "eval: !doc.pay_via_salary_slip", "fieldname": "expense_account", "fieldtype": "Link", "label": "Expense Account", - "options": "Account", - "reqd": 1 + "mandatory_depends_on": "eval: !doc.pay_via_salary_slip", + "options": "Account" }, { + "depends_on": "eval: !doc.pay_via_salary_slip", "fieldname": "mode_of_payment", "fieldtype": "Link", "label": "Mode of Payment", - "options": "Mode of Payment", - "reqd": 1 + "mandatory_depends_on": "eval: !doc.pay_via_salary_slip", + "options": "Mode of Payment" }, { "fieldname": "gratuity_rule", @@ -151,23 +156,45 @@ "read_only": 1 }, { + "depends_on": "eval: !doc.pay_via_salary_slip", "fieldname": "payable_account", "fieldtype": "Link", "label": "Payable Account", - "options": "Account", - "reqd": 1 + "mandatory_depends_on": "eval: !doc.pay_via_salary_slip", + "options": "Account" }, { "fieldname": "cost_center", "fieldtype": "Link", "label": "Cost Center", "options": "Cost Center" + }, + { + "default": "1", + "fieldname": "pay_via_salary_slip", + "fieldtype": "Check", + "label": "Pay via Salary Slip" + }, + { + "depends_on": "pay_via_salary_slip", + "fieldname": "payroll_date", + "fieldtype": "Date", + "label": "Payroll Date", + "mandatory_depends_on": "pay_via_salary_slip" + }, + { + "depends_on": "pay_via_salary_slip", + "fieldname": "salary_component", + "fieldtype": "Link", + "label": "Salary Component", + "mandatory_depends_on": "pay_via_salary_slip", + "options": "Salary Component" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-01-19 12:54:37.306145", + "modified": "2022-02-02 14:00:45.536152", "modified_by": "Administrator", "module": "Payroll", "name": "Gratuity", diff --git a/erpnext/payroll/doctype/gratuity/gratuity.py b/erpnext/payroll/doctype/gratuity/gratuity.py index 476990a88e1..939634a9310 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity.py +++ b/erpnext/payroll/doctype/gratuity/gratuity.py @@ -21,7 +21,10 @@ class Gratuity(AccountsController): self.status = "Unpaid" def on_submit(self): - self.create_gl_entries() + if self.pay_via_salary_slip: + self.create_additional_salary() + else: + self.create_gl_entries() def on_cancel(self): self.ignore_linked_doctypes = ['GL Entry'] @@ -64,6 +67,19 @@ class Gratuity(AccountsController): return gl_entry + def create_additional_salary(self): + if self.pay_via_salary_slip: + additional_salary = frappe.new_doc('Additional Salary') + additional_salary.employee = self.employee + additional_salary.salary_component = self.salary_component + additional_salary.overwrite_salary_structure_amount = 0 + additional_salary.amount = self.amount + additional_salary.payroll_date = self.payroll_date + additional_salary.company = self.company + additional_salary.ref_doctype = self.doctype + additional_salary.ref_docname = self.name + additional_salary.submit() + def set_total_advance_paid(self): paid_amount = frappe.db.sql(""" select ifnull(sum(debit_in_account_currency), 0) as paid_amount diff --git a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py index aeadba186d0..771a6fea84a 100644 --- a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py +++ b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py @@ -10,7 +10,7 @@ def get_data(): 'transactions': [ { 'label': _('Payment'), - 'items': ['Payment Entry'] + 'items': ['Payment Entry', 'Additional Salary'] } ] } diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py index 93cba06da15..90e8061fed8 100644 --- a/erpnext/payroll/doctype/gratuity/test_gratuity.py +++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py @@ -18,27 +18,25 @@ from erpnext.regional.united_arab_emirates.setup import create_gratuity_rule test_dependencies = ["Salary Component", "Salary Slip", "Account"] class TestGratuity(unittest.TestCase): - @classmethod - def setUpClass(cls): + def setUp(self): + frappe.db.delete("Gratuity") + frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"}) + make_earning_salary_component(setup=True, test_tax=True, company_list=['_Test Company']) make_deduction_salary_component(setup=True, test_tax=True, company_list=['_Test Company']) - def setUp(self): - frappe.db.sql("DELETE FROM `tabGratuity`") - def test_get_last_salary_slip_should_return_none_for_new_employee(self): new_employee = make_employee("new_employee@salary.com", company='_Test Company') salary_slip = get_last_salary_slip(new_employee) assert salary_slip is None - def test_check_gratuity_amount_based_on_current_slab(self): + def test_check_gratuity_amount_based_on_current_slab_and_additional_salary_creation(self): employee, sal_slip = create_employee_and_get_last_salary_slip() rule = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)") + gratuity = create_gratuity(pay_via_salary_slip=1, employee=employee, rule=rule.name) - gratuity = create_gratuity(employee=employee, rule=rule.name) - - #work experience calculation + # work experience calculation date_of_joining, relieving_date = frappe.db.get_value('Employee', employee, ['date_of_joining', 'relieving_date']) employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(date_of_joining)).days @@ -64,6 +62,9 @@ class TestGratuity(unittest.TestCase): self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2)) + # additional salary creation (Pay via salary slip) + self.assertTrue(frappe.db.exists("Additional Salary", {"ref_docname": gratuity.name})) + def test_check_gratuity_amount_based_on_all_previous_slabs(self): employee, sal_slip = create_employee_and_get_last_salary_slip() rule = get_gratuity_rule("Rule Under Limited Contract (UAE)") @@ -117,8 +118,8 @@ class TestGratuity(unittest.TestCase): self.assertEqual(flt(gratuity.paid_amount,2), flt(gratuity.amount, 2)) def tearDown(self): - frappe.db.sql("DELETE FROM `tabGratuity`") - frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'") + frappe.db.rollback() + def get_gratuity_rule(name): rule = frappe.db.exists("Gratuity Rule", name) @@ -141,9 +142,14 @@ def create_gratuity(**args): gratuity.employee = args.employee gratuity.posting_date = getdate() gratuity.gratuity_rule = args.rule or "Rule Under Limited Contract (UAE)" - gratuity.expense_account = args.expense_account or 'Payment Account - _TC' - gratuity.payable_account = args.payable_account or get_payable_account("_Test Company") - gratuity.mode_of_payment = args.mode_of_payment or 'Cash' + gratuity.pay_via_salary_slip = args.pay_via_salary_slip or 0 + if gratuity.pay_via_salary_slip: + gratuity.payroll_date = getdate() + gratuity.salary_component = "Performance Bonus" + else: + gratuity.expense_account = args.expense_account or 'Payment Account - _TC' + gratuity.payable_account = args.payable_account or get_payable_account("_Test Company") + gratuity.mode_of_payment = args.mode_of_payment or 'Cash' gratuity.save() gratuity.submit() diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index db88c0643c2..a634dfe8c1a 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -527,11 +527,12 @@ def get_emp_list(sal_struct, cond, end_date, payroll_payable_account): """ % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True) def remove_payrolled_employees(emp_list, start_date, end_date): + new_emp_list = [] for employee_details in emp_list: - if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}): - emp_list.remove(employee_details) + if not frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}): + new_emp_list.append(employee_details) - return emp_list + return new_emp_list @frappe.whitelist() def get_start_end_dates(payroll_frequency, start_date=None, company=None): diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index 4f097fa2c3a..3b7f4b2ba79 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -124,7 +124,7 @@ class TestPayrollEntry(unittest.TestCase): if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"): create_account(account_name="_Test Payroll Payable", - company="_Test Company", parent_account="Current Liabilities - _TC") + company="_Test Company", parent_account="Current Liabilities - _TC", account_type="Payable") if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \ frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC": @@ -214,6 +214,7 @@ class TestPayrollEntry(unittest.TestCase): create_loan_type("Car Loan", 500000, 8.4, is_term_loan=1, mode_of_payment='Cash', + disbursement_account='Disbursement Account - _TC', payment_account='Payment Account - _TC', loan_account='Loan Account - _TC', interest_income_account='Interest Income Account - _TC', diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index f33443d0d7d..d2a39989a61 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -746,11 +746,12 @@ class SalarySlip(TransactionBase): previous_total_paid_taxes = self.get_tax_paid_in_period(payroll_period.start_date, self.start_date, tax_component) # get taxable_earnings for current period (all days) - current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption) + current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption, payroll_period=payroll_period) future_structured_taxable_earnings = current_taxable_earnings.taxable_earnings * (math.ceil(remaining_sub_periods) - 1) # get taxable_earnings, addition_earnings for current actual payment days - current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption, based_on_payment_days=1) + current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption, + based_on_payment_days=1, payroll_period=payroll_period) current_structured_taxable_earnings = current_taxable_earnings_for_payment_days.taxable_earnings current_additional_earnings = current_taxable_earnings_for_payment_days.additional_income current_additional_earnings_with_full_tax = current_taxable_earnings_for_payment_days.additional_income_with_full_tax @@ -876,7 +877,7 @@ class SalarySlip(TransactionBase): return total_tax_paid - def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0): + def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0, payroll_period=None): joining_date, relieving_date = self.get_joining_and_relieving_dates() taxable_earnings = 0 @@ -903,7 +904,7 @@ class SalarySlip(TransactionBase): # Get additional amount based on future recurring additional salary if additional_amount and earning.is_recurring_additional_salary: additional_income += self.get_future_recurring_additional_amount(earning.additional_salary, - earning.additional_amount) # Used earning.additional_amount to consider the amount for the full month + earning.additional_amount, payroll_period) # Used earning.additional_amount to consider the amount for the full month if earning.deduct_full_tax_on_selected_payroll_date: additional_income_with_full_tax += additional_amount @@ -920,7 +921,7 @@ class SalarySlip(TransactionBase): if additional_amount and ded.is_recurring_additional_salary: additional_income -= self.get_future_recurring_additional_amount(ded.additional_salary, - ded.additional_amount) # Used ded.additional_amount to consider the amount for the full month + ded.additional_amount, payroll_period) # Used ded.additional_amount to consider the amount for the full month return frappe._dict({ "taxable_earnings": taxable_earnings, @@ -929,12 +930,18 @@ class SalarySlip(TransactionBase): "flexi_benefits": flexi_benefits }) - def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount): + def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount, payroll_period): future_recurring_additional_amount = 0 to_date = frappe.db.get_value("Additional Salary", additional_salary, 'to_date') # future month count excluding current from_date, to_date = getdate(self.start_date), getdate(to_date) + + # If recurring period end date is beyond the payroll period, + # last day of payroll period should be considered for recurring period calculation + if getdate(to_date) > getdate(payroll_period.end_date): + to_date = getdate(payroll_period.end_date) + future_recurring_period = ((to_date.year - from_date.year) * 12) + (to_date.month - from_date.month) if future_recurring_period > 0: @@ -1261,7 +1268,7 @@ class SalarySlip(TransactionBase): for i, earning in enumerate(self.earnings): if earning.salary_component == salary_component: self.earnings[i].amount = wages_amount - self.gross_pay += self.earnings[i].amount + self.gross_pay += flt(self.earnings[i].amount, earning.precision("amount")) self.net_pay = flt(self.gross_pay) - flt(self.total_deduction) def compute_year_to_date(self): diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index a4834d995a8..d34f6a6038e 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -6,6 +6,7 @@ import random import unittest import frappe +from frappe.model.document import Document from frappe.utils import ( add_days, add_months, @@ -147,7 +148,7 @@ class TestSalarySlip(unittest.TestCase): # Payroll based on attendance frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance") - emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company") + emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company", holiday_list="Salary Slip Test Holiday List") frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"}) # mark attendance @@ -370,6 +371,7 @@ class TestSalarySlip(unittest.TestCase): create_loan_type("Car Loan", 500000, 8.4, is_term_loan=1, mode_of_payment='Cash', + disbursement_account='Disbursement Account - _TC', payment_account='Payment Account - _TC', loan_account='Loan Account - _TC', interest_income_account='Interest Income Account - _TC', @@ -686,20 +688,25 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None): def make_salary_component(salary_components, test_tax, company_list=None): for salary_component in salary_components: - if not frappe.db.exists('Salary Component', salary_component["salary_component"]): - if test_tax: - if salary_component["type"] == "Earning": - salary_component["is_tax_applicable"] = 1 - elif salary_component["salary_component"] == "TDS": - salary_component["variable_based_on_taxable_salary"] = 1 - salary_component["amount_based_on_formula"] = 0 - salary_component["amount"] = 0 - salary_component["formula"] = "" - salary_component["condition"] = "" - salary_component["doctype"] = "Salary Component" - salary_component["salary_component_abbr"] = salary_component["abbr"] - frappe.get_doc(salary_component).insert() - get_salary_component_account(salary_component["salary_component"], company_list) + if frappe.db.exists('Salary Component', salary_component["salary_component"]): + continue + + if test_tax: + if salary_component["type"] == "Earning": + salary_component["is_tax_applicable"] = 1 + elif salary_component["salary_component"] == "TDS": + salary_component["variable_based_on_taxable_salary"] = 1 + salary_component["amount_based_on_formula"] = 0 + salary_component["amount"] = 0 + salary_component["formula"] = "" + salary_component["condition"] = "" + + salary_component["salary_component_abbr"] = salary_component["abbr"] + doc = frappe.new_doc("Salary Component") + doc.update(salary_component) + doc.insert() + + get_salary_component_account(doc, company_list) def get_salary_component_account(sal_comp, company_list=None): company = erpnext.get_default_company() @@ -707,7 +714,9 @@ def get_salary_component_account(sal_comp, company_list=None): if company_list and company not in company_list: company_list.append(company) - sal_comp = frappe.get_doc("Salary Component", sal_comp) + if not isinstance(sal_comp, Document): + sal_comp = frappe.get_doc("Salary Component", sal_comp) + if not sal_comp.get("accounts"): for d in company_list: company_abbr = frappe.get_cached_value('Company', d, 'abbr') @@ -725,7 +734,7 @@ def get_salary_component_account(sal_comp, company_list=None): }) sal_comp.save() -def create_account(account_name, company, parent_account): +def create_account(account_name, company, parent_account, account_type=None): company_abbr = frappe.get_cached_value('Company', company, 'abbr') account = frappe.db.get_value("Account", account_name + " - " + company_abbr) if not account: diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.json b/erpnext/payroll/doctype/salary_structure/salary_structure.json index 5dd1d701f02..8df995769d3 100644 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.json +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.json @@ -58,6 +58,7 @@ "width": "50%" }, { + "allow_on_submit": 1, "default": "Yes", "fieldname": "is_active", "fieldtype": "Select", @@ -232,10 +233,11 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2021-03-31 15:41:12.342380", + "modified": "2022-02-03 23:50:10.205676", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Structure", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -271,5 +273,6 @@ ], "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/portal/doctype/homepage/homepage.js b/erpnext/portal/doctype/homepage/homepage.js index c7c66e00556..59f808a3158 100644 --- a/erpnext/portal/doctype/homepage/homepage.js +++ b/erpnext/portal/doctype/homepage/homepage.js @@ -3,9 +3,9 @@ frappe.ui.form.on('Homepage', { setup: function(frm) { - frm.fields_dict["products"].grid.get_field("item_code").get_query = function(){ + frm.fields_dict["products"].grid.get_field("item").get_query = function() { return { - filters: {'show_in_website': 1} + filters: {'published': 1} } } }, @@ -21,11 +21,10 @@ frappe.ui.form.on('Homepage', { }); frappe.ui.form.on('Homepage Featured Product', { - - view: function(frm, cdt, cdn){ - var child= locals[cdt][cdn] - if(child.item_code && frm.doc.products_url){ - window.location.href = frm.doc.products_url + '/' + encodeURIComponent(child.item_code); + view: function(frm, cdt, cdn) { + var child= locals[cdt][cdn]; + if (child.item_code && child.route) { + window.open('/' + child.route, '_blank'); } } }); diff --git a/erpnext/portal/doctype/homepage/homepage.json b/erpnext/portal/doctype/homepage/homepage.json index ad27278dc69..73f816d4d49 100644 --- a/erpnext/portal/doctype/homepage/homepage.json +++ b/erpnext/portal/doctype/homepage/homepage.json @@ -1,518 +1,143 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "", + "actions": [], "beta": 1, "creation": "2016-04-22 05:27:52.109319", - "custom": 0, - "docstatus": 0, "doctype": "DocType", "document_type": "Setup", - "editable_grid": 0, "engine": "InnoDB", + "field_order": [ + "company", + "hero_section_based_on", + "column_break_2", + "title", + "section_break_4", + "tag_line", + "description", + "hero_image", + "slideshow", + "hero_section", + "products_section", + "products_url", + "products" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "company", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Company", - "length": 0, - "no_copy": 0, "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "hero_section_based_on", "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Hero Section Based On", - "length": 0, - "no_copy": 0, - "options": "Default\nSlideshow\nHomepage Section", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Default\nSlideshow\nHomepage Section" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "fieldname": "title", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Title", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Title" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "fieldname": "section_break_4", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Hero Section", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Hero Section" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Default'", "description": "Company Tagline for website homepage", "fieldname": "tag_line", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Tag Line", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Default'", "description": "Company Description for website homepage", "fieldname": "description", "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Default'", "fieldname": "hero_image", "fieldtype": "Attach Image", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Hero Image", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Hero Image" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Slideshow'", - "description": "", "fieldname": "slideshow", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Homepage Slideshow", - "length": 0, - "no_copy": 0, - "options": "Website Slideshow", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Website Slideshow" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.hero_section_based_on === 'Homepage Section'", "fieldname": "hero_section", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Homepage Section", - "length": 0, - "no_copy": 0, - "options": "Homepage Section", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "Homepage Section" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "fieldname": "products_section", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Products", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Products" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "/products", + "default": "/all-products", "fieldname": "products_url", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "URL for \"All Products\"", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "URL for \"All Products\"" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "description": "Products to be shown on website homepage", "fieldname": "products", "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Products", - "length": 0, - "no_copy": 0, "options": "Homepage Featured Product", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "40px" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-02 23:12:59.676202", + "links": [], + "modified": "2021-02-18 13:29:29.531639", "modified_by": "Administrator", "module": "Portal", "name": "Homepage", - "name_case": "", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, - "report": 0, "role": "System Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, - "report": 0, "role": "Administrator", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 } ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, "sort_field": "modified", "sort_order": "DESC", "title_field": "company", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/portal/doctype/homepage/homepage.py b/erpnext/portal/doctype/homepage/homepage.py index 1e056a6dacb..8092ba208a4 100644 --- a/erpnext/portal/doctype/homepage/homepage.py +++ b/erpnext/portal/doctype/homepage/homepage.py @@ -14,12 +14,14 @@ class Homepage(Document): delete_page_cache('home') def setup_items(self): - for d in frappe.get_all('Item', fields=['name', 'item_name', 'description', 'image'], - filters={'show_in_website': 1}, limit=3): + for d in frappe.get_all('Website Item', fields=['name', 'item_name', 'description', 'image', 'route'], + filters={'published': 1}, limit=3): - doc = frappe.get_doc('Item', d.name) + doc = frappe.get_doc('Website Item', d.name) if not doc.route: # set missing route doc.save() self.append('products', dict(item_code=d.name, - item_name=d.item_name, description=d.description, image=d.image)) + item_name=d.item_name, description=d.description, + image=d.image, route=d.route)) + diff --git a/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json b/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json index 01c32efec9d..63789e35b56 100644 --- a/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json +++ b/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json @@ -25,10 +25,10 @@ "fieldtype": "Link", "in_filter": 1, "in_list_view": 1, - "label": "Item Code", + "label": "Item", "oldfieldname": "item_code", "oldfieldtype": "Link", - "options": "Item", + "options": "Website Item", "print_width": "150px", "reqd": 1, "search_index": 1, @@ -63,7 +63,7 @@ "collapsible": 1, "fieldname": "section_break_5", "fieldtype": "Section Break", - "label": "Description" + "label": "Details" }, { "fetch_from": "item_code.web_long_description", @@ -89,12 +89,14 @@ "label": "Image" }, { + "fetch_from": "item_code.thumbnail", "fieldname": "thumbnail", "fieldtype": "Attach Image", "hidden": 1, "label": "Thumbnail" }, { + "fetch_from": "item_code.route", "fieldname": "route", "fieldtype": "Small Text", "label": "route", @@ -104,7 +106,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-25 15:27:49.573537", + "modified": "2021-02-18 13:05:50.669311", "modified_by": "Administrator", "module": "Portal", "name": "Homepage Featured Product", diff --git a/erpnext/portal/doctype/homepage_section/test_homepage_section.py b/erpnext/portal/doctype/homepage_section/test_homepage_section.py index b30d983adc3..c3be146becb 100644 --- a/erpnext/portal/doctype/homepage_section/test_homepage_section.py +++ b/erpnext/portal/doctype/homepage_section/test_homepage_section.py @@ -21,7 +21,7 @@ class TestHomepageSection(unittest.TestCase): {'title': 'Card 2', 'subtitle': 'Subtitle 2', 'content': 'This is test card 2', 'image': 'test.jpg'}, ], 'no_of_columns': 3 - }).insert() + }).insert(ignore_if_duplicate=True) except frappe.DuplicateEntryError: pass diff --git a/erpnext/portal/doctype/products_settings/products_settings.js b/erpnext/portal/doctype/products_settings/products_settings.js deleted file mode 100644 index 2f8b0371648..00000000000 --- a/erpnext/portal/doctype/products_settings/products_settings.js +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Products Settings', { - refresh: function(frm) { - frappe.model.with_doctype('Item', () => { - const item_meta = frappe.get_meta('Item'); - - const valid_fields = item_meta.fields.filter( - df => ['Link', 'Table MultiSelect'].includes(df.fieldtype) && !df.hidden - ).map(df => ({ label: df.label, value: df.fieldname })); - - frm.fields_dict.filter_fields.grid.update_docfield_property( - 'fieldname', 'fieldtype', 'Select' - ); - frm.fields_dict.filter_fields.grid.update_docfield_property( - 'fieldname', 'options', valid_fields - ); - }); - } -}); diff --git a/erpnext/portal/doctype/products_settings/products_settings.json b/erpnext/portal/doctype/products_settings/products_settings.json deleted file mode 100644 index 2cf8431497c..00000000000 --- a/erpnext/portal/doctype/products_settings/products_settings.json +++ /dev/null @@ -1,389 +0,0 @@ -{ - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-04-22 09:11:55.272398", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 0, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "If checked, the Home page will be the default Item Group for the website", - "fieldname": "home_page_is_products", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Home Page is Products", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "show_availability_status", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Show Availability Status", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_5", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Product Page", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "6", - "fieldname": "products_per_page", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Products per Page", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "enable_field_filters", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Enable Field Filters", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "enable_field_filters", - "fieldname": "filter_fields", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Item Fields", - "length": 0, - "no_copy": 0, - "options": "Website Filter Field", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "enable_attribute_filters", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Enable Attribute Filters", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "enable_attribute_filters", - "fieldname": "filter_attributes", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Attributes", - "length": 0, - "no_copy": 0, - "options": "Website Attribute", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "hide_variants", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Hide Variants", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-07 19:18:31.822309", - "modified_by": "Administrator", - "module": "Portal", - "name": "Products Settings", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 0, - "role": "Website Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 -} \ No newline at end of file diff --git a/erpnext/portal/doctype/products_settings/products_settings.py b/erpnext/portal/doctype/products_settings/products_settings.py deleted file mode 100644 index 0e106c634b8..00000000000 --- a/erpnext/portal/doctype/products_settings/products_settings.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import frappe -from frappe import _ -from frappe.model.document import Document -from frappe.utils import cint - - -class ProductsSettings(Document): - def validate(self): - if self.home_page_is_products: - frappe.db.set_value("Website Settings", None, "home_page", "products") - elif frappe.db.get_single_value("Website Settings", "home_page") == 'products': - frappe.db.set_value("Website Settings", None, "home_page", "home") - - self.validate_field_filters() - self.validate_attribute_filters() - frappe.clear_document_cache("Product Settings", "Product Settings") - - def validate_field_filters(self): - if not (self.enable_field_filters and self.filter_fields): return - - item_meta = frappe.get_meta('Item') - valid_fields = [df.fieldname for df in item_meta.fields if df.fieldtype in ['Link', 'Table MultiSelect']] - - for f in self.filter_fields: - if f.fieldname not in valid_fields: - frappe.throw(_('Filter Fields Row #{0}: Fieldname {1} must be of type "Link" or "Table MultiSelect"').format(f.idx, f.fieldname)) - - def validate_attribute_filters(self): - if not (self.enable_attribute_filters and self.filter_attributes): return - - # if attribute filters are enabled, hide_variants should be disabled - self.hide_variants = 0 - - -def home_page_is_products(doc, method): - '''Called on saving Website Settings''' - home_page_is_products = cint(frappe.db.get_single_value('Products Settings', 'home_page_is_products')) - if home_page_is_products: - doc.home_page = 'products' diff --git a/erpnext/portal/doctype/products_settings/test_products_settings.py b/erpnext/portal/doctype/products_settings/test_products_settings.py deleted file mode 100644 index 66026fc0467..00000000000 --- a/erpnext/portal/doctype/products_settings/test_products_settings.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestProductsSettings(unittest.TestCase): - pass diff --git a/erpnext/portal/doctype/website_attribute/website_attribute.json b/erpnext/portal/doctype/website_attribute/website_attribute.json index 2874dc432c7..eed33ec10e4 100644 --- a/erpnext/portal/doctype/website_attribute/website_attribute.json +++ b/erpnext/portal/doctype/website_attribute/website_attribute.json @@ -1,76 +1,32 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2019-01-01 13:04:54.479079", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2019-01-01 13:04:54.479079", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "attribute" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "attribute", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Attribute", - "length": 0, - "no_copy": 0, - "options": "Item Attribute", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "attribute", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Attribute", + "options": "Item Attribute", + "reqd": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2019-01-01 13:04:59.715572", - "modified_by": "Administrator", - "module": "Portal", - "name": "Website Attribute", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "istable": 1, + "links": [], + "modified": "2021-02-18 13:18:57.810536", + "modified_by": "Administrator", + "module": "Portal", + "name": "Website Attribute", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/portal/product_configurator/test_product_configurator.py b/erpnext/portal/product_configurator/test_product_configurator.py deleted file mode 100644 index b478489920d..00000000000 --- a/erpnext/portal/product_configurator/test_product_configurator.py +++ /dev/null @@ -1,143 +0,0 @@ -import unittest - -import frappe -from bs4 import BeautifulSoup -from frappe.utils import get_html_for_route - -from erpnext.portal.product_configurator.utils import get_products_for_website - -test_dependencies = ["Item"] - -class TestProductConfigurator(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.create_variant_item() - - @classmethod - def create_variant_item(cls): - if not frappe.db.exists('Item', '_Test Variant Item - 2XL'): - frappe.get_doc({ - "description": "_Test Variant Item - 2XL", - "item_code": "_Test Variant Item - 2XL", - "item_name": "_Test Variant Item - 2XL", - "doctype": "Item", - "is_stock_item": 1, - "variant_of": "_Test Variant Item", - "item_group": "_Test Item Group", - "stock_uom": "_Test UOM", - "item_defaults": [{ - "company": "_Test Company", - "default_warehouse": "_Test Warehouse - _TC", - "expense_account": "_Test Account Cost for Goods Sold - _TC", - "buying_cost_center": "_Test Cost Center - _TC", - "selling_cost_center": "_Test Cost Center - _TC", - "income_account": "Sales - _TC" - }], - "attributes": [ - { - "attribute": "Test Size", - "attribute_value": "2XL" - } - ], - "show_variant_in_website": 1 - }).insert() - - def create_regular_web_item(self, name, item_group=None): - if not frappe.db.exists('Item', name): - doc = frappe.get_doc({ - "description": name, - "item_code": name, - "item_name": name, - "doctype": "Item", - "is_stock_item": 1, - "item_group": item_group or "_Test Item Group", - "stock_uom": "_Test UOM", - "item_defaults": [{ - "company": "_Test Company", - "default_warehouse": "_Test Warehouse - _TC", - "expense_account": "_Test Account Cost for Goods Sold - _TC", - "buying_cost_center": "_Test Cost Center - _TC", - "selling_cost_center": "_Test Cost Center - _TC", - "income_account": "Sales - _TC" - }], - "show_in_website": 1 - }).insert() - else: - doc = frappe.get_doc("Item", name) - return doc - - def test_product_list(self): - template_items = frappe.get_all('Item', {'show_in_website': 1}) - variant_items = frappe.get_all('Item', {'show_variant_in_website': 1}) - - products_settings = frappe.get_doc('Products Settings') - products_settings.enable_field_filters = 1 - products_settings.append('filter_fields', {'fieldname': 'item_group'}) - products_settings.append('filter_fields', {'fieldname': 'stock_uom'}) - products_settings.save() - - html = get_html_for_route('all-products') - - soup = BeautifulSoup(html, 'html.parser') - products_list = soup.find(class_='products-list') - items = products_list.find_all(class_='card') - self.assertEqual(len(items), len(template_items + variant_items)) - - items_with_item_group = frappe.get_all('Item', {'item_group': '_Test Item Group Desktops', 'show_in_website': 1}) - variants_with_item_group = frappe.get_all('Item', {'item_group': '_Test Item Group Desktops', 'show_variant_in_website': 1}) - - # mock query params - frappe.form_dict = frappe._dict({ - 'field_filters': '{"item_group":["_Test Item Group Desktops"]}' - }) - html = get_html_for_route('all-products') - soup = BeautifulSoup(html, 'html.parser') - products_list = soup.find(class_='products-list') - items = products_list.find_all(class_='card') - self.assertEqual(len(items), len(items_with_item_group + variants_with_item_group)) - - - def test_get_products_for_website(self): - items = get_products_for_website(attribute_filters={ - 'Test Size': ['2XL'] - }) - self.assertEqual(len(items), 1) - - def test_products_in_multiple_item_groups(self): - """Check if product is visible on multiple item group pages barring its own.""" - from erpnext.shopping_cart.product_query import ProductQuery - - if not frappe.db.exists("Item Group", {"name": "Tech Items"}): - item_group_doc = frappe.get_doc({ - "doctype": "Item Group", - "item_group_name": "Tech Items", - "parent_item_group": "All Item Groups", - "show_in_website": 1 - }).insert() - else: - item_group_doc = frappe.get_doc("Item Group", "Tech Items") - - doc = self.create_regular_web_item("Portal Item", item_group="Tech Items") - if not frappe.db.exists("Website Item Group", {"parent": "Portal Item"}): - doc.append("website_item_groups", { - "item_group": "_Test Item Group Desktops" - }) - doc.save() - - # check if item is visible in its own Item Group's page - engine = ProductQuery() - items = engine.query({}, {"item_group": "Tech Items"}, None, start=0, item_group="Tech Items") - self.assertEqual(len(items), 1) - self.assertEqual(items[0].item_code, "Portal Item") - - # check if item is visible in configured foreign Item Group's page - engine = ProductQuery() - items = engine.query({}, {"item_group": "_Test Item Group Desktops"}, None, start=0, item_group="_Test Item Group Desktops") - item_codes = [row.item_code for row in items] - - self.assertIn(len(items), [2, 3]) - self.assertIn("Portal Item", item_codes) - - # teardown - doc.delete() - item_group_doc.delete() diff --git a/erpnext/portal/product_configurator/utils.py b/erpnext/portal/product_configurator/utils.py deleted file mode 100644 index cf623c8d429..00000000000 --- a/erpnext/portal/product_configurator/utils.py +++ /dev/null @@ -1,446 +0,0 @@ -import frappe -from frappe.utils import cint - -from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager -from erpnext.setup.doctype.item_group.item_group import get_child_groups -from erpnext.shopping_cart.product_info import get_product_info_for_website - - -def get_field_filter_data(): - product_settings = get_product_settings() - filter_fields = [row.fieldname for row in product_settings.filter_fields] - - meta = frappe.get_meta('Item') - fields = [df for df in meta.fields if df.fieldname in filter_fields] - - filter_data = [] - for f in fields: - doctype = f.get_link_doctype() - - # apply enable/disable/show_in_website filter - meta = frappe.get_meta(doctype) - filters = {} - if meta.has_field('enabled'): - filters['enabled'] = 1 - if meta.has_field('disabled'): - filters['disabled'] = 0 - if meta.has_field('show_in_website'): - filters['show_in_website'] = 1 - - values = [d.name for d in frappe.get_all(doctype, filters)] - filter_data.append([f, values]) - - return filter_data - - -def get_attribute_filter_data(): - product_settings = get_product_settings() - attributes = [row.attribute for row in product_settings.filter_attributes] - attribute_docs = [ - frappe.get_doc('Item Attribute', attribute) for attribute in attributes - ] - - # mark attribute values as checked if they are present in the request url - if frappe.form_dict: - for attr in attribute_docs: - if attr.name in frappe.form_dict: - value = frappe.form_dict[attr.name] - if value: - enabled_values = value.split(',') - else: - enabled_values = [] - - for v in enabled_values: - for item_attribute_row in attr.item_attribute_values: - if v == item_attribute_row.attribute_value: - item_attribute_row.checked = True - - return attribute_docs - - -def get_products_for_website(field_filters=None, attribute_filters=None, search=None): - if attribute_filters: - item_codes = get_item_codes_by_attributes(attribute_filters) - items_by_attributes = get_items([['name', 'in', item_codes]]) - - if field_filters: - items_by_fields = get_items_by_fields(field_filters) - - if attribute_filters and not field_filters: - return items_by_attributes - - if field_filters and not attribute_filters: - return items_by_fields - - if field_filters and attribute_filters: - items_intersection = [] - item_codes_in_attribute = [item.name for item in items_by_attributes] - - for item in items_by_fields: - if item.name in item_codes_in_attribute: - items_intersection.append(item) - - return items_intersection - - if search: - return get_items(search=search) - - return get_items() - - -@frappe.whitelist(allow_guest=True) -def get_products_html_for_website(field_filters=None, attribute_filters=None): - field_filters = frappe.parse_json(field_filters) - attribute_filters = frappe.parse_json(attribute_filters) - set_item_group_filters(field_filters) - - items = get_products_for_website(field_filters, attribute_filters) - html = ''.join(get_html_for_items(items)) - - if not items: - html = frappe.render_template('erpnext/www/all-products/not_found.html', {}) - - return html - -def set_item_group_filters(field_filters): - if field_filters is not None and 'item_group' in field_filters: - field_filters['item_group'] = [ig[0] for ig in get_child_groups(field_filters['item_group'])] - - -def get_item_codes_by_attributes(attribute_filters, template_item_code=None): - items = [] - - for attribute, values in attribute_filters.items(): - attribute_values = values - - if not isinstance(attribute_values, list): - attribute_values = [attribute_values] - - if not attribute_values: continue - - wheres = [] - query_values = [] - for attribute_value in attribute_values: - wheres.append('( attribute = %s and attribute_value = %s )') - query_values += [attribute, attribute_value] - - attribute_query = ' or '.join(wheres) - - if template_item_code: - variant_of_query = 'AND t2.variant_of = %s' - query_values.append(template_item_code) - else: - variant_of_query = '' - - query = ''' - SELECT - t1.parent - FROM - `tabItem Variant Attribute` t1 - WHERE - 1 = 1 - AND ( - {attribute_query} - ) - AND EXISTS ( - SELECT - 1 - FROM - `tabItem` t2 - WHERE - t2.name = t1.parent - {variant_of_query} - ) - GROUP BY - t1.parent - ORDER BY - NULL - '''.format(attribute_query=attribute_query, variant_of_query=variant_of_query) - - item_codes = set([r[0] for r in frappe.db.sql(query, query_values)]) - items.append(item_codes) - - res = list(set.intersection(*items)) - - return res - - -@frappe.whitelist(allow_guest=True) -def get_attributes_and_values(item_code): - '''Build a list of attributes and their possible values. - This will ignore the values upon selection of which there cannot exist one item. - ''' - item_cache = ItemVariantsCacheManager(item_code) - item_variants_data = item_cache.get_item_variants_data() - - attributes = get_item_attributes(item_code) - attribute_list = [a.attribute for a in attributes] - - valid_options = {} - for item_code, attribute, attribute_value in item_variants_data: - if attribute in attribute_list: - valid_options.setdefault(attribute, set()).add(attribute_value) - - item_attribute_values = frappe.db.get_all('Item Attribute Value', - ['parent', 'attribute_value', 'idx'], order_by='parent asc, idx asc') - ordered_attribute_value_map = frappe._dict() - for iv in item_attribute_values: - ordered_attribute_value_map.setdefault(iv.parent, []).append(iv.attribute_value) - - # build attribute values in idx order - for attr in attributes: - valid_attribute_values = valid_options.get(attr.attribute, []) - ordered_values = ordered_attribute_value_map.get(attr.attribute, []) - attr['values'] = [v for v in ordered_values if v in valid_attribute_values] - - return attributes - - -@frappe.whitelist(allow_guest=True) -def get_next_attribute_and_values(item_code, selected_attributes): - '''Find the count of Items that match the selected attributes. - Also, find the attribute values that are not applicable for further searching. - If less than equal to 10 items are found, return item_codes of those items. - If one item is matched exactly, return item_code of that item. - ''' - selected_attributes = frappe.parse_json(selected_attributes) - - item_cache = ItemVariantsCacheManager(item_code) - item_variants_data = item_cache.get_item_variants_data() - - attributes = get_item_attributes(item_code) - attribute_list = [a.attribute for a in attributes] - filtered_items = get_items_with_selected_attributes(item_code, selected_attributes) - - next_attribute = None - - for attribute in attribute_list: - if attribute not in selected_attributes: - next_attribute = attribute - break - - valid_options_for_attributes = frappe._dict({}) - - for a in attribute_list: - valid_options_for_attributes[a] = set() - - selected_attribute = selected_attributes.get(a, None) - if selected_attribute: - # already selected attribute values are valid options - valid_options_for_attributes[a].add(selected_attribute) - - for row in item_variants_data: - item_code, attribute, attribute_value = row - if item_code in filtered_items and attribute not in selected_attributes and attribute in attribute_list: - valid_options_for_attributes[attribute].add(attribute_value) - - optional_attributes = item_cache.get_optional_attributes() - exact_match = [] - # search for exact match if all selected attributes are required attributes - if len(selected_attributes.keys()) >= (len(attribute_list) - len(optional_attributes)): - item_attribute_value_map = item_cache.get_item_attribute_value_map() - for item_code, attr_dict in item_attribute_value_map.items(): - if item_code in filtered_items and set(attr_dict.keys()) == set(selected_attributes.keys()): - exact_match.append(item_code) - - filtered_items_count = len(filtered_items) - - # get product info if exact match - from erpnext.shopping_cart.product_info import get_product_info_for_website - if exact_match: - data = get_product_info_for_website(exact_match[0]) - product_info = data.product_info - if product_info: - product_info["allow_items_not_in_stock"] = cint(data.cart_settings.allow_items_not_in_stock) - if not data.cart_settings.show_price: - product_info = None - else: - product_info = None - - return { - 'next_attribute': next_attribute, - 'valid_options_for_attributes': valid_options_for_attributes, - 'filtered_items_count': filtered_items_count, - 'filtered_items': filtered_items if filtered_items_count < 10 else [], - 'exact_match': exact_match, - 'product_info': product_info - } - - -def get_items_with_selected_attributes(item_code, selected_attributes): - item_cache = ItemVariantsCacheManager(item_code) - attribute_value_item_map = item_cache.get_attribute_value_item_map() - - items = [] - for attribute, value in selected_attributes.items(): - filtered_items = attribute_value_item_map.get((attribute, value), []) - items.append(set(filtered_items)) - - return set.intersection(*items) - - -def get_items_by_fields(field_filters): - meta = frappe.get_meta('Item') - filters = [] - for fieldname, values in field_filters.items(): - if not values: continue - - _doctype = 'Item' - _fieldname = fieldname - - df = meta.get_field(fieldname) - if df.fieldtype == 'Table MultiSelect': - child_doctype = df.options - child_meta = frappe.get_meta(child_doctype) - fields = child_meta.get("fields", { "fieldtype": "Link", "in_list_view": 1 }) - if fields: - _doctype = child_doctype - _fieldname = fields[0].fieldname - - if len(values) == 1: - filters.append([_doctype, _fieldname, '=', values[0]]) - else: - filters.append([_doctype, _fieldname, 'in', values]) - - return get_items(filters) - - -def get_items(filters=None, search=None): - start = frappe.form_dict.get('start', 0) - products_settings = get_product_settings() - page_length = products_settings.products_per_page - - filters = filters or [] - # convert to list of filters - if isinstance(filters, dict): - filters = [['Item', fieldname, '=', value] for fieldname, value in filters.items()] - - enabled_items_filter = get_conditions({ 'disabled': 0 }, 'and') - - show_in_website_condition = '' - if products_settings.hide_variants: - show_in_website_condition = get_conditions({'show_in_website': 1 }, 'and') - else: - show_in_website_condition = get_conditions([ - ['show_in_website', '=', 1], - ['show_variant_in_website', '=', 1] - ], 'or') - - search_condition = '' - if search: - # Default fields to search from - default_fields = {'name', 'item_name', 'description', 'item_group'} - - # Get meta search fields - meta = frappe.get_meta("Item") - meta_fields = set(meta.get_search_fields()) - - # Join the meta fields and default fields set - search_fields = default_fields.union(meta_fields) - try: - if frappe.db.count('Item', cache=True) > 50000: - search_fields.remove('description') - except KeyError: - pass - - # Build or filters for query - search = '%{}%'.format(search) - or_filters = [[field, 'like', search] for field in search_fields] - - search_condition = get_conditions(or_filters, 'or') - - filter_condition = get_conditions(filters, 'and') - - where_conditions = ' and '.join( - [condition for condition in [enabled_items_filter, show_in_website_condition, \ - search_condition, filter_condition] if condition] - ) - - left_joins = [] - for f in filters: - if len(f) == 4 and f[0] != 'Item': - left_joins.append(f[0]) - - left_join = ' '.join(['LEFT JOIN `tab{0}` on (`tab{0}`.parent = `tabItem`.name)'.format(l) for l in left_joins]) - - results = frappe.db.sql(''' - SELECT - `tabItem`.`name`, `tabItem`.`item_name`, `tabItem`.`item_code`, - `tabItem`.`website_image`, `tabItem`.`image`, - `tabItem`.`web_long_description`, `tabItem`.`description`, - `tabItem`.`route`, `tabItem`.`item_group` - FROM - `tabItem` - {left_join} - WHERE - {where_conditions} - GROUP BY - `tabItem`.`name` - ORDER BY - `tabItem`.`weightage` DESC - LIMIT - {page_length} - OFFSET - {start} - '''.format( - where_conditions=where_conditions, - start=start, - page_length=page_length, - left_join=left_join - ) - , as_dict=1) - - for r in results: - r.description = r.web_long_description or r.description - r.image = r.website_image or r.image - product_info = get_product_info_for_website(r.item_code, skip_quotation_creation=True).get('product_info') - if product_info: - r.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None - - return results - - -def get_conditions(filter_list, and_or='and'): - from frappe.model.db_query import DatabaseQuery - - if not filter_list: - return '' - - conditions = [] - DatabaseQuery('Item').build_filter_conditions(filter_list, conditions, ignore_permissions=True) - join_by = ' {0} '.format(and_or) - - return '(' + join_by.join(conditions) + ')' - -# utilities - -def get_item_attributes(item_code): - attributes = frappe.db.get_all('Item Variant Attribute', - fields=['attribute'], - filters={ - 'parenttype': 'Item', - 'parent': item_code - }, - order_by='idx asc' - ) - - optional_attributes = ItemVariantsCacheManager(item_code).get_optional_attributes() - - for a in attributes: - if a.attribute in optional_attributes: - a.optional = True - - return attributes - -def get_html_for_items(items): - html = [] - for item in items: - html.append(frappe.render_template('erpnext/www/all-products/item_row.html', { - 'item': item - })) - return html - -def get_product_settings(): - doc = frappe.get_cached_doc('Products Settings') - doc.products_per_page = doc.products_per_page or 20 - return doc diff --git a/erpnext/portal/utils.py b/erpnext/portal/utils.py index 974b51ef0a5..24bcab445ad 100644 --- a/erpnext/portal/utils.py +++ b/erpnext/portal/utils.py @@ -1,10 +1,10 @@ import frappe from frappe.utils.nestedset import get_root_of -from erpnext.shopping_cart.cart import get_debtors_account -from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( +from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( get_shopping_cart_settings, ) +from erpnext.e_commerce.shopping_cart.cart import get_debtors_account def set_default_role(doc, method): diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index 2570df70261..1cda0a08c47 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -235,13 +235,13 @@ { "fieldname": "actual_start_date", "fieldtype": "Data", - "label": "Actual Start Date", + "label": "Actual Start Date (via Time Sheet)", "read_only": 1 }, { "fieldname": "actual_time", "fieldtype": "Float", - "label": "Actual Time (in Hours)", + "label": "Actual Time (in Hours via Time Sheet)", "read_only": 1 }, { @@ -251,7 +251,7 @@ { "fieldname": "actual_end_date", "fieldtype": "Date", - "label": "Actual End Date", + "label": "Actual End Date (via Time Sheet)", "oldfieldname": "act_completion_date", "oldfieldtype": "Date", "read_only": 1 @@ -458,10 +458,11 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, - "modified": "2021-04-28 16:36:11.654632", + "modified": "2022-01-29 13:58:27.712714", "modified_by": "Administrator", "module": "Projects", "name": "Project", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -499,6 +500,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "customer", "title_field": "project_name", "track_seen": 1 diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json index ef4740d9eef..81822085a58 100644 --- a/erpnext/projects/doctype/task/task.json +++ b/erpnext/projects/doctype/task/task.json @@ -249,7 +249,7 @@ { "fieldname": "actual_time", "fieldtype": "Float", - "label": "Actual Time (in hours)", + "label": "Actual Time (in Hours via Time Sheet)", "read_only": 1 }, { @@ -397,10 +397,11 @@ "is_tree": 1, "links": [], "max_attachments": 5, - "modified": "2021-04-16 12:46:51.556741", + "modified": "2022-01-29 13:58:47.005241", "modified_by": "Administrator", "module": "Projects", "name": "Task", + "naming_rule": "Expression (old style)", "nsm_parent_field": "parent_task", "owner": "Administrator", "permissions": [ @@ -421,6 +422,7 @@ "show_preview_popup": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "project", "title_field": "subject", "track_seen": 1 diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 989bcd1670d..8b603570217 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -151,6 +151,35 @@ class TestTimesheet(unittest.TestCase): settings.ignore_employee_time_overlap = initial_setting settings.save() + def test_timesheet_not_overlapping_with_continuous_timelogs(self): + emp = make_employee("test_employee_6@salary.com") + + update_activity_type("_Test Activity Type") + timesheet = frappe.new_doc("Timesheet") + timesheet.employee = emp + timesheet.append( + 'time_logs', + { + "billable": 1, + "activity_type": "_Test Activity Type", + "from_time": now_datetime(), + "to_time": now_datetime() + datetime.timedelta(hours=3), + "company": "_Test Company" + } + ) + timesheet.append( + 'time_logs', + { + "billable": 1, + "activity_type": "_Test Activity Type", + "from_time": now_datetime() + datetime.timedelta(hours=3), + "to_time": now_datetime() + datetime.timedelta(hours=4), + "company": "_Test Company" + } + ) + + timesheet.save() # should not throw an error + def test_to_time(self): emp = make_employee("test_employee_6@salary.com") from_time = now_datetime() diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index dd0b5f90f4d..b44d5017431 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours +from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours from erpnext.controllers.queries import get_match_cond from erpnext.hr.utils import validate_active_employee @@ -145,7 +145,7 @@ class Timesheet(Document): if not (data.from_time and data.hours): return - _to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True) + _to_time = get_datetime(add_to_date(data.from_time, hours=data.hours, as_datetime=True)) if data.to_time != _to_time: data.to_time = _to_time @@ -171,39 +171,54 @@ class Timesheet(Document): .format(args.idx, self.name, existing.name), OverlapError) def get_overlap_for(self, fieldname, args, value): - cond = "ts.`{0}`".format(fieldname) - if fieldname == 'workstation': - cond = "tsd.`{0}`".format(fieldname) + timesheet = frappe.qb.DocType("Timesheet") + timelog = frappe.qb.DocType("Timesheet Detail") - existing = frappe.db.sql("""select ts.name as name, tsd.from_time as from_time, tsd.to_time as to_time from - `tabTimesheet Detail` tsd, `tabTimesheet` ts where {0}=%(val)s and tsd.parent = ts.name and - ( - (%(from_time)s > tsd.from_time and %(from_time)s < tsd.to_time) or - (%(to_time)s > tsd.from_time and %(to_time)s < tsd.to_time) or - (%(from_time)s <= tsd.from_time and %(to_time)s >= tsd.to_time)) - and tsd.name!=%(name)s - and ts.name!=%(parent)s - and ts.docstatus < 2""".format(cond), - { - "val": value, - "from_time": args.from_time, - "to_time": args.to_time, - "name": args.name or "No Name", - "parent": args.parent or "No Name" - }, as_dict=True) - # check internal overlap - for time_log in self.time_logs: - if not (time_log.from_time and time_log.to_time - and args.from_time and args.to_time): continue + from_time = get_datetime(args.from_time) + to_time = get_datetime(args.to_time) - if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \ - args.idx != time_log.idx and ((args.from_time > time_log.from_time and args.from_time < time_log.to_time) or - (args.to_time > time_log.from_time and args.to_time < time_log.to_time) or - (args.from_time <= time_log.from_time and args.to_time >= time_log.to_time)): - return self + existing = ( + frappe.qb.from_(timesheet) + .join(timelog) + .on(timelog.parent == timesheet.name) + .select(timesheet.name.as_('name'), timelog.from_time.as_('from_time'), timelog.to_time.as_('to_time')) + .where( + (timelog.name != (args.name or "No Name")) + & (timesheet.name != (args.parent or "No Name")) + & (timesheet.docstatus < 2) + & (timesheet[fieldname] == value) + & ( + ((from_time > timelog.from_time) & (from_time < timelog.to_time)) + | ((to_time > timelog.from_time) & (to_time < timelog.to_time)) + | ((from_time <= timelog.from_time) & (to_time >= timelog.to_time)) + ) + ) + ).run(as_dict=True) + + if self.check_internal_overlap(fieldname, args): + return self return existing[0] if existing else None + def check_internal_overlap(self, fieldname, args): + for time_log in self.time_logs: + if not (time_log.from_time and time_log.to_time + and args.from_time and args.to_time): + continue + + from_time = get_datetime(time_log.from_time) + to_time = get_datetime(time_log.to_time) + args_from_time = get_datetime(args.from_time) + args_to_time = get_datetime(args.to_time) + + if (args.get(fieldname) == time_log.get(fieldname)) and (args.idx != time_log.idx) and ( + (args_from_time > from_time and args_from_time < to_time) + or (args_to_time > from_time and args_to_time < to_time) + or (args_from_time <= from_time and args_to_time >= to_time) + ): + return True + return False + def update_cost(self): for data in self.time_logs: if data.activity_type or data.is_billable: diff --git a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json index ee04c612c9a..90fdb833315 100644 --- a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json +++ b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json @@ -14,12 +14,6 @@ "to_time", "hours", "completed", - "section_break_7", - "completed_qty", - "workstation", - "column_break_12", - "operation", - "operation_id", "project_details", "project", "project_name", @@ -83,43 +77,6 @@ "fieldtype": "Check", "label": "Completed" }, - { - "fieldname": "section_break_7", - "fieldtype": "Section Break" - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "completed_qty", - "fieldtype": "Float", - "label": "Completed Qty" - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "workstation", - "fieldtype": "Link", - "label": "Workstation", - "options": "Workstation", - "read_only": 1 - }, - { - "fieldname": "column_break_12", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "operation", - "fieldtype": "Link", - "label": "Operation", - "options": "Operation", - "read_only": 1 - }, - { - "depends_on": "eval:parent.work_order", - "fieldname": "operation_id", - "fieldtype": "Data", - "hidden": 1, - "label": "Operation Id" - }, { "fieldname": "project_details", "fieldtype": "Section Break" @@ -267,7 +224,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-05-18 12:19:33.205940", + "modified": "2022-02-17 16:53:34.878798", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet Detail", @@ -275,5 +232,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/public/build.json b/erpnext/public/build.json index f8e817770d5..91a752c291d 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -7,7 +7,8 @@ ], "js/erpnext-web.min.js": [ "public/js/website_utils.js", - "public/js/shopping_cart.js" + "public/js/shopping_cart.js", + "public/js/wishlist.js" ], "css/erpnext-web.css": [ "public/scss/website.scss", @@ -38,7 +39,8 @@ "public/js/utils/dimension_tree_filter.js", "public/js/telephony.js", "public/js/templates/call_link.html", - "public/js/templates/node_card.html" + "public/js/templates/node_card.html", + "public/js/bulk_transaction_processing.js" ], "js/item-dashboard.min.js": [ "stock/dashboard/item_dashboard.html", @@ -65,5 +67,11 @@ "js/hierarchy-chart.min.js": [ "public/js/hierarchy_chart/hierarchy_chart_desktop.js", "public/js/hierarchy_chart/hierarchy_chart_mobile.js" + ], + "js/e-commerce.min.js": [ + "e_commerce/product_ui/views.js", + "e_commerce/product_ui/grid.js", + "e_commerce/product_ui/list.js", + "e_commerce/product_ui/search.js" ] } diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index ca73393c546..214a1be1344 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -181,6 +181,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "journal_entry", onchange: () => this.update_options(), }, + { + fieldtype: "Check", + label: "Loan Repayment", + fieldname: "loan_repayment", + onchange: () => this.update_options(), + }, { fieldname: "column_break_5", fieldtype: "Column Break", @@ -191,13 +197,18 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "sales_invoice", onchange: () => this.update_options(), }, - { fieldtype: "Check", label: "Purchase Invoice", fieldname: "purchase_invoice", onchange: () => this.update_options(), }, + { + fieldtype: "Check", + label: "Show Only Exact Amount", + fieldname: "exact_match", + onchange: () => this.update_options(), + }, { fieldname: "column_break_5", fieldtype: "Column Break", @@ -210,8 +221,8 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { }, { fieldtype: "Check", - label: "Show Only Exact Amount", - fieldname: "exact_match", + label: "Loan Disbursement", + fieldname: "loan_disbursement", onchange: () => this.update_options(), }, { diff --git a/erpnext/public/js/bulk_transaction_processing.js b/erpnext/public/js/bulk_transaction_processing.js new file mode 100644 index 00000000000..101f50c64aa --- /dev/null +++ b/erpnext/public/js/bulk_transaction_processing.js @@ -0,0 +1,30 @@ +frappe.provide("erpnext.bulk_transaction_processing"); + +$.extend(erpnext.bulk_transaction_processing, { + create: function(listview, from_doctype, to_doctype) { + let checked_items = listview.get_checked_items(); + const doc_name = []; + checked_items.forEach((Item)=> { + if (Item.docstatus == 0) { + doc_name.push(Item.name); + } + }); + + let count_of_rows = checked_items.length; + frappe.confirm(__("Create {0} {1} ?", [count_of_rows, to_doctype]), ()=>{ + if (doc_name.length == 0) { + frappe.call({ + method: "erpnext.utilities.bulk_transaction.transaction_processing", + args: {data: checked_items, from_doctype: from_doctype, to_doctype: to_doctype} + }).then(()=> { + + }); + if (count_of_rows > 10) { + frappe.show_alert("Starting a background job to create {0} {1}", [count_of_rows, to_doctype]); + } + } else { + frappe.msgprint(__("Selected document must be in submitted state")); + } + }); + } +}); \ No newline at end of file diff --git a/erpnext/public/js/conf.js b/erpnext/public/js/conf.js index eb709e5e85e..a0f56a2d07f 100644 --- a/erpnext/public/js/conf.js +++ b/erpnext/public/js/conf.js @@ -21,6 +21,6 @@ $.extend(frappe.breadcrumbs.module_map, { 'Geo': 'Settings', 'Portal': 'Website', 'Utilities': 'Settings', - 'Shopping Cart': 'Website', + 'E-commerce': 'Website', 'Contacts': 'CRM' }); diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index d696ef55ae6..54e5daa6bd4 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -441,7 +441,7 @@ erpnext.buying.get_items_from_product_bundle = function(frm) { type: "GET", method: "erpnext.stock.doctype.packed_item.packed_item.get_items_from_product_bundle", args: { - args: { + row: { item_code: args.product_bundle, quantity: args.quantity, parenttype: frm.doc.doctype, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 37917416635..00373a65138 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -525,6 +525,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe item.weight_per_unit = 0; item.weight_uom = ''; + item.conversion_factor = 0; if(['Sales Invoice'].includes(this.frm.doc.doctype)) { update_stock = cint(me.frm.doc.update_stock); @@ -719,6 +720,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe 'posting_time': posting_time, 'qty': item.qty * item.conversion_factor, 'serial_no': item.serial_no, + 'batch_no': item.batch_no, 'voucher_type': voucher_type, 'company': company, 'allow_zero_valuation_rate': item.allow_zero_valuation_rate @@ -1463,7 +1465,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "item_code": d.item_code, "pricing_rules": d.pricing_rules, "parenttype": d.parenttype, - "parent": d.parent + "parent": d.parent, + "price_list_rate": d.price_list_rate }) } }); @@ -2282,18 +2285,15 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } coupon_code() { - var me = this; - if (this.frm.doc.coupon_code) { - frappe.run_serially([ + if (this.frm.doc.coupon_code || this.frm._last_coupon_code) { + // reset pricing rules if coupon code is set or is unset + const _ignore_pricing_rule = this.frm.doc.ignore_pricing_rule; + return frappe.run_serially([ () => this.frm.doc.ignore_pricing_rule=1, - () => me.ignore_pricing_rule(), - () => this.frm.doc.ignore_pricing_rule=0, - () => me.apply_pricing_rule() - ]); - } else { - frappe.run_serially([ - () => this.frm.doc.ignore_pricing_rule=1, - () => me.ignore_pricing_rule() + () => this.frm.trigger('ignore_pricing_rule'), + () => this.frm.doc.ignore_pricing_rule=_ignore_pricing_rule, + () => this.frm.trigger('apply_pricing_rule'), + () => this.frm._last_coupon_code = this.frm.doc.coupon_code ]); } } diff --git a/erpnext/public/js/customer_reviews.js b/erpnext/public/js/customer_reviews.js new file mode 100644 index 00000000000..e13ded6b489 --- /dev/null +++ b/erpnext/public/js/customer_reviews.js @@ -0,0 +1,138 @@ +$(() => { + class CustomerReviews { + constructor() { + this.bind_button_actions(); + this.start = 0; + this.page_length = 10; + } + + bind_button_actions() { + this.write_review(); + this.view_more(); + } + + write_review() { + //TODO: make dialog popup on stray page + $('.page_content').on('click', '.btn-write-review', (e) => { + // Bind action on write a review button + const $btn = $(e.currentTarget); + + let d = new frappe.ui.Dialog({ + title: __("Write a Review"), + fields: [ + {fieldname: "title", fieldtype: "Data", label: "Headline", reqd: 1}, + {fieldname: "rating", fieldtype: "Rating", label: "Overall Rating", reqd: 1}, + {fieldtype: "Section Break"}, + {fieldname: "comment", fieldtype: "Small Text", label: "Your Review"} + ], + primary_action: function() { + let data = d.get_values(); + frappe.call({ + method: "erpnext.e_commerce.doctype.item_review.item_review.add_item_review", + args: { + web_item: $btn.attr('data-web-item'), + title: data.title, + rating: data.rating, + comment: data.comment + }, + freeze: true, + freeze_message: __("Submitting Review ..."), + callback: (r) => { + if (!r.exc) { + frappe.msgprint({ + message: __("Thank you for submitting your review"), + title: __("Review Submitted"), + indicator: "green" + }); + d.hide(); + location.reload(); + } + } + }); + }, + primary_action_label: __('Submit') + }); + d.show(); + }); + } + + view_more() { + $('.page_content').on('click', '.btn-view-more', (e) => { + // Bind action on view more button + const $btn = $(e.currentTarget); + $btn.prop('disabled', true); + + this.start += this.page_length; + let me = this; + + frappe.call({ + method: "erpnext.e_commerce.doctype.item_review.item_review.get_item_reviews", + args: { + web_item: $btn.attr('data-web-item'), + start: me.start, + end: me.page_length + }, + callback: (result) => { + if (result.message) { + let res = result.message; + me.get_user_review_html(res.reviews); + + $btn.prop('disabled', false); + if (res.total_reviews <= (me.start + me.page_length)) { + $btn.hide(); + } + + } + } + }); + }); + + } + + get_user_review_html(reviews) { + let me = this; + let $content = $('.user-reviews'); + + reviews.forEach((review) => { + $content.append(` +
+
+

+ ${__(review.review_title)} +

+
+ ${me.get_review_stars(review.rating)} +
+
+ +
+

+ ${__(review.comment)} +

+
+
+ ${__(review.customer)} + + ${__(review.published_on)} +
+
+ `); + }); + } + + get_review_stars(rating) { + let stars = ``; + for (let i = 1; i < 6; i++) { + let fill_class = i <= rating ? 'star-click' : ''; + stars += ` + + + + `; + } + return stars; + } + } + + new CustomerReviews(); +}); \ No newline at end of file diff --git a/erpnext/public/js/education/student_button.html b/erpnext/public/js/education/student_button.html deleted file mode 100644 index b64c73a43c7..00000000000 --- a/erpnext/public/js/education/student_button.html +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
-
diff --git a/erpnext/public/js/erpnext-web.bundle.js b/erpnext/public/js/erpnext-web.bundle.js index 7db69679236..cbe899dc060 100644 --- a/erpnext/public/js/erpnext-web.bundle.js +++ b/erpnext/public/js/erpnext-web.bundle.js @@ -1,2 +1,8 @@ import "./website_utils"; +import "./wishlist"; import "./shopping_cart"; +import "./customer_reviews"; +import "../../e_commerce/product_ui/list"; +import "../../e_commerce/product_ui/views"; +import "../../e_commerce/product_ui/grid"; +import "../../e_commerce/product_ui/search"; \ No newline at end of file diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 5259bdcc765..8409e78860b 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -16,11 +16,11 @@ import "./templates/item_quick_entry.html"; import "./utils/item_quick_entry"; import "./utils/customer_quick_entry"; import "./utils/supplier_quick_entry"; -import "./education/student_button.html"; import "./education/assessment_result_tool.html"; import "./call_popup/call_popup"; import "./utils/dimension_tree_filter"; import "./telephony"; import "./templates/call_link.html"; +import "./bulk_transaction_processing"; // import { sum } from 'frappe/public/utils/util.js' diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 831626aa915..a585aa614fb 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -304,12 +304,13 @@ erpnext.HierarchyChart = class { } get_child_nodes(node_id) { + let me = this; return new Promise(resolve => { frappe.call({ - method: this.method, + method: me.method, args: { parent: node_id, - company: this.company + company: me.company } }).then(r => resolve(r.message)); }); @@ -350,12 +351,13 @@ erpnext.HierarchyChart = class { } get_all_nodes() { + let me = this; return new Promise(resolve => { frappe.call({ method: 'erpnext.utilities.hierarchy_chart.get_all_nodes', args: { - method: this.method, - company: this.company + method: me.method, + company: me.company }, callback: (r) => { resolve(r.message); @@ -427,8 +429,8 @@ erpnext.HierarchyChart = class { add_connector(parent_id, child_id) { // using pure javascript for better performance - const parent_node = document.querySelector(`#${parent_id}`); - const child_node = document.querySelector(`#${child_id}`); + const parent_node = document.getElementById(`${parent_id}`); + const child_node = document.getElementById(`${child_id}`); let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js index 0a8ba78f643..52236e7df96 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_mobile.js @@ -235,7 +235,7 @@ erpnext.HierarchyChartMobile = class { let me = this; return new Promise(resolve => { frappe.call({ - method: this.method, + method: me.method, args: { parent: node_id, company: me.company, @@ -286,8 +286,8 @@ erpnext.HierarchyChartMobile = class { } add_connector(parent_id, child_id) { - const parent_node = document.querySelector(`#${parent_id}`); - const child_node = document.querySelector(`#${child_id}`); + const parent_node = document.getElementById(`${parent_id}`); + const child_node = document.getElementById(`${child_id}`); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); @@ -518,7 +518,8 @@ erpnext.HierarchyChartMobile = class { level.nextAll('li').remove(); let node_object = this.nodes[node.id]; - let current_node = level.find(`#${node.id}`).detach(); + let current_node = level.find(`[id="${node.id}"]`).detach(); + current_node.removeClass('active-child active-path'); node_object.expanded = 0; diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js index e746ce9ae09..83b69aebc5b 100644 --- a/erpnext/public/js/setup_wizard.js +++ b/erpnext/public/js/setup_wizard.js @@ -78,11 +78,11 @@ erpnext.setup.slides_settings = [ slide.get_input("company_name").on("change", function () { var parts = slide.get_input("company_name").val().split(" "); var abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join(""); - slide.get_field("company_abbr").set_value(abbr.slice(0, 5).toUpperCase()); + slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase()); }).val(frappe.boot.sysdefaults.company_name || "").trigger("change"); slide.get_input("company_abbr").on("change", function () { - if (slide.get_input("company_abbr").val().length > 5) { + if (slide.get_input("company_abbr").val().length > 10) { frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters")); slide.get_field("company_abbr").set_value(""); } @@ -96,7 +96,7 @@ erpnext.setup.slides_settings = [ if (!this.values.company_abbr) { return false; } - if (this.values.company_abbr.length > 5) { + if (this.values.company_abbr.length > 10) { return false; } return true; diff --git a/erpnext/public/js/shopping_cart.js b/erpnext/public/js/shopping_cart.js index 6a923ae4234..d14740c1060 100644 --- a/erpnext/public/js/shopping_cart.js +++ b/erpnext/public/js/shopping_cart.js @@ -2,8 +2,8 @@ // License: GNU General Public License v3. See license.txt // shopping cart -frappe.provide("erpnext.shopping_cart"); -var shopping_cart = erpnext.shopping_cart; +frappe.provide("erpnext.e_commerce.shopping_cart"); +var shopping_cart = erpnext.e_commerce.shopping_cart; var getParams = function (url) { var params = []; @@ -51,10 +51,10 @@ frappe.ready(function() { if (referral_sales_partner) { $(".txtreferral_sales_partner").val(referral_sales_partner); } + // update login shopping_cart.show_shoppingcart_dropdown(); shopping_cart.set_cart_count(); - shopping_cart.bind_dropdown_cart_buttons(); shopping_cart.show_cart_navbar(); }); @@ -63,7 +63,7 @@ $.extend(shopping_cart, { $(".shopping-cart").on('shown.bs.dropdown', function() { if (!$('.shopping-cart-menu .cart-container').length) { return frappe.call({ - method: 'erpnext.shopping_cart.cart.get_shopping_cart_menu', + method: 'erpnext.e_commerce.shopping_cart.cart.get_shopping_cart_menu', callback: function(r) { if (r.message) { $('.shopping-cart-menu').html(r.message); @@ -75,15 +75,18 @@ $.extend(shopping_cart, { }, update_cart: function(opts) { - if(frappe.session.user==="Guest") { - if(localStorage) { + if (frappe.session.user==="Guest") { + if (localStorage) { localStorage.setItem("last_visited", window.location.pathname); } - window.location.href = "/login"; + frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => { + window.location.href = res.message || "/login"; + }); } else { + shopping_cart.freeze(); return frappe.call({ type: "POST", - method: "erpnext.shopping_cart.cart.update_cart", + method: "erpnext.e_commerce.shopping_cart.cart.update_cart", args: { item_code: opts.item_code, qty: opts.qty, @@ -92,10 +95,8 @@ $.extend(shopping_cart, { }, btn: opts.btn, callback: function(r) { - shopping_cart.set_cart_count(); - if (r.message.shopping_cart_menu) { - $('.shopping-cart-menu').html(r.message.shopping_cart_menu); - } + shopping_cart.unfreeze(); + shopping_cart.set_cart_count(true); if(opts.callback) opts.callback(r); } @@ -103,7 +104,9 @@ $.extend(shopping_cart, { } }, - set_cart_count: function() { + set_cart_count: function(animate=false) { + $(".intermediate-empty-cart").remove(); + var cart_count = frappe.get_cookie("cart_count"); if(frappe.session.user==="Guest") { cart_count = 0; @@ -118,24 +121,37 @@ $.extend(shopping_cart, { if(parseInt(cart_count) === 0 || cart_count === undefined) { $cart.css("display", "none"); - $(".cart-items").html('Cart is Empty'); $(".cart-tax-items").hide(); $(".btn-place-order").hide(); - $(".cart-addresses").hide(); + $(".cart-payment-addresses").hide(); + + let intermediate_empty_cart_msg = ` +
+ ${ __("Cart is Empty") } +
+ `; + $(".cart-table").after(intermediate_empty_cart_msg); } else { $cart.css("display", "inline"); + $("#cart-count").text(cart_count); } if(cart_count) { $badge.html(cart_count); + + if (animate) { + $cart.addClass("cart-animate"); + setTimeout(() => { + $cart.removeClass("cart-animate"); + }, 500); + } } else { $badge.remove(); } }, shopping_cart_update: function({item_code, qty, cart_dropdown, additional_notes}) { - frappe.freeze(); shopping_cart.update_cart({ item_code, qty, @@ -143,10 +159,12 @@ $.extend(shopping_cart, { with_items: 1, btn: this, callback: function(r) { - frappe.unfreeze(); if(!r.exc) { $(".cart-items").html(r.message.items); - $(".cart-tax-items").html(r.message.taxes); + $(".cart-tax-items").html(r.message.total); + $(".payment-summary").html(r.message.taxes_and_totals); + shopping_cart.set_cart_count(); + if (cart_dropdown != true) { $(".cart-icon").hide(); } @@ -155,35 +173,71 @@ $.extend(shopping_cart, { }); }, - - bind_dropdown_cart_buttons: function () { - $(".cart-icon").on('click', '.number-spinner button', function () { - var btn = $(this), - input = btn.closest('.number-spinner').find('input'), - oldValue = input.val().trim(), - newVal = 0; - - if (btn.attr('data-dir') == 'up') { - newVal = parseInt(oldValue) + 1; - } else { - if (oldValue > 1) { - newVal = parseInt(oldValue) - 1; - } - } - input.val(newVal); - var item_code = input.attr("data-item-code"); - shopping_cart.shopping_cart_update({item_code, qty: newVal, cart_dropdown: true}); - return false; - }); - - }, - show_cart_navbar: function () { frappe.call({ - method: "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.is_cart_enabled", + method: "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.is_cart_enabled", callback: function(r) { $(".shopping-cart").toggleClass('hidden', r.message ? false : true); } }); + }, + + toggle_button_class(button, remove, add) { + button.removeClass(remove); + button.addClass(add); + }, + + bind_add_to_cart_action() { + $('.page_content').on('click', '.btn-add-to-cart-list', (e) => { + const $btn = $(e.currentTarget); + $btn.prop('disabled', true); + + if (frappe.session.user==="Guest") { + if (localStorage) { + localStorage.setItem("last_visited", window.location.pathname); + } + frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => { + window.location.href = res.message || "/login"; + }); + return; + } + + $btn.addClass('hidden'); + $btn.closest('.cart-action-container').addClass('d-flex'); + $btn.parent().find('.go-to-cart').removeClass('hidden'); + $btn.parent().find('.go-to-cart-grid').removeClass('hidden'); + $btn.parent().find('.cart-indicator').removeClass('hidden'); + + const item_code = $btn.data('item-code'); + erpnext.e_commerce.shopping_cart.update_cart({ + item_code, + qty: 1 + }); + + }); + }, + + freeze() { + if (window.location.pathname !== "/cart") return; + + if (!$('#freeze').length) { + let freeze = $('') + .appendTo("body"); + + setTimeout(function() { + freeze.addClass("show"); + }, 1); + } else { + $("#freeze").addClass("show"); + } + }, + + unfreeze() { + if ($('#freeze').length) { + let freeze = $('#freeze').removeClass("show"); + setTimeout(function() { + freeze.remove(); + }, 1); + } } }); diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 597d77c6e9f..08270bdea1a 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -592,6 +592,6 @@ function check_can_calculate_pending_qty(me) { && doc.fg_completed_qty && erpnext.stock.bom && erpnext.stock.bom.name === doc.bom_no; - const itemChecks = !!item; + const itemChecks = !!item && !item.allow_alternative_item; return docChecks && itemChecks; } diff --git a/erpnext/public/js/wishlist.js b/erpnext/public/js/wishlist.js new file mode 100644 index 00000000000..f6599e9f6d1 --- /dev/null +++ b/erpnext/public/js/wishlist.js @@ -0,0 +1,204 @@ +frappe.provide("erpnext.e_commerce.wishlist"); +var wishlist = erpnext.e_commerce.wishlist; + +frappe.provide("erpnext.e_commerce.shopping_cart"); +var shopping_cart = erpnext.e_commerce.shopping_cart; + +$.extend(wishlist, { + set_wishlist_count: function(animate=false) { + // set badge count for wishlist icon + var wish_count = frappe.get_cookie("wish_count"); + if (frappe.session.user==="Guest") { + wish_count = 0; + } + + if (wish_count) { + $(".wishlist").toggleClass('hidden', false); + } + + var $wishlist = $('.wishlist-icon'); + var $badge = $wishlist.find("#wish-count"); + + if (parseInt(wish_count) === 0 || wish_count === undefined) { + $wishlist.css("display", "none"); + } else { + $wishlist.css("display", "inline"); + } + if (wish_count) { + $badge.html(wish_count); + if (animate) { + $wishlist.addClass('cart-animate'); + setTimeout(() => { + $wishlist.removeClass('cart-animate'); + }, 500); + } + } else { + $badge.remove(); + } + }, + + bind_move_to_cart_action: function() { + // move item to cart from wishlist + $('.page_content').on("click", ".btn-add-to-cart", (e) => { + const $move_to_cart_btn = $(e.currentTarget); + let item_code = $move_to_cart_btn.data("item-code"); + + shopping_cart.shopping_cart_update({ + item_code, + qty: 1, + cart_dropdown: true + }); + + let success_action = function() { + const $card_wrapper = $move_to_cart_btn.closest(".wishlist-card"); + $card_wrapper.addClass("wish-removed"); + }; + let args = { item_code: item_code }; + this.add_remove_from_wishlist("remove", args, success_action, null, true); + }); + }, + + bind_remove_action: function() { + // remove item from wishlist + let me = this; + + $('.page_content').on("click", ".remove-wish", (e) => { + const $remove_wish_btn = $(e.currentTarget); + let item_code = $remove_wish_btn.data("item-code"); + + let success_action = function() { + const $card_wrapper = $remove_wish_btn.closest(".wishlist-card"); + $card_wrapper.addClass("wish-removed"); + if (frappe.get_cookie("wish_count") == 0) { + $(".page_content").empty(); + me.render_empty_state(); + } + }; + let args = { item_code: item_code }; + this.add_remove_from_wishlist("remove", args, success_action); + }); + }, + + bind_wishlist_action() { + // 'wish'('like') or 'unwish' item in product listing + $('.page_content').on('click', '.like-action, .like-action-list', (e) => { + const $btn = $(e.currentTarget); + this.wishlist_action($btn); + }); + }, + + wishlist_action(btn) { + const $wish_icon = btn.find('.wish-icon'); + let me = this; + + if (frappe.session.user==="Guest") { + if (localStorage) { + localStorage.setItem("last_visited", window.location.pathname); + } + this.redirect_guest(); + return; + } + + let success_action = function() { + erpnext.e_commerce.wishlist.set_wishlist_count(true); + }; + + if ($wish_icon.hasClass('wished')) { + // un-wish item + btn.removeClass("like-animate"); + btn.addClass("like-action-wished"); + this.toggle_button_class($wish_icon, 'wished', 'not-wished'); + + let args = { item_code: btn.data('item-code') }; + let failure_action = function() { + me.toggle_button_class($wish_icon, 'not-wished', 'wished'); + }; + this.add_remove_from_wishlist("remove", args, success_action, failure_action); + } else { + // wish item + btn.addClass("like-animate"); + btn.addClass("like-action-wished"); + this.toggle_button_class($wish_icon, 'not-wished', 'wished'); + + let args = {item_code: btn.data('item-code')}; + let failure_action = function() { + me.toggle_button_class($wish_icon, 'wished', 'not-wished'); + }; + this.add_remove_from_wishlist("add", args, success_action, failure_action); + } + }, + + toggle_button_class(button, remove, add) { + button.removeClass(remove); + button.addClass(add); + }, + + add_remove_from_wishlist(action, args, success_action, failure_action, async=false) { + /* AJAX call to add or remove Item from Wishlist + action: "add" or "remove" + args: args for method (item_code, price, formatted_price), + success_action: method to execute on successs, + failure_action: method to execute on failure, + async: make call asynchronously (true/false). */ + if (frappe.session.user==="Guest") { + if (localStorage) { + localStorage.setItem("last_visited", window.location.pathname); + } + this.redirect_guest(); + } else { + let method = "erpnext.e_commerce.doctype.wishlist.wishlist.add_to_wishlist"; + if (action === "remove") { + method = "erpnext.e_commerce.doctype.wishlist.wishlist.remove_from_wishlist"; + } + + frappe.call({ + async: async, + type: "POST", + method: method, + args: args, + callback: function (r) { + if (r.exc) { + if (failure_action && (typeof failure_action === 'function')) { + failure_action(); + } + frappe.msgprint({ + message: __("Sorry, something went wrong. Please refresh."), + indicator: "red", title: __("Note") + }); + } else if (success_action && (typeof success_action === 'function')) { + success_action(); + } + } + }); + } + }, + + redirect_guest() { + frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => { + window.location.href = res.message || "/login"; + }); + }, + + render_empty_state() { + $(".page_content").append(` +
+
+ Empty Cart +
+
${ __('Wishlist is empty !') }

+
+ `); + } + +}); + +frappe.ready(function() { + if (window.location.pathname !== "/wishlist") { + $(".wishlist").toggleClass('hidden', true); + wishlist.set_wishlist_count(); + } else { + wishlist.bind_move_to_cart_action(); + wishlist.bind_remove_action(); + } + +}); \ No newline at end of file diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index fef1e76154f..666043b2192 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -1,16 +1,17 @@ @import "frappe/public/scss/common/mixins"; -body.product-page { - background: var(--gray-50); +:root { + --green-info: #38A160; + --product-bg-color: white; + --body-bg-color: var(--gray-50); } +body.product-page { + background: var(--body-bg-color); +} .item-breadcrumbs { .breadcrumb-container { - ol.breadcrumb { - background-color: var(--gray-50) !important; - } - a { color: var(--gray-900); } @@ -71,9 +72,21 @@ body.product-page { } } +.no-image-item { + height: 340px; + width: 340px; + background: var(--gray-100); + border-radius: var(--border-radius); + font-size: 2rem; + color: var(--gray-500); + display: flex; + align-items: center; + justify-content: center; +} + .item-card-group-section { .card { - height: 360px; + height: 100%; align-items: center; justify-content: center; @@ -83,6 +96,19 @@ body.product-page { } } + .card:hover, .card:focus-within { + .btn-add-to-cart-list { + visibility: visible; + } + .like-action { + visibility: visible; + } + .btn-explore-variants { + visibility: visible; + } + } + + .card-img-container { height: 210px; width: 100%; @@ -96,14 +122,28 @@ body.product-page { .no-image { @include flex(flex, center, center, null); - height: 200px; - margin: 0 auto; - margin-top: var(--margin-xl); + height: 220px; + background: var(--gray-100); + width: 100%; + border-radius: var(--border-radius) var(--border-radius) 0 0; + font-size: 2rem; + color: var(--gray-500); + } + + .no-image-list { + @include flex(flex, center, center, null); + height: 150px; background: var(--gray-100); - width: 80%; border-radius: var(--border-radius); font-size: 2rem; color: var(--gray-500); + margin-top: 15px; + margin-bottom: 15px; + } + + .card-body-flex { + display: flex; + flex-direction: column; } .product-title { @@ -136,15 +176,75 @@ body.product-page { font-weight: 600; color: var(--text-color); margin: var(--margin-sm) 0; + margin-bottom: auto !important; + + .striked-price { + font-weight: 500; + font-size: 15px; + color: var(--gray-500); + } + } + + .product-info-green { + color: var(--green-info); + font-weight: 600; } .item-card { padding: var(--padding-sm); + min-width: 300px; + } + + .wishlist-card { + padding: var(--padding-sm); + min-width: 260px; + .card-body-flex { + display: flex; + flex-direction: column; + } + } +} + +#products-list-area, #products-grid-area { + padding: 0 5px; +} + +.list-row { + background-color: white; + padding-bottom: 1rem; + padding-top: 1.5rem !important; + border-radius: 8px; + border-bottom: 1px solid var(--gray-50); + + &:hover, &:focus-within { + box-shadow: 0px 16px 60px rgba(0, 0, 0, 0.08), 0px 8px 30px -20px rgba(0, 0, 0, 0.04); + transition: box-shadow 400ms; + + .btn-add-to-cart-list { + visibility: visible; + } + .like-action-list { + visibility: visible; + } + .btn-explore-variants { + visibility: visible; + } + } + + .product-code { + padding-top: 0 !important; + } + + .btn-explore-variants { + min-width: 135px; + max-height: 30px; + float: right; + padding: 0.25rem 1rem; } } [data-doctype="Item Group"], -#page-all-products { +#page-index { .page-header { font-size: 20px; font-weight: 700; @@ -184,28 +284,76 @@ body.product-page { } } +.product-filter { + width: 14px !important; + height: 14px !important; +} + +.discount-filter { + &:before { + width: 14px !important; + height: 14px !important; + } +} + +.list-image { + border: none !important; + overflow: hidden; + max-height: 200px; + background-color: white; +} + .product-container { @include card($padding: var(--padding-md)); - min-height: 70vh; + background-color: var(--product-bg-color) !important; + min-height: fit-content; .product-details { - max-width: 40%; - margin-left: -30px; + max-width: 50%; .btn-add-to-cart { - font-size: var(--text-base); + font-size: 14px; + } + } + + &.item-main { + .product-image { + width: 100%; + } + } + + .expand { + max-width: 100% !important; // expand in absence of slideshow + } + + @media (max-width: 789px) { + .product-details { + max-width: 90% !important; + + .btn-add-to-cart { + font-size: 14px; + } + } + } + + .btn-add-to-wishlist { + svg use { + --icon-stroke: #F47A7A; + } + } + + .btn-view-in-wishlist { + svg use { + fill: #F47A7A; + --icon-stroke: none; } } .product-title { - font-size: 24px; + font-size: 16px; font-weight: 600; color: var(--text-color); - } - - .product-code { - color: var(--text-muted); - font-size: 13px; + padding: 0 !important; } .product-description { @@ -242,7 +390,7 @@ body.product-page { max-height: 430px; } - overflow: scroll; + overflow: auto; } .item-slideshow-image { @@ -261,29 +409,116 @@ body.product-page { .item-cart { .product-price { - font-size: 20px; + font-size: 22px; color: var(--text-color); font-weight: 600; .formatted-price { color: var(--text-muted); - font-size: var(--text-base); + font-size: 14px; } } .no-stock { font-size: var(--text-base); } + + .offers-heading { + font-size: 16px !important; + color: var(--text-color); + .tag-icon { + --icon-stroke: var(--gray-500); + } + } + + .w-30-40 { + width: 30%; + + @media (max-width: 992px) { + width: 40%; + } + } + } + + .tab-content { + font-size: 14px; + } +} + +// Item Recommendations +.recommended-item-section { + padding-right: 0; + + .recommendation-header { + font-size: 16px; + font-weight: 500 + } + + .recommendation-container { + padding: .5rem; + min-height: 0px; + + .r-item-image { + min-height: 100px; + width: 40%; + + .r-product-image { + padding: 2px 15px; + } + + .no-image-r-item { + display: flex; justify-content: center; + background-color: var(--gray-200); + align-items: center; + color: var(--gray-400); + margin-top: .15rem; + border-radius: 6px; + height: 100%; + font-size: 24px; + } + } + + .r-item-info { + font-size: 14px; + padding-right: 0; + padding-left: 10px; + width: 60%; + + a { + color: var(--gray-800); + font-weight: 400; + } + + .item-price { + font-size: 15px; + font-weight: 600; + color: var(--text-color); + } + + .striked-item-price { + font-weight: 500; + color: var(--gray-500); + } + } + } +} + +.product-code { + padding: .5rem 0; + color: var(--text-muted); + font-size: 14px; + .product-item-group { + padding-right: .25rem; + border-right: solid 1px var(--text-muted); + } + + .product-item-code { + padding-left: .5rem; } } .item-configurator-dialog { - .modal-header { - padding: var(--padding-md) var(--padding-xl); - } - .modal-body { - padding: 0 var(--padding-xl); padding-bottom: var(--padding-xl); .status-area { @@ -323,20 +558,73 @@ body.product-page { } } -.cart-icon { - .cart-badge { - position: relative; - top: -10px; - left: -12px; - background: var(--red-600); - width: 16px; - align-items: center; - height: 16px; - font-size: 10px; - border-radius: 50%; +.sub-category-container { + padding-bottom: .5rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid var(--table-border-color); + + .heading { + color: var(--gray-500); } } +.scroll-categories { + white-space: nowrap; + overflow-x: auto; + + .category-pill { + margin: 0px 4px; + display: inline-block; + padding: 6px 12px; + background-color: #ecf5fe; + width: fit-content; + font-size: 14px; + border-radius: 18px; + color: var(--blue-500); + } +} + + +.shopping-badge { + position: relative; + top: -10px; + left: -12px; + background: var(--red-600); + align-items: center; + height: 16px; + font-size: 10px; + border-radius: 50%; +} + + +.cart-animate { + animation: wiggle 0.5s linear; +} +@keyframes wiggle { + 8%, + 41% { + transform: translateX(-10px); + } + 25%, + 58% { + transform: translate(10px); + } + 75% { + transform: translate(-5px); + } + 92% { + transform: translate(5px); + } + 0%, + 100% { + transform: translate(0); + } +} + +.total-discount { + font-size: 14px; + color: var(--primary-color) !important; +} #page-cart { .shopping-cart-header { @@ -350,6 +638,7 @@ body.product-page { display: flex; flex-direction: column; justify-content: space-between; + height: fit-content; } .cart-items-header { @@ -357,6 +646,10 @@ body.product-page { } .cart-table { + tr { + margin-bottom: 1rem; + } + th, tr, td { border-color: var(--border-color); border-width: 1px; @@ -374,71 +667,200 @@ body.product-page { color: var(--text-color); } + .cart-item-image { + width: 20%; + min-width: 100px; + img { + max-height: 112px; + } + } + .cart-items { .item-title { - font-size: var(--text-base); + width: 80%; + font-size: 14px; font-weight: 500; color: var(--text-color); } .item-subtitle { color: var(--text-muted); - font-size: var(--text-md); + font-size: 13px; } .item-subtotal { - font-size: var(--text-base); + font-size: 14px; font-weight: 500; } + .sm-item-subtotal { + font-size: 14px; + font-weight: 500; + display: none; + + @media (max-width: 992px) { + display: unset !important; + } + } + .item-rate { - font-size: var(--text-md); + font-size: 13px; color: var(--text-muted); } - textarea { - width: 40%; + .free-tag { + padding: 4px 8px; + border-radius: 4px; + background-color: var(--dark-green-50); } + + textarea { + width: 80%; + height: 60px; + font-size: 14px; + } + } .cart-tax-items { .item-grand-total { font-size: 16px; - font-weight: 600; + font-weight: 700; color: var(--text-color); } } + + .column-sm-view { + @media (max-width: 992px) { + display: none !important; + } + } + + .item-column { + width: 50%; + @media (max-width: 992px) { + width: 70%; + } + } + + .remove-cart-item { + border-radius: 6px; + border: 1px solid var(--gray-100); + width: 28px; + height: 28px; + font-weight: 300; + color: var(--gray-700); + background-color: var(--gray-100); + float: right; + cursor: pointer; + margin-top: .25rem; + justify-content: center; + } + + .remove-cart-item-logo { + margin-top: 2px; + margin-left: 2.2px; + fill: var(--gray-700) !important; + } } - .cart-addresses { + .cart-payment-addresses { hr { border-color: var(--border-color); } } + .payment-summary { + h6 { + padding-bottom: 1rem; + border-bottom: solid 1px var(--gray-200); + } + + table { + font-size: 14px; + td { + padding: 0; + padding-top: 0.35rem !important; + border: none !important; + } + + &.grand-total { + border-top: solid 1px var(--gray-200); + } + } + + .bill-label { + color: var(--gray-600); + } + + .bill-content { + font-weight: 500; + &.net-total { + font-size: 16px; + font-weight: 600; + } + } + + .btn-coupon-code { + font-size: 14px; + border: dashed 1px var(--gray-400); + box-shadow: none; + } + } + .number-spinner { width: 75%; + min-width: 105px; .cart-btn { border: none; background: var(--gray-100); box-shadow: none; + width: 24px; height: 28px; align-items: center; + justify-content: center; display: flex; + font-size: 20px; + font-weight: 300; + color: var(--gray-700); } .cart-qty { height: 28px; - font-size: var(--text-md); + font-size: 13px; + &:disabled { + background: var(--gray-100); + opacity: 0.65; + } } } .place-order-container { .btn-place-order { - width: 62%; + float: right; } } } + + .t-and-c-container { + padding: 1.5rem; + } + + .t-and-c-terms { + font-size: 14px; + } +} + +.no-image-cart-item { + max-height: 112px; + display: flex; justify-content: center; + background-color: var(--gray-200); + align-items: center; + color: var(--gray-400); + margin-top: .15rem; + border-radius: 6px; + height: 100%; + font-size: 24px; } .cart-empty.frappe-card { @@ -454,7 +876,7 @@ body.product-page { .address-card { .card-title { - font-size: var(--text-base); + font-size: 14px; font-weight: 500; } @@ -463,27 +885,37 @@ body.product-page { } .card-text { - font-size: var(--text-md); + font-size: 13px; color: var(--gray-700); } .card-link { - font-size: var(--text-md); + font-size: 13px; svg use { - stroke: var(--blue-500); + stroke: var(--primary-color); } } .btn-change-address { - color: var(--blue-500); + border: 1px solid var(--primary-color); + color: var(--primary-color); + box-shadow: none; } } +.address-header { + margin-top: .15rem;padding: 0; +} + +.btn-new-address { + float: right; + font-size: 15px !important; + color: var(--primary-color) !important; +} + .btn-new-address:hover, .btn-change-address:hover { - box-shadow: none; - color: var(--blue-500) !important; - border: 1px solid var(--blue-500); + color: var(--primary-color) !important; } .modal .address-card { @@ -493,3 +925,451 @@ body.product-page { border: 1px solid var(--dark-border-color); } } + +.cart-indicator { + position: absolute; + text-align: center; + width: 22px; + height: 22px; + left: calc(100% - 40px); + top: 22px; + + border-radius: 66px; + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); + background: white; + color: var(--primary-color); + font-size: 14px; + + &.list-indicator { + position: unset; + margin-left: auto; + } +} + + +.like-action { + visibility: hidden; + text-align: center; + position: absolute; + cursor: pointer; + width: 28px; + height: 28px; + left: 20px; + top: 20px; + + /* White */ + background: white; + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); + border-radius: 66px; + + &.like-action-wished { + visibility: visible !important; + } + + @media (max-width: 992px) { + visibility: visible !important; + } +} + +.like-action-list { + visibility: hidden; + text-align: center; + position: absolute; + cursor: pointer; + width: 28px; + height: 28px; + left: 20px; + top: 0; + + /* White */ + background: white; + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); + border-radius: 66px; + + &.like-action-wished { + visibility: visible !important; + } + + @media (max-width: 992px) { + visibility: visible !important; + } +} + +.like-action-item-fp { + visibility: visible !important; + position: unset; + float: right; +} + +.like-animate { + animation: expand cubic-bezier(0.04, 0.4, 0.5, 0.95) 1.6s forwards 1; +} + +@keyframes expand { + 30% { + transform: scale(1.3); + } + 50% { + transform: scale(0.8); + } + 70% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } + } + +.not-wished { + cursor: pointer; + --icon-stroke: #F47A7A !important; + + &:hover { + fill: #F47A7A; + } +} + +.wished { + --icon-stroke: none; + fill: #F47A7A !important; +} + +.list-row-checkbox { + &:before { + display: none; + } + + &:checked:before { + display: block; + z-index: 1; + } +} + +#pay-for-order { + padding: .5rem 1rem; // Pay button in SO +} + +.btn-explore-variants { + visibility: hidden; + box-shadow: none; + margin: var(--margin-sm) 0; + width: 90px; + max-height: 50px; // to avoid resizing on window resize + flex: none; + transition: 0.3s ease; + + color: white; + background-color: var(--orange-500); + border: 1px solid var(--orange-500); + font-size: 13px; + + &:hover { + color: white; + } +} + +.btn-add-to-cart-list{ + visibility: hidden; + box-shadow: none; + margin: var(--margin-sm) 0; + // margin-top: auto !important; + max-height: 50px; // to avoid resizing on window resize + flex: none; + transition: 0.3s ease; + + font-size: 13px; + + &:hover { + color: white; + } + + @media (max-width: 992px) { + visibility: visible !important; + } +} + +.go-to-cart-grid { + max-height: 30px; + margin-top: 1rem !important; +} + +.go-to-cart { + max-height: 30px; + float: right; +} + +.remove-wish { + background-color: white; + position: absolute; + cursor: pointer; + top:10px; + right: 20px; + width: 32px; + height: 32px; + + border-radius: 50%; + border: 1px solid var(--gray-100); + box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); +} + +.wish-removed { + display: none; +} + +.item-website-specification { + font-size: .875rem; + .product-title { + font-size: 18px; + } + + .table { + width: 70%; + } + + td { + border: none !important; + } + + .spec-label { + color: var(--gray-600); + } + + .spec-content { + color: var(--gray-800); + } +} + +.reviews-full-page { + padding: 1rem 2rem; +} + +.ratings-reviews-section { + border-top: 1px solid #E2E6E9; + padding: .5rem 1rem; +} + +.reviews-header { + font-size: 20px; + font-weight: 600; + color: var(--gray-800); + display: flex; + align-items: center; + padding: 0; +} + +.btn-write-review { + float: right; + padding: .5rem 1rem; + font-size: 14px; + font-weight: 400; + border: none !important; + box-shadow: none; + + color: var(--gray-900); + background-color: var(--gray-100); + + &:hover { + box-shadow: var(--btn-shadow); + } +} + +.btn-view-more { + font-size: 14px; +} + +.rating-summary-section { + display: flex; +} + +.rating-summary-title { + margin-top: 0.15rem; + font-size: 18px; +} + +.rating-summary-numbers { + display: flex; + flex-direction: column; + align-items: center; + + border-right: solid 1px var(--gray-100); +} + +.user-review-title { + margin-top: 0.15rem; + font-size: 15px; + font-weight: 600; +} + +.rating { + --star-fill: var(--gray-300); + .star-hover { + --star-fill: var(--yellow-100); + } + .star-click { + --star-fill: var(--yellow-300); + } +} + +.ratings-pill { + background-color: var(--gray-100); + padding: .5rem 1rem; + border-radius: 66px; +} + +.review { + max-width: 80%; + line-height: 1.6; + padding-bottom: 0.5rem; + border-bottom: 1px solid #E2E6E9; +} + +.review-signature { + display: flex; + font-size: 13px; + color: var(--gray-500); + font-weight: 400; + + .reviewer { + padding-right: 8px; + color: var(--gray-600); + } +} + +.rating-progress-bar-section { + padding-bottom: 2rem; + + .rating-bar-title { + margin-left: -15px; + } + + .rating-progress-bar { + margin-bottom: 4px; + height: 7px; + margin-top: 6px; + + .progress-bar-cosmetic { + background-color: var(--gray-600); + border-radius: var(--border-radius); + } + } +} + +.offer-container { + font-size: 14px; +} + +#search-results-container { + border: 1px solid var(--gray-200); + padding: .25rem 1rem; + + .category-chip { + background-color: var(--gray-100); + border: none !important; + box-shadow: none; + } + + .recent-search { + padding: .5rem .5rem; + border-radius: var(--border-radius); + + &:hover { + background-color: var(--gray-100); + } + } +} + +#search-box { + background-color: white; + height: 100%; + padding-left: 2.5rem; + border: 1px solid var(--gray-200); +} + +.search-icon { + position: absolute; + left: 0; + top: 0; + width: 2.5rem; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding-bottom: 1px; +} + +#toggle-view { + float: right; + + .btn-primary { + background-color: var(--gray-600); + box-shadow: 0 0 0 0.2rem var(--gray-400); + } +} + +.placeholder-div { + height:80%; + width: -webkit-fill-available; + padding: 50px; + text-align: center; + background-color: #F9FAFA; + border-top-left-radius: calc(0.75rem - 1px); + border-top-right-radius: calc(0.75rem - 1px); +} +.placeholder { + font-size: 72px; +} + +[data-path="cart"] { + .modal-backdrop { + background-color: var(--gray-50); // lighter backdrop only on cart freeze + } +} + +.item-thumb { + height: 50px; + max-width: 80px; + min-width: 80px; + object-fit: cover; +} + +.brand-line { + color: gray; +} + +.btn-next, .btn-prev { + font-size: 14px; +} + +.alert-error { + color: #e27a84; + background-color: #fff6f7; + border-color: #f5c6cb; +} + +.font-md { + font-size: 14px !important; +} + +.in-green { + color: var(--green-info) !important; + font-weight: 500; +} + +.has-stock { + font-weight: 400 !important; +} + +.out-of-stock { + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: #F47A7A; +} + +.mt-minus-2 { + margin-top: -2rem; +} + +.mt-minus-1 { + margin-top: -1rem; +} \ No newline at end of file diff --git a/erpnext/regional/doctype/e_invoice_request_log/__init__.py b/erpnext/regional/doctype/e_invoice_request_log/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js new file mode 100644 index 00000000000..7b7ba964e5e --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('E Invoice Request Log', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json new file mode 100644 index 00000000000..3034370feac --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json @@ -0,0 +1,102 @@ +{ + "actions": [], + "autoname": "EINV-REQ-.#####", + "creation": "2020-12-08 12:54:08.175992", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "url", + "headers", + "response", + "column_break_7", + "timestamp", + "reference_invoice", + "data" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User" + }, + { + "fieldname": "reference_invoice", + "fieldtype": "Data", + "label": "Reference Invoice" + }, + { + "fieldname": "headers", + "fieldtype": "Code", + "label": "Headers", + "options": "JSON" + }, + { + "fieldname": "data", + "fieldtype": "Code", + "label": "Data", + "options": "JSON" + }, + { + "default": "Now", + "fieldname": "timestamp", + "fieldtype": "Datetime", + "label": "Timestamp" + }, + { + "fieldname": "response", + "fieldtype": "Code", + "label": "Response", + "options": "JSON" + }, + { + "fieldname": "url", + "fieldtype": "Data", + "label": "URL" + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-01-13 12:06:57.253111", + "modified_by": "Administrator", + "module": "Regional", + "name": "E Invoice Request Log", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py new file mode 100644 index 00000000000..c89552d7824 --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class EInvoiceRequestLog(Document): + pass diff --git a/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py new file mode 100644 index 00000000000..091cc88e454 --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + + +class TestEInvoiceRequestLog(unittest.TestCase): + pass diff --git a/erpnext/regional/doctype/e_invoice_settings/__init__.py b/erpnext/regional/doctype/e_invoice_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js new file mode 100644 index 00000000000..54e488610df --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js @@ -0,0 +1,11 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('E Invoice Settings', { + refresh(frm) { + const docs_link = 'https://docs.erpnext.com/docs/v13/user/manual/en/regional/india/setup-e-invoicing'; + frm.dashboard.set_headline( + __("Read {0} for more information on E Invoicing features.", [`documentation`]) + ); + } +}); diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json new file mode 100644 index 00000000000..16b29633010 --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json @@ -0,0 +1,97 @@ +{ + "actions": [], + "creation": "2020-09-24 16:23:16.235722", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enable", + "section_break_2", + "sandbox_mode", + "applicable_from", + "credentials", + "advanced_settings_section", + "client_id", + "column_break_8", + "client_secret", + "auth_token", + "token_expiry" + ], + "fields": [ + { + "default": "0", + "fieldname": "enable", + "fieldtype": "Check", + "label": "Enable" + }, + { + "depends_on": "enable", + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "auth_token", + "fieldtype": "Data", + "hidden": 1, + "read_only": 1 + }, + { + "fieldname": "token_expiry", + "fieldtype": "Datetime", + "hidden": 1, + "read_only": 1 + }, + { + "fieldname": "credentials", + "fieldtype": "Table", + "label": "Credentials", + "mandatory_depends_on": "enable", + "options": "E Invoice User" + }, + { + "default": "0", + "fieldname": "sandbox_mode", + "fieldtype": "Check", + "label": "Sandbox Mode" + }, + { + "fieldname": "applicable_from", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Applicable From", + "reqd": 1 + }, + { + "collapsible": 1, + "fieldname": "advanced_settings_section", + "fieldtype": "Section Break", + "label": "Advanced Settings" + }, + { + "fieldname": "client_id", + "fieldtype": "Data", + "label": "Client ID" + }, + { + "fieldname": "client_secret", + "fieldtype": "Password", + "label": "Client Secret" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2021-11-16 19:50:28.029517", + "modified_by": "Administrator", + "module": "Regional", + "name": "E Invoice Settings", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py new file mode 100644 index 00000000000..342d583cc0d --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class EInvoiceSettings(Document): + def validate(self): + if self.enable and not self.credentials: + frappe.throw(_('You must add atleast one credentials to be able to use E Invoicing.')) diff --git a/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py new file mode 100644 index 00000000000..10770deb0ee --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + + +class TestEInvoiceSettings(unittest.TestCase): + pass diff --git a/erpnext/regional/doctype/e_invoice_user/__init__.py b/erpnext/regional/doctype/e_invoice_user/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json new file mode 100644 index 00000000000..a65b1ca7ca8 --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json @@ -0,0 +1,57 @@ +{ + "actions": [], + "creation": "2020-12-22 15:02:46.229474", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "gstin", + "username", + "password" + ], + "fields": [ + { + "fieldname": "gstin", + "fieldtype": "Data", + "in_list_view": 1, + "label": "GSTIN", + "reqd": 1 + }, + { + "fieldname": "username", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Username", + "reqd": 1 + }, + { + "fieldname": "password", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Password", + "reqd": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-03-22 12:16:56.365616", + "modified_by": "Administrator", + "module": "Regional", + "name": "E Invoice User", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.py b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py similarity index 77% rename from erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.py rename to erpnext/regional/doctype/e_invoice_user/e_invoice_user.py index dcf0e3b99de..4e0e89c09a5 100644 --- a/erpnext/accounts/doctype/distributed_cost_center/distributed_cost_center.py +++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py @@ -1,10 +1,10 @@ +# -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - # import frappe from frappe.model.document import Document -class DistributedCostCenter(Document): +class EInvoiceUser(Document): pass diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py index d48cd67c384..cb79cf82866 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -295,6 +295,10 @@ class GSTR3BReport(Document): inter_state_supply_details = {} for inv, items_based_on_rate in self.items_based_on_tax_rate.items(): + gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category') + place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory' + export_type = self.invoice_detail_map.get(inv, {}).get('export_type') + for rate, items in items_based_on_rate.items(): for item_code, taxable_value in self.invoice_items.get(inv).items(): if item_code in items: @@ -302,9 +306,8 @@ class GSTR3BReport(Document): self.report_dict['sup_details']['osup_nil_exmp']['txval'] += taxable_value elif item_code in self.is_non_gst: self.report_dict['sup_details']['osup_nongst']['txval'] += taxable_value - elif rate == 0: + elif rate == 0 or (gst_category == 'Overseas' and export_type == 'Without Payment of Tax'): self.report_dict['sup_details']['osup_zero']['txval'] += taxable_value - #self.report_dict['sup_details']['osup_zero'][key] += tax_amount else: if inv in self.cgst_sgst_invoices: tax_rate = rate/2 @@ -315,9 +318,6 @@ class GSTR3BReport(Document): self.report_dict['sup_details']['osup_det']['iamt'] += (taxable_value * rate /100) self.report_dict['sup_details']['osup_det']['txval'] += taxable_value - gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category') - place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory' - if gst_category in ['Unregistered', 'Registered Composition', 'UIN Holders'] and \ self.gst_details.get("gst_state") != place_of_supply.split("-")[1]: inter_state_supply_details.setdefault((gst_category, place_of_supply), { diff --git a/erpnext/regional/germany/utils/datev/datev_csv.py b/erpnext/regional/germany/utils/datev/datev_csv.py index 2d1e02eadba..ec271a10294 100644 --- a/erpnext/regional/germany/utils/datev/datev_csv.py +++ b/erpnext/regional/germany/utils/datev/datev_csv.py @@ -1,11 +1,11 @@ import datetime import zipfile from csv import QUOTE_NONNUMERIC +from io import BytesIO import frappe import pandas as pd from frappe import _ -from six import BytesIO from .datev_constants import DataCategory diff --git a/erpnext/regional/india/e_invoice/__init__.py b/erpnext/regional/india/e_invoice/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/india/e_invoice/einv_item_template.json b/erpnext/regional/india/e_invoice/einv_item_template.json new file mode 100644 index 00000000000..78e56518dff --- /dev/null +++ b/erpnext/regional/india/e_invoice/einv_item_template.json @@ -0,0 +1,31 @@ +{{ + "SlNo": "{item.sr_no}", + "PrdDesc": "{item.description}", + "IsServc": "{item.is_service_item}", + "HsnCd": "{item.gst_hsn_code}", + "Barcde": "{item.barcode}", + "Unit": "{item.uom}", + "Qty": "{item.qty}", + "FreeQty": "{item.free_qty}", + "UnitPrice": "{item.unit_rate}", + "TotAmt": "{item.gross_amount}", + "Discount": "{item.discount_amount}", + "AssAmt": "{item.taxable_value}", + "PrdSlNo": "{item.serial_no}", + "GstRt": "{item.tax_rate}", + "IgstAmt": "{item.igst_amount}", + "CgstAmt": "{item.cgst_amount}", + "SgstAmt": "{item.sgst_amount}", + "CesRt": "{item.cess_rate}", + "CesAmt": "{item.cess_amount}", + "CesNonAdvlAmt": "{item.cess_nadv_amount}", + "StateCesRt": "{item.state_cess_rate}", + "StateCesAmt": "{item.state_cess_amount}", + "StateCesNonAdvlAmt": "{item.state_cess_nadv_amount}", + "OthChrg": "{item.other_charges}", + "TotItemVal": "{item.total_value}", + "BchDtls": {{ + "Nm": "{item.batch_no}", + "ExpDt": "{item.batch_expiry_date}" + }} +}} \ No newline at end of file diff --git a/erpnext/regional/india/e_invoice/einv_template.json b/erpnext/regional/india/e_invoice/einv_template.json new file mode 100644 index 00000000000..c2a28f20494 --- /dev/null +++ b/erpnext/regional/india/e_invoice/einv_template.json @@ -0,0 +1,110 @@ +{{ + "Version": "1.1", + "TranDtls": {{ + "TaxSch": "{transaction_details.tax_scheme}", + "SupTyp": "{transaction_details.supply_type}", + "RegRev": "{transaction_details.reverse_charge}", + "EcmGstin": "{transaction_details.ecom_gstin}", + "IgstOnIntra": "{transaction_details.igst_on_intra}" + }}, + "DocDtls": {{ + "Typ": "{doc_details.invoice_type}", + "No": "{doc_details.invoice_name}", + "Dt": "{doc_details.invoice_date}" + }}, + "SellerDtls": {{ + "Gstin": "{seller_details.gstin}", + "LglNm": "{seller_details.legal_name}", + "TrdNm": "{seller_details.trade_name}", + "Loc": "{seller_details.location}", + "Pin": "{seller_details.pincode}", + "Stcd": "{seller_details.state_code}", + "Addr1": "{seller_details.address_line1}", + "Addr2": "{seller_details.address_line2}", + "Ph": "{seller_details.phone}", + "Em": "{seller_details.email}" + }}, + "BuyerDtls": {{ + "Gstin": "{buyer_details.gstin}", + "LglNm": "{buyer_details.legal_name}", + "TrdNm": "{buyer_details.trade_name}", + "Addr1": "{buyer_details.address_line1}", + "Addr2": "{buyer_details.address_line2}", + "Loc": "{buyer_details.location}", + "Pin": "{buyer_details.pincode}", + "Stcd": "{buyer_details.state_code}", + "Ph": "{buyer_details.phone}", + "Em": "{buyer_details.email}", + "Pos": "{buyer_details.place_of_supply}" + }}, + "DispDtls": {{ + "Nm": "{dispatch_details.legal_name}", + "Addr1": "{dispatch_details.address_line1}", + "Addr2": "{dispatch_details.address_line2}", + "Loc": "{dispatch_details.location}", + "Pin": "{dispatch_details.pincode}", + "Stcd": "{dispatch_details.state_code}" + }}, + "ShipDtls": {{ + "Gstin": "{shipping_details.gstin}", + "LglNm": "{shipping_details.legal_name}", + "TrdNm": "{shipping_details.trader_name}", + "Addr1": "{shipping_details.address_line1}", + "Addr2": "{shipping_details.address_line2}", + "Loc": "{shipping_details.location}", + "Pin": "{shipping_details.pincode}", + "Stcd": "{shipping_details.state_code}" + }}, + "ItemList": [ + {item_list} + ], + "ValDtls": {{ + "AssVal": "{invoice_value_details.base_total}", + "CgstVal": "{invoice_value_details.total_cgst_amt}", + "SgstVal": "{invoice_value_details.total_sgst_amt}", + "IgstVal": "{invoice_value_details.total_igst_amt}", + "CesVal": "{invoice_value_details.total_cess_amt}", + "Discount": "{invoice_value_details.invoice_discount_amt}", + "RndOffAmt": "{invoice_value_details.round_off}", + "OthChrg": "{invoice_value_details.total_other_charges}", + "TotInvVal": "{invoice_value_details.base_grand_total}", + "TotInvValFc": "{invoice_value_details.grand_total}" + }}, + "PayDtls": {{ + "Nm": "{payment_details.payee_name}", + "AccDet": "{payment_details.account_no}", + "Mode": "{payment_details.mode_of_payment}", + "FinInsBr": "{payment_details.ifsc_code}", + "PayTerm": "{payment_details.terms}", + "PaidAmt": "{payment_details.paid_amount}", + "PaymtDue": "{payment_details.outstanding_amount}" + }}, + "RefDtls": {{ + "DocPerdDtls": {{ + "InvStDt": "{period_details.start_date}", + "InvEndDt": "{period_details.end_date}" + }}, + "PrecDocDtls": [{{ + "InvNo": "{prev_doc_details.invoice_name}", + "InvDt": "{prev_doc_details.invoice_date}" + }}] + }}, + "ExpDtls": {{ + "ShipBNo": "{export_details.bill_no}", + "ShipBDt": "{export_details.bill_date}", + "Port": "{export_details.port}", + "ForCur": "{export_details.foreign_curr_code}", + "CntCode": "{export_details.country_code}", + "ExpDuty": "{export_details.export_duty}" + }}, + "EwbDtls": {{ + "TransId": "{eway_bill_details.gstin}", + "TransName": "{eway_bill_details.name}", + "TransMode": "{eway_bill_details.mode_of_transport}", + "Distance": "{eway_bill_details.distance}", + "TransDocNo": "{eway_bill_details.document_name}", + "TransDocDt": "{eway_bill_details.document_date}", + "VehNo": "{eway_bill_details.vehicle_no}", + "VehType": "{eway_bill_details.vehicle_type}" + }} +}} \ No newline at end of file diff --git a/erpnext/regional/india/e_invoice/einv_validation.json b/erpnext/regional/india/e_invoice/einv_validation.json new file mode 100644 index 00000000000..f4a3542a60e --- /dev/null +++ b/erpnext/regional/india/e_invoice/einv_validation.json @@ -0,0 +1,957 @@ +{ + "Version": { + "type": "string", + "minLength": 1, + "maxLength": 6, + "description": "Version of the schema" + }, + "Irn": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "description": "Invoice Reference Number" + }, + "TranDtls": { + "type": "object", + "properties": { + "TaxSch": { + "type": "string", + "minLength": 3, + "maxLength": 10, + "enum": ["GST"], + "description": "GST- Goods and Services Tax Scheme" + }, + "SupTyp": { + "type": "string", + "minLength": 3, + "maxLength": 10, + "enum": ["B2B", "SEZWP", "SEZWOP", "EXPWP", "EXPWOP", "DEXP"], + "description": "Type of Supply: B2B-Business to Business, SEZWP - SEZ with payment, SEZWOP - SEZ without payment, EXPWP - Export with Payment, EXPWOP - Export without payment,DEXP - Deemed Export" + }, + "RegRev": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["Y", "N"], + "description": "Y- whether the tax liability is payable under reverse charge" + }, + "EcmGstin": { + "type": "string", + "minLength": 15, + "maxLength": 15, + "pattern": "([0-9]{2}[0-9A-Z]{13})", + "description": "E-Commerce GSTIN", + "validationMsg": "E-Commerce GSTIN is invalid" + }, + "IgstOnIntra": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["Y", "N"], + "description": "Y- indicates the supply is intra state but chargeable to IGST" + } + }, + "required": ["TaxSch", "SupTyp"] + }, + "DocDtls": { + "type": "object", + "properties": { + "Typ": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "enum": ["INV", "CRN", "DBN"], + "description": "Document Type" + }, + "No": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "pattern": "^([A-Z1-9]{1}[A-Z0-9/-]{0,15})$", + "description": "Document Number", + "validationMsg": "Document Number should not be starting with 0, / and -" + }, + "Dt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Document Date" + } + }, + "required": ["Typ", "No", "Dt"] + }, + "SellerDtls": { + "type": "object", + "properties": { + "Gstin": { + "type": "string", + "minLength": 15, + "maxLength": 15, + "pattern": "([0-9]{2}[0-9A-Z]{13})", + "description": "Supplier GSTIN", + "validationMsg": "Company GSTIN is invalid" + }, + "LglNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Legal Name" + }, + "TrdNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Tradename" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 50, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "Supplier State Code" + }, + "Ph": { + "type": "string", + "minLength": 6, + "maxLength": 12, + "description": "Phone" + }, + "Em": { + "type": "string", + "minLength": 6, + "maxLength": 100, + "description": "Email-Id" + } + }, + "required": ["Gstin", "LglNm", "Addr1", "Loc", "Pin", "Stcd"] + }, + "BuyerDtls": { + "type": "object", + "properties": { + "Gstin": { + "type": "string", + "minLength": 3, + "maxLength": 15, + "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$", + "description": "Buyer GSTIN", + "validationMsg": "Customer GSTIN is invalid" + }, + "LglNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Legal Name" + }, + "TrdNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Trade Name" + }, + "Pos": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "Place of Supply State code" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "Buyer State Code" + }, + "Ph": { + "type": "string", + "minLength": 6, + "maxLength": 12, + "description": "Phone" + }, + "Em": { + "type": "string", + "minLength": 6, + "maxLength": 100, + "description": "Email-Id" + } + }, + "required": ["Gstin", "LglNm", "Pos", "Addr1", "Loc", "Stcd"] + }, + "DispDtls": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Dispatch Address Name" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "State Code" + } + }, + "required": ["Nm", "Addr1", "Loc", "Pin", "Stcd"] + }, + "ShipDtls": { + "type": "object", + "properties": { + "Gstin": { + "type": "string", + "maxLength": 15, + "minLength": 3, + "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$", + "description": "Shipping Address GSTIN", + "validationMsg": "Shipping Address GSTIN is invalid" + }, + "LglNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Legal Name" + }, + "TrdNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Trade Name" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "State Code" + } + }, + "required": ["LglNm", "Addr1", "Loc", "Pin", "Stcd"] + }, + "ItemList": { + "type": "Array", + "properties": { + "SlNo": { + "type": "string", + "minLength": 1, + "maxLength": 6, + "description": "Serial No. of Item" + }, + "PrdDesc": { + "type": "string", + "minLength": 3, + "maxLength": 300, + "description": "Item Name" + }, + "IsServc": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["Y", "N"], + "description": "Is Service Item" + }, + "HsnCd": { + "type": "string", + "minLength": 4, + "maxLength": 8, + "description": "HSN Code" + }, + "Barcde": { + "type": "string", + "minLength": 3, + "maxLength": 30, + "description": "Barcode" + }, + "Qty": { + "type": "number", + "minimum": 0, + "maximum": 9999999999.999, + "description": "Quantity" + }, + "FreeQty": { + "type": "number", + "minimum": 0, + "maximum": 9999999999.999, + "description": "Free Quantity" + }, + "Unit": { + "type": "string", + "minLength": 3, + "maxLength": 8, + "description": "UOM" + }, + "UnitPrice": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.999, + "description": "Rate" + }, + "TotAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Gross Amount" + }, + "Discount": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Discount" + }, + "PreTaxVal": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Pre tax value" + }, + "AssAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Taxable Value" + }, + "GstRt": { + "type": "number", + "minimum": 0, + "maximum": 999.999, + "description": "GST Rate" + }, + "IgstAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "IGST Amount" + }, + "CgstAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "CGST Amount" + }, + "SgstAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "SGST Amount" + }, + "CesRt": { + "type": "number", + "minimum": 0, + "maximum": 999.999, + "description": "Cess Rate" + }, + "CesAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Cess Amount (Advalorem)" + }, + "CesNonAdvlAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Cess Amount (Non-Advalorem)" + }, + "StateCesRt": { + "type": "number", + "minimum": 0, + "maximum": 999.999, + "description": "State CESS Rate" + }, + "StateCesAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "State CESS Amount" + }, + "StateCesNonAdvlAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "State CESS Amount (Non Advalorem)" + }, + "OthChrg": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Other Charges" + }, + "TotItemVal": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Total Item Value" + }, + "OrdLineRef": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "description": "Order line reference" + }, + "OrgCntry": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "Origin Country" + }, + "PrdSlNo": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "description": "Serial number" + }, + "BchDtls": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 3, + "maxLength": 20, + "description": "Batch number" + }, + "ExpDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Batch Expiry Date" + }, + "WrDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Warranty Date" + } + }, + "required": ["Nm"] + }, + "AttribDtls": { + "type": "Array", + "Attribute": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Attribute name of the item" + }, + "Val": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Attribute value of the item" + } + } + } + } + }, + "required": [ + "SlNo", + "IsServc", + "HsnCd", + "UnitPrice", + "TotAmt", + "AssAmt", + "GstRt", + "TotItemVal" + ] + }, + "ValDtls": { + "type": "object", + "properties": { + "AssVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total Assessable value of all items" + }, + "CgstVal": { + "type": "number", + "maximum": 99999999999999.99, + "minimum": 0, + "description": "Total CGST value of all items" + }, + "SgstVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total SGST value of all items" + }, + "IgstVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total IGST value of all items" + }, + "CesVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total CESS value of all items" + }, + "StCesVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total State CESS value of all items" + }, + "Discount": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Invoice Discount" + }, + "OthChrg": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Other Charges" + }, + "RndOffAmt": { + "type": "number", + "minimum": -99.99, + "maximum": 99.99, + "description": "Rounded off Amount" + }, + "TotInvVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Final Invoice Value " + }, + "TotInvValFc": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Final Invoice value in Foreign Currency" + } + }, + "required": ["AssVal", "TotInvVal"] + }, + "PayDtls": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Payee Name" + }, + "AccDet": { + "type": "string", + "minLength": 1, + "maxLength": 18, + "description": "Bank Account Number of Payee" + }, + "Mode": { + "type": "string", + "minLength": 1, + "maxLength": 18, + "description": "Mode of Payment" + }, + "FinInsBr": { + "type": "string", + "minLength": 1, + "maxLength": 11, + "description": "Branch or IFSC code" + }, + "PayTerm": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Terms of Payment" + }, + "PayInstr": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Payment Instruction" + }, + "CrTrn": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Credit Transfer" + }, + "DirDr": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Direct Debit" + }, + "CrDay": { + "type": "number", + "minimum": 0, + "maximum": 9999, + "description": "Credit Days" + }, + "PaidAmt": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Advance Amount" + }, + "PaymtDue": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Outstanding Amount" + } + } + }, + "RefDtls": { + "type": "object", + "properties": { + "InvRm": { + "type": "string", + "maxLength": 100, + "minLength": 3, + "pattern": "^[0-9A-Za-z/-]{3,100}$", + "description": "Remarks/Note" + }, + "DocPerdDtls": { + "type": "object", + "properties": { + "InvStDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Invoice Period Start Date" + }, + "InvEndDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Invoice Period End Date" + } + }, + "required": ["InvStDt ", "InvEndDt "] + }, + "PrecDocDtls": { + "type": "object", + "properties": { + "InvNo": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "pattern": "^[1-9A-Z]{1}[0-9A-Z/-]{1,15}$", + "description": "Reference of Original Invoice" + }, + "InvDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Date of Orginal Invoice" + }, + "OthRefNo": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "description": "Other Reference" + } + } + }, + "required": ["InvNo", "InvDt"], + "ContrDtls": { + "type": "object", + "properties": { + "RecAdvRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Receipt Advice No." + }, + "RecAdvDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Date of receipt advice" + }, + "TendRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Lot/Batch Reference No." + }, + "ContrRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Contract Reference Number" + }, + "ExtRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Any other reference" + }, + "ProjRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Project Reference Number" + }, + "PORefr": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "pattern": "^([0-9A-Za-z/-]){1,16}$", + "description": "PO Reference Number" + }, + "PORefDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "PO Reference date" + } + } + } + } + }, + "AddlDocDtls": { + "type": "Array", + "properties": { + "Url": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Supporting document URL" + }, + "Docs": { + "type": "string", + "minLength": 3, + "maxLength": 1000, + "description": "Supporting document in Base64 Format" + }, + "Info": { + "type": "string", + "minLength": 3, + "maxLength": 1000, + "description": "Any additional information" + } + } + }, + + "ExpDtls": { + "type": "object", + "properties": { + "ShipBNo": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "description": "Shipping Bill No." + }, + "ShipBDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Shipping Bill Date" + }, + "Port": { + "type": "string", + "minLength": 2, + "maxLength": 10, + "pattern": "^[0-9A-Za-z]{2,10}$", + "description": "Port Code. Refer the master" + }, + "RefClm": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "description": "Claiming Refund. Y/N" + }, + "ForCur": { + "type": "string", + "minLength": 3, + "maxLength": 16, + "description": "Additional Currency Code. Refer the master" + }, + "CntCode": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "Country Code. Refer the master" + }, + "ExpDuty": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Export Duty" + } + } + }, + "EwbDtls": { + "type": "object", + "properties": { + "TransId": { + "type": "string", + "minLength": 15, + "maxLength": 15, + "description": "Transporter GSTIN" + }, + "TransName": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Transporter Name" + }, + "TransMode": { + "type": "string", + "maxLength": 1, + "minLength": 1, + "enum": ["1", "2", "3", "4"], + "description": "Mode of Transport" + }, + "Distance": { + "type": "number", + "minimum": 1, + "maximum": 9999, + "description": "Distance" + }, + "TransDocNo": { + "type": "string", + "minLength": 1, + "maxLength": 15, + "pattern": "^([0-9A-Z/-]){1,15}$", + "description": "Tranport Document Number", + "validationMsg": "Transport Receipt No is invalid" + }, + "TransDocDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Transport Document Date" + }, + "VehNo": { + "type": "string", + "minLength": 4, + "maxLength": 20, + "description": "Vehicle Number" + }, + "VehType": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["O", "R"], + "description": "Vehicle Type" + } + }, + "required": ["Distance"] + }, + "required": [ + "Version", + "TranDtls", + "DocDtls", + "SellerDtls", + "BuyerDtls", + "ItemList", + "ValDtls" + ] +} diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js new file mode 100644 index 00000000000..348f0c6feed --- /dev/null +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -0,0 +1,292 @@ +erpnext.setup_einvoice_actions = (doctype) => { + frappe.ui.form.on(doctype, { + async refresh(frm) { + if (frm.doc.docstatus == 2) return; + + const res = await frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.validate_eligibility', + args: { doc: frm.doc } + }); + const invoice_eligible = res.message; + + if (!invoice_eligible) return; + + const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc; + + const add_custom_button = (label, action) => { + if (!frm.custom_buttons[label]) { + frm.add_custom_button(label, action, __('E Invoicing')); + } + }; + + if (!irn && !__unsaved) { + const action = () => { + if (frm.doc.__unsaved) { + frappe.throw(__('Please save the document to generate IRN.')); + } + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.get_einvoice', + args: { doctype, docname: name }, + freeze: true, + callback: (res) => { + const einvoice = res.message; + show_einvoice_preview(frm, einvoice); + } + }); + }; + + add_custom_button(__("Generate IRN"), action); + } + + if (irn && !irn_cancelled && !ewaybill) { + const fields = [ + { + "label": "Reason", + "fieldname": "reason", + "fieldtype": "Select", + "reqd": 1, + "default": "1-Duplicate", + "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"] + }, + { + "label": "Remark", + "fieldname": "remark", + "fieldtype": "Data", + "reqd": 1 + } + ]; + const action = () => { + const d = new frappe.ui.Dialog({ + title: __("Cancel IRN"), + fields: fields, + primary_action: function() { + const data = d.get_values(); + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.cancel_irn', + args: { + doctype, + docname: name, + irn: irn, + reason: data.reason.split('-')[0], + remark: data.remark + }, + freeze: true, + callback: () => frm.reload_doc() || d.hide(), + error: () => d.hide() + }); + }, + primary_action_label: __('Submit') + }); + d.show(); + }; + add_custom_button(__("Cancel IRN"), action); + } + + if (irn && !irn_cancelled && !ewaybill) { + const action = () => { + const d = new frappe.ui.Dialog({ + title: __('Generate E-Way Bill'), + size: "large", + fields: get_ewaybill_fields(frm), + primary_action: function() { + const data = d.get_values(); + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.generate_eway_bill', + args: { + doctype, + docname: name, + irn, + ...data + }, + freeze: true, + callback: () => frm.reload_doc() || d.hide(), + error: () => d.hide() + }); + }, + primary_action_label: __('Submit') + }); + d.show(); + }; + + add_custom_button(__("Generate E-Way Bill"), action); + } + + if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) { + const action = () => { + let message = __('Cancellation of e-way bill is currently not supported.') + ' '; + message += '

'; + message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.'); + + const dialog = frappe.msgprint({ + title: __('Update E-Way Bill Cancelled Status?'), + message: message, + indicator: 'orange', + primary_action: { + action: function() { + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill', + args: { doctype, docname: name }, + freeze: true, + callback: () => frm.reload_doc() || dialog.hide() + }); + } + }, + primary_action_label: __('Yes') + }); + }; + add_custom_button(__("Cancel E-Way Bill"), action); + } + } + }); +}; + +const get_ewaybill_fields = (frm) => { + return [ + { + 'fieldname': 'transporter', + 'label': 'Transporter', + 'fieldtype': 'Link', + 'options': 'Supplier', + 'default': frm.doc.transporter + }, + { + 'fieldname': 'gst_transporter_id', + 'label': 'GST Transporter ID', + 'fieldtype': 'Data', + 'fetch_from': 'transporter.gst_transporter_id', + 'default': frm.doc.gst_transporter_id + }, + { + 'fieldname': 'driver', + 'label': 'Driver', + 'fieldtype': 'Link', + 'options': 'Driver', + 'default': frm.doc.driver + }, + { + 'fieldname': 'lr_no', + 'label': 'Transport Receipt No', + 'fieldtype': 'Data', + 'default': frm.doc.lr_no + }, + { + 'fieldname': 'vehicle_no', + 'label': 'Vehicle No', + 'fieldtype': 'Data', + 'default': frm.doc.vehicle_no + }, + { + 'fieldname': 'distance', + 'label': 'Distance (in km)', + 'fieldtype': 'Float', + 'default': frm.doc.distance + }, + { + 'fieldname': 'transporter_col_break', + 'fieldtype': 'Column Break', + }, + { + 'fieldname': 'transporter_name', + 'label': 'Transporter Name', + 'fieldtype': 'Data', + 'fetch_from': 'transporter.name', + 'read_only': 1, + 'default': frm.doc.transporter_name + }, + { + 'fieldname': 'mode_of_transport', + 'label': 'Mode of Transport', + 'fieldtype': 'Select', + 'options': `\nRoad\nAir\nRail\nShip`, + 'default': frm.doc.mode_of_transport + }, + { + 'fieldname': 'driver_name', + 'label': 'Driver Name', + 'fieldtype': 'Data', + 'fetch_from': 'driver.full_name', + 'read_only': 1, + 'default': frm.doc.driver_name + }, + { + 'fieldname': 'lr_date', + 'label': 'Transport Receipt Date', + 'fieldtype': 'Date', + 'default': frm.doc.lr_date + }, + { + 'fieldname': 'gst_vehicle_type', + 'label': 'GST Vehicle Type', + 'fieldtype': 'Select', + 'options': `Regular\nOver Dimensional Cargo (ODC)`, + 'depends_on': 'eval:(doc.mode_of_transport === "Road")', + 'default': frm.doc.gst_vehicle_type + } + ]; +}; + +const request_irn_generation = (frm) => { + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.generate_irn', + args: { doctype: frm.doc.doctype, docname: frm.doc.name }, + freeze: true, + callback: () => frm.reload_doc() + }); +}; + +const get_preview_dialog = (frm, action) => { + const dialog = new frappe.ui.Dialog({ + title: __("Preview"), + size: "large", + fields: [ + { + "label": "Preview", + "fieldname": "preview_html", + "fieldtype": "HTML" + } + ], + primary_action: () => action(frm) || dialog.hide(), + primary_action_label: __('Generate IRN') + }); + return dialog; +}; + +const show_einvoice_preview = (frm, einvoice) => { + const preview_dialog = get_preview_dialog(frm, request_irn_generation); + + // initialize e-invoice fields + einvoice["Irn"] = einvoice["AckNo"] = ''; einvoice["AckDt"] = frappe.datetime.nowdate(); + frm.doc.signed_einvoice = JSON.stringify(einvoice); + + // initialize preview wrapper + const $preview_wrapper = preview_dialog.get_field("preview_html").$wrapper; + $preview_wrapper.html( + `
+ +
+
` + ); + + frappe.call({ + method: "frappe.www.printview.get_html_and_style", + args: { + doc: frm.doc, + print_format: "GST E-Invoice", + no_letterhead: 1 + }, + callback: function (r) { + if (!r.exc) { + $preview_wrapper.find(".print-format").html(r.message.html); + const style = ` + .print-format { box-shadow: 0px 0px 5px rgba(0,0,0,0.2); padding: 0.30in; min-height: 80vh; } + .print-preview { min-height: 0px; } + .modal-dialog { width: 720px; }`; + + frappe.dom.set_style(style, "custom-print-style"); + preview_dialog.show(); + } + } + }); +}; diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py new file mode 100644 index 00000000000..e3f7e90ff3c --- /dev/null +++ b/erpnext/regional/india/e_invoice/utils.py @@ -0,0 +1,1171 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import base64 +import io +import json +import os +import re +import sys +import traceback + +import frappe +import jwt +from frappe import _, bold +from frappe.core.page.background_jobs.background_jobs import get_info +from frappe.integrations.utils import make_get_request, make_post_request +from frappe.utils.background_jobs import enqueue +from frappe.utils.data import ( + add_to_date, + cint, + cstr, + flt, + format_date, + get_link_to_form, + getdate, + now_datetime, + time_diff_in_hours, + time_diff_in_seconds, +) +from frappe.utils.scheduler import is_scheduler_inactive +from pyqrcode import create as qrcreate + +from erpnext.regional.india.utils import get_gst_accounts, get_place_of_supply + + +@frappe.whitelist() +def validate_eligibility(doc): + if isinstance(doc, str): + doc = json.loads(doc) + + invalid_doctype = doc.get('doctype') != 'Sales Invoice' + if invalid_doctype: + return False + + einvoicing_enabled = cint(frappe.db.get_single_value('E Invoice Settings', 'enable')) + if not einvoicing_enabled: + return False + + einvoicing_eligible_from = frappe.db.get_single_value('E Invoice Settings', 'applicable_from') or '2021-04-01' + if getdate(doc.get('posting_date')) < getdate(einvoicing_eligible_from): + return False + + invalid_company = not frappe.db.get_value('E Invoice User', { 'company': doc.get('company') }) + invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'] + company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin') + + # if export invoice, then taxes can be empty + # invoice can only be ineligible if no taxes applied and is not an export invoice + no_taxes_applied = not doc.get('taxes') and not doc.get('gst_category') == 'Overseas' + has_non_gst_item = any(d for d in doc.get('items', []) if d.get('is_non_gst')) + + if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied or has_non_gst_item: + return False + + return True + +def validate_einvoice_fields(doc): + invoice_eligible = validate_eligibility(doc) + + if not invoice_eligible: + return + + if doc.docstatus == 0 and doc._action == 'save': + if doc.irn: + frappe.throw(_('You cannot edit the invoice after generating IRN'), title=_('Edit Not Allowed')) + if len(doc.name) > 16: + raise_document_name_too_long_error() + + doc.einvoice_status = 'Pending' + + elif doc.docstatus == 1 and doc._action == 'submit' and not doc.irn: + frappe.throw(_('You must generate IRN before submitting the document.'), title=_('Missing IRN')) + + elif doc.irn and doc.docstatus == 2 and doc._action == 'cancel' and not doc.irn_cancelled: + frappe.throw(_('You must cancel IRN before cancelling the document.'), title=_('Cancel Not Allowed')) + +def raise_document_name_too_long_error(): + title = _('Document ID Too Long') + msg = _('As you have E-Invoicing enabled, to be able to generate IRN for this invoice') + msg += ', ' + msg += _('document id {} exceed 16 letters.').format(bold(_('should not'))) + msg += '

' + msg += _('You must {} your {} in order to have document id of {} length 16.').format( + bold(_('modify')), bold(_('naming series')), bold(_('maximum')) + ) + msg += _('Please account for ammended documents too.') + frappe.throw(msg, title=title) + +def read_json(name): + file_path = os.path.join(os.path.dirname(__file__), '{name}.json'.format(name=name)) + with open(file_path, 'r') as f: + return cstr(f.read()) + +def get_transaction_details(invoice): + supply_type = '' + if invoice.gst_category == 'Registered Regular': supply_type = 'B2B' + elif invoice.gst_category == 'SEZ': supply_type = 'SEZWOP' + elif invoice.gst_category == 'Overseas': supply_type = 'EXPWOP' + elif invoice.gst_category == 'Deemed Export': supply_type = 'DEXP' + + if not supply_type: + rr, sez, overseas, export = bold('Registered Regular'), bold('SEZ'), bold('Overseas'), bold('Deemed Export') + frappe.throw(_('GST category should be one of {}, {}, {}, {}').format(rr, sez, overseas, export), + title=_('Invalid Supply Type')) + + return frappe._dict(dict( + tax_scheme='GST', + supply_type=supply_type, + reverse_charge=invoice.reverse_charge + )) + +def get_doc_details(invoice): + if getdate(invoice.posting_date) < getdate('2021-01-01'): + frappe.throw(_('IRN generation is not allowed for invoices dated before 1st Jan 2021'), title=_('Not Allowed')) + + invoice_type = 'CRN' if invoice.is_return else 'INV' + + invoice_name = invoice.name + invoice_date = format_date(invoice.posting_date, 'dd/mm/yyyy') + + return frappe._dict(dict( + invoice_type=invoice_type, + invoice_name=invoice_name, + invoice_date=invoice_date + )) + +def validate_address_fields(address, skip_gstin_validation): + if ((not address.gstin and not skip_gstin_validation) + or not address.city + or not address.pincode + or not address.address_title + or not address.address_line1 + or not address.gst_state_number): + + frappe.throw( + msg=_('Address Lines, City, Pincode, GSTIN are mandatory for address {}. Please set them and try again.').format(address.name), + title=_('Missing Address Fields') + ) + + if address.address_line2 and len(address.address_line2) < 2: + # to prevent "The field Address 2 must be a string with a minimum length of 3 and a maximum length of 100" + address.address_line2 = "" + +def get_party_details(address_name, skip_gstin_validation=False): + addr = frappe.get_doc('Address', address_name) + + validate_address_fields(addr, skip_gstin_validation) + + if addr.gst_state_number == 97: + # according to einvoice standard + addr.pincode = 999999 + + party_address_details = frappe._dict(dict( + legal_name=sanitize_for_json(addr.address_title), + location=sanitize_for_json(addr.city), + pincode=addr.pincode, gstin=addr.gstin, + state_code=addr.gst_state_number, + address_line1=sanitize_for_json(addr.address_line1), + address_line2=sanitize_for_json(addr.address_line2) + )) + + return party_address_details + +def get_overseas_address_details(address_name): + address_title, address_line1, address_line2, city = frappe.db.get_value( + 'Address', address_name, ['address_title', 'address_line1', 'address_line2', 'city'] + ) + + if not address_title or not address_line1 or not city: + frappe.throw( + msg=_('Address lines and city is mandatory for address {}. Please set them and try again.').format( + get_link_to_form('Address', address_name) + ), + title=_('Missing Address Fields') + ) + + return frappe._dict(dict( + gstin='URP', + legal_name=sanitize_for_json(address_title), + location=city, + address_line1=sanitize_for_json(address_line1), + address_line2=sanitize_for_json(address_line2), + pincode=999999, state_code=96, place_of_supply=96 + )) + +def get_item_list(invoice): + item_list = [] + + for d in invoice.items: + einvoice_item_schema = read_json('einv_item_template') + item = frappe._dict({}) + item.update(d.as_dict()) + + item.sr_no = d.idx + item.description = sanitize_for_json(d.item_name) + + item.qty = abs(item.qty) + if flt(item.qty) != 0.0: + item.unit_rate = abs(item.taxable_value / item.qty) + else: + item.unit_rate = abs(item.taxable_value) + item.gross_amount = abs(item.taxable_value) + item.taxable_value = abs(item.taxable_value) + item.discount_amount = 0 + + item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None + item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None + item.is_service_item = 'Y' if item.gst_hsn_code and item.gst_hsn_code[:2] == "99" else 'N' + item.serial_no = "" + + item = update_item_taxes(invoice, item) + + item.total_value = abs( + item.taxable_value + item.igst_amount + item.sgst_amount + + item.cgst_amount + item.cess_amount + item.cess_nadv_amount + item.other_charges + ) + einv_item = einvoice_item_schema.format(item=item) + item_list.append(einv_item) + + return ', '.join(item_list) + +def update_item_taxes(invoice, item): + gst_accounts = get_gst_accounts(invoice.company) + gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d] + + for attr in [ + 'tax_rate', 'cess_rate', 'cess_nadv_amount', + 'cgst_amount', 'sgst_amount', 'igst_amount', + 'cess_amount', 'cess_nadv_amount', 'other_charges' + ]: + item[attr] = 0 + + for t in invoice.taxes: + is_applicable = t.tax_amount and t.account_head in gst_accounts_list + if is_applicable: + # this contains item wise tax rate & tax amount (incl. discount) + item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code or item.item_name) + + item_tax_rate = item_tax_detail[0] + # item tax amount excluding discount amount + item_tax_amount = (item_tax_rate / 100) * item.taxable_value + + if t.account_head in gst_accounts.cess_account: + item_tax_amount_after_discount = item_tax_detail[1] + if t.charge_type == 'On Item Quantity': + item.cess_nadv_amount += abs(item_tax_amount_after_discount) + else: + item.cess_rate += item_tax_rate + item.cess_amount += abs(item_tax_amount_after_discount) + + for tax_type in ['igst', 'cgst', 'sgst']: + if t.account_head in gst_accounts[f'{tax_type}_account']: + item.tax_rate += item_tax_rate + item[f'{tax_type}_amount'] += abs(item_tax_amount) + else: + # TODO: other charges per item + pass + + return item + +def get_invoice_value_details(invoice): + invoice_value_details = frappe._dict(dict()) + invoice_value_details.base_total = abs(sum([i.taxable_value for i in invoice.get('items')])) + invoice_value_details.invoice_discount_amt = 0 + + invoice_value_details.round_off = invoice.base_rounding_adjustment + invoice_value_details.base_grand_total = abs(invoice.base_rounded_total) or abs(invoice.base_grand_total) + invoice_value_details.grand_total = abs(invoice.rounded_total) or abs(invoice.grand_total) + + invoice_value_details = update_invoice_taxes(invoice, invoice_value_details) + + return invoice_value_details + +def update_invoice_taxes(invoice, invoice_value_details): + gst_accounts = get_gst_accounts(invoice.company) + gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d] + + invoice_value_details.total_cgst_amt = 0 + invoice_value_details.total_sgst_amt = 0 + invoice_value_details.total_igst_amt = 0 + invoice_value_details.total_cess_amt = 0 + invoice_value_details.total_other_charges = 0 + considered_rows = [] + + for t in invoice.taxes: + tax_amount = t.base_tax_amount_after_discount_amount + if t.account_head in gst_accounts_list: + if t.account_head in gst_accounts.cess_account: + # using after discount amt since item also uses after discount amt for cess calc + invoice_value_details.total_cess_amt += abs(t.base_tax_amount_after_discount_amount) + + for tax_type in ['igst', 'cgst', 'sgst']: + if t.account_head in gst_accounts[f'{tax_type}_account']: + + invoice_value_details[f'total_{tax_type}_amt'] += abs(tax_amount) + update_other_charges(t, invoice_value_details, gst_accounts_list, invoice, considered_rows) + else: + invoice_value_details.total_other_charges += abs(tax_amount) + + return invoice_value_details + +def update_other_charges(tax_row, invoice_value_details, gst_accounts_list, invoice, considered_rows): + prev_row_id = cint(tax_row.row_id) - 1 + if tax_row.account_head in gst_accounts_list and prev_row_id not in considered_rows: + if tax_row.charge_type == 'On Previous Row Amount': + amount = invoice.get('taxes')[prev_row_id].tax_amount_after_discount_amount + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + if tax_row.charge_type == 'On Previous Row Total': + amount = invoice.get('taxes')[prev_row_id].base_total - invoice.base_net_total + invoice_value_details.total_other_charges -= abs(amount) + considered_rows.append(prev_row_id) + +def get_payment_details(invoice): + payee_name = invoice.company + mode_of_payment = ', '.join([d.mode_of_payment for d in invoice.payments]) + paid_amount = invoice.base_paid_amount + outstanding_amount = invoice.outstanding_amount + + return frappe._dict(dict( + payee_name=payee_name, mode_of_payment=mode_of_payment, + paid_amount=paid_amount, outstanding_amount=outstanding_amount + )) + +def get_return_doc_reference(invoice): + invoice_date = frappe.db.get_value('Sales Invoice', invoice.return_against, 'posting_date') + return frappe._dict(dict( + invoice_name=invoice.return_against, invoice_date=format_date(invoice_date, 'dd/mm/yyyy') + )) + +def get_eway_bill_details(invoice): + if invoice.is_return: + frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes. Please clear fields in the Transporter Section of the invoice.'), + title=_('Invalid Fields')) + + + mode_of_transport = { '': '', 'Road': '1', 'Air': '2', 'Rail': '3', 'Ship': '4' } + vehicle_type = { 'Regular': 'R', 'Over Dimensional Cargo (ODC)': 'O' } + + return frappe._dict(dict( + gstin=invoice.gst_transporter_id, + name=invoice.transporter_name, + mode_of_transport=mode_of_transport[invoice.mode_of_transport], + distance=invoice.distance or 0, + document_name=invoice.lr_no, + document_date=format_date(invoice.lr_date, 'dd/mm/yyyy'), + vehicle_no=invoice.vehicle_no, + vehicle_type=vehicle_type[invoice.gst_vehicle_type] + )) + +def validate_mandatory_fields(invoice): + if not invoice.company_address: + frappe.throw( + _('Company Address is mandatory to fetch company GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) + if not invoice.customer_address: + frappe.throw( + _('Customer Address is mandatory to fetch customer GSTIN details. Please set Company Address and try again.'), + title=_('Missing Fields') + ) + if not frappe.db.get_value('Address', invoice.company_address, 'gstin'): + frappe.throw( + _('GSTIN is mandatory to fetch company GSTIN details. Please enter GSTIN in selected company address.'), + title=_('Missing Fields') + ) + if invoice.gst_category != 'Overseas' and not frappe.db.get_value('Address', invoice.customer_address, 'gstin'): + frappe.throw( + _('GSTIN is mandatory to fetch customer GSTIN details. Please enter GSTIN in selected customer address.'), + title=_('Missing Fields') + ) + +def validate_totals(einvoice): + item_list = einvoice['ItemList'] + value_details = einvoice['ValDtls'] + + total_item_ass_value = 0 + total_item_cgst_value = 0 + total_item_sgst_value = 0 + total_item_igst_value = 0 + total_item_value = 0 + for item in item_list: + total_item_ass_value += flt(item['AssAmt']) + total_item_cgst_value += flt(item['CgstAmt']) + total_item_sgst_value += flt(item['SgstAmt']) + total_item_igst_value += flt(item['IgstAmt']) + total_item_value += flt(item['TotItemVal']) + + if abs(flt(item['AssAmt']) * flt(item['GstRt']) / 100) - (flt(item['CgstAmt']) + flt(item['SgstAmt']) + flt(item['IgstAmt'])) > 1: + frappe.throw(_('Row #{}: GST rate is invalid. Please remove tax rows with zero tax amount from taxes table.').format(item.idx)) + + if abs(flt(value_details['AssVal']) - total_item_ass_value) > 1: + frappe.throw(_('Total Taxable Value of the items is not equal to the Invoice Net Total. Please check item taxes / discounts for any correction.')) + + if abs(flt(value_details['CgstVal']) + flt(value_details['SgstVal']) - total_item_cgst_value - total_item_sgst_value) > 1: + frappe.throw(_('CGST + SGST value of the items is not equal to total CGST + SGST value. Please review taxes for any correction.')) + + if abs(flt(value_details['IgstVal']) - total_item_igst_value) > 1: + frappe.throw(_('IGST value of all items is not equal to total IGST value. Please review taxes for any correction.')) + + if abs( + flt(value_details['TotInvVal']) + flt(value_details['Discount']) - + flt(value_details['OthChrg']) - flt(value_details['RndOffAmt']) - + total_item_value + ) > 1: + frappe.throw(_('Total Value of the items is not equal to the Invoice Grand Total. Please check item taxes / discounts for any correction.')) + + calculated_invoice_value = \ + flt(value_details['AssVal']) + flt(value_details['CgstVal']) \ + + flt(value_details['SgstVal']) + flt(value_details['IgstVal']) \ + + flt(value_details['OthChrg']) + flt(value_details['RndOffAmt']) - flt(value_details['Discount']) + + if abs(flt(value_details['TotInvVal']) - calculated_invoice_value) > 1: + frappe.throw(_('Total Item Value + Taxes - Discount is not equal to the Invoice Grand Total. Please check taxes / discounts for any correction.')) + +def make_einvoice(invoice): + validate_mandatory_fields(invoice) + + schema = read_json('einv_template') + + transaction_details = get_transaction_details(invoice) + item_list = get_item_list(invoice) + doc_details = get_doc_details(invoice) + invoice_value_details = get_invoice_value_details(invoice) + seller_details = get_party_details(invoice.company_address) + + if invoice.gst_category == 'Overseas': + buyer_details = get_overseas_address_details(invoice.customer_address) + else: + buyer_details = get_party_details(invoice.customer_address) + place_of_supply = get_place_of_supply(invoice, invoice.doctype) + if place_of_supply: + place_of_supply = place_of_supply.split('-')[0] + else: + place_of_supply = sanitize_for_json(invoice.billing_address_gstin)[:2] + buyer_details.update(dict(place_of_supply=place_of_supply)) + + seller_details.update(dict(legal_name=invoice.company)) + buyer_details.update(dict(legal_name=invoice.customer_name or invoice.customer)) + + shipping_details = payment_details = prev_doc_details = eway_bill_details = frappe._dict({}) + if invoice.shipping_address_name and invoice.customer_address != invoice.shipping_address_name: + if invoice.gst_category == 'Overseas': + shipping_details = get_overseas_address_details(invoice.shipping_address_name) + else: + shipping_details = get_party_details(invoice.shipping_address_name, skip_gstin_validation=True) + + dispatch_details = frappe._dict({}) + if invoice.dispatch_address_name: + dispatch_details = get_party_details(invoice.dispatch_address_name, skip_gstin_validation=True) + + if invoice.is_pos and invoice.base_paid_amount: + payment_details = get_payment_details(invoice) + + if invoice.is_return and invoice.return_against: + prev_doc_details = get_return_doc_reference(invoice) + + if invoice.transporter and not invoice.is_return: + eway_bill_details = get_eway_bill_details(invoice) + + # not yet implemented + period_details = export_details = frappe._dict({}) + + einvoice = schema.format( + transaction_details=transaction_details, doc_details=doc_details, dispatch_details=dispatch_details, + seller_details=seller_details, buyer_details=buyer_details, shipping_details=shipping_details, + item_list=item_list, invoice_value_details=invoice_value_details, payment_details=payment_details, + period_details=period_details, prev_doc_details=prev_doc_details, + export_details=export_details, eway_bill_details=eway_bill_details + ) + + try: + einvoice = safe_json_load(einvoice) + einvoice = santize_einvoice_fields(einvoice) + except Exception: + show_link_to_error_log(invoice, einvoice) + + try: + validate_totals(einvoice) + except Exception: + log_error(einvoice) + raise + + return einvoice + +def show_link_to_error_log(invoice, einvoice): + err_log = log_error(einvoice) + link_to_error_log = get_link_to_form('Error Log', err_log.name, 'Error Log') + frappe.throw( + _('An error occurred while creating e-invoice for {}. Please check {} for more information.').format( + invoice.name, link_to_error_log), + title=_('E Invoice Creation Failed') + ) + +def log_error(data=None): + if isinstance(data, str): + data = json.loads(data) + + seperator = "--" * 50 + err_tb = traceback.format_exc() + err_msg = str(sys.exc_info()[1]) + data = json.dumps(data, indent=4) + + message = "\n".join([ + "Error", err_msg, seperator, + "Data:", data, seperator, + "Exception:", err_tb + ]) + return frappe.log_error(title=_('E Invoice Request Failed'), message=message) + +def santize_einvoice_fields(einvoice): + int_fields = ["Pin","Distance","CrDay"] + float_fields = ["Qty","FreeQty","UnitPrice","TotAmt","Discount","PreTaxVal","AssAmt","GstRt","IgstAmt","CgstAmt","SgstAmt","CesRt","CesAmt","CesNonAdvlAmt","StateCesRt","StateCesAmt","StateCesNonAdvlAmt","OthChrg","TotItemVal","AssVal","CgstVal","SgstVal","IgstVal","CesVal","StCesVal","Discount","OthChrg","RndOffAmt","TotInvVal","TotInvValFc","PaidAmt","PaymtDue","ExpDuty",] + copy = einvoice.copy() + for key, value in copy.items(): + if isinstance(value, list): + for idx, d in enumerate(value): + santized_dict = santize_einvoice_fields(d) + if santized_dict: + einvoice[key][idx] = santized_dict + else: + einvoice[key].pop(idx) + + if not einvoice[key]: + einvoice.pop(key, None) + + elif isinstance(value, dict): + santized_dict = santize_einvoice_fields(value) + if santized_dict: + einvoice[key] = santized_dict + else: + einvoice.pop(key, None) + + elif not value or value == "None": + einvoice.pop(key, None) + + elif key in float_fields: + einvoice[key] = flt(value, 2) + + elif key in int_fields: + einvoice[key] = cint(value) + + return einvoice + +def safe_json_load(json_string): + try: + return json.loads(json_string) + except json.JSONDecodeError as e: + # print a snippet of 40 characters around the location where error occured + pos = e.pos + start, end = max(0, pos-20), min(len(json_string)-1, pos+20) + snippet = json_string[start:end] + frappe.throw(_("Error in input data. Please check for any special characters near following input:
{}").format(snippet)) + +class RequestFailed(Exception): + pass +class CancellationNotAllowed(Exception): + pass + +class GSPConnector(): + def __init__(self, doctype=None, docname=None): + self.doctype = doctype + self.docname = docname + + self.set_invoice() + self.set_credentials() + + # authenticate url is same for sandbox & live + self.authenticate_url = 'https://gsp.adaequare.com/gsp/authenticate?grant_type=token' + self.base_url = 'https://gsp.adaequare.com' if not self.e_invoice_settings.sandbox_mode else 'https://gsp.adaequare.com/test' + + self.cancel_irn_url = self.base_url + '/enriched/ei/api/invoice/cancel' + self.irn_details_url = self.base_url + '/enriched/ei/api/invoice/irn' + self.generate_irn_url = self.base_url + '/enriched/ei/api/invoice' + self.gstin_details_url = self.base_url + '/enriched/ei/api/master/gstin' + self.cancel_ewaybill_url = self.base_url + '/enriched/ewb/ewayapi?action=CANEWB' + self.generate_ewaybill_url = self.base_url + '/enriched/ei/api/ewaybill' + + def set_invoice(self): + self.invoice = None + if self.doctype and self.docname: + self.invoice = frappe.get_cached_doc(self.doctype, self.docname) + + def set_credentials(self): + self.e_invoice_settings = frappe.get_cached_doc('E Invoice Settings') + + if not self.e_invoice_settings.enable: + frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings"))) + + if self.invoice: + gstin = self.get_seller_gstin() + credentials_for_gstin = [d for d in self.e_invoice_settings.credentials if d.gstin == gstin] + if credentials_for_gstin: + self.credentials = credentials_for_gstin[0] + else: + frappe.throw(_('Cannot find e-invoicing credentials for selected Company GSTIN. Please check E-Invoice Settings')) + else: + self.credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None + + def get_seller_gstin(self): + gstin = frappe.db.get_value('Address', self.invoice.company_address, 'gstin') + if not gstin: + frappe.throw(_('Cannot retrieve Company GSTIN. Please select company address with valid GSTIN.')) + return gstin + + def get_auth_token(self): + if time_diff_in_seconds(self.e_invoice_settings.token_expiry, now_datetime()) < 150.0: + self.fetch_auth_token() + + return self.e_invoice_settings.auth_token + + def make_request(self, request_type, url, headers=None, data=None): + if request_type == 'post': + res = make_post_request(url, headers=headers, data=data) + else: + res = make_get_request(url, headers=headers, data=data) + + self.log_request(url, headers, data, res) + return res + + def log_request(self, url, headers, data, res): + headers.update({ 'password': self.credentials.password }) + request_log = frappe.get_doc({ + "doctype": "E Invoice Request Log", + "user": frappe.session.user, + "reference_invoice": self.invoice.name if self.invoice else None, + "url": url, + "headers": json.dumps(headers, indent=4) if headers else None, + "data": json.dumps(data, indent=4) if isinstance(data, dict) else data, + "response": json.dumps(res, indent=4) if res else None + }) + request_log.save(ignore_permissions=True) + frappe.db.commit() + + def get_client_credentials(self): + if self.e_invoice_settings.client_id and self.e_invoice_settings.client_secret: + return self.e_invoice_settings.client_id, self.e_invoice_settings.get_password('client_secret') + + return frappe.conf.einvoice_client_id, frappe.conf.einvoice_client_secret + + def fetch_auth_token(self): + client_id, client_secret = self.get_client_credentials() + headers = { + 'gspappid': client_id, + 'gspappsecret': client_secret + } + res = {} + try: + res = self.make_request('post', self.authenticate_url, headers) + self.e_invoice_settings.auth_token = "{} {}".format(res.get('token_type'), res.get('access_token')) + self.e_invoice_settings.token_expiry = add_to_date(None, seconds=res.get('expires_in')) + self.e_invoice_settings.save(ignore_permissions=True) + self.e_invoice_settings.reload() + + except Exception: + log_error(res) + self.raise_error(True) + + def get_headers(self): + return { + 'content-type': 'application/json', + 'user_name': self.credentials.username, + 'password': self.credentials.get_password(), + 'gstin': self.credentials.gstin, + 'authorization': self.get_auth_token(), + 'requestid': str(base64.b64encode(os.urandom(18))), + } + + def fetch_gstin_details(self, gstin): + headers = self.get_headers() + + try: + params = '?gstin={gstin}'.format(gstin=gstin) + res = self.make_request('get', self.gstin_details_url + params, headers) + if res.get('success'): + return res.get('result') + else: + log_error(res) + raise RequestFailed + + except RequestFailed: + self.raise_error() + + except Exception: + log_error() + self.raise_error(True) + @staticmethod + def get_gstin_details(gstin): + '''fetch and cache GSTIN details''' + if not hasattr(frappe.local, 'gstin_cache'): + frappe.local.gstin_cache = {} + + key = gstin + gsp_connector = GSPConnector() + details = gsp_connector.fetch_gstin_details(gstin) + + frappe.local.gstin_cache[key] = details + frappe.cache().hset('gstin_cache', key, details) + return details + + def generate_irn(self): + data = {} + try: + headers = self.get_headers() + einvoice = make_einvoice(self.invoice) + data = json.dumps(einvoice, indent=4) + res = self.make_request('post', self.generate_irn_url, headers, data) + + if res.get('success'): + self.set_einvoice_data(res.get('result')) + + elif '2150' in res.get('message'): + # IRN already generated but not updated in invoice + # Extract the IRN from the response description and fetch irn details + irn = res.get('result')[0].get('Desc').get('Irn') + irn_details = self.get_irn_details(irn) + if irn_details: + self.set_einvoice_data(irn_details) + else: + raise RequestFailed('IRN has already been generated for the invoice but cannot fetch details for the it. \ + Contact ERPNext support to resolve the issue.') + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.set_failed_status(errors=errors) + self.raise_error(errors=errors) + + except Exception as e: + self.set_failed_status(errors=str(e)) + log_error(data) + self.raise_error(True) + + @staticmethod + def bulk_generate_irn(invoices): + gsp_connector = GSPConnector() + gsp_connector.doctype = 'Sales Invoice' + + failed = [] + + for invoice in invoices: + try: + gsp_connector.docname = invoice + gsp_connector.set_invoice() + gsp_connector.set_credentials() + gsp_connector.generate_irn() + + except Exception as e: + failed.append({ + 'docname': invoice, + 'message': str(e) + }) + + return failed + + def get_irn_details(self, irn): + headers = self.get_headers() + + try: + params = '?irn={irn}'.format(irn=irn) + res = self.make_request('get', self.irn_details_url + params, headers) + if res.get('success'): + return res.get('result') + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + log_error() + self.raise_error(True) + + def cancel_irn(self, irn, reason, remark): + data, res = {}, {} + try: + # validate cancellation + if time_diff_in_hours(now_datetime(), self.invoice.ack_date) > 24: + frappe.throw(_('E-Invoice cannot be cancelled after 24 hours of IRN generation.'), title=_('Not Allowed'), exc=CancellationNotAllowed) + if not irn: + frappe.throw(_('IRN not found. You must generate IRN before cancelling.'), title=_('Not Allowed'), exc=CancellationNotAllowed) + + headers = self.get_headers() + data = json.dumps({ + 'Irn': irn, + 'Cnlrsn': reason, + 'Cnlrem': remark + }, indent=4) + + res = self.make_request('post', self.cancel_irn_url, headers, data) + if res.get('success') or '9999' in res.get('message'): + self.invoice.irn_cancelled = 1 + self.invoice.irn_cancel_date = res.get('result')['CancelDate'] if res.get('result') else "" + self.invoice.einvoice_status = 'Cancelled' + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('IRN Cancelled - {}').format(remark) + } + self.update_invoice() + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.set_failed_status(errors=errors) + self.raise_error(errors=errors) + + except CancellationNotAllowed as e: + self.set_failed_status(errors=str(e)) + self.raise_error(errors=str(e)) + + except Exception as e: + self.set_failed_status(errors=str(e)) + log_error(data) + self.raise_error(True) + + @staticmethod + def bulk_cancel_irn(invoices, reason, remark): + gsp_connector = GSPConnector() + gsp_connector.doctype = 'Sales Invoice' + + failed = [] + + for invoice in invoices: + try: + gsp_connector.docname = invoice + gsp_connector.set_invoice() + gsp_connector.set_credentials() + irn = gsp_connector.invoice.irn + gsp_connector.cancel_irn(irn, reason, remark) + + except Exception as e: + failed.append({ + 'docname': invoice, + 'message': str(e) + }) + + return failed + + def generate_eway_bill(self, **kwargs): + args = frappe._dict(kwargs) + + headers = self.get_headers() + eway_bill_details = get_eway_bill_details(args) + data = json.dumps({ + 'Irn': args.irn, + 'Distance': cint(eway_bill_details.distance), + 'TransMode': eway_bill_details.mode_of_transport, + 'TransId': eway_bill_details.gstin, + 'TransName': eway_bill_details.transporter, + 'TrnDocDt': eway_bill_details.document_date, + 'TrnDocNo': eway_bill_details.document_name, + 'VehNo': eway_bill_details.vehicle_no, + 'VehType': eway_bill_details.vehicle_type + }, indent=4) + + try: + res = self.make_request('post', self.generate_ewaybill_url, headers, data) + if res.get('success'): + self.invoice.ewaybill = res.get('result').get('EwbNo') + self.invoice.eway_bill_validity = res.get('result').get('EwbValidTill') + self.invoice.eway_bill_cancelled = 0 + self.invoice.update(args) + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('E-Way Bill Generated') + } + self.update_invoice() + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + log_error(data) + self.raise_error(True) + + def cancel_eway_bill(self, eway_bill, reason, remark): + headers = self.get_headers() + data = json.dumps({ + 'ewbNo': eway_bill, + 'cancelRsnCode': reason, + 'cancelRmrk': remark + }, indent=4) + headers["username"] = headers["user_name"] + del headers["user_name"] + try: + res = self.make_request('post', self.cancel_ewaybill_url, headers, data) + if res.get('success'): + self.invoice.ewaybill = '' + self.invoice.eway_bill_cancelled = 1 + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('E-Way Bill Cancelled - {}').format(remark) + } + self.update_invoice() + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + log_error(data) + self.raise_error(True) + + def sanitize_error_message(self, message): + ''' + On validation errors, response message looks something like this: + message = '2174 : For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable, + 3095 : Supplier GSTIN is inactive' + we search for string between ':' to extract the error messages + errors = [ + ': For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable, 3095 ', + ': Test' + ] + then we trim down the message by looping over errors + ''' + if not message: + return [] + + errors = re.findall(': [^:]+', message) + for idx, e in enumerate(errors): + # remove colons + errors[idx] = errors[idx].replace(':', '').strip() + # if not last + if idx != len(errors) - 1: + # remove last 7 chars eg: ', 3095 ' + errors[idx] = errors[idx][:-6] + + return errors + + def raise_error(self, raise_exception=False, errors=None): + if errors is None: + errors = [] + title = _('E Invoice Request Failed') + if errors: + frappe.throw(errors, title=title, as_list=1) + else: + link_to_error_list = 'Error Log' + frappe.msgprint( + _('An error occurred while making e-invoicing request. Please check {} for more information.').format(link_to_error_list), + title=title, + raise_exception=raise_exception, + indicator='red' + ) + + def set_einvoice_data(self, res): + enc_signed_invoice = res.get('SignedInvoice') + dec_signed_invoice = jwt.decode(enc_signed_invoice, options={"verify_signature": False})['data'] + + self.invoice.irn = res.get('Irn') + self.invoice.ewaybill = res.get('EwbNo') + self.invoice.eway_bill_validity = res.get('EwbValidTill') + self.invoice.ack_no = res.get('AckNo') + self.invoice.ack_date = res.get('AckDt') + self.invoice.signed_einvoice = dec_signed_invoice + self.invoice.ack_no = res.get('AckNo') + self.invoice.ack_date = res.get('AckDt') + self.invoice.signed_qr_code = res.get('SignedQRCode') + self.invoice.einvoice_status = 'Generated' + + self.attach_qrcode_image() + + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('IRN Generated') + } + self.update_invoice() + + def attach_qrcode_image(self): + qrcode = self.invoice.signed_qr_code + doctype = self.invoice.doctype + docname = self.invoice.name + filename = 'QRCode_{}.png'.format(docname).replace(os.path.sep, "__") + + qr_image = io.BytesIO() + url = qrcreate(qrcode, error='L') + url.png(qr_image, scale=2, quiet_zone=1) + _file = frappe.get_doc({ + "doctype": "File", + "file_name": filename, + "attached_to_doctype": doctype, + "attached_to_name": docname, + "attached_to_field": "qrcode_image", + "is_private": 0, + "content": qr_image.getvalue()}) + _file.save() + frappe.db.commit() + self.invoice.qrcode_image = _file.file_url + + def update_invoice(self): + self.invoice.flags.ignore_validate_update_after_submit = True + self.invoice.flags.ignore_validate = True + self.invoice.save() + + def set_failed_status(self, errors=None): + frappe.db.rollback() + self.invoice.einvoice_status = 'Failed' + self.invoice.failure_description = self.get_failure_message(errors) if errors else "" + self.update_invoice() + frappe.db.commit() + + def get_failure_message(self, errors): + if isinstance(errors, list): + errors = ', '.join(errors) + return errors + +def sanitize_for_json(string): + """Escape JSON specific characters from a string.""" + + # json.dumps adds double-quotes to the string. Indexing to remove them. + return json.dumps(string)[1:-1] + +@frappe.whitelist() +def get_einvoice(doctype, docname): + invoice = frappe.get_doc(doctype, docname) + return make_einvoice(invoice) + +@frappe.whitelist() +def generate_irn(doctype, docname): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.generate_irn() + +@frappe.whitelist() +def cancel_irn(doctype, docname, irn, reason, remark): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.cancel_irn(irn, reason, remark) + +@frappe.whitelist() +def generate_eway_bill(doctype, docname, **kwargs): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.generate_eway_bill(**kwargs) + +@frappe.whitelist() +def cancel_eway_bill(doctype, docname): + # TODO: uncomment when eway_bill api from Adequare is enabled + # gsp_connector = GSPConnector(doctype, docname) + # gsp_connector.cancel_eway_bill(eway_bill, reason, remark) + + frappe.db.set_value(doctype, docname, 'ewaybill', '') + frappe.db.set_value(doctype, docname, 'eway_bill_cancelled', 1) + +@frappe.whitelist() +def generate_einvoices(docnames): + docnames = json.loads(docnames) or [] + + if len(docnames) < 10: + failures = GSPConnector.bulk_generate_irn(docnames) + frappe.local.message_log = [] + + if failures: + show_bulk_action_failure_message(failures) + + success = len(docnames) - len(failures) + frappe.msgprint( + _('{} e-invoices generated successfully').format(success), + title=_('Bulk E-Invoice Generation Complete') + ) + + else: + enqueue_bulk_action(schedule_bulk_generate_irn, docnames=docnames) + +def schedule_bulk_generate_irn(docnames): + failures = GSPConnector.bulk_generate_irn(docnames) + frappe.local.message_log = [] + + frappe.publish_realtime("bulk_einvoice_generation_complete", { + "user": frappe.session.user, + "failures": failures, + "invoices": docnames + }) + +def show_bulk_action_failure_message(failures): + for doc in failures: + docname = '{0}'.format(doc.get('docname')) + message = doc.get('message').replace("'", '"') + if message[0] == '[': + errors = json.loads(message) + error_list = ''.join(['
  • {}
  • '.format(err) for err in errors]) + message = '''{} has following errors:
    +
      {}
    '''.format(docname, error_list) + else: + message = '{} - {}'.format(docname, message) + + frappe.msgprint( + message, + title=_('Bulk E-Invoice Generation Complete'), + indicator='red' + ) + +@frappe.whitelist() +def cancel_irns(docnames, reason, remark): + docnames = json.loads(docnames) or [] + + if len(docnames) < 10: + failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark) + frappe.local.message_log = [] + + if failures: + show_bulk_action_failure_message(failures) + + success = len(docnames) - len(failures) + frappe.msgprint( + _('{} e-invoices cancelled successfully').format(success), + title=_('Bulk E-Invoice Cancellation Complete') + ) + else: + enqueue_bulk_action(schedule_bulk_cancel_irn, docnames=docnames, reason=reason, remark=remark) + +def schedule_bulk_cancel_irn(docnames, reason, remark): + failures = GSPConnector.bulk_cancel_irn(docnames, reason, remark) + frappe.local.message_log = [] + + frappe.publish_realtime("bulk_einvoice_cancellation_complete", { + "user": frappe.session.user, + "failures": failures, + "invoices": docnames + }) + +def enqueue_bulk_action(job, **kwargs): + check_scheduler_status() + + enqueue( + job, + **kwargs, + queue="long", + timeout=10000, + event="processing_bulk_einvoice_action", + now=frappe.conf.developer_mode or frappe.flags.in_test, + ) + + if job == schedule_bulk_generate_irn: + msg = _('E-Invoices will be generated in a background process.') + else: + msg = _('E-Invoices will be cancelled in a background process.') + + frappe.msgprint(msg, alert=1) + +def check_scheduler_status(): + if is_scheduler_inactive() and not frappe.flags.in_test: + frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive")) + +def job_already_enqueued(job_name): + enqueued_jobs = [d.get("job_name") for d in get_info()] + if job_name in enqueued_jobs: + return True diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index 4b9942121aa..2df52a204b1 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -53,14 +53,11 @@ def create_hsn_codes(data, code_field): hsn_code.description = d["description"] hsn_code.hsn_code = d[code_field] hsn_code.name = d[code_field] - try: - hsn_code.db_insert() - except frappe.DuplicateEntryError: - pass + hsn_code.db_insert(ignore_if_duplicate=True) def add_custom_roles_for_reports(): for report_name in ('GST Sales Register', 'GST Purchase Register', - 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill'): + 'GST Itemised Sales Register', 'GST Itemised Purchase Register', 'Eway Bill', 'E-Invoice Summary'): if not frappe.db.get_value('Custom Role', dict(report=report_name)): frappe.get_doc(dict( @@ -99,7 +96,7 @@ def add_custom_roles_for_reports(): )).insert() def add_permissions(): - for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate'): + for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate', 'E Invoice Settings'): add_permission(doctype, 'All', 0) for role in ('Accounts Manager', 'Accounts User', 'System Manager'): add_permission(doctype, role, 0) @@ -114,10 +111,12 @@ def add_permissions(): def add_print_formats(): frappe.reload_doc("regional", "print_format", "gst_tax_invoice") - frappe.reload_doc("accounts", "print_format", "gst_pos_invoice") + frappe.reload_doc("selling", "print_format", "gst_pos_invoice") + frappe.reload_doc("accounts", "print_format", "GST E-Invoice") frappe.db.set_value("Print Format", "GST POS Invoice", "disabled", 0) frappe.db.set_value("Print Format", "GST Tax Invoice", "disabled", 0) + frappe.db.set_value("Print Format", "GST E-Invoice", "disabled", 0) def make_property_setters(patch=False): # GST rules do not allow for an invoice no. bigger than 16 characters @@ -453,7 +452,7 @@ def get_custom_fields(): 'fieldname': 'ewaybill', 'label': 'E-Way Bill No.', 'fieldtype': 'Data', - 'depends_on': 'eval:(doc.docstatus === 1)', + 'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)', 'allow_on_submit': 1, 'insert_after': 'tax_id', 'translatable': 0, @@ -481,6 +480,46 @@ def get_custom_fields(): fetch_from='customer_address.gstin', print_hide=1, read_only=1) ] + si_einvoice_fields = [ + dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1, + depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'), + + dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval: doc.irn', allow_on_submit=1, insert_after='customer'), + + dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1, + depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill'), + + dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type', + print_hide=1, hidden=1), + + dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section', + no_copy=1, print_hide=1), + + dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), + + dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date', + no_copy=1, print_hide=1), + + dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code', + no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image', + options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON', + hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1) + ] + custom_fields = { 'Address': [ dict(fieldname='gstin', label='Party GSTIN', fieldtype='Data', @@ -493,7 +532,7 @@ def get_custom_fields(): 'Purchase Invoice': purchase_invoice_gst_category + invoice_gst_fields + purchase_invoice_itc_fields + purchase_invoice_gst_fields, 'Purchase Order': purchase_invoice_gst_fields, 'Purchase Receipt': purchase_invoice_gst_fields, - 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields, + 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields + si_einvoice_fields, 'POS Invoice': sales_invoice_gst_fields, 'Delivery Note': sales_invoice_gst_fields + ewaybill_fields + sales_invoice_shipping_fields + delivery_note_gst_category, 'Payment Entry': payment_entry_fields, diff --git a/erpnext/regional/report/datev/datev.js b/erpnext/regional/report/datev/datev.js index 4124e3df190..03c729e6df4 100644 --- a/erpnext/regional/report/datev/datev.js +++ b/erpnext/regional/report/datev/datev.js @@ -40,7 +40,11 @@ frappe.query_reports["DATEV"] = { }); query_report.page.add_menu_item(__("Download DATEV File"), () => { - const filters = JSON.stringify(query_report.get_values()); + const filters = encodeURIComponent( + JSON.stringify( + query_report.get_values() + ) + ); window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`); }); diff --git a/erpnext/regional/report/datev/test_datev.py b/erpnext/regional/report/datev/test_datev.py index 14d5495eed7..052fb2a7244 100644 --- a/erpnext/regional/report/datev/test_datev.py +++ b/erpnext/regional/report/datev/test_datev.py @@ -1,9 +1,9 @@ import zipfile +from io import BytesIO from unittest import TestCase import frappe from frappe.utils import cstr, now_datetime, today -from six import BytesIO from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.regional.germany.utils.datev.datev_constants import ( diff --git a/erpnext/regional/report/e_invoice_summary/__init__.py b/erpnext/regional/report/e_invoice_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js new file mode 100644 index 00000000000..4713217d83c --- /dev/null +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.js @@ -0,0 +1,55 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["E-Invoice Summary"] = { + "filters": [ + { + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "fieldname": "company", + "label": __("Company"), + "default": frappe.defaults.get_user_default("Company"), + }, + { + "fieldtype": "Link", + "options": "Customer", + "fieldname": "customer", + "label": __("Customer") + }, + { + "fieldtype": "Date", + "reqd": 1, + "fieldname": "from_date", + "label": __("From Date"), + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1), + }, + { + "fieldtype": "Date", + "reqd": 1, + "fieldname": "to_date", + "label": __("To Date"), + "default": frappe.datetime.get_today(), + }, + { + "fieldtype": "Select", + "fieldname": "status", + "label": __("Status"), + "options": "\nPending\nGenerated\nCancelled\nFailed" + } + ], + + "formatter": function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + + if (column.fieldname == "einvoice_status" && value) { + if (value == 'Pending') value = `${value}`; + else if (value == 'Generated') value = `${value}`; + else if (value == 'Cancelled') value = `${value}`; + else if (value == 'Failed') value = `${value}`; + } + + return value; + } +}; diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json new file mode 100644 index 00000000000..d0000ad50df --- /dev/null +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json @@ -0,0 +1,28 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-03-12 11:23:37.312294", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "letter_head": "Logo", + "modified": "2021-03-13 12:36:48.689413", + "modified_by": "Administrator", + "module": "Regional", + "name": "E-Invoice Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Sales Invoice", + "report_name": "E-Invoice Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Administrator" + } + ] +} \ No newline at end of file diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py new file mode 100644 index 00000000000..2110c444470 --- /dev/null +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py @@ -0,0 +1,110 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + + +def execute(filters=None): + validate_filters(filters) + + columns = get_columns() + data = get_data(filters) + + return columns, data + +def validate_filters(filters=None): + if filters is None: + filters = {} + filters = frappe._dict(filters) + + if not filters.company: + frappe.throw(_('{} is mandatory for generating E-Invoice Summary Report').format(_('Company')), title=_('Invalid Filter')) + if filters.company: + # validate if company has e-invoicing enabled + pass + if not filters.from_date or not filters.to_date: + frappe.throw(_('From Date & To Date is mandatory for generating E-Invoice Summary Report'), title=_('Invalid Filter')) + if filters.from_date > filters.to_date: + frappe.throw(_('From Date must be before To Date'), title=_('Invalid Filter')) + +def get_data(filters=None): + if filters is None: + filters = {} + query_filters = { + 'posting_date': ['between', [filters.from_date, filters.to_date]], + 'einvoice_status': ['is', 'set'], + 'company': filters.company + } + if filters.customer: + query_filters['customer'] = filters.customer + if filters.status: + query_filters['einvoice_status'] = filters.status + + data = frappe.get_all( + 'Sales Invoice', + filters=query_filters, + fields=[d.get('fieldname') for d in get_columns()] + ) + + return data + +def get_columns(): + return [ + { + "fieldtype": "Date", + "fieldname": "posting_date", + "label": _("Posting Date"), + "width": 0 + }, + { + "fieldtype": "Link", + "fieldname": "name", + "label": _("Sales Invoice"), + "options": "Sales Invoice", + "width": 140 + }, + { + "fieldtype": "Data", + "fieldname": "einvoice_status", + "label": _("Status"), + "width": 100 + }, + { + "fieldtype": "Link", + "fieldname": "customer", + "options": "Customer", + "label": _("Customer") + }, + { + "fieldtype": "Check", + "fieldname": "is_return", + "label": _("Is Return"), + "width": 85 + }, + { + "fieldtype": "Data", + "fieldname": "ack_no", + "label": "Ack. No.", + "width": 145 + }, + { + "fieldtype": "Data", + "fieldname": "ack_date", + "label": "Ack. Date", + "width": 165 + }, + { + "fieldtype": "Data", + "fieldname": "irn", + "label": _("IRN No."), + "width": 250 + }, + { + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "fieldname": "base_grand_total", + "label": _("Grand Total"), + "width": 120 + } + ] diff --git a/erpnext/regional/report/gstr_1/gstr_1.js b/erpnext/regional/report/gstr_1/gstr_1.js index 4b98978f130..9999a6d167b 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.js +++ b/erpnext/regional/report/gstr_1/gstr_1.js @@ -17,7 +17,7 @@ frappe.query_reports["GSTR-1"] = { "fieldtype": "Link", "options": "Address", "get_query": function () { - var company = frappe.query_report.get_filter_value('company'); + let company = frappe.query_report.get_filter_value('company'); if (company) { return { "query": 'frappe.contacts.doctype.address.address.address_query', @@ -26,6 +26,11 @@ frappe.query_reports["GSTR-1"] = { } } }, + { + "fieldname": "company_gstin", + "label": __("Company GSTIN"), + "fieldtype": "Select" + }, { "fieldname": "from_date", "label": __("From Date"), @@ -60,10 +65,21 @@ frappe.query_reports["GSTR-1"] = { } ], onload: function (report) { + let filters = report.get_values(); + + frappe.call({ + method: 'erpnext.regional.report.gstr_1.gstr_1.get_company_gstins', + args: { + company: filters.company + }, + callback: function(r) { + frappe.query_report.page.fields_dict.company_gstin.df.options = r.message; + frappe.query_report.page.fields_dict.company_gstin.refresh(); + } + }); + report.page.add_inner_button(__("Download as JSON"), function () { - var filters = report.get_values(); - frappe.call({ method: 'erpnext.regional.report.gstr_1.gstr_1.get_json', args: { diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index e50ff180328..8fcb6bb4448 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -28,7 +28,7 @@ class Gstr1Report(object): posting_date, base_grand_total, base_rounded_total, - COALESCE(NULLIF(customer_gstin,''), NULLIF(billing_address_gstin, '')) as customer_gstin, + NULLIF(billing_address_gstin, '') as billing_address_gstin, place_of_supply, ecommerce_gstin, reverse_charge, @@ -253,13 +253,14 @@ class Gstr1Report(object): for opts in (("company", " and company=%(company)s"), ("from_date", " and posting_date>=%(from_date)s"), ("to_date", " and posting_date<=%(to_date)s"), - ("company_address", " and company_address=%(company_address)s")): + ("company_address", " and company_address=%(company_address)s"), + ("company_gstin", " and company_gstin=%(company_gstin)s")): if self.filters.get(opts[0]): conditions += opts[1] if self.filters.get("type_of_business") == "B2B": - conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1" + conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Registered Composition', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1" if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"): b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit') @@ -383,7 +384,7 @@ class Gstr1Report(object): for invoice, items in self.invoice_items.items(): if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \ and self.invoices.get(invoice, {}).get('export_type') == "Without Payment of Tax" \ - and self.invoices.get(invoice, {}).get('gst_category') == "Overseas": + and self.invoices.get(invoice, {}).get('gst_category') in ("Overseas", "SEZ"): self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys()) def get_columns(self): @@ -409,7 +410,7 @@ class Gstr1Report(object): if self.filters.get("type_of_business") == "B2B": self.invoice_columns = [ { - "fieldname": "customer_gstin", + "fieldname": "billing_address_gstin", "label": "GSTIN/UIN of Recipient", "fieldtype": "Data", "width": 150 @@ -516,7 +517,7 @@ class Gstr1Report(object): elif self.filters.get("type_of_business") == "CDNR-REG": self.invoice_columns = [ { - "fieldname": "customer_gstin", + "fieldname": "billing_address_gstin", "label": "GSTIN/UIN of Recipient", "fieldtype": "Data", "width": 150 @@ -817,7 +818,7 @@ def get_json(filters, report_name, data): res = {} if filters["type_of_business"] == "B2B": for item in report_data[:-1]: - res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) + res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) out = get_b2b_json(res, gstin) gst_json["b2b"] = out @@ -841,7 +842,7 @@ def get_json(filters, report_name, data): gst_json["exp"] = out elif filters["type_of_business"] == "CDNR-REG": for item in report_data[:-1]: - res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) + res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item) out = get_cdnr_reg_json(res, gstin) gst_json["cdnr"] = out @@ -875,7 +876,7 @@ def get_json(filters, report_name, data): } def get_b2b_json(res, gstin): - inv_type, out = {"Registered Regular": "R", "Deemed Export": "DE", "URD": "URD", "SEZ": "SEZ"}, [] + out = [] for gst_in in res: b2b_item, inv = {"ctin": gst_in, "inv": []}, [] if not gst_in: continue @@ -889,7 +890,7 @@ def get_b2b_json(res, gstin): inv_item = get_basic_invoice_detail(invoice[0]) inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]) inv_item["rchrg"] = invoice[0]["reverse_charge"] - inv_item["inv_typ"] = inv_type.get(invoice[0].get("gst_category", ""),"") + inv_item["inv_typ"] = get_invoice_type(invoice[0]) if inv_item["pos"]=="00": continue inv_item["itms"] = [] @@ -1044,7 +1045,7 @@ def get_cdnr_reg_json(res, gstin): "ntty": invoice[0]["document_type"], "pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]), "rchrg": invoice[0]["reverse_charge"], - "inv_typ": get_invoice_type_for_cdnr(invoice[0]) + "inv_typ": get_invoice_type(invoice[0]) } inv_item["itms"] = [] @@ -1069,7 +1070,7 @@ def get_cdnr_unreg_json(res, gstin): "val": abs(flt(items[0]["invoice_value"])), "ntty": items[0]["document_type"], "pos": "%02d" % int(items[0]["place_of_supply"].split('-')[0]), - "typ": get_invoice_type_for_cdnrur(items[0]) + "typ": get_invoice_type(items[0]) } inv_item["itms"] = [] @@ -1110,29 +1111,21 @@ def get_exempted_json(data): return out -def get_invoice_type_for_cdnr(row): - if row.get('gst_category') == 'SEZ': - if row.get('export_type') == 'WPAY': - invoice_type = 'SEWP' - else: - invoice_type = 'SEWOP' - elif row.get('gst_category') == 'Deemed Export': - invoice_type = 'DE' - elif row.get('gst_category') == 'Registered Regular': - invoice_type = 'R' +def get_invoice_type(row): + gst_category = row.get('gst_category') - return invoice_type + if gst_category == 'SEZ': + return 'SEWP' if row.get('export_type') == 'WPAY' else 'SEWOP' -def get_invoice_type_for_cdnrur(row): - if row.get('gst_category') == 'Overseas': - if row.get('export_type') == 'WPAY': - invoice_type = 'EXPWP' - else: - invoice_type = 'EXPWOP' - elif row.get('gst_category') == 'Unregistered': - invoice_type = 'B2CL' + if gst_category == 'Overseas': + return 'EXPWP' if row.get('export_type') == 'WPAY' else 'EXPWOP' - return invoice_type + return ({ + 'Deemed Export': 'DE', + 'Registered Regular': 'R', + 'Registered Composition': 'R', + 'Unregistered': 'B2CL' + }).get(gst_category) def get_basic_invoice_detail(row): return { @@ -1154,7 +1147,7 @@ def get_rate_and_tax_details(row, gstin): # calculate tax amount added tax = flt((row["taxable_value"]*rate)/100.0, 2) frappe.errprint([tax, tax/2]) - if row.get("customer_gstin") and gstin[0:2] == row["customer_gstin"][0:2]: + if row.get("billing_address_gstin") and gstin[0:2] == row["billing_address_gstin"][0:2]: itm_det.update({"camt": flt(tax/2.0, 2), "samt": flt(tax/2.0, 2)}) else: itm_det.update({"iamt": tax}) @@ -1199,4 +1192,24 @@ def is_inter_state(invoice_detail): if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]: return True else: - return False \ No newline at end of file + return False + + +@frappe.whitelist() +def get_company_gstins(company): + address = frappe.qb.DocType("Address") + links = frappe.qb.DocType("Dynamic Link") + + addresses = frappe.qb.from_(address).inner_join(links).on( + address.name == links.parent + ).select( + address.gstin + ).where( + links.link_doctype == 'Company' + ).where( + links.link_name == company + ).run(as_dict=1) + + address_list = [''] + [d.gstin for d in addresses] + + return address_list \ No newline at end of file diff --git a/erpnext/regional/saudi_arabia/setup.py b/erpnext/regional/saudi_arabia/setup.py index 15d524d5b81..d2ef6f3f178 100644 --- a/erpnext/regional/saudi_arabia/setup.py +++ b/erpnext/regional/saudi_arabia/setup.py @@ -102,7 +102,7 @@ def make_custom_fields(): ] } - create_custom_fields(custom_fields, update=True) + create_custom_fields(custom_fields, ignore_validate=True, update=True) def update_regional_tax_settings(country, company): create_ksa_vat_setting(company) diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index d74d5a6df9a..7742f26ad11 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -258,30 +258,6 @@ class Customer(TransactionBase): .format(frappe.bold(self.customer_name)) ) - def create_onboarding_docs(self, args): - defaults = frappe.defaults.get_defaults() - company = defaults.get('company') or \ - frappe.db.get_single_value('Global Defaults', 'default_company') - - for i in range(1, args.get('max_count')): - customer = args.get('customer_name_' + str(i)) - if customer: - try: - doc = frappe.get_doc({ - 'doctype': self.doctype, - 'customer_name': customer, - 'customer_type': 'Company', - 'customer_group': _('Commercial'), - 'territory': defaults.get('country'), - 'company': company - }).insert() - - if args.get('customer_email_' + str(i)): - create_contact(customer, self.doctype, - doc.name, args.get("customer_email_" + str(i))) - except frappe.NameError: - pass - def create_contact(contact, party_type, party, email): """Create contact based on given contact name""" contact = contact.split(' ') diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index daab6fbb8f9..eebde766d32 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -287,7 +287,7 @@ def _make_customer(source_name, ignore_permissions=False): customer = frappe.get_doc(customer_doclist) customer.flags.ignore_permissions = ignore_permissions if quotation.get("party_name") == "Shopping Cart": - customer.customer_group = frappe.db.get_value("Shopping Cart Settings", None, + customer.customer_group = frappe.db.get_value("E Commerce Settings", None, "default_customer_group") try: diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js index b631685bd19..4c8f9c4f84c 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -12,6 +12,14 @@ frappe.listview_settings['Quotation'] = { }; }; } + + listview.page.add_action_item(__("Sales Order"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Order"); + }); + + listview.page.add_action_item(__("Sales Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Invoice"); + }); }, get_indicator: function(doc) { diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 8b53902d32f..31a95896bc1 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -649,7 +649,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-02-23 01:13:54.670763", + "modified": "2021-07-15 12:40:51.074820", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 79e9e17e414..eb98e6c0bf8 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -457,12 +457,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_delivery_note_based_on_delivery_date() { var me = this; - var delivery_dates = []; - $.each(this.frm.doc.items || [], function(i, d) { - if(!delivery_dates.includes(d.delivery_date)) { - delivery_dates.push(d.delivery_date); - } - }); + var delivery_dates = this.frm.doc.items.map(i => i.delivery_date); + delivery_dates = [ ...new Set(delivery_dates) ]; var item_grid = this.frm.fields_dict["items"].grid; if(!item_grid.get_selected().length && delivery_dates.length > 1) { @@ -500,14 +496,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex if(!dates) return; - $.each(dates, function(i, d) { - $.each(item_grid.grid_rows || [], function(j, row) { - if(row.doc.delivery_date == d) { - row.doc.__checked = 1; - } - }); - }) - me.make_delivery_note(); + me.make_delivery_note(dates); dialog.hide(); }); dialog.show(); @@ -516,10 +505,13 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } } - make_delivery_note() { + make_delivery_note(delivery_dates) { frappe.model.open_mapped_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", - frm: this.frm + frm: this.frm, + args: { + delivery_dates + } }) } diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index cc951850a4a..0f5b1e3b89c 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -565,6 +565,13 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): } if not skip_item_mapping: + def condition(doc): + # make_mapped_doc sets js `args` into `frappe.flags.args` + if frappe.flags.args and frappe.flags.args.delivery_dates: + if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: + return False + return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1 + mapper["Sales Order Item"] = { "doctype": "Delivery Note Item", "field_map": { @@ -573,7 +580,7 @@ def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): "parent": "against_sales_order", }, "postprocess": update_item, - "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1 + "condition": condition } target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values) diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js index 26d96d59f29..4691190d2a5 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_list.js +++ b/erpnext/selling/doctype/sales_order/sales_order_list.js @@ -16,7 +16,7 @@ frappe.listview_settings['Sales Order'] = { return [__("Overdue"), "red", "per_delivered,<,100|delivery_date,<,Today|status,!=,Closed"]; } else if (flt(doc.grand_total) === 0) { - // not delivered (zero-amount order) + // not delivered (zeroount order) return [__("To Deliver"), "orange", "per_delivered,<,100|grand_total,=,0|status,!=,Closed"]; } else if (flt(doc.per_billed, 6) < 100) { @@ -48,5 +48,17 @@ frappe.listview_settings['Sales Order'] = { listview.call_for_selected_items(method, {"status": "Submitted"}); }); + listview.page.add_action_item(__("Sales Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Sales Invoice"); + }); + + listview.page.add_action_item(__("Delivery Note"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Delivery Note"); + }); + + listview.page.add_action_item(__("Advance Payment"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Advance Payment"); + }); + } }; diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 42bc0b70f8e..73c5bd299a2 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -6,7 +6,7 @@ import json import frappe import frappe.permissions from frappe.core.doctype.user_permission.test_user_permission import create_user -from frappe.utils import add_days, flt, getdate, nowdate +from frappe.utils import add_days, flt, getdate, nowdate, today from erpnext.controllers.accounts_controller import update_child_qty_rate from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import ( @@ -1375,6 +1375,72 @@ class TestSalesOrder(ERPNextTestCase): automatically_fetch_payment_terms(enable=0) + def test_zero_amount_sales_order_billing_status(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + so = make_sales_order(uom="Nos", do_not_save=1) + so.items[0].rate = 0 + so.save() + so.submit() + + self.assertEqual(so.net_total, 0) + self.assertEqual(so.billing_status, 'Not Billed') + + si = create_sales_invoice(qty=10, do_not_save=1) + si.price_list = '_Test Price List' + si.items[0].rate = 0 + si.items[0].price_list_rate = 0 + si.items[0].sales_order = so.name + si.items[0].so_detail = so.items[0].name + si.save() + si.submit() + + self.assertEqual(si.net_total, 0) + so.load_from_db() + self.assertEqual(so.billing_status, 'Fully Billed') + + def test_so_back_updated_from_wo_via_mr(self): + "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO." + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_se_from_wo, + ) + from erpnext.stock.doctype.material_request.material_request import raise_work_orders + + so = make_sales_order(item_list=[{"item_code": "_Test FG Item","qty": 2, "rate":100}]) + + mr = make_material_request(so.name) + mr.material_request_type = "Manufacture" + mr.schedule_date = today() + mr.submit() + + # WO from MR + wo_name = raise_work_orders(mr.name)[0] + wo = frappe.get_doc("Work Order", wo_name) + wo.wip_warehouse = "Work In Progress - _TC" + wo.skip_transfer = True + + self.assertEqual(wo.sales_order, so.name) + self.assertEqual(wo.sales_order_item, so.items[0].name) + + wo.submit() + make_stock_entry(item_code="_Test Item", # Stock RM + target="Work In Progress - _TC", + qty=4, basic_rate=100 + ) + make_stock_entry(item_code="_Test Item Home Desktop 100", # Stock RM + target="Work In Progress - _TC", + qty=4, basic_rate=100 + ) + + se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 2)) + se.submit() # Finish WO + + mr.reload() + wo.reload() + so.reload() + self.assertEqual(so.items[0].work_order_qty, wo.produced_qty) + self.assertEqual(mr.status, "Manufactured") + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") accounts_settings.automatically_fetch_payment_terms = enable diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 95f6c4e96df..080d517d131 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -83,8 +83,8 @@ "planned_qty", "column_break_69", "work_order_qty", - "delivered_qty", "produced_qty", + "delivered_qty", "returned_qty", "shopping_cart_section", "additional_notes", @@ -701,10 +701,8 @@ "width": "50px" }, { - "description": "For Production", "fieldname": "produced_qty", "fieldtype": "Float", - "hidden": 1, "label": "Produced Quantity", "oldfieldname": "produced_qty", "oldfieldtype": "Currency", @@ -802,7 +800,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-10-05 12:27:25.014789", + "modified": "2022-02-21 13:55:08.883104", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", @@ -811,5 +809,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 27bc541d62f..7c4a3f63dcc 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -80,7 +80,7 @@ "description": "How often should Project and Company be updated based on Sales Transactions?", "fieldname": "sales_update_frequency", "fieldtype": "Select", - "label": "Sales Update Frequency", + "label": "Sales Update Frequency in Company and Project", "options": "Each Transaction\nDaily\nMonthly", "reqd": 1 }, @@ -171,7 +171,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-13 12:32:17.004404", + "modified": "2022-02-04 15:41:59.939261", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -189,5 +189,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py index fb86e614b6c..e1ef63578e9 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.py +++ b/erpnext/selling/doctype/selling_settings/selling_settings.py @@ -16,7 +16,7 @@ class SellingSettings(Document): self.toggle_editable_rate_for_bundle_items() def validate(self): - for key in ["cust_master_name", "campaign_naming_by", "customer_group", "territory", + for key in ["cust_master_name", "customer_group", "territory", "maintain_same_sales_rate", "editable_price_list_rate", "selling_price_list"]: frappe.db.set_default(key, self.get(key, "")) diff --git a/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json b/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json deleted file mode 100644 index 92d00bcb380..00000000000 --- a/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "add_more_button": 1, - "app": "ERPNext", - "creation": "2019-11-15 14:44:10.065014", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [ - { - "label": "Learn More", - "video_id": "zsrrVDk6VBs" - } - ], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:54:01.686006", - "modified_by": "Administrator", - "name": "Add A Few Customers", - "owner": "Administrator", - "ref_doctype": "Customer", - "slide_desc": "", - "slide_fields": [ - { - "align": "", - "fieldname": "customer_name", - "fieldtype": "Data", - "label": "Customer Name", - "placeholder": "", - "reqd": 1 - }, - { - "align": "", - "fieldtype": "Column Break", - "reqd": 0 - }, - { - "align": "", - "fieldname": "customer_email", - "fieldtype": "Data", - "label": "Email ID", - "reqd": 1 - } - ], - "slide_order": 40, - "slide_title": "Add A Few Customers", - "slide_type": "Create" -} \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index db5b20e3e19..993c61d5639 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -24,7 +24,7 @@ def search_by_term(search_term, warehouse, price_list): ["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"], as_dict=1) - item_stock_qty = get_stock_availability(item_code, warehouse) + item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse) price_list_rate, currency = frappe.db.get_value('Item Price', { 'price_list': price_list, 'item_code': item_code @@ -99,7 +99,6 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te ), {'warehouse': warehouse}, as_dict=1) if items_data: - items_data = filter_service_items(items_data) items = [d.item_code for d in items_data] item_prices_data = frappe.get_all("Item Price", fields = ["item_code", "price_list_rate", "currency"], @@ -112,7 +111,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te for item in items_data: item_code = item.item_code item_price = item_prices.get(item_code) or {} - item_stock_qty = get_stock_availability(item_code, warehouse) + item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse) row = {} row.update(item) @@ -144,14 +143,6 @@ def search_for_serial_or_batch_or_barcode_number(search_value): return {} -def filter_service_items(items): - for item in items: - if not item['is_stock_item']: - if not frappe.db.exists('Product Bundle', item['item_code']): - items.remove(item) - - return items - def get_conditions(search_term): condition = "(" condition += """item.name like {search_term} diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index ce74f6d0a58..ea8459f970b 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -248,7 +248,7 @@ erpnext.PointOfSale.Controller = class { numpad_event: (value, action) => this.update_item_field(value, action), - checkout: () => this.payment.checkout(), + checkout: () => this.save_and_checkout(), edit_cart: () => this.payment.edit_cart(), @@ -630,18 +630,24 @@ erpnext.PointOfSale.Controller = class { } async check_stock_availability(item_row, qty_needed, warehouse) { - const available_qty = (await this.get_available_stock(item_row.item_code, warehouse)).message; + const resp = (await this.get_available_stock(item_row.item_code, warehouse)).message; + const available_qty = resp[0]; + const is_stock_item = resp[1]; frappe.dom.unfreeze(); const bold_item_code = item_row.item_code.bold(); const bold_warehouse = warehouse.bold(); const bold_available_qty = available_qty.toString().bold() if (!(available_qty > 0)) { - frappe.model.clear_doc(item_row.doctype, item_row.name); - frappe.throw({ - title: __("Not Available"), - message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse]) - }) + if (is_stock_item) { + frappe.model.clear_doc(item_row.doctype, item_row.name); + frappe.throw({ + title: __("Not Available"), + message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse]) + }); + } else { + return; + } } else if (available_qty < qty_needed) { frappe.throw({ message: __('Stock quantity not enough for Item Code: {0} under warehouse {1}. Available quantity {2}.', [bold_item_code, bold_warehouse, bold_available_qty]), @@ -675,8 +681,8 @@ erpnext.PointOfSale.Controller = class { }, callback(res) { if (!me.item_stock_map[item_code]) - me.item_stock_map[item_code] = {} - me.item_stock_map[item_code][warehouse] = res.message; + me.item_stock_map[item_code] = {}; + me.item_stock_map[item_code][warehouse] = res.message[0]; } }); } @@ -707,4 +713,9 @@ erpnext.PointOfSale.Controller = class { }) .catch(e => console.log(e)); } + + async save_and_checkout() { + this.frm.is_dirty() && await this.frm.save(); + this.payment.checkout(); + } }; diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 4920584d95e..4a99f068cd5 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -191,10 +191,10 @@ erpnext.PointOfSale.ItemCart = class { this.numpad_value = ''; }); - this.$component.on('click', '.checkout-btn', function() { + this.$component.on('click', '.checkout-btn', async function() { if ($(this).attr('style').indexOf('--blue-500') == -1) return; - me.events.checkout(); + await me.events.checkout(); me.toggle_checkout_btn(false); me.allow_discount_change && me.$add_discount_elem.removeClass("d-none"); @@ -985,6 +985,7 @@ erpnext.PointOfSale.ItemCart = class { $(frm.wrapper).off('refresh-fields'); $(frm.wrapper).on('refresh-fields', () => { if (frm.doc.items.length) { + this.$cart_items_wrapper.html(''); frm.doc.items.forEach(item => { this.update_item_html(item); }); diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index a30bcd7cf6d..1177615aee9 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -79,14 +79,20 @@ erpnext.PointOfSale.ItemSelector = class { const me = this; // eslint-disable-next-line no-unused-vars const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom, price_list_rate } = item; - const indicator_color = actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange"; const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0; - + let indicator_color; let qty_to_display = actual_qty; - if (Math.round(qty_to_display) > 999) { - qty_to_display = Math.round(qty_to_display)/1000; - qty_to_display = qty_to_display.toFixed(1) + 'K'; + if (item.is_stock_item) { + indicator_color = (actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange"); + + if (Math.round(qty_to_display) > 999) { + qty_to_display = Math.round(qty_to_display)/1000; + qty_to_display = qty_to_display.toFixed(1) + 'K'; + } + } else { + indicator_color = ''; + qty_to_display = ''; } function get_item_image_html() { diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index b9b65591dc7..1e9f6d7d920 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -169,6 +169,24 @@ erpnext.PointOfSale.Payment = class { } }); + frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => { + if (!frm.doc.ignore_pricing_rule && frm.doc.coupon_code) { + frappe.run_serially([ + () => frm.doc.ignore_pricing_rule=1, + () => frm.trigger('ignore_pricing_rule'), + () => frm.doc.ignore_pricing_rule=0, + () => frm.trigger('apply_pricing_rule'), + () => frm.save(), + () => this.update_totals_section(frm.doc) + ]); + } else if (frm.doc.ignore_pricing_rule && frm.doc.coupon_code) { + frappe.show_alert({ + message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."), + indicator: "orange" + }); + } + }); + this.setup_listener_for_payments(); this.$payment_modes.on('click', '.shortcut', function() { diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py b/erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js new file mode 100644 index 00000000000..0e36b3fe3d2 --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js @@ -0,0 +1,84 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +function get_filters() { + let filters = [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + }, + { + "fieldname":"period_start_date", + "label": __("Start Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1) + }, + { + "fieldname":"period_end_date", + "label": __("End Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.get_today() + }, + { + "fieldname":"sales_order", + "label": __("Sales Order"), + "fieldtype": "MultiSelectList", + "width": 100, + "options": "Sales Order", + "get_data": function(txt) { + return frappe.db.get_link_options("Sales Order", txt, this.filters()); + }, + "filters": () => { + return { + docstatus: 1, + payment_terms_template: ['not in', ['']], + company: frappe.query_report.get_filter_value("company"), + transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]] + } + }, + on_change: function(){ + frappe.query_report.refresh(); + } + } + ] + + return filters; +} + +frappe.query_reports["Payment Terms Status for Sales Order"] = { + "filters": get_filters(), + "formatter": function(value, row, column, data, default_formatter){ + if(column.fieldname == 'invoices' && value) { + invoices = value.split(','); + const invoice_formatter = (prev_value, curr_value) => { + if(prev_value != "") { + return prev_value + ", " + default_formatter(curr_value, row, column, data); + } + else { + return default_formatter(curr_value, row, column, data); + } + } + return invoices.reduce(invoice_formatter, "") + } + else if (column.fieldname == 'paid_amount' && value){ + formatted_value = default_formatter(value, row, column, data); + if(value > 0) { + formatted_value = "" + formatted_value + "" + } + return formatted_value; + } + else if (column.fieldname == 'status' && value == 'Completed'){ + return "" + default_formatter(value, row, column, data) + ""; + } + + return default_formatter(value, row, column, data); + }, + +}; diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json new file mode 100644 index 00000000000..850fa4dc47a --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json @@ -0,0 +1,38 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2021-12-28 10:39:34.533964", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-12-30 10:42:06.058457", + "modified_by": "Administrator", + "module": "Selling", + "name": "Payment Terms Status for Sales Order", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Sales Order", + "report_name": "Payment Terms Status for Sales Order", + "report_type": "Script Report", + "roles": [ + { + "role": "Sales User" + }, + { + "role": "Sales Manager" + }, + { + "role": "Maintenance User" + }, + { + "role": "Accounts User" + }, + { + "role": "Stock User" + } + ] +} \ No newline at end of file diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py new file mode 100644 index 00000000000..e6a56eea310 --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -0,0 +1,205 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE + +import frappe +from frappe import _, qb, query_builder +from frappe.query_builder import functions + + +def get_columns(): + columns = [ + { + "label": _("Sales Order"), + "fieldname": "name", + "fieldtype": "Link", + "options": "Sales Order", + }, + { + "label": _("Posting Date"), + "fieldname": "submitted", + "fieldtype": "Date", + }, + { + "label": _("Payment Term"), + "fieldname": "payment_term", + "fieldtype": "Data", + }, + { + "label": _("Description"), + "fieldname": "description", + "fieldtype": "Data", + }, + { + "label": _("Due Date"), + "fieldname": "due_date", + "fieldtype": "Date", + }, + { + "label": _("Invoice Portion"), + "fieldname": "invoice_portion", + "fieldtype": "Percent", + }, + { + "label": _("Payment Amount"), + "fieldname": "base_payment_amount", + "fieldtype": "Currency", + "options": "currency", + }, + { + "label": _("Paid Amount"), + "fieldname": "paid_amount", + "fieldtype": "Currency", + "options": "currency", + }, + { + "label": _("Invoices"), + "fieldname": "invoices", + "fieldtype": "Link", + "options": "Sales Invoice", + }, + { + "label": _("Status"), + "fieldname": "status", + "fieldtype": "Data", + }, + { + "label": _("Currency"), + "fieldname": "currency", + "fieldtype": "Currency", + "hidden": 1 + } + ] + return columns + + +def get_conditions(filters): + """ + Convert filter options to conditions used in query + """ + filters = frappe._dict(filters) if filters else frappe._dict({}) + conditions = frappe._dict({}) + + conditions.company = filters.company or frappe.defaults.get_user_default("company") + conditions.end_date = filters.period_end_date or frappe.utils.today() + conditions.start_date = filters.period_start_date or frappe.utils.add_months( + conditions.end_date, -1 + ) + conditions.sales_order = filters.sales_order or [] + + return conditions + + +def get_so_with_invoices(filters): + """ + Get Sales Order with payment terms template with their associated Invoices + """ + sorders = [] + + so = qb.DocType("Sales Order") + ps = qb.DocType("Payment Schedule") + datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"]) + ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"]) + + conditions = get_conditions(filters) + query_so = ( + qb.from_(so) + .join(ps) + .on(ps.parent == so.name) + .select( + so.name, + so.transaction_date.as_("submitted"), + ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"), + ps.payment_term, + ps.description, + ps.due_date, + ps.invoice_portion, + ps.base_payment_amount, + ps.paid_amount, + ) + .where( + (so.docstatus == 1) + & (so.payment_terms_template != "NULL") + & (so.company == conditions.company) + & (so.transaction_date[conditions.start_date : conditions.end_date]) + ) + .orderby(so.name, so.transaction_date, ps.due_date) + ) + + if conditions.sales_order != []: + query_so = query_so.where(so.name.isin(conditions.sales_order)) + + sorders = query_so.run(as_dict=True) + + invoices = [] + if sorders != []: + soi = qb.DocType("Sales Order Item") + si = qb.DocType("Sales Invoice") + sii = qb.DocType("Sales Invoice Item") + query_inv = ( + qb.from_(sii) + .right_join(si) + .on(si.name == sii.parent) + .inner_join(soi) + .on(soi.name == sii.so_detail) + .select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount")) + .where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1)) + .groupby(sii.parent) + ) + invoices = query_inv.run(as_dict=True) + + return sorders, invoices + + +def set_payment_terms_statuses(sales_orders, invoices, filters): + """ + compute status for payment terms with associated sales invoice using FIFO + """ + + for so in sales_orders: + so.currency = frappe.get_cached_value('Company', filters.get('company'), 'default_currency') + so.invoices = "" + for inv in [x for x in invoices if x.sales_order == so.name and x.invoice_amount > 0]: + if so.base_payment_amount - so.paid_amount > 0: + amount = so.base_payment_amount - so.paid_amount + if inv.invoice_amount >= amount: + inv.invoice_amount -= amount + so.paid_amount += amount + so.invoices += "," + inv.invoice + so.status = "Completed" + break + else: + so.paid_amount += inv.invoice_amount + inv.invoice_amount = 0 + so.invoices += "," + inv.invoice + so.status = "Partly Paid" + + return sales_orders, invoices + + +def prepare_chart(s_orders): + if len(set([x.name for x in s_orders])) == 1: + chart = { + "data": { + "labels": [term.payment_term for term in s_orders], + "datasets": [ + {"name": "Payment Amount", "values": [x.base_payment_amount for x in s_orders],}, + {"name": "Paid Amount", "values": [x.paid_amount for x in s_orders],}, + ], + }, + "type": "bar", + } + return chart + + +def execute(filters=None): + columns = get_columns() + sales_orders, so_invoices = get_so_with_invoices(filters) + sales_orders, so_invoices = set_payment_terms_statuses(sales_orders, so_invoices, filters) + + prepare_chart(sales_orders) + + data = sales_orders + message = [] + chart = prepare_chart(sales_orders) + + return columns, data, message, chart diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py new file mode 100644 index 00000000000..cad41e1dc03 --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py @@ -0,0 +1,198 @@ +import datetime + +import frappe +from frappe.utils import add_days + +from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import ( + execute, +) +from erpnext.stock.doctype.item.test_item import create_item +from erpnext.tests.utils import ERPNextTestCase + +test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"] + + +class TestPaymentTermsStatusForSalesOrder(ERPNextTestCase): + def create_payment_terms_template(self): + # create template for 50-50 payments + template = None + if frappe.db.exists("Payment Terms Template", "_Test 50-50"): + template = frappe.get_doc("Payment Terms Template", "_Test 50-50") + else: + template = frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": "_Test 50-50", + "terms": [ + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term_name": "_Test 50% on 15 Days", + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 15, + }, + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term_name": "_Test 50% on 30 Days", + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 30, + }, + ], + } + ) + template.insert() + self.template = template + + def test_payment_terms_status(self): + self.create_payment_terms_template() + item = create_item(item_code="_Test Excavator", is_stock_item=0) + so = make_sales_order( + transaction_date="2021-06-15", + delivery_date=add_days("2021-06-15", -30), + item=item.item_code, + qty=10, + rate=100000, + do_not_save=True, + ) + so.po_no = "" + so.taxes_and_charges = "" + so.taxes = "" + so.payment_terms_template = self.template.name + so.save() + so.submit() + + # make invoice with 60% of the total sales order value + sinv = make_sales_invoice(so.name) + sinv.taxes_and_charges = "" + sinv.taxes = "" + sinv.items[0].qty = 6 + sinv.insert() + sinv.submit() + columns, data, message, chart = execute( + { + "company": "_Test Company", + "period_start_date": "2021-06-01", + "period_end_date": "2021-06-30", + "sales_order": [so.name], + } + ) + + expected_value = [ + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Completed", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 6, 30), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 500000.0, + "paid_amount": 500000.0, + "invoices": ","+sinv.name, + }, + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Partly Paid", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 7, 15), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 500000.0, + "paid_amount": 100000.0, + "invoices": ","+sinv.name, + }, + ] + self.assertEqual(data, expected_value) + + def create_exchange_rate(self, date): + # make an entry in Currency Exchange list. serves as a static exchange rate + if frappe.db.exists({'doctype': "Currency Exchange",'date': date,'from_currency': 'USD', 'to_currency':'INR'}): + return + else: + doc = frappe.get_doc({ + 'doctype': "Currency Exchange", + 'date': date, + 'from_currency': 'USD', + 'to_currency': frappe.get_cached_value("Company", '_Test Company','default_currency'), + 'exchange_rate': 70, + 'for_buying': True, + 'for_selling': True + }) + doc.insert() + + def test_alternate_currency(self): + transaction_date = "2021-06-15" + self.create_payment_terms_template() + self.create_exchange_rate(transaction_date) + item = create_item(item_code="_Test Excavator", is_stock_item=0) + so = make_sales_order( + transaction_date=transaction_date, + currency="USD", + delivery_date=add_days(transaction_date, -30), + item=item.item_code, + qty=10, + rate=10000, + do_not_save=True, + ) + so.po_no = "" + so.taxes_and_charges = "" + so.taxes = "" + so.payment_terms_template = self.template.name + so.save() + so.submit() + + # make invoice with 60% of the total sales order value + sinv = make_sales_invoice(so.name) + sinv.currency = "USD" + sinv.taxes_and_charges = "" + sinv.taxes = "" + sinv.items[0].qty = 6 + sinv.insert() + sinv.submit() + columns, data, message, chart = execute( + { + "company": "_Test Company", + "period_start_date": "2021-06-01", + "period_end_date": "2021-06-30", + "sales_order": [so.name], + } + ) + + # report defaults to company currency. + expected_value = [ + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Completed", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 6, 30), + "invoice_portion": 50.0, + "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'), + "base_payment_amount": 3500000.0, + "paid_amount": 3500000.0, + "invoices": ","+sinv.name, + }, + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Partly Paid", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 7, 15), + "invoice_portion": 50.0, + "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'), + "base_payment_amount": 3500000.0, + "paid_amount": 700000.0, + "invoices": ","+sinv.name, + }, + ] + self.assertEqual(data, expected_value) diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py index b2bf5464b5a..3e22d0fa8c8 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -85,7 +85,7 @@ def get_data(conditions, filters): and so.docstatus = 1 {conditions} GROUP BY soi.name - ORDER BY so.transaction_date ASC + ORDER BY so.transaction_date ASC, soi.item_code ASC """.format(conditions=conditions), filters, as_dict=1) return data diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index 540aca234bd..98131f96ed4 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -227,11 +227,11 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran }, callback:function(r){ if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) { - if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return; - - me.set_batch_number(cdt, cdn); - me.batch_no(doc, cdt, cdn); + if (has_batch_no) { + me.set_batch_number(cdt, cdn); + me.batch_no(doc, cdt, cdn); + } } } }); @@ -486,7 +486,7 @@ frappe.ui.form.on(cur_frm.doctype, { "options": "Competitor Detail" }, { - "fieldtype": "Text", + "fieldtype": "Small Text", "label": __("Detailed Reason"), "fieldname": "detailed_reason" }, @@ -499,7 +499,7 @@ frappe.ui.form.on(cur_frm.doctype, { method: 'declare_enquiry_lost', args: { 'lost_reasons_list': values.lost_reason, - 'competitors': values.competitors, + 'competitors': values.competitors ? values.competitors : [], 'detailed_reason': values.detailed_reason }, callback: function(r) { diff --git a/erpnext/setup/doctype/brand/brand.json b/erpnext/setup/doctype/brand/brand.json index a8f0674b1f8..45b4db81f1f 100644 --- a/erpnext/setup/doctype/brand/brand.json +++ b/erpnext/setup/doctype/brand/brand.json @@ -1,270 +1,111 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 1, - "autoname": "field:brand", - "beta": 0, - "creation": "2013-02-22 01:27:54", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:brand", + "creation": "2013-02-22 01:27:54", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "brand", + "image", + "description", + "defaults", + "brand_defaults" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 1, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "brand", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Brand Name", - "length": 0, - "no_copy": 0, - "oldfieldname": "brand", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, + "allow_in_quick_entry": 1, + "fieldname": "brand", + "fieldtype": "Data", + "label": "Brand Name", + "oldfieldname": "brand", + "oldfieldtype": "Data", + "reqd": 1, "unique": 1 - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "description", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "oldfieldname": "description", - "oldfieldtype": "Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "description", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", "width": "300px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "defaults", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Defaults", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "defaults", + "fieldtype": "Section Break", + "label": "Defaults" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "brand_defaults", - "fieldtype": "Table", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Brand Defaults", - "length": 0, - "no_copy": 0, - "options": "Item Default", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "brand_defaults", + "fieldtype": "Table", + "label": "Brand Defaults", + "options": "Item Default" + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "hidden": 1, + "label": "Image" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-certificate", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-10-23 23:18:06.067612", - "modified_by": "Administrator", - "module": "Setup", - "name": "Brand", - "owner": "Administrator", + ], + "icon": "fa fa-certificate", + "idx": 1, + "image_field": "image", + "links": [], + "modified": "2021-03-01 15:57:30.005783", + "modified_by": "Administrator", + "module": "Setup", + "name": "Brand", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 1, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Item Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "import": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Item Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Stock User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Purchase User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Purchase User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User" } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 1, - "sort_order": "ASC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 -} + ], + "quick_entry": 1, + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "ASC" +} \ No newline at end of file diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 63d96bf85e7..370a3278a01 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -3,7 +3,7 @@ "allow_import": 1, "allow_rename": 1, "autoname": "field:company_name", - "creation": "2013-04-10 08:35:39", + "creation": "2022-01-25 10:29:55.938239", "description": "Legal Entity / Subsidiary with a separate Chart of Accounts belonging to the Organization.", "doctype": "DocType", "document_type": "Setup", @@ -77,13 +77,13 @@ "default_finance_book", "auto_accounting_for_stock_settings", "enable_perpetual_inventory", - "enable_perpetual_inventory_for_non_stock_items", + "enable_provisional_accounting_for_non_stock_items", "default_inventory_account", "stock_adjustment_account", "default_in_transit_warehouse", "column_break_32", "stock_received_but_not_billed", - "service_received_but_not_billed", + "default_provisional_account", "expenses_included_in_valuation", "fixed_asset_defaults", "accumulated_depreciation_account", @@ -684,20 +684,6 @@ "label": "Default Buying Terms", "options": "Terms and Conditions" }, - { - "fieldname": "service_received_but_not_billed", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Service Received But Not Billed", - "no_copy": 1, - "options": "Account" - }, - { - "default": "0", - "fieldname": "enable_perpetual_inventory_for_non_stock_items", - "fieldtype": "Check", - "label": "Enable Perpetual Inventory For Non Stock Items" - }, { "fieldname": "default_in_transit_warehouse", "fieldtype": "Link", @@ -741,6 +727,20 @@ "fieldname": "section_break_28", "fieldtype": "Section Break", "label": "Chart of Accounts" + }, + { + "default": "0", + "fieldname": "enable_provisional_accounting_for_non_stock_items", + "fieldtype": "Check", + "label": "Enable Provisional Accounting For Non Stock Items" + }, + { + "fieldname": "default_provisional_account", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Default Provisional Account", + "no_copy": 1, + "options": "Account" } ], "icon": "fa fa-building", @@ -748,7 +748,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2021-10-04 12:09:25.833133", + "modified": "2022-01-25 10:33:16.826067", "modified_by": "Administrator", "module": "Setup", "name": "Company", @@ -809,5 +809,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 0a02bcd6cd9..95b1e8b9c63 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -10,6 +10,7 @@ import frappe.defaults from frappe import _ from frappe.cache_manager import clear_defaults_cache from frappe.contacts.address_and_contact import load_address_and_contact +from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.utils import cint, formatdate, get_timestamp, today from frappe.utils.nestedset import NestedSet @@ -45,7 +46,7 @@ class Company(NestedSet): self.validate_currency() self.validate_coa_input() self.validate_perpetual_inventory() - self.validate_perpetual_inventory_for_non_stock_items() + self.validate_provisional_account_for_non_stock_items() self.check_country_change() self.check_parent_changed() self.set_chart_of_accounts() @@ -187,11 +188,14 @@ class Company(NestedSet): frappe.msgprint(_("Set default inventory account for perpetual inventory"), alert=True, indicator='orange') - def validate_perpetual_inventory_for_non_stock_items(self): + def validate_provisional_account_for_non_stock_items(self): if not self.get("__islocal"): - if cint(self.enable_perpetual_inventory_for_non_stock_items) == 1 and not self.service_received_but_not_billed: - frappe.throw(_("Set default {0} account for perpetual inventory for non stock items").format( - frappe.bold('Service Received But Not Billed'))) + if cint(self.enable_provisional_accounting_for_non_stock_items) == 1 and not self.default_provisional_account: + frappe.throw(_("Set default {0} account for non stock items").format( + frappe.bold('Provisional Account'))) + + make_property_setter("Purchase Receipt", "provisional_expense_account", "hidden", + not self.enable_provisional_accounting_for_non_stock_items, "Check", validate_fields_for_doctype=False) def check_country_change(self): frappe.flags.country_change = False diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index c94b3463fc8..4f92240c84f 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -1,21 +1,17 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import copy +from urllib.parse import quote import frappe from frappe import _ -from frappe.utils import cint, cstr, nowdate +from frappe.utils import cint from frappe.utils.nestedset import NestedSet from frappe.website.utils import clear_cache from frappe.website.website_generator import WebsiteGenerator -from six.moves.urllib.parse import quote -from erpnext.shopping_cart.filters import ProductFiltersBuilder -from erpnext.shopping_cart.product_info import set_product_info_for_website -from erpnext.shopping_cart.product_query import ProductQuery -from erpnext.utilities.product import get_qty_in_stock +from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder class ItemGroup(NestedSet, WebsiteGenerator): @@ -67,30 +63,11 @@ class ItemGroup(NestedSet, WebsiteGenerator): self.delete_child_item_groups_key() def get_context(self, context): - context.show_search=True - context.page_length = cint(frappe.db.get_single_value('Products Settings', 'products_per_page')) or 6 + context.show_search = True + context.body_class = "product-page" + context.page_length = cint(frappe.db.get_single_value('E Commerce Settings', 'products_per_page')) or 6 context.search_link = '/product_search' - if frappe.form_dict: - search = frappe.form_dict.search - field_filters = frappe.parse_json(frappe.form_dict.field_filters) - attribute_filters = frappe.parse_json(frappe.form_dict.attribute_filters) - start = frappe.parse_json(frappe.form_dict.start) - else: - search = None - attribute_filters = None - field_filters = {} - start = 0 - - if not field_filters: - field_filters = {} - - # Ensure the query remains within current item group & sub group - field_filters['item_group'] = [ig[0] for ig in get_child_groups(self.name)] - - engine = ProductQuery() - context.items = engine.query(attribute_filters, field_filters, search, start, item_group=self.name) - filter_engine = ProductFiltersBuilder(self.name) context.field_filters = filter_engine.get_field_filters() @@ -114,15 +91,16 @@ class ItemGroup(NestedSet, WebsiteGenerator): values[f"slide_{index + 1}_image"] = slide.image values[f"slide_{index + 1}_title"] = slide.heading values[f"slide_{index + 1}_subtitle"] = slide.description - values[f"slide_{index + 1}_theme"] = slide.theme or "Light" - values[f"slide_{index + 1}_content_align"] = slide.content_align or "Centre" - values[f"slide_{index + 1}_primary_action_label"] = slide.label + values[f"slide_{index + 1}_theme"] = slide.get("theme") or "Light" + values[f"slide_{index + 1}_content_align"] = slide.get("content_align") or "Centre" values[f"slide_{index + 1}_primary_action"] = slide.url context.slideshow = values - context.breadcrumbs = 0 + context.no_breadcrumbs = False context.title = self.website_title or self.name + context.name = self.name + context.item_group_name = self.item_group_name return context @@ -133,91 +111,24 @@ class ItemGroup(NestedSet, WebsiteGenerator): from erpnext.stock.doctype.item.item import validate_item_default_company_links validate_item_default_company_links(self.item_group_defaults) -@frappe.whitelist(allow_guest=True) -def get_product_list_for_group(product_group=None, start=0, limit=10, search=None): - if product_group: - item_group = frappe.get_cached_doc('Item Group', product_group) - if item_group.is_group: - # return child item groups if the type is of "Is Group" - return get_child_groups_for_list_in_html(item_group, start, limit, search) +def get_child_groups_for_website(item_group_name, immediate=False): + """Returns child item groups *excluding* passed group.""" + item_group = frappe.get_cached_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) + filters = { + "lft": [">", item_group.lft], + "rgt": ["<", item_group.rgt], + "show_in_website": 1 + } - child_groups = ", ".join(frappe.db.escape(i[0]) for i in get_child_groups(product_group)) + if immediate: + filters["parent_item_group"] = item_group_name - # base query - query = """select I.name, I.item_name, I.item_code, I.route, I.image, I.website_image, I.thumbnail, I.item_group, - I.description, I.web_long_description as website_description, I.is_stock_item, - case when (S.actual_qty - S.reserved_qty) > 0 then 1 else 0 end as in_stock, I.website_warehouse, - I.has_batch_no - from `tabItem` I - left join tabBin S on I.item_code = S.item_code and I.website_warehouse = S.warehouse - where I.show_in_website = 1 - and I.disabled = 0 - and (I.end_of_life is null or I.end_of_life='0000-00-00' or I.end_of_life > %(today)s) - and (I.variant_of = '' or I.variant_of is null) - and (I.item_group in ({child_groups}) - or I.name in (select parent from `tabWebsite Item Group` where item_group in ({child_groups}))) - """.format(child_groups=child_groups) - # search term condition - if search: - query += """ and (I.web_long_description like %(search)s - or I.item_name like %(search)s - or I.name like %(search)s)""" - search = "%" + cstr(search) + "%" - - query += """order by I.weightage desc, in_stock desc, I.modified desc limit %s, %s""" % (cint(start), cint(limit)) - - data = frappe.db.sql(query, {"product_group": product_group,"search": search, "today": nowdate()}, as_dict=1) - data = adjust_qty_for_expired_items(data) - - if cint(frappe.db.get_single_value("Shopping Cart Settings", "enabled")): - for item in data: - set_product_info_for_website(item) - - return data - -def get_child_groups_for_list_in_html(item_group, start, limit, search): - search_filters = None - if search_filters: - search_filters = [ - dict(name = ('like', '%{}%'.format(search))), - dict(description = ('like', '%{}%'.format(search))) - ] - data = frappe.db.get_all('Item Group', - fields = ['name', 'route', 'description', 'image'], - filters = dict( - show_in_website = 1, - parent_item_group = item_group.name, - lft = ('>', item_group.lft), - rgt = ('<', item_group.rgt), - ), - or_filters = search_filters, - order_by = 'weightage desc, name asc', - start = start, - limit = limit + return frappe.get_all( + "Item Group", + filters=filters, + fields=["name", "route"] ) - return data - -def adjust_qty_for_expired_items(data): - adjusted_data = [] - - for item in data: - if item.get('has_batch_no') and item.get('website_warehouse'): - stock_qty_dict = get_qty_in_stock( - item.get('name'), 'website_warehouse', item.get('website_warehouse')) - qty = stock_qty_dict.stock_qty[0][0] if stock_qty_dict.stock_qty else 0 - item['in_stock'] = 1 if qty else 0 - adjusted_data.append(item) - - return adjusted_data - - -def get_child_groups(item_group_name): - item_group = frappe.get_doc("Item Group", item_group_name) - return frappe.db.sql("""select name - from `tabItem Group` where lft>=%(lft)s and rgt<=%(rgt)s - and show_in_website = 1""", {"lft": item_group.lft, "rgt": item_group.rgt}) - def get_child_item_groups(item_group_name): item_group = frappe.get_cached_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) @@ -233,31 +144,33 @@ def get_item_for_list_in_html(context): if (context.get("website_image") or "").startswith("files/"): context["website_image"] = "/" + quote(context["website_image"]) - context["show_availability_status"] = cint(frappe.db.get_single_value('Products Settings', + context["show_availability_status"] = cint(frappe.db.get_single_value('E Commerce Settings', 'show_availability_status')) products_template = 'templates/includes/products_as_list.html' return frappe.get_template(products_template).render(context) -def get_group_item_count(item_group): - child_groups = ", ".join('"' + i[0] + '"' for i in get_child_groups(item_group)) - return frappe.db.sql("""select count(*) from `tabItem` - where docstatus = 0 and show_in_website = 1 - and (item_group in (%s) - or name in (select parent from `tabWebsite Item Group` - where item_group in (%s))) """ % (child_groups, child_groups))[0][0] +def get_parent_item_groups(item_group_name, from_item=False): + base_nav_page = {"name": _("Shop by Category"), "route":"/shop-by-category"} + + if from_item and frappe.request.environ.get("HTTP_REFERER"): + # base page after 'Home' will vary on Item page + last_page = frappe.request.environ["HTTP_REFERER"].split('/')[-1] + if last_page and last_page in ("shop-by-category", "all-products"): + base_nav_page_title = " ".join(last_page.split("-")).title() + base_nav_page = {"name": _(base_nav_page_title), "route":"/"+last_page} -def get_parent_item_groups(item_group_name): base_parents = [ - {"name": frappe._("Home"), "route":"/"}, - {"name": frappe._("All Products"), "route":"/all-products"}, + {"name": _("Home"), "route":"/"}, + base_nav_page, ] + if not item_group_name: return base_parents - item_group = frappe.get_doc("Item Group", item_group_name) + item_group = frappe.db.get_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1) parent_groups = frappe.db.sql("""select name, route from `tabItem Group` where lft <= %s and rgt >= %s and show_in_website=1 diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index bafaab814b4..1d7bad2686a 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -189,7 +189,7 @@ def add_non_standard_user_types(): user_type_limit = {} for user_type, data in user_types.items(): - user_type_limit.setdefault(frappe.scrub(user_type), 10) + user_type_limit.setdefault(frappe.scrub(user_type), 20) update_site_config('user_type_doctype_limit', user_type_limit) @@ -204,15 +204,33 @@ def get_user_types_data(): 'apply_user_permission_on': 'Employee', 'user_id_field': 'user_id', 'doctypes': { - 'Salary Slip': ['read'], + # masters + 'Holiday List': ['read'], 'Employee': ['read', 'write'], + # payroll + 'Salary Slip': ['read'], + 'Employee Benefit Application': ['read', 'write', 'create', 'delete'], + # expenses 'Expense Claim': ['read', 'write', 'create', 'delete'], + 'Employee Advance': ['read', 'write', 'create', 'delete'], + # leave and attendance 'Leave Application': ['read', 'write', 'create', 'delete'], 'Attendance Request': ['read', 'write', 'create', 'delete'], 'Compensatory Leave Request': ['read', 'write', 'create', 'delete'], + # tax 'Employee Tax Exemption Declaration': ['read', 'write', 'create', 'delete'], 'Employee Tax Exemption Proof Submission': ['read', 'write', 'create', 'delete'], - 'Timesheet': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'] + # projects + 'Timesheet': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'], + # trainings + 'Training Program': ['read'], + 'Training Feedback': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'], + # shifts + 'Shift Request': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'], + # misc + 'Employee Grievance': ['read', 'write', 'create', 'delete'], + 'Employee Referral': ['read', 'write', 'create', 'delete'], + 'Travel Request': ['read', 'write', 'create', 'delete'] } } } diff --git a/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json b/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json deleted file mode 100644 index f00dc947d2c..00000000000 --- a/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "add_more_button": 0, - "app": "ERPNext", - "creation": "2019-12-04 19:21:39.995776", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:53:53.849953", - "modified_by": "Administrator", - "name": "Welcome back to ERPNext!", - "owner": "Administrator", - "slide_desc": "

    Let's continue where you left from!

    ", - "slide_fields": [], - "slide_module": "Setup", - "slide_order": 0, - "slide_title": "Welcome back to ERPNext!", - "slide_type": "Continue" -} \ No newline at end of file diff --git a/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json b/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json deleted file mode 100644 index 37eb67b1d7e..00000000000 --- a/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "add_more_button": 0, - "app": "ERPNext", - "creation": "2019-11-26 17:01:26.671859", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 0, - "modified": "2019-12-22 21:26:28.414597", - "modified_by": "Administrator", - "name": "Welcome to ERPNext!", - "owner": "Administrator", - "slide_desc": "
    Setting up an ERP can be overwhelming. But don't worry, we have got your back! This wizard will help you onboard to ERPNext in a short time!
    ", - "slide_fields": [], - "slide_module": "Setup", - "slide_order": 1, - "slide_title": "Welcome to ERPNext!", - "slide_type": "Information" -} \ No newline at end of file diff --git a/erpnext/setup/setup_wizard/operations/company_setup.py b/erpnext/setup/setup_wizard/operations/company_setup.py index 358b9218312..74c1bd835d6 100644 --- a/erpnext/setup/setup_wizard/operations/company_setup.py +++ b/erpnext/setup/setup_wizard/operations/company_setup.py @@ -29,10 +29,10 @@ def create_fiscal_year_and_company(args): 'domain': args.get('domains')[0] }).insert() -def enable_shopping_cart(args): +def enable_shopping_cart(args): # nosemgrep # Needs price_lists frappe.get_doc({ - "doctype": "Shopping Cart Settings", + "doctype": "E Commerce Settings", "enabled": 1, 'company': args.get('company_name') , 'price_list': frappe.db.get_value("Price List", {"selling": 1}), diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 9dbf49eae7e..cd2738aeaa7 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -535,8 +535,8 @@ def create_bank_account(args): # bank account same as a CoA entry pass -def update_shopping_cart_settings(args): - shopping_cart = frappe.get_doc("Shopping Cart Settings") +def update_shopping_cart_settings(args): # nosemgrep + shopping_cart = frappe.get_doc("E Commerce Settings") shopping_cart.update({ "enabled": 1, 'company': args.company_name, diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index 4441bb95627..a4f2207f113 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -155,7 +155,7 @@ def insert_record(records): doc = frappe.new_doc(r.get("doctype")) doc.update(r) try: - doc.insert(ignore_permissions=True) + doc.insert(ignore_permissions=True, ignore_if_duplicate=True) except frappe.DuplicateEntryError as e: # pass DuplicateEntryError and continue if e.args and e.args[0]==doc.doctype and e.args[1]==doc.name: diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json deleted file mode 100644 index 7a4bb20136f..00000000000 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "actions": [], - "creation": "2013-06-19 15:57:32", - "description": "Default settings for Shopping Cart", - "doctype": "DocType", - "document_type": "System", - "engine": "InnoDB", - "field_order": [ - "enabled", - "store_page_docs", - "display_settings", - "show_attachments", - "show_price", - "show_stock_availability", - "enable_variants", - "column_break_7", - "show_contact_us_button", - "show_quantity_in_website", - "show_apply_coupon_code_in_website", - "allow_items_not_in_stock", - "section_break_2", - "company", - "price_list", - "column_break_4", - "default_customer_group", - "quotation_series", - "section_break_8", - "enable_checkout", - "save_quotations_as_draft", - "column_break_11", - "payment_gateway_account", - "payment_success_url" - ], - "fields": [ - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Enable Shopping Cart" - }, - { - "fieldname": "display_settings", - "fieldtype": "Section Break", - "label": "Display Settings" - }, - { - "default": "0", - "fieldname": "show_attachments", - "fieldtype": "Check", - "label": "Show Public Attachments" - }, - { - "default": "0", - "fieldname": "show_price", - "fieldtype": "Check", - "label": "Show Price" - }, - { - "default": "0", - "fieldname": "show_stock_availability", - "fieldtype": "Check", - "label": "Show Stock Availability" - }, - { - "default": "0", - "fieldname": "show_contact_us_button", - "fieldtype": "Check", - "label": "Show Contact Us Button" - }, - { - "default": "0", - "depends_on": "show_stock_availability", - "fieldname": "show_quantity_in_website", - "fieldtype": "Check", - "label": "Show Stock Quantity" - }, - { - "default": "0", - "fieldname": "show_apply_coupon_code_in_website", - "fieldtype": "Check", - "label": "Show Apply Coupon Code" - }, - { - "default": "0", - "fieldname": "allow_items_not_in_stock", - "fieldtype": "Check", - "label": "Allow items not in stock to be added to cart" - }, - { - "depends_on": "enabled", - "fieldname": "section_break_2", - "fieldtype": "Section Break" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Company", - "mandatory_depends_on": "eval: doc.enabled === 1", - "options": "Company", - "remember_last_selected_value": 1 - }, - { - "description": "Prices will not be shown if Price List is not set", - "fieldname": "price_list", - "fieldtype": "Link", - "label": "Price List", - "mandatory_depends_on": "eval: doc.enabled === 1", - "options": "Price List" - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "fieldname": "default_customer_group", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Default Customer Group", - "mandatory_depends_on": "eval: doc.enabled === 1", - "options": "Customer Group" - }, - { - "fieldname": "quotation_series", - "fieldtype": "Select", - "label": "Quotation Series", - "mandatory_depends_on": "eval: doc.enabled === 1" - }, - { - "collapsible": 1, - "collapsible_depends_on": "eval:doc.enable_checkout", - "depends_on": "enabled", - "fieldname": "section_break_8", - "fieldtype": "Section Break", - "label": "Checkout Settings" - }, - { - "default": "0", - "fieldname": "enable_checkout", - "fieldtype": "Check", - "label": "Enable Checkout" - }, - { - "default": "Orders", - "depends_on": "enable_checkout", - "description": "After payment completion redirect user to selected page.", - "fieldname": "payment_success_url", - "fieldtype": "Select", - "label": "Payment Success Url", - "mandatory_depends_on": "enable_checkout", - "options": "\nOrders\nInvoices\nMy Account" - }, - { - "fieldname": "column_break_11", - "fieldtype": "Column Break" - }, - { - "depends_on": "enable_checkout", - "fieldname": "payment_gateway_account", - "fieldtype": "Link", - "label": "Payment Gateway Account", - "mandatory_depends_on": "enable_checkout", - "options": "Payment Gateway Account" - }, - { - "fieldname": "column_break_7", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "enable_variants", - "fieldtype": "Check", - "label": "Enable Variants" - }, - { - "default": "0", - "depends_on": "eval: doc.enable_checkout == 0", - "fieldname": "save_quotations_as_draft", - "fieldtype": "Check", - "label": "Save Quotations as Draft" - }, - { - "depends_on": "doc.enabled", - "fieldname": "store_page_docs", - "fieldtype": "HTML" - } - ], - "icon": "fa fa-shopping-cart", - "idx": 1, - "issingle": 1, - "links": [], - "modified": "2021-03-02 17:34:57.642565", - "modified_by": "Administrator", - "module": "Shopping Cart", - "name": "Shopping Cart Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "Website Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "ASC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py deleted file mode 100644 index ef0badc8c89..00000000000 --- a/erpnext/shopping_cart/filters.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - - -import frappe - - -class ProductFiltersBuilder: - def __init__(self, item_group=None): - if not item_group or item_group == "Products Settings": - self.doc = frappe.get_doc("Products Settings") - else: - self.doc = frappe.get_doc("Item Group", item_group) - - self.item_group = item_group - - def get_field_filters(self): - filter_fields = [row.fieldname for row in self.doc.filter_fields] - - meta = frappe.get_meta('Item') - fields = [df for df in meta.fields if df.fieldname in filter_fields] - - filter_data = [] - for df in fields: - filters, or_filters = {}, [] - if df.fieldtype == "Link": - if self.item_group: - or_filters.extend([ - ["item_group", "=", self.item_group], - ["Website Item Group", "item_group", "=", self.item_group] - ]) - - values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, or_filters=or_filters, distinct="True", pluck=df.fieldname) - else: - doctype = df.get_link_doctype() - - # apply enable/disable/show_in_website filter - meta = frappe.get_meta(doctype) - - if meta.has_field('enabled'): - filters['enabled'] = 1 - if meta.has_field('disabled'): - filters['disabled'] = 0 - if meta.has_field('show_in_website'): - filters['show_in_website'] = 1 - - values = [d.name for d in frappe.get_all(doctype, filters)] - - # Remove None - if None in values: - values.remove(None) - - if values: - filter_data.append([df, values]) - - return filter_data - - def get_attribute_filters(self): - attributes = [row.attribute for row in self.doc.filter_attributes] - - if not attributes: - return [] - - result = frappe.db.sql( - """ - select - distinct attribute, attribute_value - from - `tabItem Variant Attribute` - where - attribute in %(attributes)s - and attribute_value is not null - """, - {"attributes": attributes}, - as_dict=1, - ) - - attribute_value_map = {} - for d in result: - attribute_value_map.setdefault(d.attribute, []).append(d.attribute_value) - - out = [] - for name, values in attribute_value_map.items(): - out.append(frappe._dict(name=name, item_attribute_values=values)) - return out diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py deleted file mode 100644 index 5cc0505aed2..00000000000 --- a/erpnext/shopping_cart/product_query.py +++ /dev/null @@ -1,161 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import frappe - -from erpnext.shopping_cart.product_info import get_product_info_for_website - - -class ProductQuery: - """Query engine for product listing - - Attributes: - cart_settings (Document): Settings for Cart - fields (list): Fields to fetch in query - filters (TYPE): Description - or_filters (list): Description - page_length (Int): Length of page for the query - settings (Document): Products Settings DocType - filters (list) - or_filters (list) - """ - - def __init__(self): - self.settings = frappe.get_doc("Products Settings") - self.cart_settings = frappe.get_doc("Shopping Cart Settings") - self.page_length = self.settings.products_per_page or 20 - self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', - 'item_group', 'image', 'web_long_description', 'description', 'route', 'weightage'] - self.filters = [] - self.or_filters = [['show_in_website', '=', 1]] - if not self.settings.get('hide_variants'): - self.or_filters.append(['show_variant_in_website', '=', 1]) - - def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None): - """Summary - - Args: - attributes (dict, optional): Item Attribute filters - fields (dict, optional): Field level filters - search_term (str, optional): Search term to lookup - start (int, optional): Page start - - Returns: - list: List of results with set fields - """ - if fields: self.build_fields_filters(fields) - if search_term: self.build_search_filters(search_term) - - result = [] - website_item_groups = [] - - # if from item group page consider website item group table - if item_group: - website_item_groups = frappe.db.get_all( - "Item", - fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"], - filters=[["Website Item Group", "item_group", "=", item_group]] - ) - - if attributes: - all_items = [] - for attribute, values in attributes.items(): - if not isinstance(values, list): - values = [values] - - items = frappe.get_all( - "Item", - fields=self.fields, - filters=[ - *self.filters, - ["Item Variant Attribute", "attribute", "=", attribute], - ["Item Variant Attribute", "attribute_value", "in", values], - ], - or_filters=self.or_filters, - start=start, - limit=self.page_length, - order_by="weightage desc" - ) - - items_dict = {item.name: item for item in items} - - all_items.append(set(items_dict.keys())) - - result = [items_dict.get(item) for item in list(set.intersection(*all_items))] - else: - result = frappe.get_all( - "Item", - fields=self.fields, - filters=self.filters, - or_filters=self.or_filters, - start=start, - limit=self.page_length, - order_by="weightage desc" - ) - - # Combine results having context of website item groups into item results - if item_group and website_item_groups: - items_list = {row.name for row in result} - for row in website_item_groups: - if row.wig_parent not in items_list: - result.append(row) - - result = sorted(result, key=lambda x: x.get("weightage"), reverse=True) - - for item in result: - product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') - if product_info: - item.formatted_price = (product_info.get('price') or {}).get('formatted_price') - - return result - - def build_fields_filters(self, filters): - """Build filters for field values - - Args: - filters (dict): Filters - """ - for field, values in filters.items(): - if not values: - continue - - # handle multiselect fields in filter addition - meta = frappe.get_meta('Item', cached=True) - df = meta.get_field(field) - if df.fieldtype == 'Table MultiSelect': - child_doctype = df.options - child_meta = frappe.get_meta(child_doctype, cached=True) - fields = child_meta.get("fields") - if fields: - self.filters.append([child_doctype, fields[0].fieldname, 'IN', values]) - elif isinstance(values, list): - # If value is a list use `IN` query - self.filters.append([field, 'IN', values]) - else: - # `=` will be faster than `IN` for most cases - self.filters.append([field, '=', values]) - - def build_search_filters(self, search_term): - """Query search term in specified fields - - Args: - search_term (str): Search candidate - """ - # Default fields to search from - default_fields = {'name', 'item_name', 'description', 'item_group'} - - # Get meta search fields - meta = frappe.get_meta("Item") - meta_fields = set(meta.get_search_fields()) - - # Join the meta fields and default fields set - search_fields = default_fields.union(meta_fields) - try: - if frappe.db.count('Item', cache=True) > 50000: - search_fields.remove('description') - except KeyError: - pass - - # Build or filters for query - search = '%{}%'.format(search_term) - self.or_filters += [[field, 'like', search] for field in search_fields] diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index fc4cf1dbdb8..967c5729bf4 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -9,6 +9,8 @@ "field_order": [ "sb_disabled", "disabled", + "column_break_24", + "use_batchwise_valuation", "sb_batch", "batch_id", "item", @@ -186,6 +188,18 @@ "fieldtype": "Float", "label": "Produced Qty", "read_only": 1 + }, + { + "fieldname": "column_break_24", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "use_batchwise_valuation", + "fieldtype": "Check", + "label": "Use Batch-wise Valuation", + "read_only": 1, + "set_only_once": 1 } ], "icon": "fa fa-archive", @@ -193,10 +207,11 @@ "image_field": "image", "links": [], "max_attachments": 5, - "modified": "2021-07-08 16:22:01.343105", + "modified": "2022-02-21 08:08:23.999236", "modified_by": "Administrator", "module": "Stock", "name": "Batch", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -217,6 +232,7 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "batch_id", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 96751d6eae5..c9b4c147f1d 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -110,11 +110,18 @@ class Batch(Document): def validate(self): self.item_has_batch_enabled() + self.set_batchwise_valuation() def item_has_batch_enabled(self): if frappe.db.get_value("Item", self.item, "has_batch_no") == 0: frappe.throw(_("The selected item cannot have Batch")) + def set_batchwise_valuation(self): + from erpnext.stock.stock_ledger import get_valuation_method + + if self.is_new() and get_valuation_method(self.item) != "Moving Average": + self.use_batchwise_valuation = 1 + def before_save(self): has_expiry_date, shelf_life_in_days = frappe.db.get_value('Item', self.item, ['has_expiry_date', 'shelf_life_in_days']) if not self.expiry_date and has_expiry_date and shelf_life_in_days: diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 0a663c2a188..613dd3f14d7 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -1,13 +1,21 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.exceptions import ValidationError from frappe.utils import cint, flt +from frappe.utils.data import add_to_date, getdate from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, +) from erpnext.stock.get_item_details import get_item_details +from erpnext.stock.stock_ledger import get_valuation_rate from erpnext.tests.utils import ERPNextTestCase @@ -300,6 +308,105 @@ class TestBatch(ERPNextTestCase): details = get_item_details(args) self.assertEqual(details.get('price_list_rate'), 400) + + def test_basic_batch_wise_valuation(self, batch_qty = 100): + item_code = "_TestBatchWiseVal" + warehouse = "_Test Warehouse - _TC" + self.make_batch_item(item_code) + + rates = [42, 420] + + batches = {} + for rate in rates: + se = make_stock_entry(item_code=item_code, qty=10, rate=rate, target=warehouse) + batches[se.items[0].batch_no] = rate + + LOW, HIGH = list(batches.keys()) + + # consume things out of order + consumption_plan = [ + (HIGH, 1), + (LOW, 2), + (HIGH, 2), + (HIGH, 4), + (LOW, 6), + ] + + stock_value = sum(rates) * 10 + qty_after_transaction = 20 + for batch, qty in consumption_plan: + # consume out of order + se = make_stock_entry(item_code=item_code, source=warehouse, qty=qty, batch_no=batch) + + sle = frappe.get_last_doc("Stock Ledger Entry", {"is_cancelled": 0, "voucher_no": se.name}) + + stock_value_difference = sle.actual_qty * batches[sle.batch_no] + self.assertAlmostEqual(sle.stock_value_difference, stock_value_difference) + + stock_value += stock_value_difference + self.assertAlmostEqual(sle.stock_value, stock_value) + + qty_after_transaction += sle.actual_qty + self.assertAlmostEqual(sle.qty_after_transaction, qty_after_transaction) + self.assertAlmostEqual(sle.valuation_rate, stock_value / qty_after_transaction) + + self.assertEqual(json.loads(sle.stock_queue), []) # queues don't apply on batched items + + def test_moving_batch_valuation_rates(self): + item_code = "_TestBatchWiseVal" + warehouse = "_Test Warehouse - _TC" + self.make_batch_item(item_code) + + def assertValuation(expected): + actual = get_valuation_rate(item_code, warehouse, "voucher_type", "voucher_no", batch_no=batch_no) + self.assertAlmostEqual(actual, expected) + + se = make_stock_entry(item_code=item_code, qty=100, rate=10, target=warehouse) + batch_no = se.items[0].batch_no + assertValuation(10) + + # consumption should never affect current valuation rate + make_stock_entry(item_code=item_code, qty=20, source=warehouse) + assertValuation(10) + + make_stock_entry(item_code=item_code, qty=30, source=warehouse) + assertValuation(10) + + # 50 * 10 = 500 current value, add more item with higher valuation + make_stock_entry(item_code=item_code, qty=50, rate=20, target=warehouse, batch_no=batch_no) + assertValuation(15) + + # consuming again shouldn't do anything + make_stock_entry(item_code=item_code, qty=20, source=warehouse) + assertValuation(15) + + # reset rate with stock reconiliation + create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=10, rate=25, batch_no=batch_no) + assertValuation(25) + + make_stock_entry(item_code=item_code, qty=20, rate=20, target=warehouse, batch_no=batch_no) + assertValuation((20 * 20 + 10 * 25) / (10 + 20)) + + + def test_update_batch_properties(self): + item_code = "_TestBatchWiseVal" + self.make_batch_item(item_code) + + se = make_stock_entry(item_code=item_code, qty=100, rate=10, target="_Test Warehouse - _TC") + batch_no = se.items[0].batch_no + batch = frappe.get_doc("Batch", batch_no) + + expiry_date = add_to_date(batch.manufacturing_date, days=30) + + batch.expiry_date = expiry_date + batch.save() + + batch.reload() + + self.assertEqual(getdate(batch.expiry_date), getdate(expiry_date)) + + + def create_batch(item_code, rate, create_item_price_for_batch): pi = make_purchase_invoice(company="_Test Company", warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1, @@ -326,14 +433,13 @@ def create_price_list_for_batch(item_code, batch, rate): def make_new_batch(**args): args = frappe._dict(args) - try: + if frappe.db.exists("Batch", args.batch_id): + batch = frappe.get_doc("Batch", args.batch_id) + else: batch = frappe.get_doc({ "doctype": "Batch", "batch_id": args.batch_id, "item": args.item_code, }).insert() - except frappe.DuplicateEntryError: - batch = frappe.get_doc("Batch", args.batch_id) - return batch diff --git a/erpnext/stock/doctype/bin/bin.json b/erpnext/stock/doctype/bin/bin.json index 8e79f0e5552..56dc71c57e1 100644 --- a/erpnext/stock/doctype/bin/bin.json +++ b/erpnext/stock/doctype/bin/bin.json @@ -33,6 +33,7 @@ "oldfieldtype": "Link", "options": "Warehouse", "read_only": 1, + "reqd": 1, "search_index": 1 }, { @@ -46,6 +47,7 @@ "oldfieldtype": "Link", "options": "Item", "read_only": 1, + "reqd": 1, "search_index": 1 }, { @@ -169,10 +171,11 @@ "idx": 1, "in_create": 1, "links": [], - "modified": "2021-03-30 23:09:39.572776", + "modified": "2022-01-30 17:04:54.715288", "modified_by": "Administrator", "module": "Stock", "name": "Bin", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -200,5 +203,6 @@ "quick_entry": 1, "search_fields": "item_code,warehouse", "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 0ef7ce29230..3bc15a80250 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -20,43 +20,12 @@ class Bin(Document): + flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty) - flt(self.reserved_qty_for_production) - flt(self.reserved_qty_for_sub_contract)) - def get_first_sle(self): - sle = frappe.qb.DocType("Stock Ledger Entry") - first_sle = ( - frappe.qb.from_(sle) - .select("*") - .where((sle.item_code == self.item_code) & (sle.warehouse == self.warehouse)) - .orderby(sle.posting_date, sle.posting_time, sle.creation) - .limit(1) - ).run(as_dict=True) - - return first_sle and first_sle[0] or None - def update_reserved_qty_for_production(self): '''Update qty reserved for production from Production Item tables in open work orders''' + from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production - wo = frappe.qb.DocType("Work Order") - wo_item = frappe.qb.DocType("Work Order Item") - - self.reserved_qty_for_production = ( - frappe.qb - .from_(wo) - .from_(wo_item) - .select(Sum(Case() - .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) - .else_(wo_item.required_qty - wo_item.consumed_qty)) - ) - .where( - (wo_item.item_code == self.item_code) - & (wo_item.parent == wo.name) - & (wo.docstatus == 1) - & (wo_item.source_warehouse == self.warehouse) - & (wo.status.notin(["Stopped", "Completed"])) - & ((wo_item.required_qty > wo_item.transferred_qty) - | (wo_item.required_qty > wo_item.consumed_qty)) - ) - ).run()[0][0] or 0.0 + self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse) self.set_projected_qty() @@ -123,16 +92,9 @@ class Bin(Document): self.db_set('projected_qty', self.projected_qty) def on_doctype_update(): - frappe.db.add_index("Bin", ["item_code", "warehouse"]) + frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse") -def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False): - """WARNING: This function is deprecated. Inline this function instead of using it.""" - from erpnext.stock.stock_ledger import repost_current_voucher - - repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher) - update_qty(bin_name, args) - def get_bin_details(bin_name): return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty', 'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production', diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py index 9c390d94b4e..250126c6b98 100644 --- a/erpnext/stock/doctype/bin/test_bin.py +++ b/erpnext/stock/doctype/bin/test_bin.py @@ -1,9 +1,36 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -import unittest +import frappe -# test_records = frappe.get_test_records('Bin') +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.utils import _create_bin +from erpnext.tests.utils import ERPNextTestCase -class TestBin(unittest.TestCase): - pass + +class TestBin(ERPNextTestCase): + + + def test_concurrent_inserts(self): + """ Ensure no duplicates are possible in case of concurrent inserts""" + item_code = "_TestConcurrentBin" + make_item(item_code) + warehouse = "_Test Warehouse - _TC" + + bin1 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse) + bin1.insert() + + bin2 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse) + with self.assertRaises(frappe.UniqueValidationError): + bin2.insert() + + # util method should handle it + bin = _create_bin(item_code, warehouse) + self.assertEqual(bin.item_code, item_code) + + frappe.db.rollback() + + def test_index_exists(self): + indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1) + if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes): + self.fail(f"Expected unique index on item-warehouse") diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index d1e22440b96..2a4d63954a7 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -339,17 +339,35 @@ class DeliveryNote(SellingController): frappe.throw(_("Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again")) def update_billed_amount_based_on_so(so_detail, update_modified=True): + from frappe.query_builder.functions import Sum + # Billed against Sales Order directly - billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item` - where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail) + si = frappe.qb.DocType("Sales Invoice").as_("si") + si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item") + sum_amount = Sum(si_item.amount).as_("amount") + + billed_against_so = frappe.qb.from_(si).from_(si_item).select(sum_amount).where( + (si_item.parent == si.name) & + (si_item.so_detail == so_detail) & + ((si_item.dn_detail.isnull()) | (si_item.dn_detail == '')) & + (si_item.docstatus == 1) & + (si.update_stock == 0) + ).run() billed_against_so = billed_against_so and billed_against_so[0][0] or 0 # Get all Delivery Note Item rows against the Sales Order Item row - dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent - from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn - where dn.name=dn_item.parent and dn_item.so_detail=%s - and dn.docstatus=1 and dn.is_return = 0 - order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1) + + dn = frappe.qb.DocType("Delivery Note").as_("dn") + dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item") + + dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent, dn_item.stock_qty, dn_item.returned_qty).where( + (dn.name == dn_item.parent) & + (dn_item.so_detail == so_detail) & + (dn.docstatus == 1) & + (dn.is_return == 0) + ).orderby( + dn.posting_date, dn.posting_time, dn.name + ).run(as_dict=True) updated_dn = [] for dnd in dn_details: @@ -367,7 +385,11 @@ def update_billed_amount_based_on_so(so_detail, update_modified=True): # Distribute billed amount directly against SO between DNs based on FIFO if billed_against_so and billed_amt_agianst_dn < dnd.amount: - pending_to_bill = flt(dnd.amount) - billed_amt_agianst_dn + if dnd.returned_qty: + pending_to_bill = flt(dnd.amount) * (dnd.stock_qty - dnd.returned_qty) / dnd.stock_qty + else: + pending_to_bill = flt(dnd.amount) + pending_to_bill -= billed_amt_agianst_dn if pending_to_bill <= billed_against_so: billed_amt_agianst_dn += pending_to_bill billed_against_so -= pending_to_bill @@ -586,7 +608,18 @@ def make_packing_slip(source_name, target_doc=None): "validation": { "docstatus": ["=", 0] } + }, + + "Delivery Note Item": { + "doctype": "Packing Slip Item", + "field_map": { + "item_code": "item_code", + "item_name": "item_name", + "description": "description", + "qty": "qty", + } } + }, target_doc) return doclist diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index 04028980473..9e6f3bc9321 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -14,7 +14,7 @@ frappe.listview_settings['Delivery Note'] = { return [__("Completed"), "green", "per_billed,=,100"]; } }, - onload: function (doclist) { + onload: function (listview) { const action = () => { const selected_docs = doclist.get_checked_items(); const docnames = doclist.get_checked_items(true); @@ -54,6 +54,16 @@ frappe.listview_settings['Delivery Note'] = { }; }; - doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false); + // doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false); + + listview.page.add_action_item(__('Create Delivery Trip'), action); + + listview.page.add_action_item(__("Sales Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Sales Invoice"); + }); + + listview.page.add_action_item(__("Packaging Slip From Delivery Note"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Packing Slip"); + }); } }; diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 752a1fe732b..ffea9c2d6e0 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -17,8 +17,6 @@ frappe.ui.form.on("Item", { frm.fields_dict["attributes"].grid.set_column_disp("attribute_value", true); } - // should never check Private - frm.fields_dict["website_image"].df.is_private = 0; if (frm.doc.is_fixed_asset) { frm.trigger("set_asset_naming_series"); } @@ -91,6 +89,29 @@ frappe.ui.form.on("Item", { erpnext.toggle_naming_series(); } + if (!frm.doc.published_in_website) { + frm.add_custom_button(__("Publish in Website"), function() { + frappe.call({ + method: "erpnext.e_commerce.doctype.website_item.website_item.make_website_item", + args: {doc: frm.doc}, + freeze: true, + freeze_message: __("Publishing Item ..."), + callback: function(result) { + frappe.msgprint({ + message: __("Website Item {0} has been created.", + [repl('%(item)s', { + item_encoded: encodeURIComponent(result.message[0]), + item: result.message[1] + })] + ), + title: __("Published"), + indicator: "green" + }); + } + }); + }, __('Actions')); + } + erpnext.item.edit_prices_button(frm); erpnext.item.toggle_attributes(frm); @@ -182,25 +203,8 @@ frappe.ui.form.on("Item", { } }, - copy_from_item_group: function(frm) { - return frm.call({ - doc: frm.doc, - method: "copy_specification_from_item_group" - }); - }, - has_variants: function(frm) { erpnext.item.toggle_attributes(frm); - }, - - show_in_website: function(frm) { - if (frm.doc.default_warehouse && !frm.doc.website_warehouse){ - frm.set_value("website_warehouse", frm.doc.default_warehouse); - } - }, - - set_meta_tags(frm) { - frappe.utils.set_meta_tag(frm.doc.route); } }); @@ -376,8 +380,7 @@ $.extend(erpnext.item, { // Show Stock Levels only if is_stock_item if (frm.doc.is_stock_item) { frappe.require('item-dashboard.bundle.js', function() { - frm.dashboard.parent.find('.stock-levels').remove(); - const section = frm.dashboard.add_section('', __("Stock Levels"), 'stock-levels'); + const section = frm.dashboard.add_section('', __("Stock Levels")); erpnext.item.item_dashboard = new erpnext.stock.ItemDashboard({ parent: section, item_code: frm.doc.name, @@ -393,13 +396,15 @@ $.extend(erpnext.item, { edit_prices_button: function(frm) { frm.add_custom_button(__("Add / Edit Prices"), function() { frappe.set_route("List", "Item Price", {"item_code": frm.doc.name}); - }, __("View")); + }, __("Actions")); }, - weight_to_validate: function(frm){ - if((frm.doc.nett_weight || frm.doc.gross_weight) && !frm.doc.weight_uom) { - frappe.msgprint(__('Weight is mentioned,\nPlease mention "Weight UOM" too')); - frappe.validated = 0; + weight_to_validate: function(frm) { + if (frm.doc.weight_per_unit && !frm.doc.weight_uom) { + frappe.msgprint({ + message: __("Please mention 'Weight UOM' along with Weight."), + title: __("Note") + }); } }, @@ -540,7 +545,7 @@ $.extend(erpnext.item, { let selected_attributes = {}; me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => { if(i===0) return; - let attribute_name = $(col).find('label').html(); + let attribute_name = $(col).find('label').html().trim(); selected_attributes[attribute_name] = []; let checked_opts = $(col).find('.checkbox input'); checked_opts.each((i, opt) => { @@ -589,7 +594,7 @@ $.extend(erpnext.item, { const increment = r.message.increment; let values = []; - for(var i = from; i <= to; i += increment) { + for(var i = from; i <= to; i = flt(i + increment, 6)) { values.push(i); } attr_val_fields[d.attribute] = values; diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 2d28cc09f94..c7971878506 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -48,6 +48,7 @@ "warranty_period", "weight_per_unit", "weight_uom", + "allow_negative_stock", "reorder_section", "reorder_levels", "unit_of_measure_conversion", @@ -117,24 +118,8 @@ "customer_code", "default_item_manufacturer", "default_manufacturer_part_no", - "website_section", - "show_in_website", - "show_variant_in_website", - "route", - "weightage", - "slideshow", - "website_image", - "website_image_alt", - "thumbnail", - "cb72", - "website_warehouse", - "website_item_groups", - "set_meta_tags", - "sb72", - "copy_from_item_group", - "website_specifications", - "web_long_description", - "website_content", + "more_information_section", + "published_in_website", "total_projected_qty" ], "fields": [ @@ -362,7 +347,7 @@ "fieldname": "valuation_method", "fieldtype": "Select", "label": "Valuation Method", - "options": "\nFIFO\nMoving Average" + "options": "\nFIFO\nMoving Average\nLIFO" }, { "depends_on": "is_stock_item", @@ -856,125 +841,6 @@ "no_copy": 1, "print_hide": 1 }, - { - "collapsible": 1, - "depends_on": "eval:!doc.is_fixed_asset", - "fieldname": "website_section", - "fieldtype": "Section Break", - "label": "Website", - "options": "fa fa-globe" - }, - { - "default": "0", - "depends_on": "eval:!doc.variant_of", - "fieldname": "show_in_website", - "fieldtype": "Check", - "label": "Show in Website", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "variant_of", - "fieldname": "show_variant_in_website", - "fieldtype": "Check", - "label": "Show in Website (Variant)", - "search_index": 1 - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "route", - "fieldtype": "Small Text", - "label": "Route", - "no_copy": 1 - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Items with higher weightage will be shown higher", - "fieldname": "weightage", - "fieldtype": "Int", - "label": "Weightage" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Show a slideshow at the top of the page", - "fieldname": "slideshow", - "fieldtype": "Link", - "label": "Slideshow", - "options": "Website Slideshow" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Item Image (if not slideshow)", - "fieldname": "website_image", - "fieldtype": "Attach", - "label": "Website Image" - }, - { - "fieldname": "thumbnail", - "fieldtype": "Data", - "label": "Thumbnail", - "read_only": 1 - }, - { - "fieldname": "cb72", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "Show \"In Stock\" or \"Not in Stock\" based on stock available in this warehouse.", - "fieldname": "website_warehouse", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Website Warehouse", - "options": "Warehouse" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "description": "List this Item in multiple groups on the website.", - "fieldname": "website_item_groups", - "fieldtype": "Table", - "label": "Website Item Groups", - "options": "Website Item Group" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "set_meta_tags", - "fieldtype": "Button", - "label": "Set Meta Tags" - }, - { - "collapsible": 1, - "collapsible_depends_on": "website_specifications", - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "sb72", - "fieldtype": "Section Break", - "label": "Website Specifications" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "copy_from_item_group", - "fieldtype": "Button", - "label": "Copy From Item Group" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "website_specifications", - "fieldtype": "Table", - "label": "Website Specifications", - "options": "Item Website Specification" - }, - { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "web_long_description", - "fieldtype": "Text Editor", - "label": "Website Description" - }, - { - "description": "You can use any valid Bootstrap 4 markup in this field. It will be shown on your Item Page.", - "fieldname": "website_content", - "fieldtype": "HTML Editor", - "label": "Website Content" - }, { "fieldname": "total_projected_qty", "fieldtype": "Float", @@ -1017,10 +883,18 @@ "read_only": 1 }, { - "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", - "fieldname": "website_image_alt", - "fieldtype": "Data", - "label": "Image Description" + "collapsible": 1, + "fieldname": "more_information_section", + "fieldtype": "Section Break", + "label": "More Information" + }, + { + "default": "0", + "depends_on": "published_in_website", + "fieldname": "published_in_website", + "fieldtype": "Check", + "label": "Published in Website", + "read_only": 1 }, { "default": "1", @@ -1034,15 +908,20 @@ "fieldname": "is_grouped_asset", "fieldtype": "Check", "label": "Create Grouped Asset" + }, + { + "default": "0", + "fieldname": "allow_negative_stock", + "fieldtype": "Check", + "label": "Allow Negative Stock" } ], - "has_web_view": 1, "icon": "fa fa-tag", "idx": 2, "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-01-18 12:57:54.273202", + "modified": "2022-02-11 08:07:46.663220", "modified_by": "Administrator", "module": "Stock", "name": "Item", @@ -1115,4 +994,4 @@ "states": [], "title_field": "item_name", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index d99fadca46e..494fb3b8bb2 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -2,12 +2,12 @@ # License: GNU General Public License v3. See license.txt import copy -import itertools import json from typing import List import frappe from frappe import _ +from frappe.model.document import Document from frappe.utils import ( cint, cstr, @@ -17,13 +17,9 @@ from frappe.utils import ( getdate, now_datetime, nowtime, - random_string, strip, ) from frappe.utils.html_utils import clean_html -from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow -from frappe.website.utils import clear_cache -from frappe.website.website_generator import WebsiteGenerator import erpnext from erpnext.controllers.item_variant import ( @@ -33,10 +29,7 @@ from erpnext.controllers.item_variant import ( make_variant_item_code, validate_item_variant_attributes, ) -from erpnext.setup.doctype.item_group.item_group import ( - get_parent_item_groups, - invalidate_cache_for, -) +from erpnext.setup.doctype.item_group.item_group import invalidate_cache_for from erpnext.stock.doctype.item_default.item_default import ItemDefault @@ -51,18 +44,11 @@ class StockExistsForTemplate(frappe.ValidationError): class InvalidBarcode(frappe.ValidationError): pass +class DataValidationError(frappe.ValidationError): + pass -class Item(WebsiteGenerator): - website = frappe._dict( - page_title_field="item_name", - condition_field="show_in_website", - template="templates/generators/item/item.html", - no_cache=1 - ) - +class Item(Document): def onload(self): - super(Item, self).onload() - self.set_onload('stock_exists', self.stock_ledger_created()) self.set_asset_naming_series() @@ -103,8 +89,6 @@ class Item(WebsiteGenerator): self.set_opening_stock() def validate(self): - super(Item, self).validate() - if not self.item_name: self.item_name = self.item_code @@ -130,8 +114,6 @@ class Item(WebsiteGenerator): self.validate_attributes() self.validate_variant_attributes() self.validate_variant_based_on_change() - self.validate_website_image() - self.make_thumbnail() self.validate_fixed_asset() self.validate_retain_sample() self.validate_uom_conversion_factor() @@ -140,21 +122,17 @@ class Item(WebsiteGenerator): self.validate_item_defaults() self.validate_auto_reorder_enabled_in_stock_settings() self.cant_change() - self.update_show_in_website() self.validate_item_tax_net_rate_range() set_item_tax_from_hsn_code(self) if not self.is_new(): self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group") - self.old_website_item_groups = frappe.db.sql_list("""select item_group - from `tabWebsite Item Group` - where parentfield='website_item_groups' and parenttype='Item' and parent=%s""", self.name) def on_update(self): invalidate_cache_for_item(self) self.update_variants() self.update_item_price() - self.update_template_item() + self.update_website_item() def validate_description(self): '''Clean HTML description if set''' @@ -216,97 +194,6 @@ class Item(WebsiteGenerator): stock_entry.add_comment("Comment", _("Opening Stock")) - def make_route(self): - if not self.route: - return cstr(frappe.db.get_value('Item Group', self.item_group, - 'route')) + '/' + self.scrub((self.item_name or self.item_code) + '-' + random_string(5)) - - def validate_website_image(self): - """Validate if the website image is a public file""" - - if frappe.flags.in_import: - return - - auto_set_website_image = False - if not self.website_image and self.image: - auto_set_website_image = True - self.website_image = self.image - - if not self.website_image: - return - - # find if website image url exists as public - file_doc = frappe.get_all("File", filters={ - "file_url": self.website_image - }, fields=["name", "is_private"], order_by="is_private asc", limit_page_length=1) - - if file_doc: - file_doc = file_doc[0] - - if not file_doc: - if not auto_set_website_image: - frappe.msgprint(_("Website Image {0} attached to Item {1} cannot be found").format(self.website_image, self.name)) - - self.website_image = None - - elif file_doc.is_private: - if not auto_set_website_image: - frappe.msgprint(_("Website Image should be a public file or website URL")) - - self.website_image = None - - def make_thumbnail(self): - """Make a thumbnail of `website_image`""" - - if frappe.flags.in_import: - return - - import requests.exceptions - - if not self.is_new() and self.website_image != frappe.db.get_value(self.doctype, self.name, "website_image"): - self.thumbnail = None - - if self.website_image and not self.thumbnail: - file_doc = None - - try: - file_doc = frappe.get_doc("File", { - "file_url": self.website_image, - "attached_to_doctype": "Item", - "attached_to_name": self.name - }) - except frappe.DoesNotExistError: - # cleanup - frappe.local.message_log.pop() - - except requests.exceptions.HTTPError: - frappe.msgprint(_("Warning: Invalid attachment {0}").format(self.website_image)) - self.website_image = None - - except requests.exceptions.SSLError: - frappe.msgprint( - _("Warning: Invalid SSL certificate on attachment {0}").format(self.website_image)) - self.website_image = None - - # for CSV import - if self.website_image and not file_doc: - try: - file_doc = frappe.get_doc({ - "doctype": "File", - "file_url": self.website_image, - "attached_to_doctype": "Item", - "attached_to_name": self.name - }).save() - - except IOError: - self.website_image = None - - if file_doc: - if not file_doc.thumbnail_url: - file_doc.make_thumbnail() - - self.thumbnail = file_doc.thumbnail_url - def validate_fixed_asset(self): if self.is_fixed_asset: if self.is_stock_item: @@ -330,167 +217,6 @@ class Item(WebsiteGenerator): frappe.throw(_("{0} Retain Sample is based on batch, please check Has Batch No to retain sample of item").format( self.item_code)) - def get_context(self, context): - context.show_search = True - context.search_link = '/product_search' - - context.parents = get_parent_item_groups(self.item_group) - context.body_class = "product-page" - - self.set_variant_context(context) - self.set_attribute_context(context) - self.set_disabled_attributes(context) - self.set_metatags(context) - self.set_shopping_cart_data(context) - - return context - - def set_variant_context(self, context): - if self.has_variants: - context.no_cache = True - - # load variants - # also used in set_attribute_context - context.variants = frappe.get_all("Item", - filters={"variant_of": self.name, "show_variant_in_website": 1}, - order_by="name asc") - - variant = frappe.form_dict.variant - if not variant and context.variants: - # the case when the item is opened for the first time from its list - variant = context.variants[0] - - if variant: - context.variant = frappe.get_doc("Item", variant) - - for fieldname in ("website_image", "website_image_alt", "web_long_description", "description", - "website_specifications"): - if context.variant.get(fieldname): - value = context.variant.get(fieldname) - if isinstance(value, list): - value = [d.as_dict() for d in value] - - context[fieldname] = value - - if self.slideshow: - if context.variant and context.variant.slideshow: - context.update(get_slideshow(context.variant)) - else: - context.update(get_slideshow(self)) - - def set_attribute_context(self, context): - if not self.has_variants: - return - - attribute_values_available = {} - context.attribute_values = {} - context.selected_attributes = {} - - # load attributes - for v in context.variants: - v.attributes = frappe.get_all("Item Variant Attribute", - fields=["attribute", "attribute_value"], - filters={"parent": v.name}) - # make a map for easier access in templates - v.attribute_map = frappe._dict({}) - for attr in v.attributes: - v.attribute_map[attr.attribute] = attr.attribute_value - - for attr in v.attributes: - values = attribute_values_available.setdefault(attr.attribute, []) - if attr.attribute_value not in values: - values.append(attr.attribute_value) - - if v.name == context.variant.name: - context.selected_attributes[attr.attribute] = attr.attribute_value - - # filter attributes, order based on attribute table - for attr in self.attributes: - values = context.attribute_values.setdefault(attr.attribute, []) - - if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")): - for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt): - values.append(val) - - else: - # get list of values defined (for sequence) - for attr_value in frappe.db.get_all("Item Attribute Value", - fields=["attribute_value"], - filters={"parent": attr.attribute}, order_by="idx asc"): - - if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []): - values.append(attr_value.attribute_value) - - context.variant_info = json.dumps(context.variants) - - def set_disabled_attributes(self, context): - """Disable selection options of attribute combinations that do not result in a variant""" - if not self.attributes or not self.has_variants: - return - - context.disabled_attributes = {} - attributes = [attr.attribute for attr in self.attributes] - - def find_variant(combination): - for variant in context.variants: - if len(variant.attributes) < len(attributes): - continue - - if "combination" not in variant: - ref_combination = [] - - for attr in variant.attributes: - idx = attributes.index(attr.attribute) - ref_combination.insert(idx, attr.attribute_value) - - variant["combination"] = ref_combination - - if not (set(combination) - set(variant["combination"])): - # check if the combination is a subset of a variant combination - # eg. [Blue, 0.5] is a possible combination if exists [Blue, Large, 0.5] - return True - - for i, attr in enumerate(self.attributes): - if i == 0: - continue - - combination_source = [] - - # loop through previous attributes - for prev_attr in self.attributes[:i]: - combination_source.append([context.selected_attributes.get(prev_attr.attribute)]) - - combination_source.append(context.attribute_values[attr.attribute]) - - for combination in itertools.product(*combination_source): - if not find_variant(combination): - context.disabled_attributes.setdefault(attr.attribute, []).append(combination[-1]) - - def set_metatags(self, context): - context.metatags = frappe._dict({}) - - safe_description = frappe.utils.to_markdown(self.description) - - context.metatags.url = frappe.utils.get_url() + '/' + context.route - - if context.website_image: - if context.website_image.startswith('http'): - url = context.website_image - else: - url = frappe.utils.get_url() + context.website_image - context.metatags.image = url - - context.metatags.description = safe_description[:300] - - context.metatags.title = self.item_name or self.item_code - - context.metatags['og:type'] = 'product' - context.metatags['og:site_name'] = 'ERPNext' - - def set_shopping_cart_data(self, context): - from erpnext.shopping_cart.product_info import get_product_info_for_website - context.shopping_cart = get_product_info_for_website(self.name, skip_quotation_creation=True) - def add_default_uom_in_conversion_factor_table(self): if not self.is_new() and self.has_value_changed("stock_uom"): self.uoms = [] @@ -507,9 +233,29 @@ class Item(WebsiteGenerator): "conversion_factor": 1 }) - def update_show_in_website(self): - if self.disabled: - self.show_in_website = False + def update_website_item(self): + """Update Website Item if change in Item impacts it.""" + web_item = frappe.db.exists("Website Item", {"item_code": self.item_code}) + + if web_item: + changed = {} + editable_fields = ["item_name", "item_group", "stock_uom", "brand", "description", + "disabled"] + doc_before_save = self.get_doc_before_save() + + for field in editable_fields: + if doc_before_save.get(field) != self.get(field): + if field == "disabled": + changed["published"] = not self.get(field) + else: + changed[field] = self.get(field) + + if not changed: + return + + web_item_doc = frappe.get_doc("Website Item", web_item) + web_item_doc.update(changed) + web_item_doc.save() def validate_item_tax_net_rate_range(self): for tax in self.get('taxes'): @@ -641,7 +387,6 @@ class Item(WebsiteGenerator): ) def on_trash(self): - super(Item, self).on_trash() frappe.db.sql("""delete from tabBin where item_code=%s""", self.name) frappe.db.sql("delete from `tabItem Price` where item_code=%s", self.name) for variant_of in frappe.get_all("Item", filters={"variant_of": self.name}): @@ -652,15 +397,9 @@ class Item(WebsiteGenerator): frappe.db.set_value("Item", old_name, "item_name", new_name) if merge: - # Validate properties before merging - if not frappe.db.exists("Item", new_name): - frappe.throw(_("Item {0} does not exist").format(new_name)) - - field_list = ["stock_uom", "is_stock_item", "has_serial_no", "has_batch_no"] - new_properties = [cstr(d) for d in frappe.db.get_value("Item", new_name, field_list)] - if new_properties != [cstr(self.get(fld)) for fld in field_list]: - frappe.throw(_("To merge, following properties must be same for both items") - + ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list])) + self.validate_properties_before_merge(new_name) + self.validate_duplicate_product_bundles_before_merge(old_name, new_name) + self.validate_duplicate_website_item_before_merge(old_name, new_name) def after_rename(self, old_name, new_name, merge): if merge: @@ -668,9 +407,8 @@ class Item(WebsiteGenerator): frappe.msgprint(_("It can take upto few hours for accurate stock values to be visible after merging items."), indicator="orange", title="Note") - if self.route: + if self.published_in_website: invalidate_cache_for_item(self) - clear_cache(self.route) frappe.db.set_value("Item", new_name, "item_code", new_name) @@ -710,7 +448,56 @@ class Item(WebsiteGenerator): msg += _("Note: To merge the items, create a separate Stock Reconciliation for the old item {0}").format( frappe.bold(old_name)) - frappe.throw(_(msg), title=_("Merge not allowed")) + frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError) + + def validate_properties_before_merge(self, new_name): + # Validate properties before merging + if not frappe.db.exists("Item", new_name): + frappe.throw(_("Item {0} does not exist").format(new_name)) + + field_list = ["stock_uom", "is_stock_item", "has_serial_no", "has_batch_no"] + new_properties = [cstr(d) for d in frappe.db.get_value("Item", new_name, field_list)] + + if new_properties != [cstr(self.get(field)) for field in field_list]: + msg = _("To merge, following properties must be same for both items") + msg += ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list]) + frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) + + def validate_duplicate_product_bundles_before_merge(self, old_name, new_name): + "Block merge if both old and new items have product bundles." + old_bundle = frappe.get_value("Product Bundle",filters={"new_item_code": old_name}) + new_bundle = frappe.get_value("Product Bundle",filters={"new_item_code": new_name}) + + if old_bundle and new_bundle: + bundle_link = get_link_to_form("Product Bundle", old_bundle) + old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) + + msg = _("Please delete Product Bundle {0}, before merging {1} into {2}").format( + bundle_link, old_name, new_name + ) + frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) + + def validate_duplicate_website_item_before_merge(self, old_name, new_name): + """ + Block merge if both old and new items have website items against them. + This is to avoid duplicate website items after merging. + """ + web_items = frappe.get_all( + "Website Item", + filters={ + "item_code": ["in", [old_name, new_name]] + }, + fields=["item_code", "name"]) + + if len(web_items) <= 1: + return + + old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0] + web_item_link = get_link_to_form("Website Item", old_web_item) + old_name, new_name = frappe.bold(old_name), frappe.bold(new_name) + + msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} into {new_name}" + frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError) def set_last_purchase_rate(self, new_name): last_purchase_rate = get_last_purchase_details(new_name).get("base_net_rate", 0) @@ -732,16 +519,6 @@ class Item(WebsiteGenerator): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", existing_allow_negative_stock) - @frappe.whitelist() - def copy_specification_from_item_group(self): - self.set("website_specifications", []) - if self.item_group: - for label, desc in frappe.db.get_values("Item Website Specification", - {"parent": self.item_group}, ["label", "description"]): - row = self.append("website_specifications") - row.label = label - row.description = desc - def update_bom_item_desc(self): if self.is_new(): return @@ -765,25 +542,6 @@ class Item(WebsiteGenerator): where item_code = %s and docstatus < 2 """, (self.description, self.name)) - def update_template_item(self): - """Set Show in Website for Template Item if True for its Variant""" - if not self.variant_of: - return - - if self.show_in_website: - self.show_variant_in_website = 1 - self.show_in_website = 0 - - if self.show_variant_in_website: - # show template - template_item = frappe.get_doc("Item", self.variant_of) - - if not template_item.show_in_website: - template_item.show_in_website = 1 - template_item.flags.dont_update_variants = True - template_item.flags.ignore_permissions = True - template_item.save() - def validate_item_defaults(self): companies = {row.company for row in self.item_defaults} @@ -1034,47 +792,6 @@ class Item(WebsiteGenerator): if not enabled: frappe.msgprint(msg=_("You have to enable auto re-order in Stock Settings to maintain re-order levels."), title=_("Enable Auto Re-Order"), indicator="orange") - def create_onboarding_docs(self, args): - company = frappe.defaults.get_defaults().get('company') or \ - frappe.db.get_single_value('Global Defaults', 'default_company') - - for i in range(1, args.get('max_count')): - item = args.get('item_' + str(i)) - if item: - default_warehouse = '' - default_warehouse = frappe.db.get_value('Warehouse', filters={ - 'warehouse_name': _('Finished Goods'), - 'company': company - }) - - try: - frappe.get_doc({ - 'doctype': self.doctype, - 'item_code': item, - 'item_name': item, - 'description': item, - 'show_in_website': 1, - 'is_sales_item': 1, - 'is_purchase_item': 1, - 'is_stock_item': 1, - 'item_group': _('Products'), - 'stock_uom': _(args.get('item_uom_' + str(i))), - 'item_defaults': [{ - 'default_warehouse': default_warehouse, - 'company': company - }] - }).insert() - - except frappe.NameError: - pass - else: - if args.get('item_price_' + str(i)): - item_price = flt(args.get('item_price_' + str(i))) - - price_list_name = frappe.db.get_value('Price List', {'selling': 1}) - make_item_price(item, price_list_name, item_price) - price_list_name = frappe.db.get_value('Price List', {'buying': 1}) - make_item_price(item, price_list_name, item_price) def make_item_price(item, price_list_name, item_price): frappe.get_doc({ @@ -1189,14 +906,9 @@ def get_last_purchase_details(item_code, doc_name=None, conversion_rate=1.0): def invalidate_cache_for_item(doc): + """Invalidate Item Group cache and rebuild ItemVariantsCacheManager.""" invalidate_cache_for(doc, doc.item_group) - website_item_groups = list(set((doc.get("old_website_item_groups") or []) - + [d.item_group for d in doc.get({"doctype": "Website Item Group"}) if d.item_group])) - - for item_group in website_item_groups: - invalidate_cache_for(doc, item_group) - if doc.get("old_item_group") and doc.get("old_item_group") != doc.item_group: invalidate_cache_for(doc, doc.old_item_group) @@ -1204,12 +916,14 @@ def invalidate_cache_for_item(doc): def invalidate_item_variants_cache_for_website(doc): - from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager + """Rebuild ItemVariantsCacheManager via Item or Website Item.""" + from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager item_code = None - if doc.has_variants and doc.show_in_website: - item_code = doc.name - elif doc.variant_of and frappe.db.get_value('Item', doc.variant_of, 'show_in_website'): + is_web_item = doc.get("published_in_website") or doc.get("published") + if doc.has_variants and is_web_item: + item_code = doc.item_code + elif doc.variant_of and frappe.db.get_value('Item', doc.variant_of, 'published_in_website'): item_code = doc.variant_of if item_code: @@ -1333,10 +1047,6 @@ def update_variants(variants, template, publish_progress=True): if publish_progress: frappe.publish_progress(count / total * 100, title=_("Updating Variants...")) -def on_doctype_update(): - # since route is a Text column, it needs a length for indexing - frappe.db.add_index("Item", ["route(500)"]) - @erpnext.allow_regional def set_item_tax_from_hsn_code(item): pass diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 0957ce06159..9491e172593 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -6,6 +6,7 @@ import json import frappe from frappe.test_runner import make_test_objects +from frappe.utils import add_days, today from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, @@ -14,6 +15,7 @@ from erpnext.controllers.item_variant import ( get_variant, ) from erpnext.stock.doctype.item.item import ( + DataValidationError, InvalidBarcode, StockExistsForTemplate, get_item_attribute, @@ -387,6 +389,26 @@ class TestItem(ERPNextTestCase): self.assertTrue(frappe.db.get_value("Bin", {"item_code": "Test Item for Merging 2", "warehouse": "_Test Warehouse 1 - _TC"})) + def test_item_merging_with_product_bundle(self): + from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + + create_item("Test Item Bundle Item 1", is_stock_item=False) + create_item("Test Item Bundle Item 2", is_stock_item=False) + create_item("Test Item inside Bundle") + bundle_items = ["Test Item inside Bundle"] + + # make bundles for both items + bundle1 = make_product_bundle("Test Item Bundle Item 1", bundle_items, qty=2) + make_product_bundle("Test Item Bundle Item 2", bundle_items, qty=2) + + with self.assertRaises(DataValidationError): + frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True) + + bundle1.delete() + frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True) + + self.assertFalse(frappe.db.exists("Item", "Test Item Bundle Item 1")) + def test_uom_conversion_factor(self): if frappe.db.exists('Item', 'Test Item UOM'): frappe.delete_doc('Item', 'Test Item UOM') @@ -536,7 +558,7 @@ class TestItem(ERPNextTestCase): "check if index is getting created in db" indices = frappe.db.sql("show index from tabItem", as_dict=1) - expected_columns = {"item_code", "item_name", "item_group", "route"} + expected_columns = {"item_code", "item_name", "item_group"} for index in indices: expected_columns.discard(index.get("Column_name")) @@ -608,6 +630,45 @@ class TestItem(ERPNextTestCase): item.item_group = "All Item Groups" item.save() # if item code saved without item_code then series worked + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_item_wise_negative_stock(self): + """ When global settings are disabled check that item that allows + negative stock can still consume material in all known stock + transactions that consume inventory.""" + from erpnext.stock.stock_ledger import is_negative_stock_allowed + + item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100}) + self.assertTrue(is_negative_stock_allowed(item_code=item.name)) + + self.consume_item_code_with_differet_stock_transactions(item_code=item.name) + + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_backdated_negative_stock(self): + """ same as test above but backdated entries """ + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100}) + + # create a future entry so all new entries are backdated + make_stock_entry(qty=1, item_code=item.name, target="_Test Warehouse - _TC", posting_date = add_days(today(), 5)) + self.consume_item_code_with_differet_stock_transactions(item_code=item.name) + + + def consume_item_code_with_differet_stock_transactions(self, item_code, warehouse="_Test Warehouse - _TC"): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + typical_args = {"item_code": item_code, "warehouse": warehouse} + + create_delivery_note(**typical_args) + create_sales_invoice(update_stock=1, **typical_args) + make_stock_entry(item_code=item_code, source=warehouse, qty=1, purpose="Material Issue") + make_stock_entry(item_code=item_code, source=warehouse, target="Stores - _TC", qty=1) + # standalone return + make_purchase_receipt(is_return=True, qty=-1, **typical_args) + + def set_item_variant_settings(fields): doc = frappe.get_doc('Item Variant Settings') diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json index 6cec85288fe..91c77d51529 100644 --- a/erpnext/stock/doctype/item/test_records.json +++ b/erpnext/stock/doctype/item/test_records.json @@ -40,9 +40,7 @@ "conversion_factor": 10.0 } ], - "stock_uom": "_Test UOM", - "show_in_website": 1, - "website_warehouse": "_Test Warehouse - _TC" + "stock_uom": "_Test UOM" }, { "description": "_Test Item 2", @@ -56,8 +54,6 @@ "item_group": "_Test Item Group", "item_name": "_Test Item 2", "stock_uom": "_Test UOM", - "show_in_website": 1, - "website_warehouse": "_Test Warehouse - _TC", "gst_hsn_code": "999800", "opening_stock": 10, "valuation_rate": 100, @@ -311,8 +307,7 @@ "warehouse_reorder_level": 20, "warehouse_reorder_qty": 20 } - ], - "show_in_website": 1 + ] }, { "description": "_Test Item 1", @@ -344,9 +339,7 @@ "warehouse_reorder_qty": 20 } ], - "stock_uom": "_Test UOM", - "show_in_website": 1, - "website_warehouse": "_Test Warehouse Group-C1 - _TC" + "stock_uom": "_Test UOM" }, { "description": "_Test Item With Item Tax Template", diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js index 488920aadbc..5e1f7d53221 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js @@ -7,9 +7,8 @@ frappe.ui.form.on('Item Variant Settings', { const existing_fields = frm.doc.fields.map(row => row.field_name); const exclude_fields = [...existing_fields, "naming_series", "item_code", "item_name", - "show_in_website", "show_variant_in_website", "standard_rate", "opening_stock", "image", - "variant_of", "valuation_rate", "barcodes", "website_image", "thumbnail", - "website_specifiations", "web_long_description", "has_variants", "attributes"]; + "published_in_website", "standard_rate", "opening_stock", "image", + "variant_of", "valuation_rate", "barcodes", "has_variants", "attributes"]; const exclude_field_types = ['HTML', 'Section Break', 'Column Break', 'Button', 'Read Only']; diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py index f63498b9ac6..be1517eb587 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py @@ -1,4 +1,4 @@ -# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt @@ -13,10 +13,9 @@ class ItemVariantSettings(Document): def set_default_fields(self): self.fields = [] fields = frappe.get_meta('Item').fields - exclude_fields = {"naming_series", "item_code", "item_name", "show_in_website", - "show_variant_in_website", "standard_rate", "opening_stock", "image", "description", + exclude_fields = {"naming_series", "item_code", "item_name", "published_in_website", + "standard_rate", "opening_stock", "image", "description", "variant_of", "valuation_rate", "description", "barcodes", - "website_image", "thumbnail", "website_specifiations", "web_long_description", "has_variants", "attributes"} for d in fields: diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 9204842b8f6..df8cadd7f86 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -4,10 +4,11 @@ import frappe -from frappe.utils import flt +from frappe.utils import add_to_date, flt, now from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.accounts.utils import update_gl_entries_after from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( get_gl_entries, @@ -28,7 +29,8 @@ class TestLandedCostVoucher(ERPNextTestCase): "voucher_type": pr.doctype, "voucher_no": pr.name, "item_code": "_Test Item", - "warehouse": "Stores - TCP1" + "warehouse": "Stores - TCP1", + "is_cancelled": 0, }, fieldname=["qty_after_transaction", "stock_value"], as_dict=1) @@ -41,14 +43,39 @@ class TestLandedCostVoucher(ERPNextTestCase): "voucher_type": pr.doctype, "voucher_no": pr.name, "item_code": "_Test Item", - "warehouse": "Stores - TCP1" + "warehouse": "Stores - TCP1", + "is_cancelled": 0, }, fieldname=["qty_after_transaction", "stock_value"], as_dict=1) self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction) - self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 25.0) + # assert after submit + self.assertPurchaseReceiptLCVGLEntries(pr) + + # Mess up cancelled SLE modified timestamp to check + # if they aren't effective in any business logic. + frappe.db.set_value("Stock Ledger Entry", + { + "is_cancelled": 1, + "voucher_type": pr.doctype, + "voucher_no": pr.name + }, + "is_cancelled", 1, + modified=add_to_date(now(), hours=1, as_datetime=True, as_string=True) + ) + + items, warehouses = pr.get_items_and_warehouses() + update_gl_entries_after(pr.posting_date, pr.posting_time, + warehouses, items, company=pr.company) + + # reassert after reposting + self.assertPurchaseReceiptLCVGLEntries(pr) + + + def assertPurchaseReceiptLCVGLEntries(self, pr): + gl_entries = get_gl_entries("Purchase Receipt", pr.name) self.assertTrue(gl_entries) @@ -74,8 +101,8 @@ class TestLandedCostVoucher(ERPNextTestCase): for gle in gl_entries: if not gle.get('is_cancelled'): - self.assertEqual(expected_values[gle.account][0], gle.debit) - self.assertEqual(expected_values[gle.account][1], gle.credit) + self.assertEqual(expected_values[gle.account][0], gle.debit, msg=f"incorrect debit for {gle.account}") + self.assertEqual(expected_values[gle.account][1], gle.credit, msg=f"incorrect credit for {gle.account}") def test_landed_cost_voucher_against_purchase_invoice(self): diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 103e8d6a88c..51209acb275 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -56,14 +56,13 @@ class MaterialRequest(BuyingController): if actual_so_qty and (flt(so_items[so_no][item]) + already_indented > actual_so_qty): frappe.throw(_("Material Request of maximum {0} can be made for Item {1} against Sales Order {2}").format(actual_so_qty - already_indented, item, so_no)) - # Validate - # --------------------- def validate(self): super(MaterialRequest, self).validate() self.validate_schedule_date() self.check_for_on_hold_or_closed_status('Sales Order', 'sales_order') self.validate_uom_is_integer("uom", "qty") + self.validate_material_request_type() if not self.status: self.status = "Draft" @@ -83,6 +82,12 @@ class MaterialRequest(BuyingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") + def validate_material_request_type(self): + """ Validate fields in accordance with selected type """ + + if self.material_request_type != "Customer Provided": + self.customer = None + def set_title(self): '''Set title as comma separated list of items''' if not self.title: @@ -533,6 +538,7 @@ def raise_work_orders(material_request): "stock_uom": d.stock_uom, "expected_delivery_date": d.schedule_date, "sales_order": d.sales_order, + "sales_order_item": d.get("sales_order_item"), "bom_no": get_item_details(d.item_code).bom_no, "material_request": mr.name, "material_request_item": d.name, diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index 830d5469bf0..d2d47897658 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -218,8 +218,6 @@ "label": "Conversion Factor" }, { - "fetch_from": "item_code.valuation_rate", - "fetch_if_empty": 1, "fieldname": "rate", "fieldtype": "Currency", "in_list_view": 1, @@ -232,7 +230,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-09-01 15:10:29.646399", + "modified": "2022-01-28 16:03:30.780111", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", @@ -240,5 +238,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index e4091c40dc4..07c2f1f0dd3 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -8,187 +8,253 @@ import json import frappe from frappe.model.document import Document -from frappe.utils import cstr, flt +from frappe.utils import flt -from erpnext.stock.get_item_details import get_item_details +from erpnext.stock.get_item_details import get_item_details, get_price_list_rate class PackedItem(Document): pass -def get_product_bundle_items(item_code): - return frappe.db.sql("""select t1.item_code, t1.qty, t1.uom, t1.description - from `tabProduct Bundle Item` t1, `tabProduct Bundle` t2 - where t2.new_item_code=%s and t1.parent = t2.name order by t1.idx""", item_code, as_dict=1) - -def get_packing_item_details(item, company): - return frappe.db.sql(""" - select i.item_name, i.is_stock_item, i.description, i.stock_uom, id.default_warehouse - from `tabItem` i LEFT JOIN `tabItem Default` id ON id.parent=i.name and id.company=%s - where i.name = %s""", - (company, item), as_dict = 1)[0] - -def get_bin_qty(item, warehouse): - det = frappe.db.sql("""select actual_qty, projected_qty from `tabBin` - where item_code = %s and warehouse = %s""", (item, warehouse), as_dict = 1) - return det and det[0] or frappe._dict() - -def update_packing_list_item(doc, packing_item_code, qty, main_item_row, description): - if doc.amended_from: - old_packed_items_map = get_old_packed_item_details(doc.packed_items) - else: - old_packed_items_map = False - item = get_packing_item_details(packing_item_code, doc.company) - - # check if exists - exists = 0 - for d in doc.get("packed_items"): - if d.parent_item == main_item_row.item_code and d.item_code == packing_item_code: - if d.parent_detail_docname != main_item_row.name: - d.parent_detail_docname = main_item_row.name - - pi, exists = d, 1 - break - - if not exists: - pi = doc.append('packed_items', {}) - - pi.parent_item = main_item_row.item_code - pi.item_code = packing_item_code - pi.item_name = item.item_name - pi.parent_detail_docname = main_item_row.name - pi.uom = item.stock_uom - pi.qty = flt(qty) - pi.conversion_factor = main_item_row.conversion_factor - if description and not pi.description: - pi.description = description - if not pi.warehouse and not doc.amended_from: - pi.warehouse = (main_item_row.warehouse if ((doc.get('is_pos') or item.is_stock_item \ - or not item.default_warehouse) and main_item_row.warehouse) else item.default_warehouse) - if not pi.batch_no and not doc.amended_from: - pi.batch_no = cstr(main_item_row.get("batch_no")) - if not pi.target_warehouse: - pi.target_warehouse = main_item_row.get("target_warehouse") - bin = get_bin_qty(packing_item_code, pi.warehouse) - pi.actual_qty = flt(bin.get("actual_qty")) - pi.projected_qty = flt(bin.get("projected_qty")) - if old_packed_items_map and old_packed_items_map.get((packing_item_code, main_item_row.item_code)): - pi.batch_no = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].batch_no - pi.serial_no = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].serial_no - pi.warehouse = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].warehouse def make_packing_list(doc): - """make packing list for Product Bundle item""" - if doc.get("_action") and doc._action == "update_after_submit": return - - parent_items = [] - for d in doc.get("items"): - if frappe.db.get_value("Product Bundle", {"new_item_code": d.item_code}): - for i in get_product_bundle_items(d.item_code): - update_packing_list_item(doc, i.item_code, flt(i.qty)*flt(d.stock_qty), d, i.description) - - if [d.item_code, d.name] not in parent_items: - parent_items.append([d.item_code, d.name]) - - cleanup_packing_list(doc, parent_items) - - if frappe.db.get_single_value("Selling Settings", "editable_bundle_item_rates"): - update_product_bundle_price(doc, parent_items) - -def cleanup_packing_list(doc, parent_items): - """Remove all those child items which are no longer present in main item table""" - delete_list = [] - for d in doc.get("packed_items"): - if [d.parent_item, d.parent_detail_docname] not in parent_items: - # mark for deletion from doclist - delete_list.append(d) - - if not delete_list: - return doc - - packed_items = doc.get("packed_items") - doc.set("packed_items", []) - - for d in packed_items: - if d not in delete_list: - add_item_to_packing_list(doc, d) - -def add_item_to_packing_list(doc, packed_item): - doc.append("packed_items", { - 'parent_item': packed_item.parent_item, - 'item_code': packed_item.item_code, - 'item_name': packed_item.item_name, - 'uom': packed_item.uom, - 'qty': packed_item.qty, - 'rate': packed_item.rate, - 'conversion_factor': packed_item.conversion_factor, - 'description': packed_item.description, - 'warehouse': packed_item.warehouse, - 'batch_no': packed_item.batch_no, - 'actual_batch_qty': packed_item.actual_batch_qty, - 'serial_no': packed_item.serial_no, - 'target_warehouse': packed_item.target_warehouse, - 'actual_qty': packed_item.actual_qty, - 'projected_qty': packed_item.projected_qty, - 'incoming_rate': packed_item.incoming_rate, - 'prevdoc_doctype': packed_item.prevdoc_doctype, - 'parent_detail_docname': packed_item.parent_detail_docname - }) - -def update_product_bundle_price(doc, parent_items): - """Updates the prices of Product Bundles based on the rates of the Items in the bundle.""" - - if not doc.get('items'): + "Make/Update packing list for Product Bundle Item." + if doc.get("_action") and doc._action == "update_after_submit": return - parent_items_index = 0 - bundle_price = 0 + parent_items_price, reset = {}, False + set_price_from_children = frappe.db.get_single_value("Selling Settings", "editable_bundle_item_rates") - for bundle_item in doc.get("packed_items"): - if parent_items[parent_items_index][0] == bundle_item.parent_item: - bundle_item_rate = bundle_item.rate if bundle_item.rate else 0 - bundle_price += bundle_item.qty * bundle_item_rate - else: - update_parent_item_price(doc, parent_items[parent_items_index][0], bundle_price) + stale_packed_items_table = get_indexed_packed_items_table(doc) - bundle_item_rate = bundle_item.rate if bundle_item.rate else 0 - bundle_price = bundle_item.qty * bundle_item_rate - parent_items_index += 1 + reset = reset_packing_list(doc) - # for the last product bundle - if doc.get("packed_items"): - update_parent_item_price(doc, parent_items[parent_items_index][0], bundle_price) + for item_row in doc.get("items"): + if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}): + for bundle_item in get_product_bundle_items(item_row.item_code): + pi_row = add_packed_item_row( + doc=doc, packing_item=bundle_item, + main_item_row=item_row, packed_items_table=stale_packed_items_table, + reset=reset + ) + item_data = get_packed_item_details(bundle_item.item_code, doc.company) + update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data) + update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc) + update_packed_item_price_data(pi_row, item_data, doc) + update_packed_item_from_cancelled_doc(item_row, bundle_item, pi_row, doc) -def update_parent_item_price(doc, parent_item_code, bundle_price): - parent_item_doc = doc.get('items', {'item_code': parent_item_code})[0] + if set_price_from_children: # create/update bundle item wise price dict + update_product_bundle_rate(parent_items_price, pi_row) - current_parent_item_price = parent_item_doc.amount - if current_parent_item_price != bundle_price: - parent_item_doc.amount = bundle_price - update_parent_item_rate(parent_item_doc, bundle_price) + if parent_items_price: + set_product_bundle_rate_amount(doc, parent_items_price) # set price in bundle item -def update_parent_item_rate(parent_item_doc, bundle_price): - parent_item_doc.rate = bundle_price/parent_item_doc.qty +def get_indexed_packed_items_table(doc): + """ + Create dict from stale packed items table like: + {(Parent Item 1, Bundle Item 1, ae4b5678): {...}, (key): {value}} -@frappe.whitelist() -def get_items_from_product_bundle(args): - args = json.loads(args) - items = [] - bundled_items = get_product_bundle_items(args["item_code"]) - for item in bundled_items: - args.update({ - "item_code": item.item_code, - "qty": flt(args["quantity"]) * flt(item.qty) - }) - items.append(get_item_details(args)) + Use: to quickly retrieve/check if row existed in table instead of looping n times + """ + indexed_table = {} + for packed_item in doc.get("packed_items"): + key = (packed_item.parent_item, packed_item.item_code, packed_item.parent_detail_docname) + indexed_table[key] = packed_item - return items + return indexed_table + +def reset_packing_list(doc): + "Conditionally reset the table and return if it was reset or not." + reset_table = False + doc_before_save = doc.get_doc_before_save() + + if doc_before_save: + # reset table if: + # 1. items were deleted + # 2. if bundle item replaced by another item (same no. of items but different items) + # we maintain list to track recurring item rows as well + items_before_save = [item.item_code for item in doc_before_save.get("items")] + items_after_save = [item.item_code for item in doc.get("items")] + reset_table = items_before_save != items_after_save + else: + # reset: if via Update Items OR + # if new mapped doc with packed items set (SO -> DN) + # (cannot determine action) + reset_table = True + + if reset_table: + doc.set("packed_items", []) + return reset_table + +def get_product_bundle_items(item_code): + product_bundle = frappe.qb.DocType("Product Bundle") + product_bundle_item = frappe.qb.DocType("Product Bundle Item") + + query = ( + frappe.qb.from_(product_bundle_item) + .join(product_bundle).on(product_bundle_item.parent == product_bundle.name) + .select( + product_bundle_item.item_code, + product_bundle_item.qty, + product_bundle_item.uom, + product_bundle_item.description + ).where( + product_bundle.new_item_code == item_code + ).orderby( + product_bundle_item.idx + ) + ) + return query.run(as_dict=True) + +def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset): + """Add and return packed item row. + doc: Transaction document + packing_item (dict): Packed Item details + main_item_row (dict): Items table row corresponding to packed item + packed_items_table (dict): Packed Items table before save (indexed) + reset (bool): State if table is reset or preserved as is + """ + exists, pi_row = False, {} + + # check if row already exists in packed items table + key = (main_item_row.item_code, packing_item.item_code, main_item_row.name) + if packed_items_table.get(key): + pi_row, exists = packed_items_table.get(key), True + + if not exists: + pi_row = doc.append('packed_items', {}) + elif reset: # add row if row exists but table is reset + pi_row.idx, pi_row.name = None, None + pi_row = doc.append('packed_items', pi_row) + + return pi_row + +def get_packed_item_details(item_code, company): + item = frappe.qb.DocType("Item") + item_default = frappe.qb.DocType("Item Default") + query = ( + frappe.qb.from_(item) + .left_join(item_default) + .on( + (item_default.parent == item.name) + & (item_default.company == company) + ).select( + item.item_name, item.is_stock_item, + item.description, item.stock_uom, + item.valuation_rate, + item_default.default_warehouse + ).where( + item.name == item_code + ) + ) + return query.run(as_dict=True)[0] + +def update_packed_item_basic_data(main_item_row, pi_row, packing_item, item_data): + pi_row.parent_item = main_item_row.item_code + pi_row.parent_detail_docname = main_item_row.name + pi_row.item_code = packing_item.item_code + pi_row.item_name = item_data.item_name + pi_row.uom = item_data.stock_uom + pi_row.qty = flt(packing_item.qty) * flt(main_item_row.stock_qty) + pi_row.conversion_factor = main_item_row.conversion_factor + + if not pi_row.description: + pi_row.description = packing_item.get("description") + +def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data, doc): + # TODO batch_no, actual_batch_qty, incoming_rate + if not pi_row.warehouse and not doc.amended_from: + fetch_warehouse = (doc.get('is_pos') or item_data.is_stock_item or not item_data.default_warehouse) + pi_row.warehouse = (main_item_row.warehouse if (fetch_warehouse and main_item_row.warehouse) + else item_data.default_warehouse) + + if not pi_row.target_warehouse: + pi_row.target_warehouse = main_item_row.get("target_warehouse") + + bin = get_packed_item_bin_qty(packing_item.item_code, pi_row.warehouse) + pi_row.actual_qty = flt(bin.get("actual_qty")) + pi_row.projected_qty = flt(bin.get("projected_qty")) + +def update_packed_item_price_data(pi_row, item_data, doc): + "Set price as per price list or from the Item master." + if pi_row.rate: + return + + item_doc = frappe.get_cached_doc("Item", pi_row.item_code) + row_data = pi_row.as_dict().copy() + row_data.update({ + "company": doc.get("company"), + "price_list": doc.get("selling_price_list"), + "currency": doc.get("currency") + }) + rate = get_price_list_rate(row_data, item_doc).get("price_list_rate") + + pi_row.rate = rate or item_data.get("valuation_rate") or 0.0 + +def update_packed_item_from_cancelled_doc(main_item_row, packing_item, pi_row, doc): + "Update packed item row details from cancelled doc into amended doc." + prev_doc_packed_items_map = None + if doc.amended_from: + prev_doc_packed_items_map = get_cancelled_doc_packed_item_details(doc.packed_items) + + if prev_doc_packed_items_map and prev_doc_packed_items_map.get((packing_item.item_code, main_item_row.item_code)): + prev_doc_row = prev_doc_packed_items_map.get((packing_item.item_code, main_item_row.item_code)) + pi_row.batch_no = prev_doc_row[0].batch_no + pi_row.serial_no = prev_doc_row[0].serial_no + pi_row.warehouse = prev_doc_row[0].warehouse + +def get_packed_item_bin_qty(item, warehouse): + bin_data = frappe.db.get_values( + "Bin", + fieldname=["actual_qty", "projected_qty"], + filters={"item_code": item, "warehouse": warehouse}, + as_dict=True + ) + + return bin_data[0] if bin_data else {} + +def get_cancelled_doc_packed_item_details(old_packed_items): + prev_doc_packed_items_map = {} + for items in old_packed_items: + prev_doc_packed_items_map.setdefault((items.item_code ,items.parent_item), []).append(items.as_dict()) + return prev_doc_packed_items_map + +def update_product_bundle_rate(parent_items_price, pi_row): + """ + Update the price dict of Product Bundles based on the rates of the Items in the bundle. + + Stucture: + {(Bundle Item 1, ae56fgji): 150.0, (Bundle Item 2, bc78fkjo): 200.0} + """ + key = (pi_row.parent_item, pi_row.parent_detail_docname) + rate = parent_items_price.get(key) + if not rate: + parent_items_price[key] = 0.0 + + parent_items_price[key] += flt(pi_row.rate) + +def set_product_bundle_rate_amount(doc, parent_items_price): + "Set cumulative rate and amount in bundle item." + for item in doc.get("items"): + bundle_rate = parent_items_price.get((item.item_code, item.name)) + if bundle_rate and bundle_rate != item.rate: + item.rate = bundle_rate + item.amount = flt(bundle_rate * item.qty) def on_doctype_update(): frappe.db.add_index("Packed Item", ["item_code", "warehouse"]) -def get_old_packed_item_details(old_packed_items): - old_packed_items_map = {} - for items in old_packed_items: - old_packed_items_map.setdefault((items.item_code ,items.parent_item), []).append(items.as_dict()) - return old_packed_items_map + +@frappe.whitelist() +def get_items_from_product_bundle(row): + row, items = json.loads(row), [] + + bundled_items = get_product_bundle_items(row["item_code"]) + for item in bundled_items: + row.update({ + "item_code": item.item_code, + "qty": flt(row["quantity"]) * flt(item.qty) + }) + items.append(get_item_details(row)) + + return items diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py new file mode 100644 index 00000000000..2521ac9fe72 --- /dev/null +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -0,0 +1,158 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from frappe.utils import add_to_date, nowdate + +from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle +from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestCase, change_settings + + +class TestPackedItem(ERPNextTestCase): + "Test impact on Packed Items table in various scenarios." + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.bundle = "_Test Product Bundle X" + cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"] + make_item(cls.bundle, {"is_stock_item": 0}) + for item in cls.bundle_items: + make_item(item, {"is_stock_item": 1}) + + make_item("_Test Normal Stock Item", {"is_stock_item": 1}) + + make_product_bundle(cls.bundle, cls.bundle_items, qty=2) + + def test_adding_bundle_item(self): + "Test impact on packed items if bundle item row is added." + so = make_sales_order(item_code = self.bundle, qty=1, + do_not_submit=True) + + self.assertEqual(so.items[0].qty, 1) + self.assertEqual(len(so.packed_items), 2) + self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0]) + self.assertEqual(so.packed_items[0].qty, 2) + + def test_updating_bundle_item(self): + "Test impact on packed items if bundle item row is updated." + so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True) + + so.items[0].qty = 2 # change qty + so.save() + + self.assertEqual(so.packed_items[0].qty, 4) + self.assertEqual(so.packed_items[1].qty, 4) + + # change item code to non bundle item + so.items[0].item_code = "_Test Normal Stock Item" + so.save() + + self.assertEqual(len(so.packed_items), 0) + + def test_recurring_bundle_item(self): + "Test impact on packed items if same bundle item is added and removed." + so_items = [] + for qty in [2, 4, 6, 8]: + so_items.append({ + "item_code": self.bundle, + "qty": qty, + "rate": 400, + "warehouse": "_Test Warehouse - _TC" + }) + + # create SO with recurring bundle item + so = make_sales_order(item_list=so_items, do_not_submit=True) + + # check alternate rows for qty + self.assertEqual(len(so.packed_items), 8) + self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1]) + self.assertEqual(so.packed_items[1].qty, 4) + self.assertEqual(so.packed_items[3].qty, 8) + self.assertEqual(so.packed_items[5].qty, 12) + self.assertEqual(so.packed_items[7].qty, 16) + + # delete intermediate row (2nd) + del so.items[1] + so.save() + + # check alternate rows for qty + self.assertEqual(len(so.packed_items), 6) + self.assertEqual(so.packed_items[1].qty, 4) + self.assertEqual(so.packed_items[3].qty, 12) + self.assertEqual(so.packed_items[5].qty, 16) + + # delete last row + del so.items[2] + so.save() + + # check alternate rows for qty + self.assertEqual(len(so.packed_items), 4) + self.assertEqual(so.packed_items[1].qty, 4) + self.assertEqual(so.packed_items[3].qty, 12) + + @change_settings("Selling Settings", {"editable_bundle_item_rates": 1}) + def test_bundle_item_cumulative_price(self): + "Test if Bundle Item rate is cumulative from packed items." + so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True) + + so.packed_items[0].rate = 150 + so.packed_items[1].rate = 200 + so.save() + + self.assertEqual(so.items[0].rate, 350) + self.assertEqual(so.items[0].amount, 700) + + def test_newly_mapped_doc_packed_items(self): + "Test impact on packed items in newly mapped DN from SO." + so_items = [] + for qty in [2, 4]: + so_items.append({ + "item_code": self.bundle, + "qty": qty, + "rate": 400, + "warehouse": "_Test Warehouse - _TC" + }) + + # create SO with recurring bundle item + so = make_sales_order(item_list=so_items) + + dn = make_delivery_note(so.name) + dn.items[1].qty = 3 # change second row qty for inserting doc + dn.save() + + self.assertEqual(len(dn.packed_items), 4) + self.assertEqual(dn.packed_items[2].qty, 6) + self.assertEqual(dn.packed_items[3].qty, 6) + + def test_reposting_packed_items(self): + warehouse = "Stores - TCP1" + company = "_Test Company with perpetual inventory" + + today = nowdate() + yesterday = add_to_date(today, days=-1, as_string=True) + + for item in self.bundle_items: + make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today) + + so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse) + + dn = make_delivery_note(so.name) + dn.save() + dn.submit() + + gles = get_gl_entries(dn.doctype, dn.name) + credit_before_repost = sum(gle.credit for gle in gles) + + # backdated stock entry + for item in self.bundle_items: + make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday) + + # assert correct reposting + gles = get_gl_entries(dn.doctype, dn.name) + credit_after_reposting = sum(gle.credit for gle in gles) + self.assertNotEqual(credit_before_repost, credit_after_reposting) + self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost) diff --git a/erpnext/stock/doctype/price_list/price_list.py b/erpnext/stock/doctype/price_list/price_list.py index 74b823ada3e..8a3172e9e22 100644 --- a/erpnext/stock/doctype/price_list/price_list.py +++ b/erpnext/stock/doctype/price_list/price_list.py @@ -36,14 +36,14 @@ class PriceList(Document): (self.currency, cint(self.buying), cint(self.selling), self.name)) def check_impact_on_shopping_cart(self): - "Check if Price List currency change impacts Shopping Cart." - from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import ( + "Check if Price List currency change impacts E Commerce Cart." + from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( validate_cart_settings, ) doc_before_save = self.get_doc_before_save() currency_changed = self.currency != doc_before_save.currency - affects_cart = self.name == frappe.get_cached_value("Shopping Cart Settings", None, "price_list") + affects_cart = self.name == frappe.get_cached_value("E Commerce Settings", None, "price_list") if currency_changed and affects_cart: validate_cart_settings() diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index 112ddedac29..b54a90eed35 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -106,6 +106,8 @@ "terms", "bill_no", "bill_date", + "accounting_details_section", + "provisional_expense_account", "more_info", "project", "status", @@ -1144,16 +1146,30 @@ "label": "Represents Company", "options": "Company", "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_details_section", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fieldname": "provisional_expense_account", + "fieldtype": "Link", + "hidden": 1, + "label": "Provisional Expense Account", + "options": "Account" } ], "icon": "fa fa-truck", "idx": 261, "is_submittable": 1, "links": [], - "modified": "2021-09-28 13:11:10.181328", + "modified": "2022-02-01 11:40:52.690984", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -1214,6 +1230,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "supplier", "title_field": "title", "track_changes": 1 diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index c97b306c4e6..33e40c85f1a 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -8,6 +8,7 @@ from frappe.desk.notifications import clear_doctype_notifications from frappe.model.mapper import get_mapped_doc from frappe.utils import cint, flt, getdate, nowdate +import erpnext from erpnext.accounts.utils import get_account_currency from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account @@ -112,6 +113,7 @@ class PurchaseReceipt(BuyingController): self.validate_uom_is_integer("uom", ["qty", "received_qty"]) self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_cwip_accounts() + self.validate_provisional_expense_account() self.check_on_hold_or_closed_status() @@ -133,6 +135,15 @@ class PurchaseReceipt(BuyingController): company = self.company) break + def validate_provisional_expense_account(self): + provisional_accounting_for_non_stock_items = \ + cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items')) + + if provisional_accounting_for_non_stock_items: + default_provisional_account = self.get_company_default("default_provisional_account") + if not self.provisional_expense_account: + self.provisional_expense_account = default_provisional_account + def validate_with_previous_doc(self): super(PurchaseReceipt, self).validate_with_previous_doc({ "Purchase Order": { @@ -258,13 +269,15 @@ class PurchaseReceipt(BuyingController): get_purchase_document_details, ) - stock_rbnb = self.get_company_default("stock_received_but_not_billed") - landed_cost_entries = get_item_account_wise_additional_cost(self.name) - expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") - auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items')) + if erpnext.is_perpetual_inventory_enabled(self.company): + stock_rbnb = self.get_company_default("stock_received_but_not_billed") + landed_cost_entries = get_item_account_wise_additional_cost(self.name) + expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") warehouse_with_no_account = [] stock_items = self.get_stock_items() + provisional_accounting_for_non_stock_items = \ + cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items')) exchange_rate_map, net_rate_map = get_purchase_document_details(self) @@ -273,10 +286,7 @@ class PurchaseReceipt(BuyingController): if warehouse_account.get(d.warehouse): stock_value_diff = frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Purchase Receipt", "voucher_no": self.name, - "voucher_detail_no": d.name, "warehouse": d.warehouse}, "stock_value_difference") - - if not stock_value_diff: - continue + "voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference") warehouse_account_name = warehouse_account[d.warehouse]["account"] warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"] @@ -422,43 +432,58 @@ class PurchaseReceipt(BuyingController): elif d.warehouse not in warehouse_with_no_account or \ d.rejected_warehouse not in warehouse_with_no_account: warehouse_with_no_account.append(d.warehouse) - elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and auto_accounting_for_non_stock_items: - service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed") - credit_currency = get_account_currency(service_received_but_not_billed_account) - debit_currency = get_account_currency(d.expense_account) - remarks = self.get("remarks") or _("Accounting Entry for Service") - - self.add_gl_entry( - gl_entries=gl_entries, - account=service_received_but_not_billed_account, - cost_center=d.cost_center, - debit=0.0, - credit=d.amount, - remarks=remarks, - against_account=d.expense_account, - account_currency=credit_currency, - project=d.project, - voucher_detail_no=d.name, item=d) - - self.add_gl_entry( - gl_entries=gl_entries, - account=d.expense_account, - cost_center=d.cost_center, - debit=d.amount, - credit=0.0, - remarks=remarks, - against_account=service_received_but_not_billed_account, - account_currency = debit_currency, - project=d.project, - voucher_detail_no=d.name, - item=d) + elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and provisional_accounting_for_non_stock_items: + self.add_provisional_gl_entry(d, gl_entries, self.posting_date) if warehouse_with_no_account: frappe.msgprint(_("No accounting entries for the following warehouses") + ": \n" + "\n".join(warehouse_with_no_account)) + def add_provisional_gl_entry(self, item, gl_entries, posting_date, reverse=0): + provisional_expense_account = self.get('provisional_expense_account') + credit_currency = get_account_currency(provisional_expense_account) + debit_currency = get_account_currency(item.expense_account) + expense_account = item.expense_account + remarks = self.get("remarks") or _("Accounting Entry for Service") + multiplication_factor = 1 + + if reverse: + multiplication_factor = -1 + expense_account = frappe.db.get_value('Purchase Receipt Item', {'name': item.get('pr_detail')}, ['expense_account']) + + self.add_gl_entry( + gl_entries=gl_entries, + account=provisional_expense_account, + cost_center=item.cost_center, + debit=0.0, + credit=multiplication_factor * item.amount, + remarks=remarks, + against_account=expense_account, + account_currency=credit_currency, + project=item.project, + voucher_detail_no=item.name, + item=item, + posting_date=posting_date) + + self.add_gl_entry( + gl_entries=gl_entries, + account=expense_account, + cost_center=item.cost_center, + debit=multiplication_factor * item.amount, + credit=0.0, + remarks=remarks, + against_account=provisional_expense_account, + account_currency = debit_currency, + project=item.project, + voucher_detail_no=item.name, + item=item, + posting_date=posting_date) + def make_tax_gl_entries(self, gl_entries): - expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") + + if erpnext.is_perpetual_inventory_enabled(self.company): + expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") + negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get('items')]) # Cost center-wise amount breakup for other charges included for valuation valuation_tax = {} @@ -515,7 +540,8 @@ class PurchaseReceipt(BuyingController): def add_gl_entry(self, gl_entries, account, cost_center, debit, credit, remarks, against_account, debit_in_account_currency=None, credit_in_account_currency=None, account_currency=None, - project=None, voucher_detail_no=None, item=None): + project=None, voucher_detail_no=None, item=None, posting_date=None): + gl_entry = { "account": account, "cost_center": cost_center, @@ -534,6 +560,9 @@ class PurchaseReceipt(BuyingController): if credit_in_account_currency: gl_entry.update({"credit_in_account_currency": credit_in_account_currency}) + if posting_date: + gl_entry.update({"posting_date": posting_date}) + gl_entries.append(self.get_gl_dict(gl_entry, item=item)) def get_asset_gl_entry(self, gl_entries): @@ -562,6 +591,7 @@ class PurchaseReceipt(BuyingController): # debit cwip account debit_in_account_currency = (base_asset_amount if cwip_account_currency == self.company_currency else asset_amount) + self.add_gl_entry( gl_entries=gl_entries, account=cwip_account, @@ -577,6 +607,7 @@ class PurchaseReceipt(BuyingController): # credit arbnb account credit_in_account_currency = (base_asset_amount if asset_rbnb_currency == self.company_currency else asset_amount) + self.add_gl_entry( gl_entries=gl_entries, account=arbnb_account, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js index 77711de93f7..4029f0c127b 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js @@ -13,5 +13,13 @@ frappe.listview_settings['Purchase Receipt'] = { } else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) { return [__("Completed"), "green", "per_billed,=,100"]; } + }, + + onload: function(listview) { + + listview.page.add_action_item(__("Purchase Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(listview, "Purchase Receipt", "Purchase Invoice"); + }); } + }; diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 2909a2d2e74..d481689c130 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4,6 +4,7 @@ import json import unittest +from collections import defaultdict import frappe from frappe.utils import add_days, cint, cstr, flt, today @@ -16,7 +17,7 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchas from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction -from erpnext.tests.utils import ERPNextTestCase +from erpnext.tests.utils import ERPNextTestCase, change_settings class TestPurchaseReceipt(ERPNextTestCase): @@ -1312,58 +1313,6 @@ class TestPurchaseReceipt(ERPNextTestCase): self.assertEqual(pr.status, "To Bill") self.assertAlmostEqual(pr.per_billed, 50.0, places=2) - def test_service_item_purchase_with_perpetual_inventory(self): - company = '_Test Company with perpetual inventory' - service_item = '_Test Non Stock Item' - - before_test_value = frappe.db.get_value( - 'Company', company, 'enable_perpetual_inventory_for_non_stock_items' - ) - frappe.db.set_value( - 'Company', company, - 'enable_perpetual_inventory_for_non_stock_items', 1 - ) - srbnb_account = 'Stock Received But Not Billed - TCP1' - frappe.db.set_value( - 'Company', company, - 'service_received_but_not_billed', srbnb_account - ) - - pr = make_purchase_receipt( - company=company, item=service_item, - warehouse='Finished Goods - TCP1', do_not_save=1 - ) - item_row_with_diff_rate = frappe.copy_doc(pr.items[0]) - item_row_with_diff_rate.rate = 100 - pr.append('items', item_row_with_diff_rate) - - pr.save() - pr.submit() - - item_one_gl_entry = frappe.db.get_all("GL Entry", { - 'voucher_type': pr.doctype, - 'voucher_no': pr.name, - 'account': srbnb_account, - 'voucher_detail_no': pr.items[0].name - }, pluck="name") - - item_two_gl_entry = frappe.db.get_all("GL Entry", { - 'voucher_type': pr.doctype, - 'voucher_no': pr.name, - 'account': srbnb_account, - 'voucher_detail_no': pr.items[1].name - }, pluck="name") - - # check if the entries are not merged into one - # seperate entries should be made since voucher_detail_no is different - self.assertEqual(len(item_one_gl_entry), 1) - self.assertEqual(len(item_two_gl_entry), 1) - - frappe.db.set_value( - 'Company', company, - 'enable_perpetual_inventory_for_non_stock_items', before_test_value - ) - def test_purchase_receipt_with_exchange_rate_difference(self): from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( make_purchase_receipt as create_purchase_receipt, @@ -1439,6 +1388,36 @@ class TestPurchaseReceipt(ERPNextTestCase): automatically_fetch_payment_terms(enable=0) + @change_settings("Stock Settings", {"allow_negative_stock": 1}) + def test_neg_to_positive(self): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = "_TestNegToPosItem" + warehouse = "Stores - TCP1" + company = "_Test Company with perpetual inventory" + account = "Stock Received But Not Billed - TCP1" + + make_item(item_code) + se = make_stock_entry(item_code=item_code, from_warehouse=warehouse, qty=50, do_not_save=True, rate=0) + se.items[0].allow_zero_valuation_rate = 1 + se.save() + se.submit() + + pr = make_purchase_receipt( + qty=50, + rate=1, + item_code=item_code, + warehouse=warehouse, + get_taxes_and_charges=True, + company=company, + ) + gles = get_gl_entries(pr.doctype, pr.name) + + for gle in gles: + if gle.account == account: + self.assertEqual(gle.credit, 50) + + def get_sl_entries(voucher_type, voucher_no): return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s @@ -1561,6 +1540,7 @@ def make_purchase_receipt(**args): "conversion_factor": args.conversion_factor or 1.0, "stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0), "serial_no": args.serial_no, + "batch_no": args.batch_no, "stock_uom": args.stock_uom or "_Test UOM", "uom": uom, "cost_center": args.cost_center or frappe.get_cached_value('Company', pr.company, 'cost_center'), diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 30ea1c3cadc..e5994b2dd48 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -976,7 +976,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-11-15 15:46:10.591600", + "modified": "2022-02-01 11:32:27.980524", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", @@ -985,5 +985,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py index 523ba120de8..4e472a92dc1 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py @@ -9,7 +9,7 @@ from collections import defaultdict import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, floor, flt, nowdate +from frappe.utils import cint, cstr, floor, flt, nowdate from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.utils import get_stock_balance @@ -142,11 +142,44 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None): if items_not_accomodated: show_unassigned_items_message(items_not_accomodated) - items[:] = updated_table if updated_table else items # modify items table + if updated_table and _items_changed(items, updated_table, doctype): + items[:] = updated_table + frappe.msgprint(_("Applied putaway rules."), alert=True) if sync and json.loads(sync): # sync with client side return items +def _items_changed(old, new, doctype: str) -> bool: + """ Check if any items changed by application of putaway rules. + + If not, changing item table can have side effects since `name` items also changes. + """ + if len(old) != len(new): + return True + + old = [frappe._dict(item) if isinstance(item, dict) else item for item in old] + + if doctype == "Stock Entry": + compare_keys = ("item_code", "t_warehouse", "transfer_qty", "serial_no") + sort_key = lambda item: (item.item_code, cstr(item.t_warehouse), # noqa + flt(item.transfer_qty), cstr(item.serial_no)) + else: + # purchase receipt / invoice + compare_keys = ("item_code", "warehouse", "stock_qty", "received_qty", "serial_no") + sort_key = lambda item: (item.item_code, cstr(item.warehouse), # noqa + flt(item.stock_qty), flt(item.received_qty), cstr(item.serial_no)) + + old_sorted = sorted(old, key=sort_key) + new_sorted = sorted(new, key=sort_key) + + # Once sorted by all relevant keys both tables should align if they are same. + for old_item, new_item in zip(old_sorted, new_sorted): + for key in compare_keys: + if old_item.get(key) != new_item.get(key): + return True + return False + + def get_ordered_putaway_rules(item_code, company, source_warehouse=None): """Returns an ordered list of putaway rules to apply on an item.""" filters = { diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py index bd4d811e76c..ff1c19a8275 100644 --- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py @@ -35,6 +35,18 @@ class TestPutawayRule(ERPNextTestCase): new_uom.uom_name = "Bag" new_uom.save() + def assertUnchangedItemsOnResave(self, doc): + """ Check if same items remain even after reapplication of rules. + + This is required since some business logic like subcontracting + depends on `name` of items to be same if item isn't changed. + """ + doc.reload() + old_items = {d.name for d in doc.items} + doc.save() + new_items = {d.name for d in doc.items} + self.assertSetEqual(old_items, new_items) + def test_putaway_rules_priority(self): """Test if rule is applied by priority, irrespective of free space.""" rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, @@ -50,6 +62,8 @@ class TestPutawayRule(ERPNextTestCase): self.assertEqual(pr.items[1].qty, 100) self.assertEqual(pr.items[1].warehouse, self.warehouse_2) + self.assertUnchangedItemsOnResave(pr) + pr.delete() rule_1.delete() rule_2.delete() @@ -162,6 +176,8 @@ class TestPutawayRule(ERPNextTestCase): # leftover space was for 500 kg (0.5 Bag) # Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned + self.assertUnchangedItemsOnResave(pr) + pr.delete() rule_1.delete() rule_2.delete() @@ -196,6 +212,8 @@ class TestPutawayRule(ERPNextTestCase): self.assertEqual(pr.items[1].warehouse, self.warehouse_1) self.assertEqual(pr.items[1].putaway_rule, rule_1.name) + self.assertUnchangedItemsOnResave(pr) + pr.delete() rule_1.delete() @@ -239,6 +257,8 @@ class TestPutawayRule(ERPNextTestCase): self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg self.assertEqual(stock_entry_item.putaway_rule, rule_2.name) + self.assertUnchangedItemsOnResave(stock_entry) + stock_entry.delete() rule_1.delete() rule_2.delete() @@ -294,6 +314,8 @@ class TestPutawayRule(ERPNextTestCase): self.assertEqual(stock_entry.items[2].qty, 200) self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name) + self.assertUnchangedItemsOnResave(stock_entry) + stock_entry.delete() rule_1.delete() rule_2.delete() @@ -344,6 +366,8 @@ class TestPutawayRule(ERPNextTestCase): self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:])) self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1") + self.assertUnchangedItemsOnResave(stock_entry) + stock_entry.delete() pr.cancel() rule_1.delete() @@ -366,6 +390,8 @@ class TestPutawayRule(ERPNextTestCase): self.assertEqual(stock_entry_item.qty, 100) self.assertEqual(stock_entry_item.putaway_rule, rule_1.name) + self.assertUnchangedItemsOnResave(stock_entry) + stock_entry.delete() rule_1.delete() rule_2.delete() diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 01c5e3e4e2a..977d470995e 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -13,7 +13,7 @@ from erpnext.accounts.utils import ( check_if_stock_and_account_balance_synced, update_gl_entries_after, ) -from erpnext.stock.stock_ledger import repost_future_sle +from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle class RepostItemValuation(Document): @@ -138,13 +138,20 @@ def repost_gl_entries(doc): if doc.based_on == 'Transaction': ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no) - items, warehouses = ref_doc.get_items_and_warehouses() + doc_items, doc_warehouses = ref_doc.get_items_and_warehouses() + + sles = get_items_to_be_repost(doc.voucher_type, doc.voucher_no) + sle_items = [sle.item_code for sle in sles] + sle_warehouse = [sle.warehouse for sle in sles] + + items = list(set(doc_items).union(set(sle_items))) + warehouses = list(set(doc_warehouses).union(set(sle_warehouse))) else: items = [doc.item_code] warehouses = [doc.warehouse] update_gl_entries_after(doc.posting_date, doc.posting_time, - warehouses, items, company=doc.company) + for_warehouses=warehouses, for_items=items, company=doc.company) def notify_error_to_stock_managers(doc, traceback): recipients = get_users_with_role("Stock Manager") diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index ee55af34753..ee08e38f33c 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -465,6 +465,13 @@ def get_serial_nos(serial_no): return [s.strip() for s in cstr(serial_no).strip().upper().replace(',', '\n').split('\n') if s.strip()] +def clean_serial_no_string(serial_no: str) -> str: + if not serial_no: + return "" + + serial_no_list = get_serial_nos(serial_no) + return "\n".join(serial_no_list) + def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False): for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]: if args.get(field): diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index c4b8131305e..324ca7ac599 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -425,6 +425,7 @@ frappe.ui.form.on('Stock Entry', { 'posting_time' : frm.doc.posting_time, 'warehouse' : cstr(item.s_warehouse) || cstr(item.t_warehouse), 'serial_no' : item.serial_no, + 'batch_no' : item.batch_no, 'company' : frm.doc.company, 'qty' : item.s_warehouse ? -1*flt(item.transfer_qty) : flt(item.transfer_qty), 'voucher_type' : frm.doc.doctype, @@ -457,6 +458,7 @@ frappe.ui.form.on('Stock Entry', { 'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse), 'transfer_qty': child.transfer_qty, 'serial_no': child.serial_no, + 'batch_no': child.batch_no, 'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty, 'posting_date': frm.doc.posting_date, 'posting_time': frm.doc.posting_time, @@ -627,6 +629,12 @@ frappe.ui.form.on('Stock Entry Detail', { frm.events.set_serial_no(frm, cdt, cdn, () => { frm.events.get_warehouse_details(frm, cdt, cdn); }); + + // set allow_zero_valuation_rate to 0 if s_warehouse is selected. + let item = frappe.get_doc(cdt, cdn); + if (item.s_warehouse) { + item.allow_zero_valuation_rate = 0; + } }, t_warehouse: function(frm, cdt, cdn) { @@ -680,6 +688,7 @@ frappe.ui.form.on('Stock Entry Detail', { 'warehouse' : cstr(d.s_warehouse) || cstr(d.t_warehouse), 'transfer_qty' : d.transfer_qty, 'serial_no' : d.serial_no, + 'batch_no' : d.batch_no, 'bom_no' : d.bom_no, 'expense_account' : d.expense_account, 'cost_center' : d.cost_center, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 2f377788961..c38dfaa1c84 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -8,7 +8,6 @@ "engine": "InnoDB", "field_order": [ "items_section", - "title", "naming_series", "stock_entry_type", "outgoing_stock_entry", @@ -83,14 +82,6 @@ "fieldtype": "Section Break", "oldfieldtype": "Section Break" }, - { - "fieldname": "title", - "fieldtype": "Data", - "hidden": 1, - "label": "Title", - "no_copy": 1, - "print_hide": 1 - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -353,9 +344,9 @@ }, { "fieldname": "scan_barcode", - "options": "Barcode", "fieldtype": "Data", - "label": "Scan Barcode" + "label": "Scan Barcode", + "options": "Barcode" }, { "allow_bulk_edit": 1, @@ -628,10 +619,11 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-08-20 19:19:31.514846", + "modified": "2022-02-07 12:55:14.614077", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -698,6 +690,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", - "title_field": "title", + "states": [], + "title_field": "stock_entry_type", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 60154afa15c..99cf4de5de7 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -76,7 +76,6 @@ class StockEntry(StockController): self.validate_posting_time() self.validate_purpose() - self.set_title() self.validate_item() self.validate_customer_provided_item() self.validate_qty() @@ -434,9 +433,10 @@ class StockEntry(StockController): ) def set_actual_qty(self): - allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock")) + from erpnext.stock.stock_ledger import is_negative_stock_allowed for d in self.get('items'): + allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code) previous_sle = get_previous_sle({ "item_code": d.item_code, "warehouse": d.s_warehouse or d.t_warehouse, @@ -510,7 +510,7 @@ class StockEntry(StockController): d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse, self.doctype, self.name, d.allow_zero_valuation_rate, currency=erpnext.get_company_currency(self.company), company=self.company, - raise_error_if_no_rate=raise_error_if_no_rate) + raise_error_if_no_rate=raise_error_if_no_rate, batch_no=d.batch_no) d.basic_rate = flt(d.basic_rate, d.precision("basic_rate")) if d.is_process_loss: @@ -541,6 +541,7 @@ class StockEntry(StockController): "posting_time": self.posting_time, "qty": item.s_warehouse and -1*flt(item.transfer_qty) or flt(item.transfer_qty), "serial_no": item.serial_no, + "batch_no": item.batch_no, "voucher_type": self.doctype, "voucher_no": self.name, "company": self.company, @@ -1116,7 +1117,7 @@ class StockEntry(StockController): self.set_actual_qty() self.update_items_for_process_loss() self.validate_customer_provided_item() - self.calculate_rate_and_amount() + self.calculate_rate_and_amount(raise_error_if_no_rate=False) def set_scrap_items(self): if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: @@ -1673,6 +1674,8 @@ class StockEntry(StockController): for d in self.get("items"): item_code = d.get('original_item') or d.get('item_code') reserve_warehouse = item_wh.get(item_code) + if not (reserve_warehouse and item_code): + continue stock_bin = get_bin(item_code, reserve_warehouse) stock_bin.update_reserved_qty_for_sub_contracting() @@ -1833,14 +1836,6 @@ class StockEntry(StockController): return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos))) - def set_title(self): - if frappe.flags.in_import and self.title: - # Allow updating title during data import/update - return - - self.title = self.purpose - - @frappe.whitelist() def move_sample_to_retention_warehouse(company, items): if isinstance(items, str): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 306f2c3e69f..c5afa491666 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -44,6 +44,7 @@ def get_sle(**args): class TestStockEntry(ERPNextTestCase): def tearDown(self): + frappe.db.rollback() frappe.set_user("Administrator") frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0") @@ -565,6 +566,7 @@ class TestStockEntry(ERPNextTestCase): st1.set_stock_entry_type() st1.insert() st1.submit() + st1.cancel() frappe.set_user("Administrator") remove_user_permission("Warehouse", "_Test Warehouse 1 - _TC", "test@example.com") @@ -689,6 +691,8 @@ class TestStockEntry(ERPNextTestCase): bom_no = frappe.db.get_value("BOM", {"item": "_Test Variant Item", "is_default": 1, "docstatus": 1}) + make_item_variant() # make variant of _Test Variant Item if absent + work_order = frappe.new_doc("Work Order") work_order.update({ "company": "_Test Company", @@ -1023,13 +1027,10 @@ class TestStockEntry(ERPNextTestCase): # Check if FG cost is calculated based on RM total cost # RM total cost = 200, FG rate = 200/4(FG qty) = 50 - self.assertEqual(se.items[1].basic_rate, 50) + self.assertEqual(se.items[1].basic_rate, flt(se.items[0].basic_rate/4)) self.assertEqual(se.value_difference, 0.0) self.assertEqual(se.total_incoming_value, se.total_outgoing_value) - # teardown - se.delete() - @change_settings("Stock Settings", {"allow_negative_stock": 0}) def test_future_negative_sle(self): # Initialize item, batch, warehouse, opening qty @@ -1107,6 +1108,52 @@ class TestStockEntry(ERPNextTestCase): posting_date='2021-09-02', # backdated consumption of 2nd batch purpose='Material Issue') + def test_multi_batch_value_diff(self): + """ Test value difference on stock entry in case of multi-batch. + | Stock entry | batch | qty | rate | value diff on SE | + | --- | --- | --- | --- | --- | + | receipt | A | 1 | 10 | 30 | + | receipt | B | 1 | 20 | | + | issue | A | -1 | 10 | -30 (to assert after submit) | + | issue | B | -1 | 20 | | + """ + from erpnext.stock.doctype.batch.test_batch import TestBatch + + batch_nos = [] + + item_code = '_TestMultibatchFifo' + TestBatch.make_batch_item(item_code) + warehouse = '_Test Warehouse - _TC' + receipt = make_stock_entry( + item_code=item_code, + qty=1, + rate=10, + to_warehouse=warehouse, + purpose='Material Receipt', + do_not_save=True + ) + receipt.append("items", frappe.copy_doc(receipt.items[0], ignore_no_copy=False).update({"basic_rate": 20}) ) + receipt.save() + receipt.submit() + batch_nos.extend(row.batch_no for row in receipt.items) + self.assertEqual(receipt.value_difference, 30) + + issue = make_stock_entry( + item_code=item_code, + qty=1, + from_warehouse=warehouse, + purpose='Material Issue', + do_not_save=True + ) + issue.append("items", frappe.copy_doc(issue.items[0], ignore_no_copy=False)) + for row, batch_no in zip(issue.items, batch_nos): + row.batch_no = batch_no + issue.save() + issue.submit() + + issue.reload() # reload because reposting current voucher updates rate + self.assertEqual(issue.value_difference, -30) + def make_serialized_item(**args): args = frappe._dict(args) se = frappe.copy_doc(test_records[0]) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index df65706c39d..83aed904ddd 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -1,7 +1,7 @@ { "actions": [], "autoname": "hash", - "creation": "2013-03-29 18:22:12", + "creation": "2022-02-05 00:17:49.860824", "doctype": "DocType", "document_type": "Other", "editable_grid": 1, @@ -340,13 +340,13 @@ "label": "More Information" }, { - "allow_on_submit": 1, "default": "0", "fieldname": "allow_zero_valuation_rate", "fieldtype": "Check", "label": "Allow Zero Valuation Rate", "no_copy": 1, - "print_hide": 1 + "print_hide": 1, + "read_only_depends_on": "eval:doc.s_warehouse" }, { "allow_on_submit": 1, @@ -556,12 +556,14 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-06-22 16:47:11.268975", + "modified": "2022-02-26 00:51:24.963653", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index a1030d54964..0864ece995f 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1,6 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +import json +from operator import itemgetter +from uuid import uuid4 + import frappe from frappe.core.page.permission_manager.permission_manager import reset from frappe.utils import add_days, today @@ -349,6 +353,317 @@ class TestStockLedgerEntry(ERPNextTestCase): frappe.set_user("Administrator") user.remove_roles("Stock Manager") + def test_batchwise_item_valuation_moving_average(self): + item, warehouses, batches = setup_item_valuation_test(valuation_method="Moving Average") + + # Incoming Entries for Stock Value check + pr_entry_list = [ + (item, warehouses[0], batches[0], 1, 100), + (item, warehouses[0], batches[1], 1, 50), + (item, warehouses[0], batches[0], 1, 150), + (item, warehouses[0], batches[1], 1, 100), + ] + prs = create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list) + sle_details = fetch_sle_details_for_doc_list(prs, ['stock_value']) + sv_list = [d['stock_value'] for d in sle_details] + expected_sv = [100, 150, 300, 400] + self.assertEqual(expected_sv, sv_list, "Incorrect 'Stock Value' values") + + # Outgoing Entries for Stock Value Difference check + dn_entry_list = [ + (item, warehouses[0], batches[1], 1, 200), + (item, warehouses[0], batches[0], 1, 200), + (item, warehouses[0], batches[1], 1, 200), + (item, warehouses[0], batches[0], 1, 200) + ] + dns = create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list) + sle_details = fetch_sle_details_for_doc_list(dns, ['stock_value_difference']) + svd_list = [-1 * d['stock_value_difference'] for d in sle_details] + expected_incoming_rates = expected_abs_svd = [75, 125, 75, 125] + + self.assertEqual(expected_abs_svd, svd_list, "Incorrect 'Stock Value Difference' values") + for dn, incoming_rate in zip(dns, expected_incoming_rates): + self.assertEqual( + dn.items[0].incoming_rate, incoming_rate, + "Incorrect 'Incoming Rate' values fetched for DN items" + ) + + + def assertSLEs(self, doc, expected_sles): + """ Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" + sles = frappe.get_all("Stock Ledger Entry", fields=["*"], + filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0}, + order_by="timestamp(posting_date, posting_time), creation") + + for exp_sle, act_sle in zip(expected_sles, sles): + for k, v in exp_sle.items(): + act_value = act_sle[k] + if k == "stock_queue": + act_value = json.loads(act_value) + if act_value and act_value[0][0] == 0: + # ignore empty fifo bins + continue + + self.assertEqual(v, act_value, msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}") + + + def test_batchwise_item_valuation_stock_reco(self): + item, warehouses, batches = setup_item_valuation_test() + state = { + "stock_value" : 0.0, + "qty": 0.0 + } + def update_invariants(exp_sles): + for sle in exp_sles: + state["stock_value"] += sle["stock_value_difference"] + state["qty"] += sle["actual_qty"] + sle["stock_value"] = state["stock_value"] + sle["qty_after_transaction"] = state["qty"] + + osr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=10, rate=100, batch_no=batches[1]) + expected_sles = [ + {"actual_qty": 10, "stock_value_difference": 1000}, + ] + update_invariants(expected_sles) + self.assertSLEs(osr1, expected_sles) + + osr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=13, rate=200, batch_no=batches[0]) + expected_sles = [ + {"actual_qty": 13, "stock_value_difference": 200*13}, + ] + update_invariants(expected_sles) + self.assertSLEs(osr2, expected_sles) + + sr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=5, rate=50, batch_no=batches[1]) + + expected_sles = [ + {"actual_qty": -10, "stock_value_difference": -10 * 100}, + {"actual_qty": 5, "stock_value_difference": 250} + ] + update_invariants(expected_sles) + self.assertSLEs(sr1, expected_sles) + + sr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=20, rate=75, batch_no=batches[0]) + expected_sles = [ + {"actual_qty": -13, "stock_value_difference": -13 * 200}, + {"actual_qty": 20, "stock_value_difference": 20 * 75} + ] + update_invariants(expected_sles) + self.assertSLEs(sr2, expected_sles) + + def test_batch_wise_valuation_across_warehouse(self): + item_code, warehouses, batches = setup_item_valuation_test() + source = warehouses[0] + target = warehouses[1] + + unrelated_batch = make_stock_entry(item_code=item_code, target=source, batch_no=batches[1], + qty=5, rate=10) + self.assertSLEs(unrelated_batch, [ + {"actual_qty": 5, "stock_value_difference": 10 * 5}, + ]) + + reciept = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0], qty=5, rate=10) + self.assertSLEs(reciept, [ + {"actual_qty": 5, "stock_value_difference": 10 * 5}, + ]) + + transfer = make_stock_entry(item_code=item_code, source=source, target=target, batch_no=batches[0], qty=5) + self.assertSLEs(transfer, [ + {"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source}, + {"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target} + ]) + + backdated_receipt = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0], + qty=5, rate=20, posting_date=add_days(today(), -1)) + self.assertSLEs(backdated_receipt, [ + {"actual_qty": 5, "stock_value_difference": 20 * 5}, + ]) + + # check reposted average rate in *future* transfer + self.assertSLEs(transfer, [ + {"actual_qty": -5, "stock_value_difference": -15 * 5, "warehouse": source, "stock_value": 15 * 5 + 10 * 5}, + {"actual_qty": 5, "stock_value_difference": 15 * 5, "warehouse": target, "stock_value": 15 * 5} + ]) + + transfer_unrelated = make_stock_entry(item_code=item_code, source=source, + target=target, batch_no=batches[1], qty=5) + self.assertSLEs(transfer_unrelated, [ + {"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source, "stock_value": 15 * 5}, + {"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target, "stock_value": 15 * 5 + 10 * 5} + ]) + + def test_intermediate_average_batch_wise_valuation(self): + """ A batch has moving average up until posting time, + check if same is respected when backdated entry is inserted in middle""" + item_code, warehouses, batches = setup_item_valuation_test() + warehouse = warehouses[0] + + batch = batches[0] + + yesterday = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batch, + qty=1, rate=10, posting_date=add_days(today(), -1)) + self.assertSLEs(yesterday, [ + {"actual_qty": 1, "stock_value_difference": 10}, + ]) + + tomorrow = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=1, rate=30, posting_date=add_days(today(), 1)) + self.assertSLEs(tomorrow, [ + {"actual_qty": 1, "stock_value_difference": 30}, + ]) + + create_today = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=1, rate=20) + self.assertSLEs(create_today, [ + {"actual_qty": 1, "stock_value_difference": 20}, + ]) + + consume_today = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0], + qty=1) + self.assertSLEs(consume_today, [ + {"actual_qty": -1, "stock_value_difference": -15}, + ]) + + consume_tomorrow = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0], + qty=2, posting_date=add_days(today(), 2)) + self.assertSLEs(consume_tomorrow, [ + {"stock_value_difference": -(30 + 15), "stock_value": 0, "qty_after_transaction": 0}, + ]) + + def test_legacy_item_valuation_stock_entry(self): + columns = [ + 'stock_value_difference', + 'stock_value', + 'actual_qty', + 'qty_after_transaction', + 'stock_queue', + ] + item, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0) + + def check_sle_details_against_expected(sle_details, expected_sle_details, detail, columns): + for i, (sle_vals, ex_sle_vals) in enumerate(zip(sle_details, expected_sle_details)): + for col, sle_val, ex_sle_val in zip(columns, sle_vals, ex_sle_vals): + if col == 'stock_queue': + sle_val = get_stock_value_from_q(sle_val) + ex_sle_val = get_stock_value_from_q(ex_sle_val) + self.assertEqual( + sle_val, ex_sle_val, + f"Incorrect {col} value on transaction #: {i} in {detail}" + ) + + # List used to defer assertions to prevent commits cause of error skipped rollback + details_list = [] + + + # Test Material Receipt Entries + se_entry_list_mr = [ + (item, None, warehouses[0], batches[0], 1, 50, "2021-01-21"), + (item, None, warehouses[0], batches[1], 1, 100, "2021-01-23"), + ] + ses = create_stock_entry_entries_for_batchwise_item_valuation_test( + se_entry_list_mr, "Material Receipt" + ) + sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0) + expected_sle_details = [ + (50.0, 50.0, 1.0, 1.0, '[[1.0, 50.0]]'), + (100.0, 150.0, 1.0, 2.0, '[[1.0, 50.0], [1.0, 100.0]]'), + ] + details_list.append(( + sle_details, expected_sle_details, + "Material Receipt Entries", columns + )) + + + # Test Material Issue Entries + se_entry_list_mi = [ + (item, warehouses[0], None, batches[1], 1, None, "2021-01-29"), + ] + ses = create_stock_entry_entries_for_batchwise_item_valuation_test( + se_entry_list_mi, "Material Issue" + ) + sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0) + expected_sle_details = [ + (-50.0, 100.0, -1.0, 1.0, '[[1, 100.0]]') + ] + details_list.append(( + sle_details, expected_sle_details, + "Material Issue Entries", columns + )) + + + # Run assertions + for details in details_list: + check_sle_details_against_expected(*details) + + def test_mixed_valuation_batches_fifo(self): + item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0) + warehouse = warehouses[0] + + state = { + "qty": 0.0, + "stock_value": 0.0 + } + def update_invariants(exp_sles): + for sle in exp_sles: + state["stock_value"] += sle["stock_value_difference"] + state["qty"] += sle["actual_qty"] + sle["stock_value"] = state["stock_value"] + sle["qty_after_transaction"] = state["qty"] + return exp_sles + + old1 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=10, rate=10) + self.assertSLEs(old1, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*10, "stock_queue": [[10, 10]]}, + ])) + old2 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[1], + qty=10, rate=20) + self.assertSLEs(old2, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*20, "stock_queue": [[10, 10], [10, 20]]}, + ])) + old3 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0], + qty=5, rate=15) + + self.assertSLEs(old3, update_invariants([ + {"actual_qty": 5, "stock_value_difference": 5*15, "stock_queue": [[10, 10], [10, 20], [5, 15]]}, + ])) + + new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40) + batches.append(new1.items[0].batch_no) + # assert old queue remains + self.assertSLEs(new1, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*40, "stock_queue": [[10, 10], [10, 20], [5, 15]]}, + ])) + + new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42) + batches.append(new2.items[0].batch_no) + self.assertSLEs(new2, update_invariants([ + {"actual_qty": 10, "stock_value_difference": 10*42, "stock_queue": [[10, 10], [10, 20], [5, 15]]}, + ])) + + # consume old batch as per FIFO + consume_old1 = make_stock_entry(item_code=item_code, source=warehouse, qty=15, batch_no=batches[0]) + self.assertSLEs(consume_old1, update_invariants([ + {"actual_qty": -15, "stock_value_difference": -10*10 - 5*20, "stock_queue": [[5, 20], [5, 15]]}, + ])) + + # consume new batch as per batch + consume_new2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1]) + self.assertSLEs(consume_new2, update_invariants([ + {"actual_qty": -10, "stock_value_difference": -10*42, "stock_queue": [[5, 20], [5, 15]]}, + ])) + + # finish all old batches + consume_old2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[1]) + self.assertSLEs(consume_old2, update_invariants([ + {"actual_qty": -10, "stock_value_difference": -5*20 - 5*15, "stock_queue": []}, + ])) + + # finish all new batches + consume_new1 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2]) + self.assertSLEs(consume_new1, update_invariants([ + {"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []}, + ])) def create_repack_entry(**args): args = frappe._dict(args) @@ -412,3 +727,118 @@ def create_items(): make_item(d, properties=properties) return items + +def setup_item_valuation_test(valuation_method="FIFO", suffix=None, use_batchwise_valuation=1, batches_list=['X', 'Y']): + from erpnext.stock.doctype.batch.batch import make_batch + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + if not suffix: + suffix = get_unique_suffix() + + item = make_item( + f"IV - Test Item {valuation_method} {suffix}", + dict(valuation_method=valuation_method, has_batch_no=1, create_new_batch=1) + ) + warehouses = [create_warehouse(f"IV - Test Warehouse {i}") for i in ['J', 'K']] + batches = [f"IV - Test Batch {i} {valuation_method} {suffix}" for i in batches_list] + + for i, batch_id in enumerate(batches): + if not frappe.db.exists("Batch", batch_id): + ubw = use_batchwise_valuation + if isinstance(use_batchwise_valuation, (list, tuple)): + ubw = use_batchwise_valuation[i] + batch = frappe.get_doc(frappe._dict( + doctype="Batch", + batch_id=batch_id, + item=item.item_code, + use_batchwise_valuation=ubw + ) + ).insert() + batch.use_batchwise_valuation = ubw + batch.db_update() + + return item.item_code, warehouses, batches + +def create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list): + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + prs = [] + + for item, warehouse, batch_no, qty, rate in pr_entry_list: + pr = make_purchase_receipt(item=item, warehouse=warehouse, qty=qty, rate=rate, batch_no=batch_no) + prs.append(pr) + + return prs + +def create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list): + from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + dns = [] + for item, warehouse, batch_no, qty, rate in dn_entry_list: + so = make_sales_order( + rate=rate, + qty=qty, + item=item, + warehouse=warehouse, + against_blanket_order=0 + ) + + dn = make_delivery_note(so.name) + dn.items[0].batch_no = batch_no + dn.insert() + dn.submit() + dns.append(dn) + return dns + +def fetch_sle_details_for_doc_list(doc_list, columns, as_dict=1): + return frappe.db.sql(f""" + SELECT { ', '.join(columns)} + FROM `tabStock Ledger Entry` + WHERE + voucher_no IN %(voucher_nos)s + and docstatus = 1 + ORDER BY timestamp(posting_date, posting_time) ASC, CREATION ASC + """, dict( + voucher_nos=[doc.name for doc in doc_list] + ), as_dict=as_dict) + +def get_stock_value_from_q(q): + return sum(r*q for r,q in json.loads(q)) + +def create_stock_entry_entries_for_batchwise_item_valuation_test(se_entry_list, purpose): + ses = [] + for item, source, target, batch, qty, rate, posting_date in se_entry_list: + args = dict( + item_code=item, + qty=qty, + company="_Test Company", + batch_no=batch, + posting_date=posting_date, + purpose=purpose + ) + + if purpose == "Material Receipt": + args.update( + dict(to_warehouse=target, rate=rate) + ) + + elif purpose == "Material Issue": + args.update( + dict(from_warehouse=source) + ) + + elif purpose == "Material Transfer": + args.update( + dict(from_warehouse=source, to_warehouse=target) + ) + + else: + raise ValueError(f"Invalid purpose: {purpose}") + ses.append(make_stock_entry(**args)) + + return ses + +def get_unique_suffix(): + # Used to isolate valuation sensitive + # tests to prevent future tests from failing. + return str(uuid4())[:8].upper() diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json index 3402972bb89..a882a61e5a5 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json @@ -18,7 +18,6 @@ "items", "section_break_9", "expense_account", - "reconciliation_json", "column_break_13", "difference_amount", "amended_from", @@ -111,15 +110,6 @@ "label": "Cost Center", "options": "Cost Center" }, - { - "fieldname": "reconciliation_json", - "fieldtype": "Long Text", - "hidden": 1, - "label": "Reconciliation JSON", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "column_break_13", "fieldtype": "Column Break" @@ -155,7 +145,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2021-11-30 01:33:51.437194", + "modified": "2022-02-06 14:28:19.043905", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation", @@ -178,5 +168,6 @@ "search_fields": "posting_date", "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 428370cc758..2ffe127d9a5 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -25,8 +25,8 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings class TestStockReconciliation(ERPNextTestCase): @classmethod def setUpClass(cls): - super().setUpClass() create_batch_or_serial_no_items() + super().setUpClass() frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) def tearDown(self): @@ -200,7 +200,6 @@ class TestStockReconciliation(ERPNextTestCase): def test_stock_reco_for_batch_item(self): to_delete_records = [] - to_delete_serial_nos = [] # Add new serial nos item_code = "Stock-Reco-batch-Item-1" @@ -208,20 +207,22 @@ class TestStockReconciliation(ERPNextTestCase): sr = create_stock_reconciliation(item_code=item_code, warehouse = warehouse, qty=5, rate=200, do_not_submit=1) - sr.save(ignore_permissions=True) + sr.save() sr.submit() - self.assertTrue(sr.items[0].batch_no) + batch_no = sr.items[0].batch_no + self.assertTrue(batch_no) to_delete_records.append(sr.name) sr1 = create_stock_reconciliation(item_code=item_code, - warehouse = warehouse, qty=6, rate=300, batch_no=sr.items[0].batch_no) + warehouse = warehouse, qty=6, rate=300, batch_no=batch_no) args = { "item_code": item_code, "warehouse": warehouse, "posting_date": nowdate(), "posting_time": nowtime(), + "batch_no": batch_no, } valuation_rate = get_incoming_rate(args) @@ -230,7 +231,7 @@ class TestStockReconciliation(ERPNextTestCase): sr2 = create_stock_reconciliation(item_code=item_code, - warehouse = warehouse, qty=0, rate=0, batch_no=sr.items[0].batch_no) + warehouse = warehouse, qty=0, rate=0, batch_no=batch_no) stock_value = get_stock_value_on(warehouse, nowdate(), item_code) self.assertEqual(stock_value, 0) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 33d9a6ce414..ec7fb0f4a28 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -5,35 +5,41 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "defaults_tab", "item_defaults_section", "item_naming_by", "item_group", "stock_uom", - "default_warehouse", "column_break_4", - "valuation_method", + "default_warehouse", "sample_retention_warehouse", - "use_naming_series", - "naming_series_prefix", + "valuation_method", + "price_list_defaults_section", + "auto_insert_price_list_rate_if_missing", + "column_break_12", + "update_existing_price_list_rate", + "stock_validations_tab", "section_break_9", "over_delivery_receipt_allowance", - "role_allowed_to_over_deliver_receive", "mr_qty_allowance", - "column_break_12", - "auto_insert_price_list_rate_if_missing", - "update_existing_price_list_rate", + "column_break_121", + "role_allowed_to_over_deliver_receive", "allow_negative_stock", "show_barcode_field", "clean_description_html", "quality_inspection_settings_section", "action_if_quality_inspection_is_not_submitted", - "column_break_21", + "column_break_23", "action_if_quality_inspection_is_rejected", + "serial_and_batch_item_settings_tab", "section_break_7", "automatically_set_serial_nos_based_on_fifo", "set_qty_in_transactions_based_on_serial_no_input", "column_break_10", "disable_serial_no_and_batch_selector", + "use_naming_series", + "naming_series_prefix", + "stock_planning_tab", "auto_material_request", "auto_indent", "column_break_27", @@ -42,6 +48,7 @@ "allow_from_dn", "column_break_31", "allow_from_pr", + "stock_closing_tab", "control_historical_stock_transactions_section", "stock_frozen_upto", "stock_frozen_upto_days", @@ -92,7 +99,7 @@ "fieldname": "valuation_method", "fieldtype": "Select", "label": "Default Valuation Method", - "options": "FIFO\nMoving Average" + "options": "FIFO\nMoving Average\nLIFO" }, { "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", @@ -122,7 +129,7 @@ { "fieldname": "section_break_7", "fieldtype": "Section Break", - "label": "Serialised and Batch Setting" + "label": "Serial & Batch Item Settings" }, { "default": "0", @@ -275,10 +282,6 @@ "fieldtype": "Section Break", "label": "Quality Inspection Settings" }, - { - "fieldname": "column_break_21", - "fieldtype": "Column Break" - }, { "default": "Stop", "fieldname": "action_if_quality_inspection_is_rejected", @@ -298,6 +301,44 @@ "fieldname": "update_existing_price_list_rate", "fieldtype": "Check", "label": "Update Existing Price List Rate" + }, + { + "fieldname": "defaults_tab", + "fieldtype": "Tab Break", + "label": "Defaults" + }, + { + "fieldname": "stock_validations_tab", + "fieldtype": "Tab Break", + "label": "Stock Validations" + }, + { + "fieldname": "stock_planning_tab", + "fieldtype": "Tab Break", + "label": "Stock Planning" + }, + { + "fieldname": "stock_closing_tab", + "fieldtype": "Tab Break", + "label": "Stock Closing" + }, + { + "fieldname": "serial_and_batch_item_settings_tab", + "fieldtype": "Tab Break", + "label": "Serial & Batch Item" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "fieldname": "price_list_defaults_section", + "fieldtype": "Section Break", + "label": "Price List Defaults" + }, + { + "fieldname": "column_break_121", + "fieldtype": "Column Break" } ], "icon": "icon-cog", @@ -305,7 +346,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-11-06 19:40:02.183592", + "modified": "2022-02-05 15:33:43.692736", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", @@ -324,5 +365,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 06f8fa71a94..9bec5f74940 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -6,6 +6,7 @@ import json import frappe from frappe import _, throw +from frappe.model import child_table_fields, default_fields from frappe.model.meta import get_field_precision from frappe.utils import add_days, add_months, cint, cstr, flt, getdate @@ -119,8 +120,15 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru out.rate = args.rate or out.price_list_rate out.amount = flt(args.qty) * flt(out.rate) + out = remove_standard_fields(out) return out +def remove_standard_fields(details): + for key in child_table_fields + default_fields: + details.pop(key, None) + return details + + def update_stock(args, out): if (args.get("doctype") == "Delivery Note" or (args.get("doctype") == "Sales Invoice" and args.get('update_stock'))) \ @@ -343,6 +351,7 @@ def get_basic_details(args, item, overwrite_warehouse=True): args.conversion_factor = out.conversion_factor out.stock_qty = out.qty * out.conversion_factor + args.stock_qty = out.stock_qty # calculate last purchase rate if args.get('doctype') in purchase_doctypes: diff --git a/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json b/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json deleted file mode 100644 index 5ee316786ca..00000000000 --- a/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "add_more_button": 1, - "app": "ERPNext", - "creation": "2019-11-15 14:41:12.007359", - "docstatus": 0, - "doctype": "Onboarding Slide", - "domains": [], - "help_links": [], - "idx": 0, - "image_src": "", - "is_completed": 0, - "max_count": 3, - "modified": "2019-12-09 17:54:09.602885", - "modified_by": "Administrator", - "name": "Add A Few Products You Buy Or Sell", - "owner": "Administrator", - "ref_doctype": "Item", - "slide_desc": "", - "slide_fields": [ - { - "align": "", - "fieldname": "item", - "fieldtype": "Data", - "label": "Item", - "placeholder": "Product Name", - "reqd": 1 - }, - { - "align": "", - "fieldname": "item_price", - "fieldtype": "Currency", - "label": "Item Price", - "reqd": 1 - }, - { - "align": "", - "fieldtype": "Column Break", - "reqd": 0 - }, - { - "align": "", - "fieldname": "uom", - "fieldtype": "Link", - "label": "UOM", - "options": "UOM", - "reqd": 1 - } - ], - "slide_order": 30, - "slide_title": "Add A Few Products You Buy Or Sell", - "slide_type": "Create" -} \ No newline at end of file diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index e6dfc97a998..97a740e1844 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos Filters = frappe._dict +precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) def execute(filters: Filters = None) -> Tuple: to_date = filters["to_date"] @@ -48,10 +49,13 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li if filters.get("show_warehouse_wise_stock"): row.append(details.warehouse) - row.extend([item_dict.get("total_qty"), average_age, + row.extend([ + flt(item_dict.get("total_qty"), precision), + average_age, range1, range2, range3, above_range3, earliest_age, latest_age, - details.stock_uom]) + details.stock_uom + ]) data.append(row) @@ -79,13 +83,13 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0 if age <= filters.range1: - range1 += qty + range1 = flt(range1 + qty, precision) elif age <= filters.range2: - range2 += qty + range2 = flt(range2 + qty, precision) elif age <= filters.range3: - range3 += qty + range3 = flt(range3 + qty, precision) else: - above_range3 += qty + above_range3 = flt(above_range3 + qty, precision) return range1, range2, range3, above_range3 @@ -252,6 +256,7 @@ class FIFOSlots: key, fifo_queue, transferred_item_key = self.__init_key_stores(d) if d.voucher_type == "Stock Reconciliation": + # get difference in qty shift as actual qty prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) @@ -264,12 +269,16 @@ class FIFOSlots: self.__update_balances(d, key) + if not self.filters.get("show_warehouse_wise_stock"): + # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) + self.item_details = self.__aggregate_details_by_item(self.item_details) + return self.item_details def __init_key_stores(self, row: Dict) -> Tuple: "Initialise keys and FIFO Queue." - key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name + key = (row.name, row.warehouse) self.item_details.setdefault(key, {"details": row, "fifo_queue": []}) fifo_queue = self.item_details[key]["fifo_queue"] @@ -281,14 +290,16 @@ class FIFOSlots: def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List): "Update FIFO Queue on inward stock." - if self.transferred_item_details.get(transfer_key): + transfer_data = self.transferred_item_details.get(transfer_key) + if transfer_data: # inward/outward from same voucher, item & warehouse - slot = self.transferred_item_details[transfer_key].pop(0) - fifo_queue.append(slot) + # eg: Repack with same item, Stock reco for batch item + # consume transfer data and add stock to fifo queue + self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) else: if not serial_nos: - if fifo_queue and flt(fifo_queue[0][0]) < 0: - # neutralize negative stock by adding positive stock + if fifo_queue and flt(fifo_queue[0][0]) <= 0: + # neutralize 0/negative stock by adding positive stock fifo_queue[0][0] += flt(row.actual_qty) fifo_queue[0][1] = row.posting_date else: @@ -319,7 +330,7 @@ class FIFOSlots: elif not fifo_queue: # negative stock, no balance but qty yet to consume fifo_queue.append([-(qty_to_pop), row.posting_date]) - self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date]) + self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date]) qty_to_pop = 0 else: # qty to pop < slot qty, ample balance @@ -328,6 +339,33 @@ class FIFOSlots: self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]]) qty_to_pop = 0 + def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict): + "Add previously removed stock back to FIFO Queue." + transfer_qty_to_pop = flt(row.actual_qty) + + def add_to_fifo_queue(slot): + if fifo_queue and flt(fifo_queue[0][0]) <= 0: + # neutralize 0/negative stock by adding positive stock + fifo_queue[0][0] += flt(slot[0]) + fifo_queue[0][1] = slot[1] + else: + fifo_queue.append(slot) + + while transfer_qty_to_pop: + if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop: + # bucket qty is not enough, consume whole + transfer_qty_to_pop -= transfer_data[0][0] + add_to_fifo_queue(transfer_data.pop(0)) + elif not transfer_data: + # transfer bucket is empty, extra incoming qty + add_to_fifo_queue([transfer_qty_to_pop, row.posting_date]) + transfer_qty_to_pop = 0 + else: + # ample bucket qty to consume + transfer_data[0][0] -= transfer_qty_to_pop + add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]]) + transfer_qty_to_pop = 0 + def __update_balances(self, row: Dict, key: Union[Tuple, str]): self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction @@ -338,6 +376,27 @@ class FIFOSlots: self.item_details[key]["has_serial_no"] = row.has_serial_no + def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict: + "Aggregate Item-Wh wise data into single Item entry." + item_aggregated_data = {} + for key,row in wh_wise_data.items(): + item = key[0] + if not item_aggregated_data.get(item): + item_aggregated_data.setdefault(item, { + "details": frappe._dict(), + "fifo_queue": [], + "qty_after_transaction": 0.0, + "total_qty": 0.0 + }) + item_row = item_aggregated_data.get(item) + item_row["details"].update(row["details"]) + item_row["fifo_queue"].extend(row["fifo_queue"]) + item_row["qty_after_transaction"] += flt(row["qty_after_transaction"]) + item_row["total_qty"] += flt(row["total_qty"]) + item_row["has_serial_no"] = row["has_serial_no"] + + return item_aggregated_data + def __get_stock_ledger_entries(self) -> List[Dict]: sle = frappe.qb.DocType("Stock Ledger Entry") item = self.__get_item_query() # used as derived table in sle query diff --git a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md index 5ffe97fd742..3d759dd9989 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md +++ b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md @@ -15,6 +15,7 @@ Here, the balance qty is 70. 50 qty is (today-the 1st) days old 20 qty is (today-the 2nd) days old +> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values. ### Calculation of FIFO Slots #### Case 1: Outward from sufficient balance qty @@ -70,4 +71,39 @@ Date | Qty | Queue 2nd | -60 | [[-10, 1-12-2021]] 3rd | +5 | [[-5, 3-12-2021]] 4th | +10 | [[5, 4-12-2021]] -4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]] \ No newline at end of file +4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]] + +### Concept of Transfer Qty Bucket +In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse. + +Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue. +While adding stock back to the queue we need to know how much to add. +For this we need to keep track of how much was previously consumed. +Hence we use **Transfer Qty Bucket**. + +While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness. + +#### Case 1: Same Item-Warehouse in Repack +Eg: +------------------------------------------------------------------------------------- +Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets +------------------------------------------------------------------------------------- +1st | +500 | PR | [[500, 1-12-2021]] | +2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]] +2nd | +50 | Repack | [[450, 1-12-2021], [50, 1-12-2021]] | [] + +- The balance at the end is restored back to 500 +- However, the initial 500 qty bucket is now split into 450 and 50, with the same date +- The net effect is the same as that before the Repack + +#### Case 2: Same Item-Warehouse in Repack with Split Consumption rows +Eg: +------------------------------------------------------------------------------------- +Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets +------------------------------------------------------------------------------------- +1st | +500 | PR | [[500, 1-12-2021]] | +2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]] +2nd | -50 | Repack | [[400, 1-12-2021]] | [[50, 1-12-2021], +- | | | |[50, 1-12-2021]] +2nd | +100 | Repack | [[400, 1-12-2021], [50, 1-12-2021], | [] +- | | | [50, 1-12-2021]] | diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 949bb7c15a8..3fc357e8d4f 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -3,7 +3,7 @@ import frappe -from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots +from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data from erpnext.tests.utils import ERPNextTestCase @@ -11,15 +11,17 @@ class TestStockAgeing(ERPNextTestCase): def setUp(self) -> None: self.filters = frappe._dict( company="_Test Company", - to_date="2021-12-10" + to_date="2021-12-10", + range1=30, range2=60, range3=90 ) def test_normal_inward_outward_queue(self): - "Reference: Case 1 in stock_ageing_fifo_logic.md" + "Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)" sle = [ frappe._dict( name="Flask Item", actual_qty=30, qty_after_transaction=30, + warehouse="WH 1", posting_date="2021-12-01", voucher_type="Stock Entry", voucher_no="001", has_serial_no=False, serial_no=None @@ -27,6 +29,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=20, qty_after_transaction=50, + warehouse="WH 1", posting_date="2021-12-02", voucher_type="Stock Entry", voucher_no="002", has_serial_no=False, serial_no=None @@ -34,6 +37,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=(-10), qty_after_transaction=40, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="003", has_serial_no=False, serial_no=None @@ -50,11 +54,12 @@ class TestStockAgeing(ERPNextTestCase): self.assertEqual(queue[0][0], 20.0) def test_insufficient_balance(self): - "Reference: Case 3 in stock_ageing_fifo_logic.md" + "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" sle = [ frappe._dict( name="Flask Item", actual_qty=(-30), qty_after_transaction=(-30), + warehouse="WH 1", posting_date="2021-12-01", voucher_type="Stock Entry", voucher_no="001", has_serial_no=False, serial_no=None @@ -62,6 +67,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=20, qty_after_transaction=(-10), + warehouse="WH 1", posting_date="2021-12-02", voucher_type="Stock Entry", voucher_no="002", has_serial_no=False, serial_no=None @@ -69,6 +75,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=20, qty_after_transaction=10, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="003", has_serial_no=False, serial_no=None @@ -76,6 +83,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=10, qty_after_transaction=20, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="004", has_serial_no=False, serial_no=None @@ -91,11 +99,16 @@ class TestStockAgeing(ERPNextTestCase): self.assertEqual(queue[0][0], 10.0) self.assertEqual(queue[1][0], 10.0) - def test_stock_reconciliation(self): + def test_basic_stock_reconciliation(self): + """ + Ledger (same wh): [+30, reco reset >> 50, -10] + Bal: 40 + """ sle = [ frappe._dict( name="Flask Item", actual_qty=30, qty_after_transaction=30, + warehouse="WH 1", posting_date="2021-12-01", voucher_type="Stock Entry", voucher_no="001", has_serial_no=False, serial_no=None @@ -103,6 +116,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=0, qty_after_transaction=50, + warehouse="WH 1", posting_date="2021-12-02", voucher_type="Stock Reconciliation", voucher_no="002", has_serial_no=False, serial_no=None @@ -110,6 +124,7 @@ class TestStockAgeing(ERPNextTestCase): frappe._dict( name="Flask Item", actual_qty=(-10), qty_after_transaction=40, + warehouse="WH 1", posting_date="2021-12-03", voucher_type="Stock Entry", voucher_no="003", has_serial_no=False, serial_no=None @@ -122,5 +137,477 @@ class TestStockAgeing(ERPNextTestCase): queue = result["fifo_queue"] self.assertEqual(result["qty_after_transaction"], result["total_qty"]) + self.assertEqual(result["total_qty"], 40.0) self.assertEqual(queue[0][0], 20.0) self.assertEqual(queue[1][0], 20.0) + + def test_sequential_stock_reco_same_warehouse(self): + """ + Test back to back stock recos (same warehouse). + Ledger: [reco opening >> +1000, reco reset >> 400, -10] + Bal: 390 + """ + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=1000, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=400, + warehouse="WH 1", + posting_date="2021-12-02", voucher_type="Stock Reconciliation", + voucher_no="003", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-10), qty_after_transaction=390, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, serial_no=None + ) + ] + slots = FIFOSlots(self.filters, sle).generate() + + result = slots["Flask Item"] + queue = result["fifo_queue"] + + self.assertEqual(result["qty_after_transaction"], result["total_qty"]) + self.assertEqual(result["total_qty"], 390.0) + self.assertEqual(queue[0][0], 390.0) + + def test_sequential_stock_reco_different_warehouse(self): + """ + Ledger: + WH | Voucher | Qty + ------------------- + WH1 | Reco | 1000 + WH2 | Reco | 400 + WH1 | SE | -10 + + Bal: WH1 bal + WH2 bal = 990 + 400 = 1390 + """ + sle = [ + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=1000, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Reconciliation", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=0, qty_after_transaction=400, + warehouse="WH 2", + posting_date="2021-12-02", voucher_type="Stock Reconciliation", + voucher_no="003", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-10), qty_after_transaction=990, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, serial_no=None + ) + ] + + item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots( + filters=self.filters,sle=sle + ) + + # test without 'show_warehouse_wise_stock' + item_result = item_wise_slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 1390.0) + self.assertEqual(queue[0][0], 990.0) + self.assertEqual(queue[1][0], 400.0) + + # test with 'show_warehouse_wise_stock' checked + item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots] + self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"]) + + def test_repack_entry_same_item_split_rows(self): + """ + Split consumption rows and have single repacked item row (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 500 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | -50 | 002 (repack) + Item 1 | 100 | 002 (repack) + + Case most likely for batch items. Test time bucket computation. + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=500, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=450, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=400, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=100, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 500.0) + self.assertEqual(queue[0][0], 400.0) + self.assertEqual(queue[1][0], 50.0) + self.assertEqual(queue[2][0], 50.0) + # check if time buckets add up to balance qty + self.assertEqual(sum([i[0] for i in queue]), 500.0) + + def test_repack_entry_same_item_overconsume(self): + """ + Over consume item and have less repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 500 | 001 + Item 1 | -100 | 002 (repack) + Item 1 | 50 | 002 (repack) + + Case most likely for batch items. Test time bucket computation. + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=500, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-100), qty_after_transaction=400, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=450, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 450.0) + self.assertEqual(queue[0][0], 400.0) + self.assertEqual(queue[1][0], 50.0) + # check if time buckets add up to balance qty + self.assertEqual(sum([i[0] for i in queue]), 450.0) + + def test_repack_entry_same_item_overconsume_with_split_rows(self): + """ + Over consume item and have less repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 20 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | -50 | 002 (repack) + Item 1 | 50 | 002 (repack) + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=20, qty_after_transaction=20, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-30), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-80), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=(-30), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], -30.0) + self.assertEqual(queue[0][0], -30.0) + + # check transfer bucket + transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')] + self.assertEqual(transfer_bucket[0][0], 50) + + def test_repack_entry_same_item_overproduce(self): + """ + Under consume item and have more repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 500 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | 100 | 002 (repack) + + Case most likely for batch items. Test time bucket computation. + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=500, qty_after_transaction=500, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=450, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=100, qty_after_transaction=550, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 550.0) + self.assertEqual(queue[0][0], 450.0) + self.assertEqual(queue[1][0], 50.0) + self.assertEqual(queue[2][0], 50.0) + # check if time buckets add up to balance qty + self.assertEqual(sum([i[0] for i in queue]), 550.0) + + def test_repack_entry_same_item_overproduce_with_split_rows(self): + """ + Over consume item and have less repacked item qty (same warehouse). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | 20 | 001 + Item 1 | -50 | 002 (repack) + Item 1 | 50 | 002 (repack) + Item 1 | 50 | 002 (repack) + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=20, qty_after_transaction=20, + warehouse="WH 1", + posting_date="2021-12-03", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-30), + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=20, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + frappe._dict( + name="Flask Item", + actual_qty=50, qty_after_transaction=70, + warehouse="WH 1", + posting_date="2021-12-04", voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, serial_no=None + ), + ] + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + queue = item_result["fifo_queue"] + + self.assertEqual(item_result["total_qty"], 70.0) + self.assertEqual(queue[0][0], 20.0) + self.assertEqual(queue[1][0], 50.0) + + # check transfer bucket + transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')] + self.assertFalse(transfer_bucket) + + def test_negative_stock_same_voucher(self): + """ + Test negative stock scenario in transfer bucket via repack entry (same wh). + Ledger: + Item | Qty | Voucher + ------------------------ + Item 1 | -50 | 001 + Item 1 | -50 | 001 + Item 1 | 30 | 001 + Item 1 | 80 | 001 + """ + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-50), + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( # stock up item + name="Flask Item", + actual_qty=(-50), qty_after_transaction=(-100), + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( # stock up item + name="Flask Item", + actual_qty=30, qty_after_transaction=(-70), + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + ] + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + + # check transfer bucket + transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')] + self.assertEqual(transfer_bucket[0][0], 20) + self.assertEqual(transfer_bucket[1][0], 50) + self.assertEqual(item_result["fifo_queue"][0][0], -70.0) + + sle.append(frappe._dict( + name="Flask Item", + actual_qty=80, qty_after_transaction=10, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + )) + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots["Flask Item"] + + transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')] + self.assertFalse(transfer_bucket) + self.assertEqual(item_result["fifo_queue"][0][0], 10.0) + + def test_precision(self): + "Test if final balance qty is rounded off correctly." + sle = [ + frappe._dict( # stock up item + name="Flask Item", + actual_qty=0.3, qty_after_transaction=0.3, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + frappe._dict( # stock up item + name="Flask Item", + actual_qty=0.6, qty_after_transaction=0.9, + warehouse="WH 1", + posting_date="2021-12-01", voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, serial_no=None + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + report_data = format_report_data(self.filters, slots, self.filters["to_date"]) + row = report_data[0] # first row in report + bal_qty = row[5] + range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance + + # check if value of Available Qty column matches with range bucket post format + self.assertEqual(bal_qty, 0.9) + self.assertEqual(bal_qty, range_qty_sum) + +def generate_item_and_item_wh_wise_slots(filters, sle): + "Return results with and without 'show_warehouse_wise_stock'" + item_wise_slots = FIFOSlots(filters, sle).generate() + + filters.show_warehouse_wise_stock = True + item_wh_wise_slots = FIFOSlots(filters, sle).generate() + filters.show_warehouse_wise_stock = False + + return item_wise_slots, item_wh_wise_slots \ No newline at end of file diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index fe2417bba7e..ef7c2cc7d9e 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -86,10 +86,10 @@ frappe.query_reports["Stock Ledger"] = { ], "formatter": function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (column.fieldname == "out_qty" && data.out_qty < 0) { + if (column.fieldname == "out_qty" && data && data.out_qty < 0) { value = "" + value + ""; } - else if (column.fieldname == "in_qty" && data.in_qty > 0) { + else if (column.fieldname == "in_qty" && data && data.in_qty > 0) { value = "" + value + ""; } diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index c60a6ca56ea..81fa0458f29 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -104,6 +104,7 @@ def get_columns(): {"label": _("Incoming Rate"), "fieldname": "incoming_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"}, {"label": _("Valuation Rate"), "fieldname": "valuation_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"}, {"label": _("Balance Value"), "fieldname": "stock_value", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"}, + {"label": _("Value Change"), "fieldname": "stock_value_difference", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"}, {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110}, {"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100}, {"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100}, diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 48753b0edd4..7826d344225 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -60,6 +60,9 @@ def add_invariant_check_fields(sles): fifo_qty += qty fifo_value += qty * rate + if sle.actual_qty < 0: + sle.consumption_rate = sle.stock_value_difference / sle.actual_qty + balance_qty += sle.actual_qty balance_stock_value += sle.stock_value_difference if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: @@ -90,6 +93,9 @@ def add_invariant_check_fields(sles): sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference + if sle.batch_no: + sle.use_batchwise_valuation = frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True) + return sles @@ -134,6 +140,11 @@ def get_columns(): "label": "Batch", "options": "Batch", }, + { + "fieldname": "use_batchwise_valuation", + "fieldtype": "Check", + "label": "Batchwise Valuation", + }, { "fieldname": "actual_qty", "fieldtype": "Float", @@ -145,9 +156,9 @@ def get_columns(): "label": "Incoming Rate", }, { - "fieldname": "outgoing_rate", + "fieldname": "consumption_rate", "fieldtype": "Float", - "label": "Outgoing Rate", + "label": "Consumption Rate", }, { "fieldname": "qty_after_transaction", @@ -167,7 +178,7 @@ def get_columns(): { "fieldname": "stock_queue", "fieldtype": "Data", - "label": "FIFO Queue", + "label": "FIFO/LIFO Queue", }, { diff --git a/erpnext/stock/report/test_reports.py b/erpnext/stock/report/test_reports.py index 1dcf863a9d0..76c20798bfb 100644 --- a/erpnext/stock/report/test_reports.py +++ b/erpnext/stock/report/test_reports.py @@ -1,6 +1,8 @@ import unittest from typing import List, Tuple +import frappe + from erpnext.tests.utils import ReportFilters, ReportName, execute_script_report DEFAULT_FILTERS = { @@ -10,8 +12,12 @@ DEFAULT_FILTERS = { } +batch = frappe.db.get_value("Batch", fieldname=["name"], as_dict=True, order_by="creation desc") + REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ ("Stock Ledger", {"_optional": True}), + ("Stock Ledger", {"batch_no": batch}), + ("Stock Ledger", {"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"}), ("Stock Balance", {"_optional": True}), ("Stock Projected Qty", {"_optional": True}), ("Batch-Wise Balance History", {}), @@ -40,6 +46,13 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ ("Item Variant Details", {"item": "_Test Variant Item",}), ("Total Stock Summary", {"group_by": "warehouse",}), ("Batch Item Expiry Status", {}), + ("Incorrect Stock Value Report", {"company": "_Test Company with perpetual inventory"}), + ("Incorrect Serial No Valuation", {}), + ("Incorrect Balance Qty After Transaction", {}), + ("Supplier-Wise Sales Analytics", {}), + ("Item Prices", {"items": "Enabled Items only"}), + ("Delayed Item Report", {"based_on": "Sales Invoice"}), + ("Delayed Item Report", {"based_on": "Delivery Note"}), ("Stock Ageing", {"range1": 30, "range2": 60, "range3": 90, "_optional": True}), ("Stock Ledger Invariant Check", { @@ -60,10 +73,11 @@ class TestReports(unittest.TestCase): def test_execute_all_stock_reports(self): """Test that all script report in stock modules are executable with supported filters""" for report, filter in REPORT_FILTER_TEST_CASES: - execute_script_report( - report_name=report, - module="Stock", - filters=filter, - default_filters=DEFAULT_FILTERS, - optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, - ) + with self.subTest(report=report): + execute_script_report( + report_name=report, + module="Stock", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) diff --git a/erpnext/stock/spec/README.md b/erpnext/stock/spec/README.md new file mode 100644 index 00000000000..f5a3501fe47 --- /dev/null +++ b/erpnext/stock/spec/README.md @@ -0,0 +1,103 @@ +# Implementation notes for Stock Ledger + + +## Important files + +- `stock/stock_ledger.py` +- `controllers/stock_controller.py` +- `stock/valuation.py` + +## What is in an Stock Ledger Entry (SLE)? + +Stock Ledger Entry is a single row in the Stock Ledger. It signifies some +modification of stock for a particular Item in the specified warehouse. + +- `item_code`: item for which ledger entry is made +- `warehouse`: warehouse where inventory is affected +- `actual_qty`: change in qty +- `qty_after_transaction`: quantity available after the transaction is processed +- `incoming_rate`: rate at which inventory was received. +- `is_cancelled`: if 1 then stock ledger entry is cancelled and should not be used +for any business logic except for the code that handles cancellation. +- `posting_date` & `posting_time`: Specify the temporal ordering of stock ledger + entries. Ties are broken by `creation` timestamp. +- `voucher_type`: Many transaction can create SLE, e.g. Stock Entry, Purchase + Invoice +- `voucher_no`: `name` of the transaction that created SLE +- `voucher_detail_no`: `name` of the child table row from parent transaction + that created the SLE. +- `dependant_sle_voucher_detail_no`: cross-warehouse transfers need this + reference in order to update dependent warehouse rates in case of change in + rate. +- `recalculate_rate`: if this is checked in/out rates are recomputed on + transactions. +- `valuation_rate`: current average valuation rate. +- `stock_value`: current total stock value +- `stock_value_difference`: stock value difference made between last and current + entry. This value is booked in accounting ledger. +- `stock_queue`: if FIFO/LIFO is used this represents queue/stack maintained for + computing incoming rate for inventory getting consumed. +- `batch_no`: batch no for which stock entry is made; each stock entry can only + affect one batch number. +- `serial_no`: newline separated list of serial numbers that were added (if + actual_qty > 0) or else removed. Currently multiple serial nos can have single + SLE but this will likely change in future. + + +## Implementation of Stock Ledger + +Stock Ledger Entry affects stock of combinations of (item_code, warehouse) and +optionally batch no if specified. For simplicity, lets avoid batch no. for now. + + +Stock Ledger Entry table stores stock ledger for all combinations of item_code +and warehouse. So whenever any operations are to be performed on said +item-warehouse combination stock ledger is filtered and sorted by posting +datetime. A typical query that will give you individual ledger looks like this: + +```sql +select * +from `tabStock Ledger Entry` as sle +where + is_cancelled = 0 --- cancelled entries don't affect ledger + and item_code = 'item_code' and warehouse = 'warehouse_name' +order by timestamp(posting_date, posting_time), creation +``` + +New entry is just an update to the last entry which is found by looking at last +row in the filter ledger. + + +### Serial nos + +Serial numbers do not follow any valuation method configuration and they are +consumed at rate they were produced unless they are grouped in which case they +are consumed at weighted average rate. + + +### Batch Nos + +Batches are currently NOT consumed as per batch wise valuation rate, instead +global FIFO queue for the item is used for valuation rate. + + +## Creation process of SLEs + +- SLE creation is usually triggered by Stock Transactions using a method + conventionally named `update_stock_ledger()` This might not be defined for + stock transaction and could be specified somewhere in inheritance hierarchy of + controllers. +- This method produces SLE objects which are processed by `make_sl_entries` in + `stock_ledger.py` which commits the SLE to database. +- `update_entries_after` class is used to process ONLY the inserted SLE's queue + and valuation. +- The change in qty is propagated to future entries immediately. Valuation and + queue for future entries is processed in background using repost item + valuation. + + +## Accounting impact + +- Accounting impact for stock transaction is handled by `get_gl_entries()` + method on controllers. Each transaction has different business logic for + booking the accounting impact. diff --git a/erpnext/stock/spec/reposting.md b/erpnext/stock/spec/reposting.md new file mode 100644 index 00000000000..b0d59fe9bb1 --- /dev/null +++ b/erpnext/stock/spec/reposting.md @@ -0,0 +1,38 @@ +# Stock Reposting + +Stock "reposting" is process of re-processing Stock Ledger Entry and GL Entries +in event of backdated stock transaction. + +*Backdated stock transaction*: Any stock transaction for which some +item-warehouse combination has a future transactions. + +## Why is this required? +Stock Ledger is stateful, it maintains queue, qty at any +point in time. So if you do a backdated transaction all future values change, +queues need to be re-evaluated etc. Watch Nabin and Rohit's conference +presentation for explanation: https://www.youtube.com/watch?v=mw3WAnekGIM + +## How is this implemented? +Whenever backdated transaction is detected, instead of +fully processing it while submitting, the processing is queued using "Repost +Item Valuation" doctype. Every hour a scheduled job runs and processes this +queue (for up to maximum of 25 minutes) + + +## Queue implementation +- "Repost item valuation" (RIV) is automatically submitted from backdated transactions. (check stock_controller.py) +- Draft and cancelled RIV are ignored. +- Keep filter of "submitted" documents when doing anything with RIVs. +- The default status is "Queued". +- When background job runs, it picks the oldest pending reposts and changes the status to "In Progress" and when it finishes it +changes to "Completed" +- There are two more status: "Failed" when reposting failed and "Skipped" when reposting is deemed not necessary so it's skipped. +- technical detail: Entry point for whole process is "repost_entries" function in repost_item_valuation.py + + +## How to identify broken stock data: +There are 4 major reports for checking broken stock data: +- Incorrect balance qty after the transaction - to check if the running total of qty isn't correct. +- Incorrect stock value report - to check incorrect value books in accounts for stock transactions +- Incorrect serial no valuation -specific to serial nos +- Stock ledger invariant check - combined report for checking qty, running total, queue, balance value etc diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index 6663458e651..62017e41593 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -3,10 +3,9 @@ import frappe -from frappe.utils import cstr, flt, nowdate, nowtime +from frappe.utils import cstr, flt, now, nowdate, nowtime from erpnext.controllers.stock_controller import create_repost_item_valuation_entry -from erpnext.stock.utils import update_bin def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False): @@ -175,6 +174,7 @@ def update_bin_qty(item_code, warehouse, qty_dict=None): bin.set(field, flt(value)) mismatch = True + bin.modified = now() if mismatch: bin.set_projected_qty() bin.db_update() @@ -227,8 +227,6 @@ def set_stock_balance_as_per_serial_no(item_code=None, posting_date=None, postin "sle_id": sle_doc.name }) - update_bin(args) - create_repost_item_valuation_entry({ "item_code": d[0], "warehouse": d[1], diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 0a7ab4009c0..1b90086440f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -3,11 +3,14 @@ import copy import json +from typing import Optional import frappe from frappe import _ from frappe.model.meta import get_field_precision +from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate +from pypika import CustomFunction import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty @@ -16,14 +19,13 @@ from erpnext.stock.utils import ( get_or_make_bin, get_valuation_method, ) -from erpnext.stock.valuation import FIFOValuation +from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero class NegativeStockError(frappe.ValidationError): pass class SerialNoExistsInFutureTransaction(frappe.ValidationError): pass -_exceptions = frappe.local('stockledger_exceptions') def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): from erpnext.controllers.stock_controller import future_sle_exists @@ -268,11 +270,10 @@ class update_entries_after(object): self.verbose = verbose self.allow_zero_rate = allow_zero_rate self.via_landed_cost_voucher = via_landed_cost_voucher - self.allow_negative_stock = allow_negative_stock \ - or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) + self.item_code = args.get("item_code") + self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed(item_code=self.item_code) self.args = frappe._dict(args) - self.item_code = args.get("item_code") if self.args.sle_id: self.args['name'] = self.args.sle_id @@ -447,6 +448,8 @@ class update_entries_after(object): self.wh_data.qty_after_transaction = sle.qty_after_transaction self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) + elif sle.batch_no and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True): + self.update_batched_values(sle) else: if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no: # assert @@ -461,11 +464,12 @@ class update_entries_after(object): self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: - self.update_fifo_values(sle) - self.wh_data.qty_after_transaction += flt(sle.actual_qty) + self.update_queue_values(sle) # rounding as per precision self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision) + if not self.wh_data.qty_after_transaction: + self.wh_data.stock_value = 0.0 stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value self.wh_data.prev_stock_value = self.wh_data.stock_value @@ -481,6 +485,7 @@ class update_entries_after(object): if not self.args.get("sle_id"): self.update_outgoing_rate_on_transaction(sle) + def validate_negative_stock(self, sle): """ validate negative stock for entries current datetime onwards @@ -629,9 +634,7 @@ class update_entries_after(object): if not self.wh_data.valuation_rate and sle.voucher_detail_no: allow_zero_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_rate: - self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, - sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company) + self.wh_data.valuation_rate = self.get_fallback_rate(sle) def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company @@ -697,42 +700,70 @@ class update_entries_after(object): if not self.wh_data.valuation_rate and sle.voucher_detail_no: allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_valuation_rate: - self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, - sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company) + self.wh_data.valuation_rate = self.get_fallback_rate(sle) - def update_fifo_values(self, sle): + def update_queue_values(self, sle): incoming_rate = flt(sle.incoming_rate) actual_qty = flt(sle.actual_qty) outgoing_rate = flt(sle.outgoing_rate) - fifo_queue = FIFOValuation(self.wh_data.stock_queue) + self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty) + + if self.valuation_method == "LIFO": + stock_queue = LIFOValuation(self.wh_data.stock_queue) + else: + stock_queue = FIFOValuation(self.wh_data.stock_queue) + + _prev_qty, prev_stock_value = stock_queue.get_total_stock_and_value() + if actual_qty > 0: - fifo_queue.add_stock(qty=actual_qty, rate=incoming_rate) + stock_queue.add_stock(qty=actual_qty, rate=incoming_rate) else: def rate_generator() -> float: allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_valuation_rate: - return get_valuation_rate(sle.item_code, sle.warehouse, - sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company), company=sle.company) + return self.get_fallback_rate(sle) else: return 0.0 - fifo_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) + stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) - stock_qty, stock_value = fifo_queue.get_total_stock_and_value() + _qty, stock_value = stock_queue.get_total_stock_and_value() - self.wh_data.stock_queue = fifo_queue.get_state() - self.wh_data.stock_value = stock_value - if stock_qty: - self.wh_data.valuation_rate = stock_value / stock_qty + stock_value_difference = stock_value - prev_stock_value + self.wh_data.stock_queue = stock_queue.state + self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference) if not self.wh_data.stock_queue: self.wh_data.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.wh_data.valuation_rate]) + if self.wh_data.qty_after_transaction: + self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction + def update_batched_values(self, sle): + incoming_rate = flt(sle.incoming_rate) + actual_qty = flt(sle.actual_qty) + + self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty) + + if actual_qty > 0: + stock_value_difference = incoming_rate * actual_qty + else: + outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code, + warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date, + posting_time=sle.posting_time, creation=sle.creation) + if outgoing_rate is None: + # This can *only* happen if qty available for the batch is zero. + # in such case fall back various other rates. + # future entries will correct the overall accounting as each + # batch individually uses moving average rates. + outgoing_rate = self.get_fallback_rate(sle) + stock_value_difference = outgoing_rate * actual_qty + + self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference) + if self.wh_data.qty_after_transaction: + self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction def check_if_allow_zero_valuation_rate(self, voucher_type, voucher_detail_no): ref_item_dt = "" @@ -747,6 +778,13 @@ class update_entries_after(object): else: return 0 + def get_fallback_rate(self, sle) -> float: + """When exact incoming rate isn't available use any of other "average" rates as fallback. + This should only get used for negative stock.""" + return get_valuation_rate(sle.item_code, sle.warehouse, + sle.voucher_type, sle.voucher_no, self.allow_zero_rate, + currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no) + def get_sle_before_datetime(self, args): """get previous stock ledger entry before current time-bucket""" sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False) @@ -893,22 +931,72 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): ['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'], as_dict=1) +def get_batch_incoming_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation=None): + + Timestamp = CustomFunction('timestamp', ['date', 'time']) + + sle = frappe.qb.DocType("Stock Ledger Entry") + + timestamp_condition = (Timestamp(sle.posting_date, sle.posting_time) < Timestamp(posting_date, posting_time)) + if creation: + timestamp_condition |= ( + (Timestamp(sle.posting_date, sle.posting_time) == Timestamp(posting_date, posting_time)) + & (sle.creation < creation) + ) + + batch_details = ( + frappe.qb + .from_(sle) + .select( + Sum(sle.stock_value_difference).as_("batch_value"), + Sum(sle.actual_qty).as_("batch_qty") + ) + .where( + (sle.item_code == item_code) + & (sle.warehouse == warehouse) + & (sle.batch_no == batch_no) + & (sle.is_cancelled == 0) + ) + .where(timestamp_condition) + ).run(as_dict=True) + + if batch_details and batch_details[0].batch_qty: + return batch_details[0].batch_value / batch_details[0].batch_qty + + def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, - allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True): + allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True, batch_no=None): if not company: company = frappe.get_cached_value("Warehouse", warehouse, "company") + last_valuation_rate = None + + # Get moving average rate of a specific batch number + if warehouse and batch_no and frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"): + last_valuation_rate = frappe.db.sql(""" + select sum(stock_value_difference) / sum(actual_qty) + from `tabStock Ledger Entry` + where + item_code = %s + AND warehouse = %s + AND batch_no = %s + AND is_cancelled = 0 + AND NOT (voucher_no = %s AND voucher_type = %s) + """, + (item_code, warehouse, batch_no, voucher_no, voucher_type)) + # Get valuation rate from last sle for the same item and warehouse - last_valuation_rate = frappe.db.sql("""select valuation_rate - from `tabStock Ledger Entry` force index (item_warehouse) - where - item_code = %s - AND warehouse = %s - AND valuation_rate >= 0 - AND is_cancelled = 0 - AND NOT (voucher_no = %s AND voucher_type = %s) - order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type)) + if not last_valuation_rate or last_valuation_rate[0][0] is None: + last_valuation_rate = frappe.db.sql("""select valuation_rate + from `tabStock Ledger Entry` force index (item_warehouse) + where + item_code = %s + AND warehouse = %s + AND valuation_rate >= 0 + AND is_cancelled = 0 + AND NOT (voucher_no = %s AND voucher_type = %s) + order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type)) if not last_valuation_rate: # Get valuation rate from last sle for the item against any warehouse @@ -1045,10 +1133,7 @@ def get_datetime_limit_condition(detail): )""" def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): - allow_negative_stock = cint(allow_negative_stock) \ - or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) - - if allow_negative_stock: + if allow_negative_stock or is_negative_stock_allowed(item_code=args.item_code): return if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"): return @@ -1117,3 +1202,11 @@ def get_future_sle_with_negative_batch_qty(args): and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) limit 1 """, args, as_dict=1) + + +def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool: + if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)): + return True + if item_code and cint(frappe.db.get_value("Item", item_code, "allow_negative_stock", cache=True)): + return True + return False diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py index 85788bac7f8..bdb768f1ade 100644 --- a/erpnext/stock/tests/test_valuation.py +++ b/erpnext/stock/tests/test_valuation.py @@ -1,16 +1,21 @@ +import json import unittest +import frappe from hypothesis import given from hypothesis import strategies as st -from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero +from erpnext.tests.utils import ERPNextTestCase qty_gen = st.floats(min_value=-1e6, max_value=1e6) value_gen = st.floats(min_value=1, max_value=1e6) stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10) -class TestFifoValuation(unittest.TestCase): +class TestFIFOValuation(unittest.TestCase): def setUp(self): self.queue = FIFOValuation([]) @@ -108,11 +113,11 @@ class TestFifoValuation(unittest.TestCase): self.assertTotalQty(0) def test_rounding_off_near_zero(self): - self.assertEqual(_round_off_if_near_zero(0), 0) - self.assertEqual(_round_off_if_near_zero(1), 1) - self.assertEqual(_round_off_if_near_zero(-1), -1) - self.assertEqual(_round_off_if_near_zero(-1e-8), 0) - self.assertEqual(_round_off_if_near_zero(1e-8), 0) + self.assertEqual(round_off_if_near_zero(0), 0) + self.assertEqual(round_off_if_near_zero(1), 1) + self.assertEqual(round_off_if_near_zero(-1), -1) + self.assertEqual(round_off_if_near_zero(-1e-8), 0) + self.assertEqual(round_off_if_near_zero(1e-8), 0) def test_totals(self): self.queue.add_stock(1, 10) @@ -164,3 +169,184 @@ class TestFifoValuation(unittest.TestCase): total_value -= sum(q * r for q, r in consumed) self.assertTotalQty(total_qty) self.assertTotalValue(total_value) + + +class TestLIFOValuation(unittest.TestCase): + + def setUp(self): + self.stack = LIFOValuation([]) + + def tearDown(self): + qty, value = self.stack.get_total_stock_and_value() + self.assertTotalQty(qty) + self.assertTotalValue(value) + + def assertTotalQty(self, qty): + self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4) + + def assertTotalValue(self, value): + self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2) + + def test_simple_addition(self): + self.stack.add_stock(1, 10) + self.assertTotalQty(1) + + def test_merge_new_stock(self): + self.stack.add_stock(1, 10) + self.stack.add_stock(1, 10) + self.assertEqual(self.stack, [[2, 10]]) + + def test_simple_removal(self): + self.stack.add_stock(1, 10) + self.stack.remove_stock(1) + self.assertTotalQty(0) + + def test_adding_negative_stock_keeps_rate(self): + self.stack = LIFOValuation([[-5.0, 100]]) + self.stack.add_stock(1, 10) + self.assertEqual(self.stack, [[-4, 100]]) + + def test_adding_negative_stock_updates_rate(self): + self.stack = LIFOValuation([[-5.0, 100]]) + self.stack.add_stock(6, 10) + self.assertEqual(self.stack, [[1, 10]]) + + def test_rounding_off(self): + self.stack.add_stock(1.0, 1.0) + self.stack.remove_stock(1.0 - 1e-9) + self.assertTotalQty(0) + + def test_lifo_consumption(self): + self.stack.add_stock(10, 10) + self.stack.add_stock(10, 20) + consumed = self.stack.remove_stock(15) + self.assertEqual(consumed, [[10, 20], [5, 10]]) + self.assertTotalQty(5) + + def test_lifo_consumption_going_negative(self): + self.stack.add_stock(10, 10) + self.stack.add_stock(10, 20) + consumed = self.stack.remove_stock(25) + self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]]) + self.assertTotalQty(-5) + + def test_lifo_consumption_multiple(self): + self.stack.add_stock(1, 1) + self.stack.add_stock(2, 2) + consumed = self.stack.remove_stock(1) + self.assertEqual(consumed, [[1, 2]]) + + self.stack.add_stock(3, 3) + consumed = self.stack.remove_stock(4) + self.assertEqual(consumed, [[3, 3], [1, 2]]) + + self.stack.add_stock(4, 4) + consumed = self.stack.remove_stock(5) + self.assertEqual(consumed, [[4, 4], [1, 1]]) + + self.stack.add_stock(5, 5) + consumed = self.stack.remove_stock(5) + self.assertEqual(consumed, [[5, 5]]) + + + @given(stock_queue_generator) + def test_lifo_qty_hypothesis(self, stock_stack): + self.stack = LIFOValuation([]) + total_qty = 0 + + for qty, rate in stock_stack: + if qty == 0: + continue + if qty > 0: + self.stack.add_stock(qty, rate) + total_qty += qty + else: + qty = abs(qty) + consumed = self.stack.remove_stock(qty) + self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") + total_qty -= qty + self.assertTotalQty(total_qty) + + @given(stock_queue_generator) + def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack): + self.stack = LIFOValuation([]) + total_qty = 0.0 + total_value = 0.0 + + for qty, rate in stock_stack: + # don't allow negative stock + if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1: + continue + if qty > 0: + self.stack.add_stock(qty, rate) + total_qty += qty + total_value += qty * rate + else: + qty = abs(qty) + consumed = self.stack.remove_stock(qty) + self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}") + total_qty -= qty + total_value -= sum(q * r for q, r in consumed) + self.assertTotalQty(total_qty) + self.assertTotalValue(total_value) + +class TestLIFOValuationSLE(ERPNextTestCase): + ITEM_CODE = "_Test LIFO item" + WAREHOUSE = "_Test Warehouse - _TC" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"}) + + def _make_stock_entry(self, qty, rate=None): + kwargs = { + "item_code": self.ITEM_CODE, + "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE, + "rate": rate, + "qty": abs(qty), + } + return make_stock_entry(**kwargs) + + def assertStockQueue(self, se, expected_queue): + sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"}) + sle = frappe.get_doc("Stock Ledger Entry", sle_name) + + stock_queue = json.loads(sle.stock_queue) + + total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value() + self.assertEqual(sle.qty_after_transaction, total_qty) + self.assertEqual(sle.stock_value, total_value) + + if total_qty > 0: + self.assertEqual(stock_queue, expected_queue) + + + def test_lifo_values(self): + + in1 = self._make_stock_entry(1, 1) + self.assertStockQueue(in1, [[1, 1]]) + + in2 = self._make_stock_entry(2, 2) + self.assertStockQueue(in2, [[1, 1], [2, 2]]) + + out1 = self._make_stock_entry(-1) + self.assertStockQueue(out1, [[1, 1], [1, 2]]) + + in3 = self._make_stock_entry(3, 3) + self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]]) + + out2 = self._make_stock_entry(-4) + self.assertStockQueue(out2, [[1, 1]]) + + in4 = self._make_stock_entry(4, 4) + self.assertStockQueue(in4, [[1, 1], [4,4]]) + + out3 = self._make_stock_entry(-5) + self.assertStockQueue(out3, []) + + in5 = self._make_stock_entry(5, 5) + self.assertStockQueue(in5, [[5, 5]]) + + out5 = self._make_stock_entry(-5) + self.assertStockQueue(out5, []) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index f620c183ebb..f85a04f9447 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime import erpnext +from erpnext.stock.valuation import FIFOValuation, LIFOValuation class InvalidWarehouseCompany(frappe.ValidationError): pass @@ -176,13 +177,7 @@ def get_latest_stock_balance(): def get_bin(item_code, warehouse): bin = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) if not bin: - bin_obj = frappe.get_doc({ - "doctype": "Bin", - "item_code": item_code, - "warehouse": warehouse, - }) - bin_obj.flags.ignore_permissions = 1 - bin_obj.insert() + bin_obj = _create_bin(item_code, warehouse) else: bin_obj = frappe.get_doc('Bin', bin, for_update=True) bin_obj.flags.ignore_permissions = True @@ -192,53 +187,65 @@ def get_or_make_bin(item_code: str , warehouse: str) -> str: bin_record = frappe.db.get_value('Bin', {'item_code': item_code, 'warehouse': warehouse}) if not bin_record: - bin_obj = frappe.get_doc({ - "doctype": "Bin", - "item_code": item_code, - "warehouse": warehouse, - }) - bin_obj.flags.ignore_permissions = 1 - bin_obj.insert() + bin_obj = _create_bin(item_code, warehouse) bin_record = bin_obj.name - return bin_record -def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False): - """WARNING: This function is deprecated. Inline this function instead of using it.""" - from erpnext.stock.doctype.bin.bin import update_stock - is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item') - if is_stock_item: - bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse")) - update_stock(bin_name, args, allow_negative_stock, via_landed_cost_voucher) - else: - frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code"))) +def _create_bin(item_code, warehouse): + """Create a bin and take care of concurrent inserts.""" + + bin_creation_savepoint = "create_bin" + try: + frappe.db.savepoint(bin_creation_savepoint) + bin_obj = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse) + bin_obj.flags.ignore_permissions = 1 + bin_obj.insert() + except frappe.UniqueValidationError: + frappe.db.rollback(save_point=bin_creation_savepoint) # preserve transaction in postgres + bin_obj = frappe.get_last_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + + return bin_obj @frappe.whitelist() def get_incoming_rate(args, raise_error_if_no_rate=True): """Get Incoming Rate based on valuation method""" - from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate + from erpnext.stock.stock_ledger import ( + get_batch_incoming_rate, + get_previous_sle, + get_valuation_rate, + ) if isinstance(args, str): args = json.loads(args) - in_rate = 0 + voucher_no = args.get('voucher_no') or args.get('name') + + in_rate = None if (args.get("serial_no") or "").strip(): in_rate = get_avg_purchase_rate(args.get("serial_no")) + elif args.get("batch_no") and \ + frappe.db.get_value("Batch", args.get("batch_no"), "use_batchwise_valuation", cache=True): + in_rate = get_batch_incoming_rate( + item_code=args.get('item_code'), + warehouse=args.get('warehouse'), + batch_no=args.get("batch_no"), + posting_date=args.get("posting_date"), + posting_time=args.get("posting_time"), + ) else: valuation_method = get_valuation_method(args.get("item_code")) previous_sle = get_previous_sle(args) - if valuation_method == 'FIFO': + if valuation_method in ('FIFO', 'LIFO'): if previous_sle: previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]') - in_rate = get_fifo_rate(previous_stock_queue, args.get("qty") or 0) if previous_stock_queue else 0 + in_rate = _get_fifo_lifo_rate(previous_stock_queue, args.get("qty") or 0, valuation_method) if previous_stock_queue else 0 elif valuation_method == 'Moving Average': in_rate = previous_sle.get('valuation_rate') or 0 - if not in_rate: - voucher_no = args.get('voucher_no') or args.get('name') + if in_rate is None: in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'), args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'), currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'), - raise_error_if_no_rate=raise_error_if_no_rate) + raise_error_if_no_rate=raise_error_if_no_rate, batch_no=args.get("batch_no")) return flt(in_rate) @@ -254,34 +261,30 @@ def get_valuation_method(item_code): """get valuation method from item or default""" val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True) if not val_method: - val_method = frappe.db.get_value("Stock Settings", None, "valuation_method") or "FIFO" + val_method = frappe.db.get_value("Stock Settings", None, "valuation_method", cache=True) or "FIFO" return val_method def get_fifo_rate(previous_stock_queue, qty): """get FIFO (average) Rate from Queue""" - if flt(qty) >= 0: - total = sum(f[0] for f in previous_stock_queue) - return sum(flt(f[0]) * flt(f[1]) for f in previous_stock_queue) / flt(total) if total else 0.0 - else: - available_qty_for_outgoing, outgoing_cost = 0, 0 - qty_to_pop = abs(flt(qty)) - while qty_to_pop and previous_stock_queue: - batch = previous_stock_queue[0] - if 0 < batch[0] <= qty_to_pop: - # if batch qty > 0 - # not enough or exactly same qty in current batch, clear batch - available_qty_for_outgoing += flt(batch[0]) - outgoing_cost += flt(batch[0]) * flt(batch[1]) - qty_to_pop -= batch[0] - previous_stock_queue.pop(0) - else: - # all from current batch - available_qty_for_outgoing += flt(qty_to_pop) - outgoing_cost += flt(qty_to_pop) * flt(batch[1]) - batch[0] -= qty_to_pop - qty_to_pop = 0 + return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO") - return outgoing_cost / available_qty_for_outgoing +def get_lifo_rate(previous_stock_queue, qty): + """get LIFO (average) Rate from Queue""" + return _get_fifo_lifo_rate(previous_stock_queue, qty, "LIFO") + + +def _get_fifo_lifo_rate(previous_stock_queue, qty, method): + ValuationKlass = LIFOValuation if method == "LIFO" else FIFOValuation + + stock_queue = ValuationKlass(previous_stock_queue) + if flt(qty) >= 0: + total_qty, total_value = stock_queue.get_total_stock_and_value() + return total_value / total_qty if total_qty else 0.0 + else: + popped_bins = stock_queue.remove_stock(abs(flt(qty))) + + total_qty, total_value = ValuationKlass(popped_bins).get_total_stock_and_value() + return total_value / total_qty if total_qty else 0.0 def get_valid_serial_nos(sr_nos, qty=0, item_code=''): """split serial nos, validate and return list of valid serial nos""" diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py index 45c50830995..e2bd1ad4dfe 100644 --- a/erpnext/stock/valuation.py +++ b/erpnext/stock/valuation.py @@ -1,15 +1,54 @@ +from abc import ABC, abstractmethod, abstractproperty from typing import Callable, List, NewType, Optional, Tuple from frappe.utils import flt -FifoBin = NewType("FifoBin", List[float]) +StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...] # Indexes of values inside FIFO bin 2-tuple QTY = 0 RATE = 1 -class FIFOValuation: +class BinWiseValuation(ABC): + + @abstractmethod + def add_stock(self, qty: float, rate: float) -> None: + pass + + @abstractmethod + def remove_stock( + self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None + ) -> List[StockBin]: + pass + + @abstractproperty + def state(self) -> List[StockBin]: + pass + + def get_total_stock_and_value(self) -> Tuple[float, float]: + total_qty = 0.0 + total_value = 0.0 + + for qty, rate in self.state: + total_qty += flt(qty) + total_value += flt(qty) * flt(rate) + + return round_off_if_near_zero(total_qty), round_off_if_near_zero(total_value) + + def __repr__(self): + return str(self.state) + + def __iter__(self): + return iter(self.state) + + def __eq__(self, other): + if isinstance(other, list): + return self.state == other + return type(self) == type(other) and self.state == other.state + + +class FIFOValuation(BinWiseValuation): """Valuation method where a queue of all the incoming stock is maintained. New stock is added at end of the queue. @@ -24,34 +63,14 @@ class FIFOValuation: # ref: https://docs.python.org/3/reference/datamodel.html#slots __slots__ = ["queue",] - def __init__(self, state: Optional[List[FifoBin]]): - self.queue: List[FifoBin] = state if state is not None else [] + def __init__(self, state: Optional[List[StockBin]]): + self.queue: List[StockBin] = state if state is not None else [] - def __repr__(self): - return str(self.queue) - - def __iter__(self): - return iter(self.queue) - - def __eq__(self, other): - if isinstance(other, list): - return self.queue == other - return self.queue == other.queue - - def get_state(self) -> List[FifoBin]: + @property + def state(self) -> List[StockBin]: """Get current state of queue.""" return self.queue - def get_total_stock_and_value(self) -> Tuple[float, float]: - total_qty = 0.0 - total_value = 0.0 - - for qty, rate in self.queue: - total_qty += flt(qty) - total_value += flt(qty) * flt(rate) - - return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value) - def add_stock(self, qty: float, rate: float) -> None: """Update fifo queue with new stock. @@ -78,7 +97,7 @@ class FIFOValuation: def remove_stock( self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None - ) -> List[FifoBin]: + ) -> List[StockBin]: """Remove stock from the queue and return popped bins. args: @@ -117,7 +136,7 @@ class FIFOValuation: fifo_bin = self.queue[index] if qty >= fifo_bin[QTY]: # consume current bin - qty = _round_off_if_near_zero(qty - fifo_bin[QTY]) + qty = round_off_if_near_zero(qty - fifo_bin[QTY]) to_consume = self.queue.pop(index) consumed_bins.append(list(to_consume)) @@ -129,14 +148,109 @@ class FIFOValuation: break else: # qty found in current bin consume it and exit - fifo_bin[QTY] = _round_off_if_near_zero(fifo_bin[QTY] - qty) + fifo_bin[QTY] = round_off_if_near_zero(fifo_bin[QTY] - qty) consumed_bins.append([qty, fifo_bin[RATE]]) qty = 0 return consumed_bins -def _round_off_if_near_zero(number: float, precision: int = 7) -> float: +class LIFOValuation(BinWiseValuation): + """Valuation method where a *stack* of all the incoming stock is maintained. + + New stock is added at top of the stack. + Qty consumption happens on Last In First Out basis. + + Stack is implemented using "bins" of [qty, rate]. + + ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting + Implementation detail: appends and pops both at end of list. + """ + + # specifying the attributes to save resources + # ref: https://docs.python.org/3/reference/datamodel.html#slots + __slots__ = ["stack",] + + def __init__(self, state: Optional[List[StockBin]]): + self.stack: List[StockBin] = state if state is not None else [] + + @property + def state(self) -> List[StockBin]: + """Get current state of stack.""" + return self.stack + + def add_stock(self, qty: float, rate: float) -> None: + """Update lifo stack with new stock. + + args: + qty: new quantity to add + rate: incoming rate of new quantity. + + Behaviour of this is same as FIFO valuation. + """ + if not len(self.stack): + self.stack.append([0, 0]) + + # last row has the same rate, merge new bin. + if self.stack[-1][RATE] == rate: + self.stack[-1][QTY] += qty + else: + # Item has a positive balance qty, add new entry + if self.stack[-1][QTY] > 0: + self.stack.append([qty, rate]) + else: # negative balance qty + qty = self.stack[-1][QTY] + qty + if qty > 0: # new balance qty is positive + self.stack[-1] = [qty, rate] + else: # new balance qty is still negative, maintain same rate + self.stack[-1][QTY] = qty + + + def remove_stock( + self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None + ) -> List[StockBin]: + """Remove stock from the stack and return popped bins. + + args: + qty: quantity to remove + rate: outgoing rate - ignored. Kept for backwards compatibility. + rate_generator: function to be called if stack is not found and rate is required. + """ + if not rate_generator: + rate_generator = lambda : 0.0 # noqa + + consumed_bins = [] + while qty: + if not len(self.stack): + # rely on rate generator. + self.stack.append([0, rate_generator()]) + + # start at the end. + index = -1 + + stock_bin = self.stack[index] + if qty >= stock_bin[QTY]: + # consume current bin + qty = round_off_if_near_zero(qty - stock_bin[QTY]) + to_consume = self.stack.pop(index) + consumed_bins.append(list(to_consume)) + + if not self.stack and qty: + # stock finished, qty still remains to be withdrawn + # negative stock, keep in as a negative bin + self.stack.append([-qty, outgoing_rate or stock_bin[RATE]]) + consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]]) + break + else: + # qty found in current bin consume it and exit + stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty) + consumed_bins.append([qty, stock_bin[RATE]]) + qty = 0 + + return consumed_bins + + +def round_off_if_near_zero(number: float, precision: int = 7) -> float: """Rounds off the number to zero only if number is close to zero for decimal specified in precision. Precision defaults to 7. """ diff --git a/erpnext/templates/generators/item/item.html b/erpnext/templates/generators/item/item.html index 17f6880293c..4070d40d47e 100644 --- a/erpnext/templates/generators/item/item.html +++ b/erpnext/templates/generators/item/item.html @@ -1,4 +1,5 @@ {% extends "templates/web.html" %} +{% from "erpnext/templates/includes/macros.html" import recommended_item_row %} {% block title %} {{ title }} {% endblock %} @@ -9,25 +10,70 @@ {% endblock %} {% block page_content %} -
    +
    {% from "erpnext/templates/includes/macros.html" import product_image %}
    +
    {% include "templates/generators/item/item_image.html" %} {% include "templates/generators/item/item_details.html" %}
    - - {% include "templates/generators/item/item_specifications.html" %} - - {{ doc.website_content or '' }}
    + + +
    + {% set show_recommended_items = recommended_items and shopping_cart.cart_settings.enable_recommendations %} + {% set info_col = 'col-9' if show_recommended_items else 'col-12' %} + + {% set padding_top = 'pt-0' if (show_tabs and tabs) else '' %} + +
    +
    +
    + + {% if show_tabs and tabs %} +
    + + {{ web_block("Section with Tabs", values=tabs, add_container=0, + add_top_padding=0, add_bottom_padding=0) + }} +
    + {% elif website_specifications %} + {% include "templates/generators/item/item_specifications.html"%} + {% endif %} + + + {{ doc.website_content or '' }} + + + {% if shopping_cart.cart_settings.enable_reviews and not doc.has_variants %} + {% include "templates/generators/item/item_reviews.html"%} + {% endif %} +
    +
    +
    + + + {% if show_recommended_items %} + + {% endif %} + +
    {% endblock %} {% block base_scripts %} + {{ include_script("frappe-web.bundle.js") }} {{ include_script("controls.bundle.js") }} {{ include_script("dialog.bundle.js") }} diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html index 167c848eff1..8000a2446be 100644 --- a/erpnext/templates/generators/item/item_add_to_cart.html +++ b/erpnext/templates/generators/item/item_add_to_cart.html @@ -5,54 +5,115 @@
    + {% if cart_settings.show_price and product_info.price %} -
    - {{ product_info.price.formatted_price_sales_uom }} - ({{ product_info.price.formatted_price }} / {{ product_info.uom }}) -
    + {% set price_info = product_info.price %} + +
    + + {{ price_info.formatted_price_sales_uom }} + + + {% if price_info.formatted_mrp %} + + MRP {{ price_info.formatted_mrp }} + + + -{{ price_info.get("formatted_discount_percent") or price_info.get("formatted_discount_rate")}} + + {% endif %} + + + + ({{ price_info.formatted_price }} / {{ product_info.uom }}) + +
    {% else %} {{ _("UOM") }} : {{ product_info.uom }} {% endif %} {% if cart_settings.show_stock_availability %} -
    - {% if product_info.in_stock == 0 %} - - {{ _('Not in stock') }} - +
    + {% if product_info.get("on_backorder") %} + + {{ _('Available on backorder') }} + + {% elif product_info.in_stock == 0 %} + + {{ _('Out of stock') }} + {% elif product_info.in_stock == 1 %} - - {{ _('In stock') }} - {% if product_info.show_stock_qty and product_info.stock_qty %} - ({{ product_info.stock_qty[0][0] }}) - {% endif %} - + + {{ _('In stock') }} + {% if product_info.show_stock_qty and product_info.stock_qty %} + ({{ product_info.stock_qty[0][0] }}) + {% endif %} + {% endif %}
    {% endif %} -
    - {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %} - - {{ _("View in Cart") }} - - - {% endif %} - {% if cart_settings.show_contact_us_button %} - {% include "templates/generators/item/item_inquiry.html" %} - {% endif %} +
    +

    + {{ _(offer.offer_title) }}: + {{ _(offer.offer_subtitle) if offer.offer_subtitle else '' }} + + {{ _("More") }} + +

    +
    + {% endfor %} +
    + {% endif %} + + +
    +
    + + {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %} + + + {% endif %} + + + {% if cart_settings.show_contact_us_button %} + {% include "templates/generators/item/item_inquiry.html" %} + {% endif %} +
    @@ -60,10 +121,11 @@ {% endif %} diff --git a/erpnext/templates/generators/item/item_configure.html b/erpnext/templates/generators/item/item_configure.html index b61ac73072d..e97a275fbd8 100644 --- a/erpnext/templates/generators/item/item_configure.html +++ b/erpnext/templates/generators/item/item_configure.html @@ -3,11 +3,11 @@
    {% if cart_settings.enable_variants | int %} - {% endif %} {% if cart_settings.show_contact_us_button %} diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js index 8eadb842899..231ae0587ed 100644 --- a/erpnext/templates/generators/item/item_configure.js +++ b/erpnext/templates/generators/item/item_configure.js @@ -29,7 +29,7 @@ class ItemConfigure { }); this.dialog = new frappe.ui.Dialog({ - title: __('Configure {0}', [this.item_name]), + title: __('Select Variant for {0}', [this.item_name]), fields, on_hide: () => { set_continue_configuration(); @@ -201,7 +201,7 @@ class ItemConfigure { ${frappe.utils.icon('assets', 'md')} - ${__("Add to Cart")}s + ${__("Add to Cart")} ` : ''; @@ -214,7 +214,7 @@ class ItemConfigure { ? `