diff --git a/.github/stale.yml b/.github/stale.yml index fbf64471567..da15d32680f 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -24,14 +24,4 @@ pulls: :) Also, even if it is closed, you can always reopen the PR when you're ready. Thank you for contributing. -issues: - daysUntilStale: 90 - daysUntilClose: 7 - 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 - closed within a week if no further activity occurs. +only: pulls diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index afabe43fec0..2cf44444cc3 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -115,4 +115,5 @@ jobs: echo "Updating to latest version" git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA" + bench setup requirements --python bench --site test_site migrate diff --git a/.mergify.yml b/.mergify.yml index cc8c0802f12..d7f82e696b4 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -9,6 +9,8 @@ pull_request_rules: - author!=nabinhait - author!=ankush - author!=deepeshgarg007 + - author!=mergify[bot] + - or: - base=version-13 - base=version-12 @@ -19,6 +21,16 @@ pull_request_rules: @{{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: Auto-close PRs on pre-release branch + conditions: + - base=version-13-pre-release + actions: + close: + comment: + message: | + @{{author}}, pre-release branch is not maintained anymore. Releases are directly done by merging hotfix branch to stable branches. + + - name: backport to develop conditions: - label="backport develop" diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index c71ea3648b9..2610c8655ef 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -322,9 +322,9 @@ def get_parent_account(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name from tabAccount where is_group = 1 and docstatus != 2 and company = %s - and %s like %s order by name limit %s, %s""" + and %s like %s order by name limit %s offset %s""" % ("%s", searchfield, "%s", "%s", "%s"), - (filters["company"], "%%%s%%" % txt, start, page_len), + (filters["company"], "%%%s%%" % txt, page_len, start), as_list=1, ) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 145118957bb..8f0fe51e3dc 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1239,7 +1239,7 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters): AND jv.docstatus = 1 AND jv.`{0}` LIKE %(txt)s ORDER BY jv.name DESC - LIMIT %(offset)s, %(limit)s + LIMIT %(limit)s offset %(offset)s """.format( searchfield ), diff --git a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json index d96107678f2..39e90420c7d 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json +++ b/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.json @@ -1,7 +1,6 @@ { "actions": [], "allow_rename": 1, - "autoname": "format:PLE-{YY}-{MM}-{######}", "creation": "2022-05-09 19:35:03.334361", "doctype": "DocType", "editable_grid": 1, @@ -138,11 +137,10 @@ "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2022-05-19 18:04:44.609115", + "modified": "2022-05-30 19:04:55.532171", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Ledger Entry", - "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/accounts/doctype/payment_order/payment_order.py b/erpnext/accounts/doctype/payment_order/payment_order.py index 3c45d20770d..ff9615d14fc 100644 --- a/erpnext/accounts/doctype/payment_order/payment_order.py +++ b/erpnext/accounts/doctype/payment_order/payment_order.py @@ -39,7 +39,7 @@ def get_mop_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """ select mode_of_payment from `tabPayment Order Reference` where parent = %(parent)s and mode_of_payment like %(txt)s - limit %(start)s, %(page_len)s""", + limit %(page_len)s offset %(start)s""", {"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt}, ) @@ -51,7 +51,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters): """ select supplier from `tabPayment Order Reference` where parent = %(parent)s and supplier like %(txt)s and (payment_reference is null or payment_reference='') - limit %(start)s, %(page_len)s""", + limit %(page_len)s offset %(start)s""", {"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt}, ) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index 53b1c64c460..5a86376199c 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -54,8 +54,8 @@ class PeriodClosingVoucher(AccountsController): pce = frappe.db.sql( """select name from `tabPeriod Closing Voucher` - where posting_date > %s and fiscal_year = %s and docstatus = 1""", - (self.posting_date, self.fiscal_year), + where posting_date > %s and fiscal_year = %s and docstatus = 1 and company = %s""", + (self.posting_date, self.fiscal_year, self.company), ) if pce and pce[0][0]: frappe.throw( diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index e83dc0f11e5..e8aee737f29 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -173,7 +173,7 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters): where pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s and (pf.name like %(txt)s) - and pf.disabled = 0 limit %(start)s, %(page_len)s""", + and pf.disabled = 0 limit %(page_len)s offset %(start)s""", args, ) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 42917f811db..7e3597e491e 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -45,8 +45,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. if (this.frm.doc.supplier && this.frm.doc.__islocal) { this.frm.trigger('supplier'); } - - erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype); } refresh(doc) { diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index e6da6669ac2..23ad223e777 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -545,7 +545,16 @@ class PurchaseInvoice(BuyingController): from_repost=from_repost, ) elif self.docstatus == 2: + provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"] make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) + if provisional_entries: + for entry in provisional_entries: + frappe.db.set_value( + "GL Entry", + {"voucher_type": "Purchase Receipt", "voucher_detail_no": entry.voucher_detail_no}, + "is_cancelled", + 1, + ) if update_outstanding == "No": update_outstanding_amt( @@ -1127,7 +1136,7 @@ class PurchaseInvoice(BuyingController): # Stock ledger value is not matching with the warehouse amount if ( self.update_stock - and voucher_wise_stock_value.get(item.name) + and voucher_wise_stock_value.get((item.name, item.warehouse)) and warehouse_debit_amount != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) ): diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 30d26acf3a2..3c70e24cae1 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -27,12 +27,13 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( make_purchase_receipt, ) from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction +from erpnext.stock.tests.test_utils import StockTestMixin test_dependencies = ["Item", "Cost Center", "Payment Term", "Payment Terms Template"] test_ignore = ["Serial No"] -class TestPurchaseInvoice(unittest.TestCase): +class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): @classmethod def setUpClass(self): unlink_payment_on_cancel_of_invoice() @@ -693,6 +694,80 @@ class TestPurchaseInvoice(unittest.TestCase): self.assertEqual(expected_values[gle.account][0], gle.debit) self.assertEqual(expected_values[gle.account][1], gle.credit) + def test_standalone_return_using_pi(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item = self.make_item().name + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + + make_stock_entry(item_code=item, target=warehouse, qty=50, rate=120) + + return_pi = make_purchase_invoice( + is_return=1, + item=item, + qty=-10, + update_stock=1, + rate=100, + company=company, + warehouse=warehouse, + cost_center="Main - TCP1", + ) + + # assert that stock consumption is with actual rate + self.assertGLEs( + return_pi, + [{"credit": 1200, "debit": 0}], + gle_filters={"account": "Stock In Hand - TCP1"}, + ) + + # assert loss booked in COGS + self.assertGLEs( + return_pi, + [{"credit": 0, "debit": 200}], + gle_filters={"account": "Cost of Goods Sold - TCP1"}, + ) + + def test_return_with_lcv(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + create_landed_cost_voucher, + ) + + item = self.make_item().name + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + cost_center = "Main - TCP1" + + pi = make_purchase_invoice( + item=item, + company=company, + warehouse=warehouse, + cost_center=cost_center, + update_stock=1, + qty=10, + rate=100, + ) + + # Create landed cost voucher - will increase valuation of received item by 10 + create_landed_cost_voucher("Purchase Invoice", pi.name, pi.company, charges=100) + return_pi = make_return_doc(pi.doctype, pi.name) + return_pi.save().submit() + + # assert that stock consumption is with actual in rate + self.assertGLEs( + return_pi, + [{"credit": 1100, "debit": 0}], + gle_filters={"account": "Stock In Hand - TCP1"}, + ) + + # assert loss booked in COGS + self.assertGLEs( + return_pi, + [{"credit": 0, "debit": 100}], + gle_filters={"account": "Cost of Goods Sold - TCP1"}, + ) + def test_multi_currency_gle(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", @@ -1526,6 +1601,18 @@ class TestPurchaseInvoice(unittest.TestCase): check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date) + # Cancel purchase invoice to check reverse provisional entry cancellation + pi.cancel() + + expected_gle_for_purchase_receipt_post_pi_cancel = [ + ["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_post_pi_cancel, pr.posting_date + ) + company.enable_provisional_accounting_for_non_stock_items = 0 company.save() diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 9dde85fe12e..aefa9a59ddf 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -52,7 +52,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e me.frm.refresh_fields(); } erpnext.queries.setup_warehouse_query(this.frm); - erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype); } refresh(doc, dt, dn) { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 80b95db8868..327545aa54e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -1790,6 +1790,8 @@ "width": "50%" }, { + "fetch_from": "sales_partner.commission_rate", + "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "hide_days": 1, @@ -2038,7 +2040,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2022-03-08 16:08:53.517903", + "modified": "2022-06-10 03:52:51.409913", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/module_onboarding/accounts/accounts.json b/erpnext/accounts/module_onboarding/accounts/accounts.json index aa7cdf788b0..b9040e33097 100644 --- a/erpnext/accounts/module_onboarding/accounts/accounts.json +++ b/erpnext/accounts/module_onboarding/accounts/accounts.json @@ -13,7 +13,7 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/accounts", "idx": 0, "is_complete": 0, - "modified": "2022-01-18 18:35:52.326688", + "modified": "2022-06-07 14:29:21.352132", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts", diff --git a/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json b/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json index 67553baec74..0973ab39626 100644 --- a/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json +++ b/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json @@ -1,8 +1,8 @@ { - "action": "Watch Video", + "action": "Go to Page", "action_label": "Learn more about Chart of Accounts", "callback_message": "You can continue with the onboarding after exploring this page", - "callback_title": "Awesome Work", + "callback_title": "Explore Chart of Accounts", "creation": "2020-05-13 19:58:20.928127", "description": "# Chart Of Accounts\n\nERPNext sets up a simple chart of accounts for each Company you create, but you can modify it according to business and legal requirements.", "docstatus": 0, @@ -12,7 +12,7 @@ "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2021-08-13 11:46:25.878506", + "modified": "2022-06-07 14:21:26.264769", "modified_by": "Administrator", "name": "Chart of Accounts", "owner": "Administrator", diff --git a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json index 9f4c873e349..b6e9f5cd878 100644 --- a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json +++ b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json @@ -2,14 +2,14 @@ "action": "Create Entry", "action_label": "Manage Sales Tax Templates", "creation": "2020-05-13 19:29:43.844463", - "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n", + "description": "# Setting up Taxes\n\nERPNext lets you configure your taxes so that they are automatically applied in your buying and selling transactions. You can configure them globally or even on Items. ERPNext taxes are pre-configured for most regions.\n\n[Checkout pre-configured taxes](/app/sales-taxes-and-charges-template)\n", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2021-08-13 11:48:37.238610", + "modified": "2022-06-07 14:27:15.906286", "modified_by": "Administrator", "name": "Setup Taxes", "owner": "Administrator", diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index f4a44bd3629..e39f22b4cff 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -211,7 +211,7 @@ def set_address_details( else: party_details.update(get_company_address(company)) - if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order"]: + if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order", "Quotation"]: if party_details.company_address: party_details.update( get_fetch_values(doctype, "company_address", party_details.company_address) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html index f4fd06ba037..f2bf9424f72 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html @@ -42,7 +42,7 @@ {% if(filters.show_future_payments) { %} {% var balance_row = data.slice(-1).pop(); - var start = filters.based_on_payment_terms ? 13 : 11; + var start = report.columns.findIndex((elem) => (elem.fieldname == 'age')); var range1 = report.columns[start].label; var range2 = report.columns[start+1].label; var range3 = report.columns[start+2].label; diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 748bcde4354..0238711a70e 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -172,11 +172,6 @@ frappe.query_reports["Accounts Receivable"] = { "label": __("Show Sales Person"), "fieldtype": "Check", }, - { - "fieldname": "show_remarks", - "label": __("Show Remarks"), - "fieldtype": "Check", - }, { "fieldname": "tax_id", "label": __("Tax Id"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index de9d63d849a..1911152dec1 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -5,7 +5,9 @@ from collections import OrderedDict import frappe -from frappe import _, scrub +from frappe import _, qb, scrub +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Date from frappe.utils import cint, cstr, flt, getdate, nowdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( @@ -41,6 +43,8 @@ def execute(filters=None): class ReceivablePayableReport(object): def __init__(self, filters=None): self.filters = frappe._dict(filters or {}) + self.qb_selection_filter = [] + self.ple = qb.DocType("Payment Ledger Entry") self.filters.report_date = getdate(self.filters.report_date or nowdate()) self.age_as_on = ( getdate(nowdate()) @@ -78,7 +82,7 @@ class ReceivablePayableReport(object): self.skip_total_row = 1 def get_data(self): - self.get_gl_entries() + self.get_ple_entries() self.get_sales_invoices_or_customers_based_on_sales_person() self.voucher_balance = OrderedDict() self.init_voucher_balance() # invoiced, paid, credit_note, outstanding @@ -96,25 +100,25 @@ class ReceivablePayableReport(object): self.get_return_entries() self.data = [] - for gle in self.gl_entries: - self.update_voucher_balance(gle) + + for ple in self.ple_entries: + self.update_voucher_balance(ple) self.build_data() def init_voucher_balance(self): # build all keys, since we want to exclude vouchers beyond the report date - for gle in self.gl_entries: + for ple in self.ple_entries: # get the balance object for voucher_type - key = (gle.voucher_type, gle.voucher_no, gle.party) + key = (ple.voucher_type, ple.voucher_no, ple.party) if not key in self.voucher_balance: self.voucher_balance[key] = frappe._dict( - voucher_type=gle.voucher_type, - voucher_no=gle.voucher_no, - party=gle.party, - party_account=gle.account, - posting_date=gle.posting_date, - account_currency=gle.account_currency, - remarks=gle.remarks if self.filters.get("show_remarks") else None, + voucher_type=ple.voucher_type, + voucher_no=ple.voucher_no, + party=ple.party, + party_account=ple.account, + posting_date=ple.posting_date, + account_currency=ple.account_currency, invoiced=0.0, paid=0.0, credit_note=0.0, @@ -124,23 +128,22 @@ class ReceivablePayableReport(object): credit_note_in_account_currency=0.0, outstanding_in_account_currency=0.0, ) - self.get_invoices(gle) if self.filters.get("group_by_party"): - self.init_subtotal_row(gle.party) + self.init_subtotal_row(ple.party) if self.filters.get("group_by_party"): self.init_subtotal_row("Total") - def get_invoices(self, gle): - if gle.voucher_type in ("Sales Invoice", "Purchase Invoice"): + def get_invoices(self, ple): + if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"): if self.filters.get("sales_person"): - if gle.voucher_no in self.sales_person_records.get( + if ple.voucher_no in self.sales_person_records.get( "Sales Invoice", [] - ) or gle.party in self.sales_person_records.get("Customer", []): - self.invoices.add(gle.voucher_no) + ) or ple.party in self.sales_person_records.get("Customer", []): + self.invoices.add(ple.voucher_no) else: - self.invoices.add(gle.voucher_no) + self.invoices.add(ple.voucher_no) def init_subtotal_row(self, party): if not self.total_row_map.get(party): @@ -162,39 +165,49 @@ class ReceivablePayableReport(object): "range5", ] - def update_voucher_balance(self, gle): + def get_voucher_balance(self, ple): + if self.filters.get("sales_person"): + if not ( + ple.party in self.sales_person_records.get("Customer", []) + or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", []) + ): + return + + key = (ple.against_voucher_type, ple.against_voucher_no, ple.party) + row = self.voucher_balance.get(key) + return row + + def update_voucher_balance(self, ple): # get the row where this balance needs to be updated # if its a payment, it will return the linked invoice or will be considered as advance - row = self.get_voucher_balance(gle) + row = self.get_voucher_balance(ple) if not row: return - # gle_balance will be the total "debit - credit" for receivable type reports and - # and vice-versa for payable type reports - gle_balance = self.get_gle_balance(gle) - gle_balance_in_account_currency = self.get_gle_balance_in_account_currency(gle) - if gle_balance > 0: - if gle.voucher_type in ("Journal Entry", "Payment Entry") and gle.against_voucher: - # debit against sales / purchase invoice - row.paid -= gle_balance - row.paid_in_account_currency -= gle_balance_in_account_currency + amount = ple.amount + amount_in_account_currency = ple.amount_in_account_currency + + # update voucher + if ple.amount > 0: + if ( + ple.voucher_type in ["Journal Entry", "Payment Entry"] + and ple.voucher_no != ple.against_voucher_no + ): + row.paid -= amount + row.paid_in_account_currency -= amount_in_account_currency else: - # invoice - row.invoiced += gle_balance - row.invoiced_in_account_currency += gle_balance_in_account_currency + row.invoiced += amount + row.invoiced_in_account_currency += amount_in_account_currency else: - # payment or credit note for receivables - if self.is_invoice(gle): - # stand alone debit / credit note - row.credit_note -= gle_balance - row.credit_note_in_account_currency -= gle_balance_in_account_currency + if self.is_invoice(ple): + row.credit_note -= amount + row.credit_note_in_account_currency -= amount_in_account_currency else: - # advance / unlinked payment or other adjustment - row.paid -= gle_balance - row.paid_in_account_currency -= gle_balance_in_account_currency + row.paid -= amount + row.paid_in_account_currency -= amount_in_account_currency - if gle.cost_center: - row.cost_center = str(gle.cost_center) + if ple.cost_center: + row.cost_center = str(ple.cost_center) def update_sub_total_row(self, row, party): total_row = self.total_row_map.get(party) @@ -210,39 +223,6 @@ class ReceivablePayableReport(object): self.data.append({}) self.update_sub_total_row(sub_total_row, "Total") - def get_voucher_balance(self, gle): - if self.filters.get("sales_person"): - against_voucher = gle.against_voucher or gle.voucher_no - if not ( - gle.party in self.sales_person_records.get("Customer", []) - or against_voucher in self.sales_person_records.get("Sales Invoice", []) - ): - return - - voucher_balance = None - if gle.against_voucher: - # find invoice - against_voucher = gle.against_voucher - - # If payment is made against credit note - # and credit note is made against a Sales Invoice - # then consider the payment against original sales invoice. - if gle.against_voucher_type in ("Sales Invoice", "Purchase Invoice"): - if gle.against_voucher in self.return_entries: - return_against = self.return_entries.get(gle.against_voucher) - if return_against: - against_voucher = return_against - - voucher_balance = self.voucher_balance.get( - (gle.against_voucher_type, against_voucher, gle.party) - ) - - if not voucher_balance: - # no invoice, this is an invoice / stand-alone payment / credit note - voucher_balance = self.voucher_balance.get((gle.voucher_type, gle.voucher_no, gle.party)) - - return voucher_balance - def build_data(self): # set outstanding for all the accumulated balances # as we can use this to filter out invoices without outstanding @@ -260,6 +240,7 @@ class ReceivablePayableReport(object): if (abs(row.outstanding) > 1.0 / 10**self.currency_precision) and ( abs(row.outstanding_in_account_currency) > 1.0 / 10**self.currency_precision ): + # non-zero oustanding, we must consider this row if self.is_invoice(row) and self.filters.based_on_payment_terms: @@ -669,48 +650,53 @@ class ReceivablePayableReport(object): index = 4 row["range" + str(index + 1)] = row.outstanding - def get_gl_entries(self): + def get_ple_entries(self): # get all the GL entries filtered by the given filters - conditions, values = self.prepare_conditions() - order_by = self.get_order_by_condition() + self.prepare_conditions() if self.filters.show_future_payments: - values.insert(2, self.filters.report_date) - - date_condition = """AND (posting_date <= %s - OR (against_voucher IS NULL AND DATE(creation) <= %s))""" + self.qb_selection_filter.append( + ( + self.ple.posting_date.lte(self.filters.report_date) + | ( + (self.ple.voucher_no == self.ple.against_voucher_no) + & (Date(self.ple.creation).lte(self.filters.report_date)) + ) + ) + ) else: - date_condition = "AND posting_date <=%s" + self.qb_selection_filter.append(self.ple.posting_date.lte(self.filters.report_date)) - if self.filters.get(scrub(self.party_type)): - select_fields = "debit_in_account_currency as debit, credit_in_account_currency as credit" - else: - select_fields = "debit, credit" - - doc_currency_fields = "debit_in_account_currency, credit_in_account_currency" - - remarks = ", remarks" if self.filters.get("show_remarks") else "" - - self.gl_entries = frappe.db.sql( - """ - select - name, posting_date, account, party_type, party, voucher_type, voucher_no, cost_center, - against_voucher_type, against_voucher, account_currency, {0}, {1} {remarks} - from - `tabGL Entry` - where - docstatus < 2 - and is_cancelled = 0 - and party_type=%s - and (party is not null and party != '') - {2} {3} {4}""".format( - select_fields, doc_currency_fields, date_condition, conditions, order_by, remarks=remarks - ), - values, - as_dict=True, + ple = qb.DocType("Payment Ledger Entry") + query = ( + qb.from_(ple) + .select( + ple.account, + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.party_type, + ple.cost_center, + ple.party, + ple.posting_date, + ple.due_date, + ple.account_currency.as_("currency"), + ple.amount, + ple.amount_in_account_currency, + ) + .where(ple.delinked == 0) + .where(Criterion.all(self.qb_selection_filter)) ) + if self.filters.get("group_by_party"): + query = query.orderby(self.ple.party, self.ple.posting_date) + else: + query = query.orderby(self.ple.posting_date, self.ple.party) + + self.ple_entries = query.run(as_dict=True) + def get_sales_invoices_or_customers_based_on_sales_person(self): if self.filters.get("sales_person"): lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"]) @@ -731,23 +717,21 @@ class ReceivablePayableReport(object): self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent) def prepare_conditions(self): - conditions = [""] - values = [self.party_type, self.filters.report_date] + self.qb_selection_filter = [] party_type_field = scrub(self.party_type) - self.add_common_filters(conditions, values, party_type_field) + self.add_common_filters(party_type_field=party_type_field) if party_type_field == "customer": - self.add_customer_filters(conditions, values) + self.add_customer_filters() elif party_type_field == "supplier": - self.add_supplier_filters(conditions, values) + self.add_supplier_filters() if self.filters.cost_center: - self.get_cost_center_conditions(conditions) + self.get_cost_center_conditions() - self.add_accounting_dimensions_filters(conditions, values) - return " and ".join(conditions), values + self.add_accounting_dimensions_filters() def get_cost_center_conditions(self, conditions): lft, rgt = frappe.db.get_value("Cost Center", self.filters.cost_center, ["lft", "rgt"]) @@ -755,32 +739,20 @@ class ReceivablePayableReport(object): center.name for center in frappe.get_list("Cost Center", filters={"lft": (">=", lft), "rgt": ("<=", rgt)}) ] + self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list)) - cost_center_string = '", "'.join(cost_center_list) - conditions.append('cost_center in ("{0}")'.format(cost_center_string)) - - def get_order_by_condition(self): - if self.filters.get("group_by_party"): - return "order by party, posting_date" - else: - return "order by posting_date, party" - - def add_common_filters(self, conditions, values, party_type_field): + def add_common_filters(self, party_type_field): if self.filters.company: - conditions.append("company=%s") - values.append(self.filters.company) + self.qb_selection_filter.append(self.ple.company == self.filters.company) if self.filters.finance_book: - conditions.append("ifnull(finance_book, '') in (%s, '')") - values.append(self.filters.finance_book) + self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book) if self.filters.get(party_type_field): - conditions.append("party=%s") - values.append(self.filters.get(party_type_field)) + self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field)) if self.filters.party_account: - conditions.append("account =%s") - values.append(self.filters.party_account) + self.qb_selection_filter.append(self.ple.account == self.filters.party_account) else: # get GL with "receivable" or "payable" account_type account_type = "Receivable" if self.party_type == "Customer" else "Payable" @@ -792,46 +764,68 @@ class ReceivablePayableReport(object): ] if accounts: - conditions.append("account in (%s)" % ",".join(["%s"] * len(accounts))) - values += accounts + self.qb_selection_filter.append(self.ple.account.isin(accounts)) + + def add_customer_filters( + self, + ): + self.customter = qb.DocType("Customer") - def add_customer_filters(self, conditions, values): if self.filters.get("customer_group"): - conditions.append(self.get_hierarchical_filters("Customer Group", "customer_group")) + self.get_hierarchical_filters("Customer Group", "customer_group") if self.filters.get("territory"): - conditions.append(self.get_hierarchical_filters("Territory", "territory")) + self.get_hierarchical_filters("Territory", "territory") if self.filters.get("payment_terms_template"): - conditions.append("party in (select name from tabCustomer where payment_terms=%s)") - values.append(self.filters.get("payment_terms_template")) + self.qb_selection_filter.append( + self.ple.party_isin( + qb.from_(self.customer).where( + self.customer.payment_terms == self.filters.get("payment_terms_template") + ) + ) + ) if self.filters.get("sales_partner"): - conditions.append("party in (select name from tabCustomer where default_sales_partner=%s)") - values.append(self.filters.get("sales_partner")) - - def add_supplier_filters(self, conditions, values): - if self.filters.get("supplier_group"): - conditions.append( - """party in (select name from tabSupplier - where supplier_group=%s)""" + self.qb_selection_filter.append( + self.ple.party_isin( + qb.from_(self.customer).where( + self.customer.default_sales_partner == self.filters.get("payment_terms_template") + ) + ) + ) + + def add_supplier_filters(self): + supplier = qb.DocType("Supplier") + if self.filters.get("supplier_group"): + self.qb_selection_filter.append( + self.ple.party.isin( + qb.from_(supplier) + .select(supplier.name) + .where(supplier.supplier_group == self.filters.get("supplier_group")) + ) ) - values.append(self.filters.get("supplier_group")) if self.filters.get("payment_terms_template"): - conditions.append("party in (select name from tabSupplier where payment_terms=%s)") - values.append(self.filters.get("payment_terms_template")) + self.qb_selection_filter.append( + self.ple.party.isin( + qb.from_(supplier) + .select(supplier.name) + .where(supplier.payment_terms == self.filters.get("supplier_group")) + ) + ) def get_hierarchical_filters(self, doctype, key): lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"]) - return """party in (select name from tabCustomer - where exists(select name from `tab{doctype}` where lft >= {lft} and rgt <= {rgt} - and name=tabCustomer.{key}))""".format( - doctype=doctype, lft=lft, rgt=rgt, key=key - ) + doc = qb.DocType(doctype) + ple = self.ple + customer = self.customer + groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt)) + customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups)) + self.qb_selection_filter.append(ple.isin(ple.party.isin(customers))) - def add_accounting_dimensions_filters(self, conditions, values): + def add_accounting_dimensions_filters(self): accounting_dimensions = get_accounting_dimensions(as_list=False) if accounting_dimensions: @@ -841,30 +835,16 @@ class ReceivablePayableReport(object): self.filters[dimension.fieldname] = get_dimension_with_children( dimension.document_type, self.filters.get(dimension.fieldname) ) - conditions.append("{0} in %s".format(dimension.fieldname)) - values.append(tuple(self.filters.get(dimension.fieldname))) + self.qb_selection_filter.append( + self.ple[dimension.fieldname].isin(self.filters[dimension.fieldname]) + ) + else: + self.qb_selection_filter.append( + self.ple[dimension.fieldname] == self.filters[dimension.fieldname] + ) - def get_gle_balance(self, gle): - # get the balance of the GL (debit - credit) or reverse balance based on report type - return gle.get(self.dr_or_cr) - self.get_reverse_balance(gle) - - def get_gle_balance_in_account_currency(self, gle): - # get the balance of the GL (debit - credit) or reverse balance based on report type - return gle.get( - self.dr_or_cr + "_in_account_currency" - ) - self.get_reverse_balance_in_account_currency(gle) - - def get_reverse_balance_in_account_currency(self, gle): - return gle.get( - "debit_in_account_currency" if self.dr_or_cr == "credit" else "credit_in_account_currency" - ) - - def get_reverse_balance(self, gle): - # get "credit" balance if report type is "debit" and vice versa - return gle.get("debit" if self.dr_or_cr == "credit" else "credit") - - def is_invoice(self, gle): - if gle.voucher_type in ("Sales Invoice", "Purchase Invoice"): + def is_invoice(self, ple): + if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"): return True def get_party_details(self, party): @@ -926,9 +906,6 @@ class ReceivablePayableReport(object): width=180, ) - if self.filters.show_remarks: - self.add_column(label=_("Remarks"), fieldname="remarks", fieldtype="Text", width=200), - self.add_column(label="Due Date", fieldtype="Date") if self.party_type == "Supplier": diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index f38890e980c..edddbbce219 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -12,6 +12,7 @@ class TestAccountsReceivable(unittest.TestCase): def test_accounts_receivable(self): frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'") frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'") + frappe.db.sql("delete from `tabPayment Ledger Entry` where company='_Test Company 2'") filters = { "company": "_Test Company 2", diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js index d3e836afd10..dd965a9813e 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js @@ -50,7 +50,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "fieldtype": "Link", "options": "Fiscal Year", "default": frappe.defaults.get_user_default("fiscal_year"), - "reqd": 1 + "reqd": 1, + on_change: () => { + frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) { + let year_start_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), "year_start_date"); + frappe.query_report.set_filter_value({ + period_start_date: year_start_date + }); + }); + } }, { "fieldname":"to_fiscal_year", @@ -58,7 +66,15 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "fieldtype": "Link", "options": "Fiscal Year", "default": frappe.defaults.get_user_default("fiscal_year"), - "reqd": 1 + "reqd": 1, + on_change: () => { + frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) { + let year_end_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), "year_end_date"); + frappe.query_report.set_filter_value({ + period_end_date: year_end_date + }); + }); + } }, { "fieldname":"finance_book", diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js index 158ff4d3437..3d37b5898c1 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.js +++ b/erpnext/accounts/report/gross_profit/gross_profit.js @@ -35,7 +35,7 @@ frappe.query_reports["Gross Profit"] = { "fieldname":"group_by", "label": __("Group By"), "fieldtype": "Select", - "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject", + "options": "Invoice\nItem Code\nItem Group\nBrand\nWarehouse\nCustomer\nCustomer Group\nTerritory\nSales Person\nProject\nMonthly\nPayment Term", "default": "Invoice" }, ], diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 9668992e022..526ea9d6e2e 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -4,7 +4,7 @@ import frappe from frappe import _, scrub -from frappe.utils import cint, flt +from frappe.utils import cint, flt, formatdate from erpnext.controllers.queries import get_match_cond from erpnext.stock.utils import get_incoming_rate @@ -124,6 +124,23 @@ def execute(filters=None): "gross_profit", "gross_profit_percent", ], + "monthly": [ + "monthly", + "qty", + "base_rate", + "buying_rate", + "base_amount", + "buying_amount", + "gross_profit", + "gross_profit_percent", + ], + "payment_term": [ + "payment_term", + "base_amount", + "buying_amount", + "gross_profit", + "gross_profit_percent", + ], } ) @@ -317,6 +334,19 @@ def get_columns(group_wise_columns, filters): "options": "territory", "width": 100, }, + "monthly": { + "label": _("Monthly"), + "fieldname": "monthly", + "fieldtype": "Data", + "width": 100, + }, + "payment_term": { + "label": _("Payment Term"), + "fieldname": "payment_term", + "fieldtype": "Link", + "options": "Payment Term", + "width": 170, + }, } ) @@ -390,6 +420,9 @@ class GrossProfitGenerator(object): buying_amount = 0 for row in reversed(self.si_list): + if self.filters.get("group_by") == "Monthly": + row.monthly = formatdate(row.posting_date, "MMM YYYY") + if self.skip_row(row): continue @@ -445,17 +478,7 @@ class GrossProfitGenerator(object): def get_average_rate_based_on_group_by(self): for key in list(self.grouped): - if self.filters.get("group_by") != "Invoice": - for i, row in enumerate(self.grouped[key]): - if i == 0: - new_row = row - else: - new_row.qty += flt(row.qty) - new_row.buying_amount += flt(row.buying_amount, self.currency_precision) - 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) - else: + if self.filters.get("group_by") == "Invoice": for i, row in enumerate(self.grouped[key]): if row.indent == 1.0: if ( @@ -469,6 +492,44 @@ class GrossProfitGenerator(object): if flt(row.qty) or row.base_amount: row = self.set_average_rate(row) self.grouped_data.append(row) + elif self.filters.get("group_by") == "Payment Term": + for i, row in enumerate(self.grouped[key]): + invoice_portion = 0 + + if row.is_return: + invoice_portion = 100 + elif row.invoice_portion: + invoice_portion = row.invoice_portion + else: + invoice_portion = row.payment_amount * 100 / row.base_net_amount + + if i == 0: + new_row = row + self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion) + else: + new_row.qty += flt(row.qty) + self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True) + + new_row = self.set_average_rate(new_row) + self.grouped_data.append(new_row) + else: + for i, row in enumerate(self.grouped[key]): + if i == 0: + new_row = row + else: + new_row.qty += flt(row.qty) + new_row.buying_amount += flt(row.buying_amount, self.currency_precision) + 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) + + def set_average_based_on_payment_term_portion(self, new_row, row, invoice_portion, aggr=False): + cols = ["base_amount", "buying_amount", "gross_profit"] + for col in cols: + if aggr: + new_row[col] += row[col] * invoice_portion / 100 + else: + new_row[col] = row[col] * invoice_portion / 100 def is_not_invoice_row(self, row): return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get( @@ -622,6 +683,20 @@ class GrossProfitGenerator(object): sales_person_cols = "" sales_team_table = "" + if self.filters.group_by == "Payment Term": + payment_term_cols = """,if(`tabSales Invoice`.is_return = 1, + '{0}', + coalesce(schedule.payment_term, '{1}')) as payment_term, + schedule.invoice_portion, + schedule.payment_amount """.format( + _("Sales Return"), _("No Terms") + ) + payment_term_table = """ left join `tabPayment Schedule` schedule on schedule.parent = `tabSales Invoice`.name and + `tabSales Invoice`.is_return = 0 """ + else: + payment_term_cols = "" + payment_term_table = "" + if self.filters.get("sales_invoice"): conditions += " and `tabSales Invoice`.name = %(sales_invoice)s" @@ -644,10 +719,12 @@ class GrossProfitGenerator(object): `tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return, `tabSales Invoice Item`.cost_center {sales_person_cols} + {payment_term_cols} from `tabSales Invoice` inner join `tabSales Invoice Item` on `tabSales Invoice Item`.parent = `tabSales Invoice`.name {sales_team_table} + {payment_term_table} where `tabSales Invoice`.docstatus=1 and `tabSales Invoice`.is_opening!='Yes' {conditions} {match_cond} order by @@ -655,6 +732,8 @@ class GrossProfitGenerator(object): conditions=conditions, sales_person_cols=sales_person_cols, sales_team_table=sales_team_table, + payment_term_cols=payment_term_cols, + payment_term_table=payment_term_table, match_cond=get_match_cond("Sales Invoice"), ), self.filters, diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index 2e7213f42b1..ac706666547 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -443,12 +443,6 @@ def get_grand_total(filters, doctype): ] # nosec -def get_deducted_taxes(): - return frappe.db.sql_list( - "select name from `tabPurchase Taxes and Charges` where add_deduct_tax = 'Deduct'" - ) - - def get_tax_accounts( item_list, columns, @@ -462,6 +456,7 @@ def get_tax_accounts( tax_columns = [] invoice_item_row = {} itemised_tax = {} + add_deduct_tax = "charge_type" tax_amount_precision = ( get_field_precision( @@ -477,13 +472,13 @@ def get_tax_accounts( conditions = "" if doctype == "Purchase Invoice": conditions = " and category in ('Total', 'Valuation and Total') and base_tax_amount_after_discount_amount != 0" + add_deduct_tax = "add_deduct_tax" - deducted_tax = get_deducted_taxes() tax_details = frappe.db.sql( """ select name, parent, description, item_wise_tax_detail, - charge_type, base_tax_amount_after_discount_amount + charge_type, {add_deduct_tax}, base_tax_amount_after_discount_amount from `tab%s` where parenttype = %s and docstatus = 1 @@ -491,12 +486,22 @@ def get_tax_accounts( and parent in (%s) %s order by description - """ + """.format( + add_deduct_tax=add_deduct_tax + ) % (tax_doctype, "%s", ", ".join(["%s"] * len(invoice_item_row)), conditions), tuple([doctype] + list(invoice_item_row)), ) - for name, parent, description, item_wise_tax_detail, charge_type, tax_amount in tax_details: + for ( + name, + parent, + description, + item_wise_tax_detail, + charge_type, + add_deduct_tax, + tax_amount, + ) in tax_details: description = handle_html(description) if description not in tax_columns and tax_amount: # as description is text editor earlier and markup can break the column convention in reports @@ -529,7 +534,9 @@ def get_tax_accounts( if item_tax_amount: tax_value = flt(item_tax_amount, tax_amount_precision) tax_value = ( - tax_value * -1 if (doctype == "Purchase Invoice" and name in deducted_tax) else tax_value + tax_value * -1 + if (doctype == "Purchase Invoice" and add_deduct_tax == "Deduct") + else tax_value ) itemised_tax.setdefault(d.name, {})[description] = frappe._dict( diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index 34b3f032068..33bd3c74965 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -346,9 +346,13 @@ def get_columns(invoice_list, additional_table_columns): def get_conditions(filters): conditions = "" + accounting_dimensions = get_accounting_dimensions(as_list=False) or [] + accounting_dimensions_list = [d.fieldname for d in accounting_dimensions] + if filters.get("company"): conditions += " and company=%(company)s" - if filters.get("customer"): + + if filters.get("customer") and "customer" not in accounting_dimensions_list: conditions += " and customer = %(customer)s" if filters.get("from_date"): @@ -359,32 +363,18 @@ def get_conditions(filters): if filters.get("owner"): conditions += " and owner = %(owner)s" - if filters.get("mode_of_payment"): - conditions += """ and exists(select name from `tabSales Invoice Payment` - where parent=`tabSales Invoice`.name - and ifnull(`tabSales Invoice Payment`.mode_of_payment, '') = %(mode_of_payment)s)""" + def get_sales_invoice_item_field_condition(field, table="Sales Invoice Item") -> str: + if not filters.get(field) or field in accounting_dimensions_list: + return "" + return f""" and exists(select name from `tab{table}` + where parent=`tabSales Invoice`.name + and ifnull(`tab{table}`.{field}, '') = %({field})s)""" - if filters.get("cost_center"): - conditions += """ and exists(select name from `tabSales Invoice Item` - where parent=`tabSales Invoice`.name - and ifnull(`tabSales Invoice Item`.cost_center, '') = %(cost_center)s)""" - - if filters.get("warehouse"): - conditions += """ and exists(select name from `tabSales Invoice Item` - where parent=`tabSales Invoice`.name - and ifnull(`tabSales Invoice Item`.warehouse, '') = %(warehouse)s)""" - - if filters.get("brand"): - conditions += """ and exists(select name from `tabSales Invoice Item` - where parent=`tabSales Invoice`.name - and ifnull(`tabSales Invoice Item`.brand, '') = %(brand)s)""" - - if filters.get("item_group"): - conditions += """ and exists(select name from `tabSales Invoice Item` - where parent=`tabSales Invoice`.name - and ifnull(`tabSales Invoice Item`.item_group, '') = %(item_group)s)""" - - accounting_dimensions = get_accounting_dimensions(as_list=False) + conditions += get_sales_invoice_item_field_condition("mode_of_payments", "Sales Invoice Payment") + conditions += get_sales_invoice_item_field_condition("cost_center") + conditions += get_sales_invoice_item_field_condition("warehouse") + conditions += get_sales_invoice_item_field_condition("brand") + conditions += get_sales_invoice_item_field_condition("item_group") if accounting_dimensions: common_condition = """ diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index e5a4ed2f347..6bd08ad837a 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -160,14 +160,12 @@ def get_rootwise_opening_balances(filters, report_type): if filters.project: additional_conditions += " and project = %(project)s" - if filters.finance_book: - fb_conditions = " AND finance_book = %(finance_book)s" - if filters.include_default_book_entries: - fb_conditions = ( - " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" - ) - - additional_conditions += fb_conditions + if filters.get("include_default_book_entries"): + additional_conditions += ( + " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" + ) + else: + additional_conditions += " AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)" accounting_dimensions = get_accounting_dimensions(as_list=False) diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py index 19fe74fffc1..3f06c30adb6 100644 --- a/erpnext/accounts/test/test_reports.py +++ b/erpnext/accounts/test/test_reports.py @@ -28,6 +28,7 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ ("Item-wise Sales Register", {}), ("Item-wise Purchase Register", {}), ("Sales Register", {}), + ("Sales Register", {"item_group": "All Item Groups"}), ("Purchase Register", {}), ( "Tax Detail", diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py index 77c40bae2d9..882cd694a32 100644 --- a/erpnext/accounts/test/test_utils.py +++ b/erpnext/accounts/test/test_utils.py @@ -62,8 +62,8 @@ class TestUtils(unittest.TestCase): stock_entry = {"item": item, "to_warehouse": "_Test Warehouse - _TC", "qty": 1, "rate": 10} se1 = make_stock_entry(posting_date="2022-01-01", **stock_entry) - se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry) se3 = make_stock_entry(posting_date="2022-03-01", **stock_entry) + se2 = make_stock_entry(posting_date="2022-02-01", **stock_entry) for doc in (se1, se2, se3): vouchers.append((doc.doctype, doc.name)) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 1869cc7b290..2d86dead2fd 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2,14 +2,18 @@ # License: GNU General Public License v3. See license.txt +import itertools from json import loads -from typing import List, Tuple +from typing import TYPE_CHECKING, List, Optional, Tuple import frappe import frappe.defaults from frappe import _, qb, throw from frappe.model.meta import get_field_precision +from frappe.query_builder.utils import DocType from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate +from pypika import Order +from pypika.terms import ExistsCriterion import erpnext @@ -19,6 +23,9 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g from erpnext.stock import get_warehouse_account_map from erpnext.stock.utils import get_stock_value_on +if TYPE_CHECKING: + from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import RepostItemValuation + class FiscalYearError(frappe.ValidationError): pass @@ -28,6 +35,9 @@ class PaymentEntryUnlinkError(frappe.ValidationError): pass +GL_REPOSTING_CHUNK = 100 + + @frappe.whitelist() def get_fiscal_year( date=None, fiscal_year=None, label="Date", verbose=1, company=None, as_dict=False @@ -42,37 +52,32 @@ def get_fiscal_years( if not fiscal_years: # if year start date is 2012-04-01, year end date should be 2013-03-31 (hence subdate) - cond = "" - if fiscal_year: - cond += " and fy.name = {0}".format(frappe.db.escape(fiscal_year)) - if company: - cond += """ - and (not exists (select name - from `tabFiscal Year Company` fyc - where fyc.parent = fy.name) - or exists(select company - from `tabFiscal Year Company` fyc - where fyc.parent = fy.name - and fyc.company=%(company)s) - ) - """ + FY = DocType("Fiscal Year") - fiscal_years = frappe.db.sql( - """ - select - fy.name, fy.year_start_date, fy.year_end_date - from - `tabFiscal Year` fy - where - disabled = 0 {0} - order by - fy.year_start_date desc""".format( - cond - ), - {"company": company}, - as_dict=True, + query = ( + frappe.qb.from_(FY) + .select(FY.name, FY.year_start_date, FY.year_end_date) + .where(FY.disabled == 0) ) + if fiscal_year: + query = query.where(FY.name == fiscal_year) + + if company: + FYC = DocType("Fiscal Year Company") + query = query.where( + ExistsCriterion(frappe.qb.from_(FYC).select(FYC.name).where(FYC.parent == FY.name)).negate() + | ExistsCriterion( + frappe.qb.from_(FYC) + .select(FYC.company) + .where(FYC.parent == FY.name) + .where(FYC.company == company) + ) + ) + + query = query.orderby(FY.year_start_date, Order.desc) + fiscal_years = query.run(as_dict=True) + frappe.cache().hset("fiscal_years", company, fiscal_years) if not transaction_date and not fiscal_year: @@ -1122,38 +1127,62 @@ def update_gl_entries_after( def repost_gle_for_stock_vouchers( - stock_vouchers, posting_date, company=None, warehouse_account=None + stock_vouchers: List[Tuple[str, str]], + posting_date: str, + company: Optional[str] = None, + warehouse_account=None, + repost_doc: Optional["RepostItemValuation"] = None, ): + + from erpnext.accounts.general_ledger import toggle_debit_credit_if_negative + if not stock_vouchers: return - def _delete_gl_entries(voucher_type, voucher_no): - frappe.db.sql( - """delete from `tabGL Entry` - where voucher_type=%s and voucher_no=%s""", - (voucher_type, voucher_no), - ) - - stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers) - if not warehouse_account: warehouse_account = get_warehouse_account_map(company) + stock_vouchers = sort_stock_vouchers_by_posting_date(stock_vouchers) + if repost_doc and repost_doc.gl_reposting_index: + # Restore progress + stock_vouchers = stock_vouchers[cint(repost_doc.gl_reposting_index) :] + precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2 - gle = get_voucherwise_gl_entries(stock_vouchers, posting_date) - for voucher_type, voucher_no in stock_vouchers: - existing_gle = gle.get((voucher_type, voucher_no), []) - voucher_obj = frappe.get_cached_doc(voucher_type, voucher_no) - expected_gle = voucher_obj.get_gl_entries(warehouse_account) - if expected_gle: - if not existing_gle or not compare_existing_and_expected_gle( - existing_gle, expected_gle, precision - ): + stock_vouchers_iterator = iter(stock_vouchers) + + while stock_vouchers_chunk := list(itertools.islice(stock_vouchers_iterator, GL_REPOSTING_CHUNK)): + gle = get_voucherwise_gl_entries(stock_vouchers_chunk, posting_date) + + for voucher_type, voucher_no in stock_vouchers_chunk: + existing_gle = gle.get((voucher_type, voucher_no), []) + voucher_obj = frappe.get_doc(voucher_type, voucher_no) + # Some transactions post credit as negative debit, this is handled while posting GLE + # but while comparing we need to make sure it's flipped so comparisons are accurate + expected_gle = toggle_debit_credit_if_negative(voucher_obj.get_gl_entries(warehouse_account)) + if expected_gle: + if not existing_gle or not compare_existing_and_expected_gle( + existing_gle, expected_gle, precision + ): + _delete_gl_entries(voucher_type, voucher_no) + voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True) + else: _delete_gl_entries(voucher_type, voucher_no) - voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True) - else: - _delete_gl_entries(voucher_type, voucher_no) + frappe.db.commit() + + if repost_doc: + repost_doc.db_set( + "gl_reposting_index", + cint(repost_doc.gl_reposting_index) + GL_REPOSTING_CHUNK, + ) + + +def _delete_gl_entries(voucher_type, voucher_no): + frappe.db.sql( + """delete from `tabGL Entry` + where voucher_type=%s and voucher_no=%s""", + (voucher_type, voucher_no), + ) def sort_stock_vouchers_by_posting_date( @@ -1167,6 +1196,9 @@ def sort_stock_vouchers_by_posting_date( .select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation) .where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos))) .groupby(sle.voucher_type, sle.voucher_no) + .orderby(sle.posting_date) + .orderby(sle.posting_time) + .orderby(sle.creation) ).run(as_dict=True) sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles] diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json index a456c7fb57a..61f62254593 100644 --- a/erpnext/accounts/workspace/accounting/accounting.json +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -504,18 +504,6 @@ "onboard": 0, "type": "Link" }, - { - "dependencies": "GL Entry", - "hidden": 0, - "is_query_report": 1, - "label": "DATEV Export", - "link_count": 0, - "link_to": "DATEV", - "link_type": "Report", - "onboard": 0, - "only_for": "Germany", - "type": "Link" - }, { "dependencies": "GL Entry", "hidden": 0, @@ -1024,16 +1012,16 @@ "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, + "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, @@ -1235,13 +1223,14 @@ "type": "Link" } ], - "modified": "2022-01-13 17:25:09.835345", + "modified": "2022-06-10 15:49:42.990860", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting", "owner": "Administrator", "parent_page": "", "public": 1, + "quick_lists": [], "restrict_to_domain": "", "roles": [], "sequence_id": 2.0, diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index c9e67987c6b..da45610eaff 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -43,8 +43,6 @@ frappe.ui.form.on("Purchase Order", { erpnext.queries.setup_queries(frm, "Warehouse", function() { return erpnext.queries.warehouse(frm.doc); }); - - erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, apply_tds: function(frm) { diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index d39aec19bab..67affe770fb 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -285,7 +285,7 @@ def get_supplier_contacts(doctype, txt, searchfield, start, page_len, filters): """select `tabContact`.name from `tabContact`, `tabDynamic Link` where `tabDynamic Link`.link_doctype = 'Supplier' and (`tabDynamic Link`.link_name=%(name)s and `tabDynamic Link`.link_name like %(txt)s) and `tabContact`.name = `tabDynamic Link`.parent - limit %(start)s, %(page_len)s""", + limit %(page_len)s offset %(start)s""", {"start": start, "page_len": page_len, "txt": "%%%s%%" % txt, "name": filters.get("supplier")}, ) diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 97d0ba0b9c1..43152e89a83 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -84,6 +84,9 @@ class Supplier(TransactionBase): self.save() def validate_internal_supplier(self): + if not self.is_internal_supplier: + self.represents_company = "" + internal_supplier = frappe.db.get_value( "Supplier", { diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index bebfa6c76f5..854c0d00f54 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1465,8 +1465,8 @@ class AccountsController(TransactionBase): if not party_gle_currency and (party_account_currency != self.currency): frappe.throw( - _("Party Account {0} currency and document currency should be same").format( - frappe.bold(party_account) + _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format( + frappe.bold(party_account), party_account_currency, self.currency ) ) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index eeb5a7f1a95..1497b182e59 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -33,7 +33,7 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), idx desc, name, employee_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "fields": ", ".join(fields), "key": searchfield, @@ -65,7 +65,7 @@ def lead_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, company_name), locate(%(_txt)s, company_name), 99999), idx desc, name, lead_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{"fields": ", ".join(fields), "key": searchfield, "mcond": get_match_cond(doctype)} ), {"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len}, @@ -100,7 +100,7 @@ def customer_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, customer_name), locate(%(_txt)s, customer_name), 99999), idx desc, name, customer_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "fields": ", ".join(fields), "scond": searchfields, @@ -137,7 +137,7 @@ def supplier_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999), idx desc, name, supplier_name - limit %(start)s, %(page_len)s """.format( + limit %(page_len)s offset %(start)s""".format( **{"field": ", ".join(fields), "key": searchfield, "mcond": get_match_cond(doctype)} ), {"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len}, @@ -167,7 +167,7 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters): AND `{searchfield}` LIKE %(txt)s {mcond} ORDER BY idx DESC, name - LIMIT %(offset)s, %(limit)s + LIMIT %(limit)s offset %(offset)s """.format( account_type_condition=account_type_condition, searchfield=searchfield, @@ -351,7 +351,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), idx desc, `tabProject`.name asc - limit {start}, {page_len}""".format( + limit {page_len} offset {start}""".format( fields=", ".join(["`tabProject`.{0}".format(f) for f in fields]), cond=cond, scond=searchfields, @@ -383,7 +383,7 @@ def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, and return_against in (select name from `tabDelivery Note` where per_billed < 100) ) ) - %(mcond)s order by `tabDelivery Note`.`%(key)s` asc limit %(start)s, %(page_len)s + %(mcond)s order by `tabDelivery Note`.`%(key)s` asc limit %(page_len)s offset %(start)s """ % { "fields": ", ".join(["`tabDelivery Note`.{0}".format(f) for f in fields]), @@ -456,7 +456,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): {match_conditions} group by batch_no {having_clause} order by batch.expiry_date, sle.batch_no desc - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( search_columns=search_columns, cond=cond, match_conditions=get_match_cond(doctype), @@ -483,7 +483,7 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): {match_conditions} order by expiry_date, name desc - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( cond, search_columns=search_columns, search_cond=search_cond, @@ -662,7 +662,7 @@ def warehouse_query(doctype, txt, searchfield, start, page_len, filters): {fcond} {mcond} order by ifnull(`tabBin`.actual_qty, 0) desc limit - {start}, {page_len} + {page_len} offset {start} """.format( bin_conditions=get_filters_cond( doctype, filter_dict.get("Bin"), bin_conditions, ignore_permissions=True diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index bd4b59b3855..d24ac3f2cf3 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -316,7 +316,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype): return data[0] -def make_return_doc(doctype, source_name, target_doc=None): +def make_return_doc(doctype: str, source_name: str, target_doc=None): from frappe.model.mapper import get_mapped_doc from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 3c0a10e0860..517e080c972 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -35,7 +35,8 @@ status_map = { ["Draft", None], ["Open", "eval:self.docstatus==1"], ["Lost", "eval:self.status=='Lost'"], - ["Ordered", "has_sales_order"], + ["Partially Ordered", "is_partially_ordered"], + ["Ordered", "is_fully_ordered"], ["Cancelled", "eval:self.docstatus==2"], ], "Sales Order": [ diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py index 61b4b9ee1f4..1f649c7b486 100644 --- a/erpnext/e_commerce/redisearch_utils.py +++ b/erpnext/e_commerce/redisearch_utils.py @@ -38,7 +38,7 @@ def is_search_module_loaded(): 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) + (" ".join([frappe.as_unicode(s) for s in o if not isinstance(s, int)]) for o in out) ) return "search" in parsed_output except Exception: diff --git a/erpnext/erpnext_integrations/connectors/github_connection.py b/erpnext/erpnext_integrations/connectors/github_connection.py deleted file mode 100644 index f28065e7248..00000000000 --- a/erpnext/erpnext_integrations/connectors/github_connection.py +++ /dev/null @@ -1,44 +0,0 @@ -import frappe -from frappe.data_migration.doctype.data_migration_connector.connectors.base import BaseConnection -from github import Github - -class GithubConnection(BaseConnection): - def __init__(self, connector): - self.connector = connector - - try: - password = self.get_password() - except frappe.AuthenticationError: - password = None - - if self.connector.username and password: - self.connection = Github(self.connector.username, self.get_password()) - else: - self.connection = Github() - - self.name_field = 'id' - - def insert(self, doctype, doc): - pass - - def update(self, doctype, doc, migration_id): - pass - - def delete(self, doctype, migration_id): - pass - - def get(self, remote_objectname, fields=None, filters=None, start=0, page_length=10): - repo = filters.get('repo') - - if remote_objectname == 'Milestone': - return self.get_milestones(repo, start, page_length) - if remote_objectname == 'Issue': - return self.get_issues(repo, start, page_length) - - def get_milestones(self, repo, start=0, page_length=10): - _repo = self.connection.get_repo(repo) - return list(_repo.get_milestones()[start:start+page_length]) - - def get_issues(self, repo, start=0, page_length=10): - _repo = self.connection.get_repo(repo) - return list(_repo.get_issues()[start:start+page_length]) diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py deleted file mode 100644 index 616ecfbac68..00000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -import frappe - - -def pre_process(issue): - - project = frappe.db.get_value("Project", filters={"project_name": issue.milestone}) - return { - "title": issue.title, - "body": frappe.utils.md_to_html(issue.body or ""), - "state": issue.state.title(), - "project": project or "", - } diff --git a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json b/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json deleted file mode 100644 index e945ba22617..00000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/issue_to_task/issue_to_task.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "condition": "{\"repo\":\"frappe/erpnext\"}", - "creation": "2017-10-16 16:03:32.772191", - "docstatus": 0, - "doctype": "Data Migration Mapping", - "fields": [ - { - "is_child_table": 0, - "local_fieldname": "subject", - "remote_fieldname": "title" - }, - { - "is_child_table": 0, - "local_fieldname": "description", - "remote_fieldname": "body" - }, - { - "is_child_table": 0, - "local_fieldname": "status", - "remote_fieldname": "state" - } - ], - "idx": 0, - "local_doctype": "Task", - "local_primary_key": "name", - "mapping_name": "Issue to Task", - "mapping_type": "Pull", - "migration_id_field": "github_sync_id", - "modified": "2017-10-20 11:48:54.575993", - "modified_by": "Administrator", - "name": "Issue to Task", - "owner": "Administrator", - "page_length": 10, - "remote_objectname": "Issue", - "remote_primary_key": "id" -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py deleted file mode 100644 index d44fc0454ca..00000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -def pre_process(milestone): - return { - "title": milestone.title, - "description": milestone.description, - "state": milestone.state.title(), - } diff --git a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json b/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json deleted file mode 100644 index 5a3e07e37ea..00000000000 --- a/erpnext/erpnext_integrations/data_migration_mapping/milestone_to_project/milestone_to_project.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "condition": "{\"repo\": \"frappe/erpnext\"}", - "creation": "2017-10-13 11:16:49.664925", - "docstatus": 0, - "doctype": "Data Migration Mapping", - "fields": [ - { - "is_child_table": 0, - "local_fieldname": "project_name", - "remote_fieldname": "title" - }, - { - "is_child_table": 0, - "local_fieldname": "notes", - "remote_fieldname": "description" - }, - { - "is_child_table": 0, - "local_fieldname": "status", - "remote_fieldname": "state" - } - ], - "idx": 0, - "local_doctype": "Project", - "local_primary_key": "project_name", - "mapping_name": "Milestone to Project", - "mapping_type": "Pull", - "migration_id_field": "github_sync_id", - "modified": "2017-10-20 11:48:54.552305", - "modified_by": "Administrator", - "name": "Milestone to Project", - "owner": "Administrator", - "page_length": 10, - "remote_objectname": "Milestone", - "remote_primary_key": "id" -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json b/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json deleted file mode 100644 index 20eb387cd85..00000000000 --- a/erpnext/erpnext_integrations/data_migration_plan/github_sync/github_sync.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "creation": "2017-10-13 11:16:53.600026", - "docstatus": 0, - "doctype": "Data Migration Plan", - "idx": 0, - "mappings": [ - { - "enabled": 1, - "mapping": "Milestone to Project" - }, - { - "enabled": 1, - "mapping": "Issue to Task" - } - ], - "modified": "2017-10-20 11:48:54.496123", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "GitHub Sync", - "owner": "Administrator", - "plan_name": "GitHub Sync" -} \ No newline at end of file diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 1c4bbbc3fc1..7d7f65dfd7a 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -392,9 +392,12 @@ after_migrate = ["erpnext.setup.install.update_select_perm_after_install"] scheduler_events = { "cron": { + "0/5 * * * *": [ + "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs", + ], "0/30 * * * *": [ "erpnext.utilities.doctype.video.video.update_youtube_data", - ] + ], }, "all": [ "erpnext.projects.doctype.project.project.project_status_update_reminder", diff --git a/erpnext/hr/doctype/employee/employee.json b/erpnext/hr/doctype/employee/employee.json index a3638e1a650..42479143e74 100644 --- a/erpnext/hr/doctype/employee/employee.json +++ b/erpnext/hr/doctype/employee/employee.json @@ -827,7 +827,7 @@ "idx": 24, "image_field": "image", "links": [], - "modified": "2022-04-22 16:21:55.811983", + "modified": "2022-06-10 01:29:32.952091", "modified_by": "Administrator", "module": "HR", "name": "Employee", @@ -872,7 +872,6 @@ ], "search_fields": "employee_name", "show_name_in_global_search": 1, - "show_title_field_in_link": 1, "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/erpnext/hr/doctype/leave_application/leave_application.js b/erpnext/hr/doctype/leave_application/leave_application.js index 85997a4087f..ee00e6719c0 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.js +++ b/erpnext/hr/doctype/leave_application/leave_application.js @@ -173,7 +173,7 @@ frappe.ui.form.on("Leave Application", { date: frm.doc.from_date, to_date: frm.doc.to_date, leave_type: frm.doc.leave_type, - consider_all_leaves_in_the_allocation_period: true + consider_all_leaves_in_the_allocation_period: 1 }, callback: function (r) { if (!r.exc && r.message) { diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py index cd6b1686675..43c2bb37b21 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -88,7 +88,7 @@ class LeaveApplication(Document): share_doc_with_approver(self, self.leave_approver) def on_submit(self): - if self.status == "Open": + if self.status in ["Open", "Cancelled"]: frappe.throw( _("Only Leave Applications with status 'Approved' and 'Rejected' can be submitted") ) @@ -757,22 +757,6 @@ def get_leave_details(employee, date): leave_allocation = {} for d in allocation_records: allocation = allocation_records.get(d, frappe._dict()) - - total_allocated_leaves = ( - frappe.db.get_value( - "Leave Allocation", - { - "from_date": ("<=", date), - "to_date": (">=", date), - "employee": employee, - "leave_type": allocation.leave_type, - "docstatus": 1, - }, - "SUM(total_leaves_allocated)", - ) - or 0 - ) - remaining_leaves = get_leave_balance_on( employee, d, date, to_date=allocation.to_date, consider_all_leaves_in_the_allocation_period=True ) @@ -782,10 +766,11 @@ def get_leave_details(employee, date): leaves_pending = get_leaves_pending_approval_for_period( employee, d, allocation.from_date, end_date ) + expired_leaves = allocation.total_leaves_allocated - (remaining_leaves + leaves_taken) leave_allocation[d] = { - "total_leaves": total_allocated_leaves, - "expired_leaves": total_allocated_leaves - (remaining_leaves + leaves_taken), + "total_leaves": allocation.total_leaves_allocated, + "expired_leaves": expired_leaves if expired_leaves > 0 else 0, "leaves_taken": leaves_taken, "leaves_pending_approval": leaves_pending, "remaining_leaves": remaining_leaves, @@ -830,7 +815,7 @@ def get_leave_balance_on( allocation_records = get_leave_allocation_records(employee, date, leave_type) allocation = allocation_records.get(leave_type, frappe._dict()) - end_date = allocation.to_date if consider_all_leaves_in_the_allocation_period else date + end_date = allocation.to_date if cint(consider_all_leaves_in_the_allocation_period) else date cf_expiry = get_allocation_expiry_for_cf_leaves(employee, leave_type, to_date, date) leaves_taken = get_leaves_for_period(employee, leave_type, allocation.from_date, end_date) @@ -1117,7 +1102,7 @@ def add_leaves(events, start, end, filter_conditions=None): WHERE from_date <= %(end)s AND to_date >= %(start)s <= to_date AND docstatus < 2 - AND status != 'Rejected' + AND status in ('Approved', 'Open') """ if conditions: @@ -1201,24 +1186,33 @@ def get_mandatory_approval(doctype): def get_approved_leaves_for_period(employee, leave_type, from_date, to_date): - query = """ - select employee, leave_type, from_date, to_date, total_leave_days - from `tabLeave Application` - where employee=%(employee)s - and docstatus=1 - and (from_date between %(from_date)s and %(to_date)s - or to_date between %(from_date)s and %(to_date)s - or (from_date < %(from_date)s and to_date > %(to_date)s)) - """ - if leave_type: - query += "and leave_type=%(leave_type)s" - - leave_applications = frappe.db.sql( - query, - {"from_date": from_date, "to_date": to_date, "employee": employee, "leave_type": leave_type}, - as_dict=1, + LeaveApplication = frappe.qb.DocType("Leave Application") + query = ( + frappe.qb.from_(LeaveApplication) + .select( + LeaveApplication.employee, + LeaveApplication.leave_type, + LeaveApplication.from_date, + LeaveApplication.to_date, + LeaveApplication.total_leave_days, + ) + .where( + (LeaveApplication.employee == employee) + & (LeaveApplication.docstatus == 1) + & (LeaveApplication.status == "Approved") + & ( + (LeaveApplication.from_date.between(from_date, to_date)) + | (LeaveApplication.to_date.between(from_date, to_date)) + | ((LeaveApplication.from_date < from_date) & (LeaveApplication.to_date > to_date)) + ) + ) ) + if leave_type: + query = query.where(LeaveApplication.leave_type == leave_type) + + leave_applications = query.run(as_dict=True) + leave_days = 0 for leave_app in leave_applications: if leave_app.from_date >= getdate(from_date) and leave_app.to_date <= getdate(to_date): diff --git a/erpnext/hr/doctype/leave_application/leave_application_list.js b/erpnext/hr/doctype/leave_application/leave_application_list.js index a3c03b1bec7..157271a5a0e 100644 --- a/erpnext/hr/doctype/leave_application/leave_application_list.js +++ b/erpnext/hr/doctype/leave_application/leave_application_list.js @@ -1,13 +1,14 @@ -frappe.listview_settings['Leave Application'] = { +frappe.listview_settings["Leave Application"] = { add_fields: ["leave_type", "employee", "employee_name", "total_leave_days", "from_date", "to_date"], has_indicator_for_draft: 1, get_indicator: function (doc) { - if (doc.status === "Approved") { - return [__("Approved"), "green", "status,=,Approved"]; - } else if (doc.status === "Rejected") { - return [__("Rejected"), "red", "status,=,Rejected"]; - } else { - return [__("Open"), "red", "status,=,Open"]; - } + let status_color = { + "Approved": "green", + "Rejected": "red", + "Open": "orange", + "Cancelled": "red", + "Submitted": "blue" + }; + return [__(doc.status), status_color[doc.status], "status,=," + doc.status]; } }; diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 7506c611083..27c54109dea 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -76,7 +76,14 @@ _test_records = [ class TestLeaveApplication(unittest.TestCase): def setUp(self): - for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]: + for dt in [ + "Leave Application", + "Leave Allocation", + "Salary Slip", + "Leave Ledger Entry", + "Leave Period", + "Leave Policy Assignment", + ]: frappe.db.delete(dt) frappe.set_user("Administrator") @@ -702,59 +709,24 @@ class TestLeaveApplication(unittest.TestCase): self.assertEqual(details.leave_balance, 30) def test_earned_leaves_creation(self): - - frappe.db.sql("""delete from `tabLeave Period`""") - frappe.db.sql("""delete from `tabLeave Policy Assignment`""") - frappe.db.sql("""delete from `tabLeave Allocation`""") - frappe.db.sql("""delete from `tabLeave Ledger Entry`""") + from erpnext.hr.utils import allocate_earned_leaves leave_period = get_leave_period() employee = get_employee() leave_type = "Test Earned Leave Type" - frappe.delete_doc_if_exists("Leave Type", "Test Earned Leave Type", force=1) - frappe.get_doc( - dict( - leave_type_name=leave_type, - doctype="Leave Type", - is_earned_leave=1, - earned_leave_frequency="Monthly", - rounding=0.5, - max_leaves_allowed=6, - ) - ).insert() + make_policy_assignment(employee, leave_type, leave_period) - leave_policy = frappe.get_doc( - { - "doctype": "Leave Policy", - "title": "Test Leave Policy", - "leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}], - } - ).insert() - - 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) - ) - - from erpnext.hr.utils import allocate_earned_leaves - - i = 0 - while i < 14: + for i in range(0, 14): allocate_earned_leaves() - i += 1 + self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6) # validate earned leaves creation without maximum leaves frappe.db.set_value("Leave Type", leave_type, "max_leaves_allowed", 0) - i = 0 - while i < 6: + + for i in range(0, 6): allocate_earned_leaves() - i += 1 + self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9) # test to not consider current leave in leave balance while submitting @@ -970,6 +942,54 @@ class TestLeaveApplication(unittest.TestCase): self.assertEqual(leave_allocation["leaves_pending_approval"], 1) self.assertEqual(leave_allocation["remaining_leaves"], 26) + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_get_earned_leave_details_for_dashboard(self): + from erpnext.hr.utils import allocate_earned_leaves + + leave_period = get_leave_period() + employee = get_employee() + leave_type = "Test Earned Leave Type" + leave_policy_assignments = make_policy_assignment(employee, leave_type, leave_period) + allocation = frappe.db.get_value( + "Leave Allocation", + {"leave_policy_assignment": leave_policy_assignments[0]}, + "name", + ) + allocation = frappe.get_doc("Leave Allocation", allocation) + allocation.new_leaves_allocated = 2 + allocation.save() + + for i in range(0, 6): + allocate_earned_leaves() + + first_sunday = get_first_sunday(self.holiday_list) + make_leave_application( + employee.name, add_days(first_sunday, 1), add_days(first_sunday, 1), leave_type + ) + + details = get_leave_details(employee.name, allocation.from_date) + leave_allocation = details["leave_allocation"][leave_type] + expected = { + "total_leaves": 2.0, + "expired_leaves": 0.0, + "leaves_taken": 1.0, + "leaves_pending_approval": 0.0, + "remaining_leaves": 1.0, + } + self.assertEqual(leave_allocation, expected) + + details = get_leave_details(employee.name, getdate()) + leave_allocation = details["leave_allocation"][leave_type] + + expected = { + "total_leaves": 5.0, + "expired_leaves": 0.0, + "leaves_taken": 1.0, + "leaves_pending_approval": 0.0, + "remaining_leaves": 4.0, + } + self.assertEqual(leave_allocation, expected) + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") def test_get_leave_allocation_records(self): employee = get_employee() @@ -1100,3 +1120,36 @@ def get_first_sunday(holiday_list, for_date=None): )[0][0] return first_sunday + + +def make_policy_assignment(employee, leave_type, leave_period): + frappe.delete_doc_if_exists("Leave Type", leave_type, force=1) + frappe.get_doc( + dict( + leave_type_name=leave_type, + doctype="Leave Type", + is_earned_leave=1, + earned_leave_frequency="Monthly", + rounding=0.5, + max_leaves_allowed=6, + ) + ).insert() + + leave_policy = frappe.get_doc( + { + "doctype": "Leave Policy", + "title": "Test Leave Policy", + "leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}], + } + ).insert() + + 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) + ) + return leave_policy_assignments diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index c730b199247..3f4e31b1b2b 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -55,6 +55,8 @@ def update_employee_work_history(employee, details, date=None, cancel=False): new_data = getdate(new_data) elif fieldtype == "Datetime" and new_data: new_data = get_datetime(new_data) + elif fieldtype in ["Currency", "Float"] and new_data: + new_data = flt(new_data) setattr(employee, item.fieldname, new_data) if item.fieldname in ["department", "designation", "branch"]: internal_work_history[item.fieldname] = item.new diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js index 940a1bbc000..38328e69674 100644 --- a/erpnext/loan_management/doctype/loan/loan.js +++ b/erpnext/loan_management/doctype/loan/loan.js @@ -93,6 +93,12 @@ frappe.ui.form.on('Loan', { frm.trigger("make_loan_refund"); },__('Create')); } + + if (frm.doc.status == "Loan Closure Requested" && frm.doc.is_term_loan && !frm.doc.is_secured_loan) { + frm.add_custom_button(__('Close Loan'), function() { + frm.trigger("close_unsecured_term_loan"); + },__('Status')); + } } frm.trigger("toggle_fields"); }, @@ -174,6 +180,18 @@ frappe.ui.form.on('Loan', { }) }, + close_unsecured_term_loan: function(frm) { + frappe.call({ + args: { + "loan": frm.doc.name + }, + method: "erpnext.loan_management.doctype.loan.loan.close_unsecured_term_loan", + callback: function () { + frm.refresh(); + } + }) + }, + 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.py b/erpnext/loan_management/doctype/loan/loan.py index 3b76ba4edb6..90ce0040583 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -60,11 +60,11 @@ class Loan(AccountsController): ) def validate_cost_center(self): - if not self.cost_center and self.rate_of_interest != 0: + if not self.cost_center and self.rate_of_interest != 0.0: self.cost_center = frappe.db.get_value("Company", self.company, "cost_center") - if not self.cost_center: - frappe.throw(_("Cost center is mandatory for loans having rate of interest greater than 0")) + if not self.cost_center: + frappe.throw(_("Cost center is mandatory for loans having rate of interest greater than 0")) def on_submit(self): self.link_loan_security_pledge() @@ -73,7 +73,7 @@ class Loan(AccountsController): def on_cancel(self): self.unlink_loan_security_pledge() - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] def set_missing_fields(self): if not self.company: @@ -342,6 +342,22 @@ def get_loan_application(loan_application): return loan.as_dict() +@frappe.whitelist() +def close_unsecured_term_loan(loan): + loan_details = frappe.db.get_value( + "Loan", {"name": loan}, ["status", "is_term_loan", "is_secured_loan"], as_dict=1 + ) + + if ( + loan_details.status == "Loan Closure Requested" + and loan_details.is_term_loan + and not loan_details.is_secured_loan + ): + frappe.db.set_value("Loan", loan, "status", "Closed") + else: + frappe.throw(_("Cannot close this loan until full repayment")) + + def close_loan(loan, total_amount_paid): frappe.db.set_value("Loan", loan, "total_amount_paid", total_amount_paid) frappe.db.set_value("Loan", loan, "status", "Closed") diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py index 10174e531a1..0c2042ba500 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -29,7 +29,7 @@ class LoanDisbursement(AccountsController): def on_cancel(self): self.set_status_and_amounts(cancel=1) self.make_gl_entries(cancel=1) - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] def set_missing_values(self): if not self.disbursement_date: 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 3a4c6513e45..0aeb4489184 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 @@ -32,7 +32,7 @@ class LoanInterestAccrual(AccountsController): self.update_is_accrued() self.make_gl_entries(cancel=1) - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] def update_is_accrued(self): frappe.db.set_value("Repayment Schedule", self.repayment_schedule_name, "is_accrued", 0) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index dcbdf8a81e6..51f40d9a200 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -41,7 +41,7 @@ class LoanRepayment(AccountsController): self.check_future_accruals() self.update_repayment_schedule(cancel=1) self.mark_as_unpaid() - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] self.make_gl_entries(cancel=1) def set_missing_values(self, amounts): diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py index e19fd15fc84..25aecf673bb 100644 --- a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py +++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py @@ -42,7 +42,7 @@ class LoanWriteOff(AccountsController): def on_cancel(self): self.update_outstanding_amount(cancel=1) - self.ignore_linked_doctypes = ["GL Entry"] + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] self.make_gl_entries(cancel=1) def update_outstanding_amount(self, cancel=0): diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index d74379881ca..ecad41fe7b8 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -81,7 +81,7 @@ frappe.ui.form.on("BOM", { } ) - if (!frm.doc.__islocal && frm.doc.docstatus<2) { + if (!frm.is_new() && frm.doc.docstatus<2) { frm.add_custom_button(__("Update Cost"), function() { frm.events.update_cost(frm, true); }); @@ -93,10 +93,12 @@ frappe.ui.form.on("BOM", { }); } - frm.add_custom_button(__("New Version"), function() { - let new_bom = frappe.model.copy_doc(frm.doc); - frappe.set_route("Form", "BOM", new_bom.name); - }); + if (!frm.is_new() && !frm.doc.docstatus == 0) { + frm.add_custom_button(__("New Version"), function() { + let new_bom = frappe.model.copy_doc(frm.doc); + frappe.set_route("Form", "BOM", new_bom.name); + }); + } if(frm.doc.docstatus==1) { frm.add_custom_button(__("Work Order"), function() { diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 6376359a705..631548b3099 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1,11 +1,11 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt import functools import re from collections import deque from operator import itemgetter -from typing import List +from typing import Dict, List import frappe from frappe import _ @@ -189,6 +189,7 @@ class BOM(WebsiteGenerator): self.validate_transfer_against() self.set_routing_operations() self.validate_operations() + self.update_exploded_items(save=False) self.calculate_cost() self.update_stock_qty() self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) @@ -386,40 +387,14 @@ class BOM(WebsiteGenerator): existing_bom_cost = self.total_cost - for d in self.get("items"): - if not d.item_code: - continue - - rate = self.get_rm_rate( - { - "company": self.company, - "item_code": d.item_code, - "bom_no": d.bom_no, - "qty": d.qty, - "uom": d.uom, - "stock_uom": d.stock_uom, - "conversion_factor": d.conversion_factor, - "sourced_by_supplier": d.sourced_by_supplier, - } - ) - - if rate: - d.rate = rate - d.amount = flt(d.rate) * flt(d.qty) - d.base_rate = flt(d.rate) * flt(self.conversion_rate) - d.base_amount = flt(d.amount) * flt(self.conversion_rate) - - if save: - d.db_update() - if self.docstatus == 1: self.flags.ignore_validate_update_after_submit = True - self.calculate_cost(update_hour_rate) + + self.calculate_cost(save_updates=save, update_hour_rate=update_hour_rate) + if save: self.db_update() - self.update_exploded_items(save=save) - # update parent BOMs if self.total_cost != existing_bom_cost and update_parent: parent_boms = frappe.db.sql_list( @@ -608,11 +583,15 @@ class BOM(WebsiteGenerator): bom_list.reverse() return bom_list - def calculate_cost(self, update_hour_rate=False): + def calculate_cost(self, save_updates=False, update_hour_rate=False): """Calculate bom totals""" self.calculate_op_cost(update_hour_rate) - self.calculate_rm_cost() - self.calculate_sm_cost() + self.calculate_rm_cost(save=save_updates) + self.calculate_sm_cost(save=save_updates) + if save_updates: + # not via doc event, table is not regenerated and needs updation + self.calculate_exploded_cost() + self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost self.base_total_cost = ( self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost @@ -654,12 +633,26 @@ class BOM(WebsiteGenerator): if update_hour_rate: row.db_update() - def calculate_rm_cost(self): + def calculate_rm_cost(self, save=False): """Fetch RM rate as per today's valuation rate and calculate totals""" total_rm_cost = 0 base_total_rm_cost = 0 for d in self.get("items"): + old_rate = d.rate + d.rate = self.get_rm_rate( + { + "company": self.company, + "item_code": d.item_code, + "bom_no": d.bom_no, + "qty": d.qty, + "uom": d.uom, + "stock_uom": d.stock_uom, + "conversion_factor": d.conversion_factor, + "sourced_by_supplier": d.sourced_by_supplier, + } + ) + d.base_rate = flt(d.rate) * flt(self.conversion_rate) d.amount = flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty")) d.base_amount = d.amount * flt(self.conversion_rate) @@ -669,11 +662,13 @@ class BOM(WebsiteGenerator): total_rm_cost += d.amount base_total_rm_cost += d.base_amount + if save and (old_rate != d.rate): + d.db_update() self.raw_material_cost = total_rm_cost self.base_raw_material_cost = base_total_rm_cost - def calculate_sm_cost(self): + def calculate_sm_cost(self, save=False): """Fetch RM rate as per today's valuation rate and calculate totals""" total_sm_cost = 0 base_total_sm_cost = 0 @@ -688,10 +683,45 @@ class BOM(WebsiteGenerator): ) total_sm_cost += d.amount base_total_sm_cost += d.base_amount + if save: + d.db_update() self.scrap_material_cost = total_sm_cost self.base_scrap_material_cost = base_total_sm_cost + def calculate_exploded_cost(self): + "Set exploded row cost from it's parent BOM." + rm_rate_map = self.get_rm_rate_map() + + for row in self.get("exploded_items"): + old_rate = flt(row.rate) + row.rate = rm_rate_map.get(row.item_code) + row.amount = flt(row.stock_qty) * flt(row.rate) + + if old_rate != row.rate: + # Only db_update if changed + row.db_update() + + def get_rm_rate_map(self) -> Dict[str, float]: + "Create Raw Material-Rate map for Exploded Items. Fetch rate from Items table or Subassembly BOM." + rm_rate_map = {} + + for item in self.get("items"): + if item.bom_no: + # Get Item-Rate from Subassembly BOM + explosion_items = frappe.get_all( + "BOM Explosion Item", + filters={"parent": item.bom_no}, + fields=["item_code", "rate"], + order_by=None, # to avoid sort index creation at db level (granular change) + ) + explosion_item_rate = {item.item_code: flt(item.rate) for item in explosion_items} + rm_rate_map.update(explosion_item_rate) + else: + rm_rate_map[item.item_code] = flt(item.base_rate) / flt(item.conversion_factor or 1.0) + + return rm_rate_map + def update_exploded_items(self, save=True): """Update Flat BOM, following will be correct data""" self.get_exploded_items() @@ -902,44 +932,46 @@ def get_bom_item_rate(args, bom_doc): return flt(rate) -def get_valuation_rate(args): - """Get weighted average of valuation rate from all warehouses""" +def get_valuation_rate(data): + """ + 1) Get average valuation rate from all warehouses + 2) If no value, get last valuation rate from SLE + 3) If no value, get valuation rate from Item + """ + from frappe.query_builder.functions import Sum - total_qty, total_value, valuation_rate = 0.0, 0.0, 0.0 - item_bins = frappe.db.sql( - """ - select - bin.actual_qty, bin.stock_value - from - `tabBin` bin, `tabWarehouse` warehouse - where - bin.item_code=%(item)s - and bin.warehouse = warehouse.name - and warehouse.company=%(company)s""", - {"item": args["item_code"], "company": args["company"]}, - as_dict=1, - ) + item_code, company = data.get("item_code"), data.get("company") + valuation_rate = 0.0 - for d in item_bins: - total_qty += flt(d.actual_qty) - total_value += flt(d.stock_value) + bin_table = frappe.qb.DocType("Bin") + wh_table = frappe.qb.DocType("Warehouse") + item_valuation = ( + frappe.qb.from_(bin_table) + .join(wh_table) + .on(bin_table.warehouse == wh_table.name) + .select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate")) + .where((bin_table.item_code == item_code) & (wh_table.company == company)) + ).run(as_dict=True)[0] - if total_qty: - valuation_rate = total_value / total_qty + valuation_rate = item_valuation.get("valuation_rate") - if valuation_rate <= 0: - last_valuation_rate = frappe.db.sql( - """select valuation_rate - from `tabStock Ledger Entry` - where item_code = %s and valuation_rate > 0 and is_cancelled = 0 - order by posting_date desc, posting_time desc, creation desc limit 1""", - args["item_code"], - ) + if (valuation_rate is not None) and valuation_rate <= 0: + # Explicit null value check. If None, Bins don't exist, neither does SLE + sle = frappe.qb.DocType("Stock Ledger Entry") + last_val_rate = ( + frappe.qb.from_(sle) + .select(sle.valuation_rate) + .where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0)) + .orderby(sle.posting_date, order=frappe.qb.desc) + .orderby(sle.posting_time, order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) + .limit(1) + ).run(as_dict=True) - valuation_rate = flt(last_valuation_rate[0][0]) if last_valuation_rate else 0 + valuation_rate = flt(last_val_rate[0].get("valuation_rate")) if last_val_rate else 0 if not valuation_rate: - valuation_rate = frappe.db.get_value("Item", args["item_code"], "valuation_rate") + valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate") return flt(valuation_rate) @@ -1125,39 +1157,6 @@ def get_children(parent=None, is_root=False, **filters): return bom_items -def get_boms_in_bottom_up_order(bom_no=None): - def _get_parent(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, - ) - - count = 0 - bom_list = [] - if bom_no: - bom_list.append(bom_no) - else: - # get all leaf BOMs - 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, '')!='')""" - ) - - while count < len(bom_list): - for child_bom in _get_parent(bom_list[count]): - if child_bom not in bom_list: - bom_list.append(child_bom) - count += 1 - - return bom_list - - def add_additional_cost(stock_entry, work_order): # Add non stock items cost in the additional cost stock_entry.additional_costs = [] diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index f235e449a3c..182a20c6bb7 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -11,7 +11,9 @@ from frappe.utils import cstr, flt from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query, make_variant_bom -from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost +from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import ( + update_cost_in_all_boms_in_test, +) from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, @@ -69,26 +71,31 @@ class TestBOM(FrappeTestCase): def test_update_bom_cost_in_all_boms(self): # get current rate for '_Test Item 2' - rm_rate = frappe.db.sql( - """select rate from `tabBOM Item` - where parent='BOM-_Test Item Home Desktop Manufactured-001' - and item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""" + bom_rates = frappe.db.get_values( + "BOM Item", + { + "parent": "BOM-_Test Item Home Desktop Manufactured-001", + "item_code": "_Test Item 2", + "docstatus": 1, + }, + fieldname=["rate", "base_rate"], + as_dict=True, ) - rm_rate = rm_rate[0][0] if rm_rate else 0 + rm_base_rate = bom_rates[0].get("base_rate") if bom_rates else 0 # Reset item valuation rate - reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_rate + 10) + reset_item_valuation_rate(item_code="_Test Item 2", qty=200, rate=rm_base_rate + 10) # update cost of all BOMs based on latest valuation rate - update_cost() + update_cost_in_all_boms_in_test() # check if new valuation rate updated in all BOMs for d in frappe.db.sql( - """select rate from `tabBOM Item` + """select base_rate from `tabBOM Item` where item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""", as_dict=1, ): - self.assertEqual(d.rate, rm_rate + 10) + self.assertEqual(d.base_rate, rm_base_rate + 10) def test_bom_cost(self): bom = frappe.copy_doc(test_records[2]) diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json index 25730f9b9f4..507d319b515 100644 --- a/erpnext/manufacturing/doctype/bom/test_records.json +++ b/erpnext/manufacturing/doctype/bom/test_records.json @@ -32,6 +32,7 @@ "is_active": 1, "is_default": 1, "item": "_Test Item Home Desktop Manufactured", + "company": "_Test Company", "quantity": 1.0 }, { diff --git a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json index f01d856e72a..9b1db63494b 100644 --- a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json +++ b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json @@ -169,13 +169,15 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-08 16:21:29.386212", + "modified": "2022-05-27 13:42:23.305455", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Explosion Item", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/erpnext_integrations/data_migration_mapping/__init__.py b/erpnext/manufacturing/doctype/bom_update_batch/__init__.py similarity index 100% rename from erpnext/erpnext_integrations/data_migration_mapping/__init__.py rename to erpnext/manufacturing/doctype/bom_update_batch/__init__.py diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json new file mode 100644 index 00000000000..83b54d326cb --- /dev/null +++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.json @@ -0,0 +1,55 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2022-05-31 17:34:39.825537", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "level", + "batch_no", + "boms_updated", + "status" + ], + "fields": [ + { + "fieldname": "level", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Level" + }, + { + "fieldname": "batch_no", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Batch No." + }, + { + "fieldname": "boms_updated", + "fieldtype": "Long Text", + "hidden": 1, + "in_list_view": 1, + "label": "BOMs Updated" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Pending\nCompleted", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-06-06 14:50:35.161062", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "BOM Update Batch", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.py new file mode 100644 index 00000000000..f952e435e67 --- /dev/null +++ b/erpnext/manufacturing/doctype/bom_update_batch/bom_update_batch.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 BOMUpdateBatch(Document): + pass diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index 98c1acb71ce..c32e383b08a 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -13,6 +13,10 @@ "update_type", "status", "error_log", + "progress_section", + "current_level", + "processed_boms", + "bom_batches", "amended_from" ], "fields": [ @@ -63,13 +67,36 @@ "fieldtype": "Link", "label": "Error Log", "options": "Error Log" + }, + { + "collapsible": 1, + "depends_on": "eval: doc.update_type == \"Update Cost\"", + "fieldname": "progress_section", + "fieldtype": "Section Break", + "label": "Progress" + }, + { + "fieldname": "processed_boms", + "fieldtype": "Long Text", + "hidden": 1, + "label": "Processed BOMs" + }, + { + "fieldname": "bom_batches", + "fieldtype": "Table", + "options": "BOM Update Batch" + }, + { + "fieldname": "current_level", + "fieldtype": "Int", + "label": "Current Level" } ], "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-03-31 12:51:44.885102", + "modified": "2022-06-06 15:15:23.883251", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index c0770fac90a..9c9c24044aa 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -1,13 +1,20 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from typing import Dict, List, Literal, Optional +import json +from typing import Any, Dict, List, Optional, Tuple, Union import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr, flt +from frappe.utils import cint, cstr -from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost +from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( + get_leaf_boms, + get_next_higher_level_boms, + handle_exception, + replace_bom, + set_values_in_log, +) class BOMMissingError(frappe.ValidationError): @@ -20,6 +27,8 @@ class BOMUpdateLog(Document): self.validate_boms_are_specified() self.validate_same_bom() self.validate_bom_items() + else: + self.validate_bom_cost_update_in_progress() self.status = "Queued" @@ -42,123 +51,184 @@ class BOMUpdateLog(Document): if current_bom_item != new_bom_item: frappe.throw(_("The selected BOMs are not for the same item")) - def on_submit(self): - if frappe.flags.in_test: - return + def validate_bom_cost_update_in_progress(self): + "If another Cost Updation Log is still in progress, dont make new ones." + wip_log = frappe.get_all( + "BOM Update Log", + {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]}, + limit_page_length=1, + ) + if wip_log: + log_link = frappe.utils.get_link_to_form("BOM Update Log", wip_log[0].name) + frappe.throw( + _("BOM Updation already in progress. Please wait until {0} is complete.").format(log_link), + title=_("Note"), + ) + + def on_submit(self): if self.update_type == "Replace BOM": boms = {"current_bom": self.current_bom, "new_bom": self.new_bom} frappe.enqueue( - method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job", + method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_replace_bom_job", doc=self, boms=boms, timeout=40000, + now=frappe.flags.in_test, ) else: - frappe.enqueue( - method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job", - doc=self, - update_type="Update Cost", - timeout=40000, - ) + process_boms_cost_level_wise(self) -def replace_bom(boms: Dict) -> None: - """Replace current BOM with new BOM in parent BOMs.""" - current_bom = boms.get("current_bom") - new_bom = boms.get("new_bom") - - unit_cost = get_new_bom_unit_cost(new_bom) - update_new_bom_in_bom_items(unit_cost, current_bom, new_bom) - - frappe.cache().delete_key("bom_children") - parent_boms = get_parent_boms(new_bom) - - for bom in parent_boms: - bom_obj = frappe.get_doc("BOM", bom) - # this is only used for versioning and we do not want - # to make separate db calls by using load_doc_before_save - # which proves to be expensive while doing bulk replace - bom_obj._doc_before_save = bom_obj - bom_obj.update_exploded_items() - bom_obj.calculate_cost() - bom_obj.update_parent_cost() - bom_obj.db_update() - if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version: - bom_obj.save_version() - - -def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None: - bom_item = frappe.qb.DocType("BOM Item") - ( - frappe.qb.update(bom_item) - .set(bom_item.bom_no, new_bom) - .set(bom_item.rate, unit_cost) - .set(bom_item.amount, (bom_item.stock_qty * unit_cost)) - .where( - (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM") - ) - ).run() - - -def get_parent_boms(new_bom: str, bom_list: Optional[List] = None) -> List: - bom_list = bom_list or [] - bom_item = frappe.qb.DocType("BOM Item") - - parents = ( - frappe.qb.from_(bom_item) - .select(bom_item.parent) - .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")) - .run(as_dict=True) - ) - - for d in parents: - if new_bom == d.parent: - frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent)) - - bom_list.append(d.parent) - get_parent_boms(d.parent, bom_list) - - return list(set(bom_list)) - - -def get_new_bom_unit_cost(new_bom: str) -> float: - bom = frappe.qb.DocType("BOM") - new_bom_unitcost = ( - frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run() - ) - - return flt(new_bom_unitcost[0][0]) - - -def run_bom_job( +def run_replace_bom_job( doc: "BOMUpdateLog", boms: Optional[Dict[str, str]] = None, - update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM", ) -> None: try: doc.db_set("status", "In Progress") + if not frappe.flags.in_test: frappe.db.commit() frappe.db.auto_commit_on_many_writes = 1 - boms = frappe._dict(boms or {}) - - if update_type == "Replace BOM": - replace_bom(boms) - else: - update_cost() + replace_bom(boms, doc.name) doc.db_set("status", "Completed") - except Exception: - frappe.db.rollback() - error_log = doc.log_error("BOM Update Tool Error") - - doc.db_set("status", "Failed") - doc.db_set("error_log", error_log.name) - + handle_exception(doc) finally: frappe.db.auto_commit_on_many_writes = 0 - frappe.db.commit() # nosemgrep + + if not frappe.flags.in_test: + frappe.db.commit() # nosemgrep + + +def process_boms_cost_level_wise( + update_doc: "BOMUpdateLog", parent_boms: List[str] = None +) -> Union[None, Tuple]: + "Queue jobs at the start of new BOM Level in 'Update Cost' Jobs." + + current_boms = {} + values = {} + + if update_doc.status == "Queued": + # First level yet to process. On Submit. + current_level = 0 + current_boms = get_leaf_boms() + values = { + "processed_boms": json.dumps({}), + "status": "In Progress", + "current_level": current_level, + } + else: + # Resume next level. via Cron Job. + if not parent_boms: + return + + current_level = cint(update_doc.current_level) + 1 + + # Process the next level BOMs. Stage parents as current BOMs. + current_boms = parent_boms.copy() + values = {"current_level": current_level} + + set_values_in_log(update_doc.name, values, commit=True) + queue_bom_cost_jobs(current_boms, update_doc, current_level) + + +def queue_bom_cost_jobs( + current_boms_list: List[str], update_doc: "BOMUpdateLog", current_level: int +) -> None: + "Queue batches of 20k BOMs of the same level to process parallelly" + batch_no = 0 + + while current_boms_list: + batch_no += 1 + batch_size = 20_000 + boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs + + # update list to exclude 20K (queued) BOMs + current_boms_list = current_boms_list[batch_size:] if len(current_boms_list) > batch_size else [] + + batch_row = update_doc.append( + "bom_batches", {"level": current_level, "batch_no": batch_no, "status": "Pending"} + ) + batch_row.db_insert() + + frappe.enqueue( + method="erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils.update_cost_in_level", + doc=update_doc, + bom_list=boms_to_process, + batch_name=batch_row.name, + queue="long", + now=frappe.flags.in_test, + ) + + +def resume_bom_cost_update_jobs(): + """ + 1. Checks for In Progress BOM Update Log. + 2. Checks if this job has completed the _current level_. + 3. If current level is complete, get parent BOMs and start next level. + 4. If no parents, mark as Complete. + 5. If current level is WIP, skip the Log. + + Called every 5 minutes via Cron job. + """ + + in_progress_logs = frappe.db.get_all( + "BOM Update Log", + {"update_type": "Update Cost", "status": "In Progress"}, + ["name", "processed_boms", "current_level"], + ) + if not in_progress_logs: + return + + for log in in_progress_logs: + # check if all log batches of current level are processed + bom_batches = frappe.db.get_all( + "BOM Update Batch", + {"parent": log.name, "level": log.current_level}, + ["name", "boms_updated", "status"], + ) + incomplete_level = any(row.get("status") == "Pending" for row in bom_batches) + if not bom_batches or incomplete_level: + continue + + # Prep parent BOMs & updated processed BOMs for next level + current_boms, processed_boms = get_processed_current_boms(log, bom_batches) + parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) + + # Unset processed BOMs if log is complete, it is used for next level BOMs + set_values_in_log( + log.name, + values={ + "processed_boms": json.dumps([] if not parent_boms else processed_boms), + "status": "Completed" if not parent_boms else "In Progress", + }, + commit=True, + ) + + if parent_boms: # there is a next level to process + process_boms_cost_level_wise( + update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms + ) + + +def get_processed_current_boms( + log: Dict[str, Any], bom_batches: Dict[str, Any] +) -> Tuple[List[str], Dict[str, Any]]: + """ + Aggregate all BOMs from BOM Update Batch rows into 'processed_boms' field + and into current boms list. + """ + processed_boms = json.loads(log.processed_boms) if log.processed_boms else {} + current_boms = [] + + for row in bom_batches: + boms_updated = json.loads(row.boms_updated) + current_boms.extend(boms_updated) + boms_updated_dict = {bom: True for bom in boms_updated} + processed_boms.update(boms_updated_dict) + + return current_boms, processed_boms diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py new file mode 100644 index 00000000000..af115e3e421 --- /dev/null +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -0,0 +1,225 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import copy +import json +from collections import defaultdict +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +if TYPE_CHECKING: + from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog + +import frappe +from frappe import _ + + +def replace_bom(boms: Dict, log_name: str) -> None: + "Replace current BOM with new BOM in parent BOMs." + + current_bom = boms.get("current_bom") + new_bom = boms.get("new_bom") + + unit_cost = get_bom_unit_cost(new_bom) + update_new_bom_in_bom_items(unit_cost, current_bom, new_bom) + + frappe.cache().delete_key("bom_children") + parent_boms = get_ancestor_boms(new_bom) + + for bom in parent_boms: + bom_obj = frappe.get_doc("BOM", bom) + # this is only used for versioning and we do not want + # to make separate db calls by using load_doc_before_save + # which proves to be expensive while doing bulk replace + bom_obj._doc_before_save = copy.deepcopy(bom_obj) + bom_obj.update_exploded_items() + bom_obj.calculate_cost() + bom_obj.update_parent_cost() + bom_obj.db_update() + bom_obj.flags.updater_reference = { + "doctype": "BOM Update Log", + "docname": log_name, + "label": _("via BOM Update Tool"), + } + bom_obj.save_version() + + +def update_cost_in_level( + doc: "BOMUpdateLog", bom_list: List[str], batch_name: Union[int, str] +) -> None: + "Updates Cost for BOMs within a given level. Runs via background jobs." + + try: + status = frappe.db.get_value("BOM Update Log", doc.name, "status") + if status == "Failed": + return + + update_cost_in_boms(bom_list=bom_list) # main updation logic + + bom_batch = frappe.qb.DocType("BOM Update Batch") + ( + frappe.qb.update(bom_batch) + .set(bom_batch.boms_updated, json.dumps(bom_list)) + .set(bom_batch.status, "Completed") + .where(bom_batch.name == batch_name) + ).run() + except Exception: + handle_exception(doc) + finally: + if not frappe.flags.in_test: + frappe.db.commit() # nosemgrep + + +def get_ancestor_boms(new_bom: str, bom_list: Optional[List] = None) -> List: + "Recursively get all ancestors of BOM." + + bom_list = bom_list or [] + bom_item = frappe.qb.DocType("BOM Item") + + parents = ( + frappe.qb.from_(bom_item) + .select(bom_item.parent) + .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")) + .run(as_dict=True) + ) + + for d in parents: + if new_bom == d.parent: + frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent)) + + bom_list.append(d.parent) + get_ancestor_boms(d.parent, bom_list) + + return list(set(bom_list)) + + +def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None: + bom_item = frappe.qb.DocType("BOM Item") + ( + frappe.qb.update(bom_item) + .set(bom_item.bom_no, new_bom) + .set(bom_item.rate, unit_cost) + .set(bom_item.amount, (bom_item.stock_qty * unit_cost)) + .where( + (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM") + ) + ).run() + + +def get_bom_unit_cost(bom_name: str) -> float: + bom = frappe.qb.DocType("BOM") + new_bom_unitcost = ( + frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == bom_name).run() + ) + + return frappe.utils.flt(new_bom_unitcost[0][0]) + + +def update_cost_in_boms(bom_list: List[str]) -> None: + "Updates cost in given BOMs. Returns current and total updated BOMs." + + for index, bom in enumerate(bom_list): + bom_doc = frappe.get_doc("BOM", bom, for_update=True) + bom_doc.calculate_cost(save_updates=True, update_hour_rate=True) + bom_doc.db_update() + + if (index % 50 == 0) and not frappe.flags.in_test: + frappe.db.commit() # nosemgrep + + +def get_next_higher_level_boms( + child_boms: List[str], processed_boms: Dict[str, bool] +) -> List[str]: + "Generate immediate higher level dependants with no unresolved dependencies (children)." + + def _all_children_are_processed(parent_bom): + child_boms = dependency_map.get(parent_bom) + return all(processed_boms.get(bom) for bom in child_boms) + + dependants_map, dependency_map = _generate_dependence_map() + + dependants = [] + for bom in child_boms: + # generate list of immediate dependants + parents = dependants_map.get(bom) or [] + dependants.extend(parents) + + dependants = set(dependants) # remove duplicates + resolved_dependants = set() + + # consider only if children are all resolved + for parent_bom in dependants: + if _all_children_are_processed(parent_bom): + resolved_dependants.add(parent_bom) + + return list(resolved_dependants) + + +def get_leaf_boms() -> List[str]: + "Get BOMs that have no dependencies." + + return 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, '')!='')""" + ) + + +def _generate_dependence_map() -> defaultdict: + """ + Generate maps such as: { BOM-1: [Dependant-BOM-1, Dependant-BOM-2, ..] }. + Here BOM-1 is the leaf/lower level node/dependency. + The list contains one level higher nodes/dependants that depend on BOM-1. + + Generate and return the reverse as well. + """ + + bom = frappe.qb.DocType("BOM") + bom_item = frappe.qb.DocType("BOM Item") + + bom_items = ( + frappe.qb.from_(bom_item) + .join(bom) + .on(bom_item.parent == bom.name) + .select(bom_item.bom_no, bom_item.parent) + .where( + (bom_item.bom_no.isnotnull()) + & (bom_item.bom_no != "") + & (bom.docstatus == 1) + & (bom.is_active == 1) + & (bom_item.parenttype == "BOM") + ) + ).run(as_dict=True) + + child_parent_map = defaultdict(list) + parent_child_map = defaultdict(list) + for row in bom_items: + child_parent_map[row.bom_no].append(row.parent) + parent_child_map[row.parent].append(row.bom_no) + + return child_parent_map, parent_child_map + + +def set_values_in_log(log_name: str, values: Dict[str, Any], commit: bool = False) -> None: + "Update BOM Update Log record." + + if not values: + return + + bom_update_log = frappe.qb.DocType("BOM Update Log") + query = frappe.qb.update(bom_update_log).where(bom_update_log.name == log_name) + + for key, value in values.items(): + query = query.set(key, value) + query.run() + + if commit and not frappe.flags.in_test: + frappe.db.commit() # nosemgrep + + +def handle_exception(doc: "BOMUpdateLog") -> None: + "Rolls back and fails BOM Update Log." + + frappe.db.rollback() + error_log = doc.log_error("BOM Update Tool Error") + set_values_in_log(doc.name, {"status": "Failed", "error_log": error_log.name}) diff --git a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py index 47efea961b4..b38fc8976b2 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py @@ -6,9 +6,12 @@ from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import ( BOMMissingError, - run_bom_job, + resume_bom_cost_update_jobs, +) +from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import ( + enqueue_replace_bom, + enqueue_update_cost, ) -from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom test_records = frappe.get_test_records("BOM") @@ -31,17 +34,12 @@ class TestBOMUpdateLog(FrappeTestCase): def tearDown(self): frappe.db.rollback() - if self._testMethodName == "test_bom_update_log_completion": - # clear logs and delete BOM created via setUp - frappe.db.delete("BOM Update Log") - self.new_bom_doc.cancel() - self.new_bom_doc.delete() - - # explicitly commit and restore to original state - frappe.db.commit() # nosemgrep - def test_bom_update_log_validate(self): - "Test if BOM presence is validated." + """ + 1) Test if BOM presence is validated. + 2) Test if same BOMs are validated. + 3) Test of non-existent BOM is validated. + """ with self.assertRaises(BOMMissingError): enqueue_replace_bom(boms={}) @@ -52,45 +50,22 @@ class TestBOMUpdateLog(FrappeTestCase): with self.assertRaises(frappe.ValidationError): enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom="Dummy BOM")) - def test_bom_update_log_queueing(self): - "Test if BOM Update Log is created and queued." - - log = enqueue_replace_bom( - boms=self.boms, - ) - - self.assertEqual(log.docstatus, 1) - self.assertEqual(log.status, "Queued") - def test_bom_update_log_completion(self): "Test if BOM Update Log handles job completion correctly." - log = enqueue_replace_bom( - boms=self.boms, - ) - - # Explicitly commits log, new bom (setUp) and replacement impact. - # Is run via background jobs IRL - run_bom_job( - doc=log, - boms=self.boms, - update_type="Replace BOM", - ) + log = enqueue_replace_bom(boms=self.boms) log.reload() - self.assertEqual(log.status, "Completed") - # teardown (undo replace impact) due to commit - boms = frappe._dict( - current_bom=self.boms.new_bom, - new_bom=self.boms.current_bom, - ) - log2 = enqueue_replace_bom( - boms=self.boms, - ) - run_bom_job( # Explicitly commits - doc=log2, - boms=boms, - update_type="Replace BOM", - ) - self.assertEqual(log2.status, "Completed") + +def update_cost_in_all_boms_in_test(): + """ + Utility to run 'Update Cost' job in tests without Cron job until fully complete. + """ + log = enqueue_update_cost() # create BOM Update Log + + while log.status != "Completed": + resume_bom_cost_update_jobs() # run cron job until complete + log.reload() + + return log diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index b0e7da12017..d16fcd08326 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -10,8 +10,6 @@ if TYPE_CHECKING: import frappe from frappe.model.document import Document -from erpnext.manufacturing.doctype.bom.bom import get_boms_in_bottom_up_order - class BOMUpdateTool(Document): pass @@ -40,14 +38,13 @@ def enqueue_update_cost() -> "BOMUpdateLog": def auto_update_latest_price_in_all_boms() -> None: """Called via hooks.py.""" if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"): - update_cost() - - -def update_cost() -> None: - """Updates Cost for all BOMs from bottom to top.""" - bom_list = get_boms_in_bottom_up_order() - for bom in bom_list: - frappe.get_doc("BOM", bom).update_cost(update_parent=False, from_child_bom=True) + wip_log = frappe.get_all( + "BOM Update Log", + {"update_type": "Update Cost", "status": ["in", ["Queued", "In Progress"]]}, + limit_page_length=1, + ) + if not wip_log: + create_bom_update_log(update_type="Update Cost") def create_bom_update_log( diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py index fae72a0f6f7..5dd557f8ab1 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py @@ -1,11 +1,13 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt import frappe from frappe.tests.utils import FrappeTestCase -from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import replace_bom -from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost +from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import ( + update_cost_in_all_boms_in_test, +) +from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.stock.doctype.item.test_item import create_item @@ -15,6 +17,9 @@ test_records = frappe.get_test_records("BOM") class TestBOMUpdateTool(FrappeTestCase): "Test major functions run via BOM Update Tool." + def tearDown(self): + frappe.db.rollback() + def test_replace_bom(self): current_bom = "BOM-_Test Item Home Desktop Manufactured-001" @@ -23,15 +28,10 @@ class TestBOMUpdateTool(FrappeTestCase): bom_doc.insert() boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name) - replace_bom(boms) + enqueue_replace_bom(boms=boms) - self.assertFalse(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", current_bom)) - self.assertTrue(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", bom_doc.name)) - - # reverse, as it affects other testcases - boms.current_bom = bom_doc.name - boms.new_bom = current_bom - replace_bom(boms) + self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1})) + self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1})) def test_bom_cost(self): for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]: @@ -52,13 +52,13 @@ class TestBOMUpdateTool(FrappeTestCase): self.assertEqual(doc.total_cost, 200) frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 200) - update_cost() + update_cost_in_all_boms_in_test() doc.load_from_db() self.assertEqual(doc.total_cost, 300) frappe.db.set_value("Item", "BOM Cost Test Item 2", "valuation_rate", 100) - update_cost() + update_cost_in_all_boms_in_test() doc.load_from_db() self.assertEqual(doc.total_cost, 200) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 0a9fd8a0996..ed45106634d 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -621,19 +621,20 @@ class JobCard(Document): self.set_status(update_status) def set_status(self, update_status=False): - if self.status == "On Hold": + if self.status == "On Hold" and self.docstatus == 0: return self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] - if self.for_quantity <= self.transferred_qty: - self.status = "Material Transferred" + if self.docstatus < 2: + if self.for_quantity <= self.transferred_qty: + self.status = "Material Transferred" - if self.time_logs: - self.status = "Work In Progress" + if self.time_logs: + self.status = "Work In Progress" - if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items): - self.status = "Completed" + if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items): + self.status = "Completed" if update_status: self.db_set("status", self.status) diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js index 7f60bdc6d92..5d883bf9fa7 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_list.js +++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js @@ -1,16 +1,17 @@ frappe.listview_settings['Job Card'] = { has_indicator_for_draft: true, + get_indicator: function(doc) { - if (doc.status === "Work In Progress") { - return [__("Work In Progress"), "orange", "status,=,Work In Progress"]; - } else if (doc.status === "Completed") { - return [__("Completed"), "green", "status,=,Completed"]; - } else if (doc.docstatus == 2) { - return [__("Cancelled"), "red", "status,=,Cancelled"]; - } else if (doc.status === "Material Transferred") { - return [__('Material Transferred'), "blue", "status,=,Material Transferred"]; - } else { - return [__("Open"), "red", "status,=,Open"]; - } + const status_colors = { + "Work In Progress": "orange", + "Completed": "green", + "Cancelled": "red", + "Material Transferred": "blue", + "Open": "red", + }; + const status = doc.status || "Open"; + const color = status_colors[status] || "blue"; + + return [__(status), color, `status,=,${status}`]; } }; diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 7f3c7fefe9f..ac7114138c3 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -344,6 +344,30 @@ class TestJobCard(FrappeTestCase): cost_after_cancel = self.work_order.total_operating_cost self.assertEqual(cost_after_cancel, original_cost) + def test_job_card_statuses(self): + def assertStatus(status): + jc.set_status() + self.assertEqual(jc.status, status) + + jc = frappe.new_doc("Job Card") + jc.for_quantity = 2 + jc.transferred_qty = 1 + jc.total_completed_qty = 0 + assertStatus("Open") + + jc.transferred_qty = jc.for_quantity + assertStatus("Material Transferred") + + jc.append("time_logs", {}) + assertStatus("Work In Progress") + + jc.docstatus = 1 + jc.total_completed_qty = jc.for_quantity + assertStatus("Completed") + + jc.docstatus = 2 + assertStatus("Cancelled") + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 891a4978789..e88049d810d 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -798,7 +798,6 @@ def make_bom(**args): for item in args.raw_materials: item_doc = frappe.get_doc("Item", item) - bom.append( "items", { diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index bd19dec504c..6bb4cfceadd 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -421,7 +421,7 @@ class TestWorkOrder(FrappeTestCase): "doctype": "Item Price", "item_code": "_Test FG Non Stock Item", "price_list_rate": 1000, - "price_list": "Standard Buying", + "price_list": "_Test Price List India", } ).insert(ignore_permissions=True) @@ -430,8 +430,17 @@ class TestWorkOrder(FrappeTestCase): item_code="_Test FG Item", target="_Test Warehouse - _TC", qty=1, basic_rate=100 ) - if not frappe.db.get_value("BOM", {"item": fg_item}): - make_bom(item=fg_item, rate=1000, raw_materials=["_Test FG Item", "_Test FG Non Stock Item"]) + if not frappe.db.get_value("BOM", {"item": fg_item, "docstatus": 1}): + bom = make_bom( + item=fg_item, + rate=1000, + raw_materials=["_Test FG Item", "_Test FG Non Stock Item"], + do_not_save=True, + ) + bom.rm_cost_as_per = "Price List" # non stock item won't have valuation rate + bom.buying_price_list = "_Test Price List India" + bom.currency = "INR" + bom.save() wo = make_wo_order_test_record(production_item=fg_item) diff --git a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py index 3fe2198966c..da283435b9f 100644 --- a/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py +++ b/erpnext/manufacturing/report/bom_variance_report/bom_variance_report.py @@ -102,7 +102,7 @@ def get_work_orders(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name from `tabWork Order` where name like %(name)s and {0} and produced_qty > qty and docstatus = 1 - order by name limit {1}, {2}""".format( + order by name limit {2} offset {1}""".format( cond, start, page_len ), {"name": "%%%s%%" % txt}, diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 8594ebbe9d5..5a984635fdc 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -373,3 +373,4 @@ erpnext.patches.v13_0.create_accounting_dimensions_in_orders erpnext.patches.v13_0.set_per_billed_in_return_delivery_note execute:frappe.delete_doc("DocType", "Naming Series") erpnext.patches.v13_0.set_payroll_entry_status +erpnext.patches.v13_0.job_card_status_on_hold diff --git a/erpnext/patches/v13_0/job_card_status_on_hold.py b/erpnext/patches/v13_0/job_card_status_on_hold.py new file mode 100644 index 00000000000..8c67c3c858e --- /dev/null +++ b/erpnext/patches/v13_0/job_card_status_on_hold.py @@ -0,0 +1,19 @@ +import frappe + + +def execute(): + job_cards = frappe.get_all( + "Job Card", + {"status": "On Hold", "docstatus": ("!=", 0)}, + pluck="name", + ) + + for idx, job_card in enumerate(job_cards): + try: + doc = frappe.get_doc("Job Card", job_card) + doc.set_status() + doc.db_set("status", doc.status, update_modified=False) + if idx % 100 == 0: + frappe.db.commit() + except Exception: + continue diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py index c2267aa9af9..1e0d20d0591 100644 --- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py +++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py @@ -1,11 +1,13 @@ import frappe from frappe import qb +from frappe.query_builder import Case +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import IfNull from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_dimensions, make_dimension_in_accounting_doctypes, ) -from erpnext.accounts.utils import create_payment_ledger_entry def create_accounting_dimension_fields(): @@ -15,24 +17,119 @@ def create_accounting_dimension_fields(): make_dimension_in_accounting_doctypes(dimension, ["Payment Ledger Entry"]) -def execute(): - # create accounting dimension fields in Payment Ledger - create_accounting_dimension_fields() +def generate_name_for_payment_ledger_entries(gl_entries): + for index, entry in enumerate(gl_entries, 1): + entry.name = index + + +def get_columns(): + columns = [ + "name", + "creation", + "modified", + "modified_by", + "owner", + "docstatus", + "posting_date", + "account_type", + "account", + "party_type", + "party", + "voucher_type", + "voucher_no", + "against_voucher_type", + "against_voucher_no", + "amount", + "amount_in_account_currency", + "account_currency", + "company", + "cost_center", + "due_date", + "finance_book", + ] + + dimensions_and_defaults = get_dimensions() + if dimensions_and_defaults: + for dimension in dimensions_and_defaults[0]: + columns.append(dimension.fieldname) + + return columns + + +def build_insert_query(): + ple = qb.DocType("Payment Ledger Entry") + columns = get_columns() + insert_query = qb.into(ple) + + # build 'insert' columns in query + insert_query = insert_query.columns(tuple(columns)) + + return insert_query + + +def insert_chunk_into_payment_ledger(insert_query, gl_entries): + if gl_entries: + columns = get_columns() + + # build tuple of data with same column order + for entry in gl_entries: + data = () + for column in columns: + data += (entry[column],) + insert_query = insert_query.insert(data) + insert_query.run() + + +def execute(): + if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"): + # create accounting dimension fields in Payment Ledger + create_accounting_dimension_fields() + + gl = qb.DocType("GL Entry") + account = qb.DocType("Account") - gl = qb.DocType("GL Entry") - accounts = frappe.db.get_list( - "Account", "name", filters={"account_type": ["in", ["Receivable", "Payable"]]}, as_list=True - ) - gl_entries = [] - if accounts: - # get all gl entries on receivable/payable accounts gl_entries = ( qb.from_(gl) - .select("*") - .where(gl.account.isin(accounts)) + .inner_join(account) + .on((gl.account == account.name) & (account.account_type.isin(["Receivable", "Payable"]))) + .select( + gl.star, + ConstantColumn(1).as_("docstatus"), + account.account_type.as_("account_type"), + IfNull(gl.against_voucher_type, gl.voucher_type).as_("against_voucher_type"), + IfNull(gl.against_voucher, gl.voucher_no).as_("against_voucher_no"), + # convert debit/credit to amount + Case() + .when(account.account_type == "Receivable", gl.debit - gl.credit) + .else_(gl.credit - gl.debit) + .as_("amount"), + # convert debit/credit in account currency to amount in account currency + Case() + .when( + account.account_type == "Receivable", + gl.debit_in_account_currency - gl.credit_in_account_currency, + ) + .else_(gl.credit_in_account_currency - gl.debit_in_account_currency) + .as_("amount_in_account_currency"), + ) .where(gl.is_cancelled == 0) + .orderby(gl.creation) .run(as_dict=True) ) - if gl_entries: - # create payment ledger entries for the accounts receivable/payable - create_payment_ledger_entry(gl_entries, 0) + + # primary key(name) for payment ledger records + generate_name_for_payment_ledger_entries(gl_entries) + + # split data into chunks + chunk_size = 1000 + try: + for i in range(0, len(gl_entries), chunk_size): + insert_query = build_insert_query() + insert_chunk_into_payment_ledger(insert_query, gl_entries[i : i + chunk_size]) + frappe.db.commit() + except Exception as err: + frappe.db.rollback() + ple = qb.DocType("Payment Ledger Entry") + qb.from_(ple).delete().where(ple.docstatus >= 0).run() + frappe.db.commit() + raise err diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py index 0acd44711b0..8df1bb6e87e 100644 --- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py +++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_days, cint, cstr, date_diff, getdate, rounded +from frappe.utils import add_days, cstr, date_diff, flt, getdate, rounded from erpnext.hr.utils import ( get_holiday_dates_for_employee, @@ -27,11 +27,14 @@ class EmployeeBenefitApplication(Document): validate_active_employee(self.employee) self.validate_duplicate_on_payroll_period() if not self.max_benefits: - self.max_benefits = get_max_benefits_remaining(self.employee, self.date, self.payroll_period) + self.max_benefits = flt( + get_max_benefits_remaining(self.employee, self.date, self.payroll_period), + self.precision("max_benefits"), + ) if self.max_benefits and self.max_benefits > 0: self.validate_max_benefit_for_component() self.validate_prev_benefit_claim() - if self.remaining_benefit > 0: + if self.remaining_benefit and self.remaining_benefit > 0: self.validate_remaining_benefit_amount() else: frappe.throw( @@ -110,7 +113,7 @@ class EmployeeBenefitApplication(Document): max_benefit_amount = 0 for employee_benefit in self.employee_benefits: self.validate_max_benefit(employee_benefit.earning_component) - max_benefit_amount += employee_benefit.amount + max_benefit_amount += flt(employee_benefit.amount) if max_benefit_amount > self.max_benefits: frappe.throw( _("Maximum benefit amount of employee {0} exceeds {1}").format( @@ -125,7 +128,8 @@ class EmployeeBenefitApplication(Document): benefit_amount = 0 for employee_benefit in self.employee_benefits: if employee_benefit.earning_component == earning_component_name: - benefit_amount += employee_benefit.amount + benefit_amount += flt(employee_benefit.amount) + prev_sal_slip_flexi_amount = get_sal_slip_total_benefit_given( self.employee, frappe.get_doc("Payroll Period", self.payroll_period), earning_component_name ) @@ -207,26 +211,47 @@ def get_max_benefits_remaining(employee, on_date, payroll_period): def calculate_lwp(employee, start_date, holidays, working_days): lwp = 0 holidays = "','".join(holidays) + for d in range(working_days): - dt = add_days(cstr(getdate(start_date)), d) - leave = frappe.db.sql( - """ - select t1.name, t1.half_day - from `tabLeave Application` t1, `tabLeave Type` t2 - where t2.name = t1.leave_type - and t2.is_lwp = 1 - and t1.docstatus = 1 - and t1.employee = %(employee)s - and CASE WHEN t2.include_holiday != 1 THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date - WHEN t2.include_holiday THEN %(dt)s between from_date and to_date - END - """.format( - holidays - ), - {"employee": employee, "dt": dt}, + date = add_days(cstr(getdate(start_date)), d) + + LeaveApplication = frappe.qb.DocType("Leave Application") + LeaveType = frappe.qb.DocType("Leave Type") + + is_half_day = ( + frappe.qb.terms.Case() + .when( + ( + (LeaveApplication.half_day_date == date) + | (LeaveApplication.from_date == LeaveApplication.to_date) + ), + LeaveApplication.half_day, + ) + .else_(0) + ).as_("is_half_day") + + query = ( + frappe.qb.from_(LeaveApplication) + .inner_join(LeaveType) + .on((LeaveType.name == LeaveApplication.leave_type)) + .select(LeaveApplication.name, is_half_day) + .where( + (LeaveType.is_lwp == 1) + & (LeaveApplication.docstatus == 1) + & (LeaveApplication.status == "Approved") + & (LeaveApplication.employee == employee) + & ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date)) + ) ) - if leave: - lwp = cint(leave[0][1]) and (lwp + 0.5) or (lwp + 1) + + # if it's a holiday only include if leave type has "include holiday" enabled + if date in holidays: + query = query.where((LeaveType.include_holiday == "1")) + leaves = query.run(as_dict=True) + + if leaves: + lwp += 0.5 if leaves[0].is_half_day else 1 + return lwp diff --git a/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py index 02149adfce5..de8f9b6a7ad 100644 --- a/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py +++ b/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py @@ -3,6 +3,82 @@ import unittest +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, date_diff, get_year_ending, get_year_start, getdate -class TestEmployeeBenefitApplication(unittest.TestCase): - pass +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list +from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday +from erpnext.hr.utils import get_holiday_dates_for_employee +from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import ( + calculate_lwp, +) +from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import ( + create_payroll_period, +) +from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( + make_holiday_list, + make_leave_application, +) +from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip +from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + + +class TestEmployeeBenefitApplication(FrappeTestCase): + def setUp(self): + date = getdate() + make_holiday_list(from_date=get_year_start(date), to_date=get_year_ending(date)) + + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_employee_benefit_application(self): + payroll_period = create_payroll_period(name="_Test Payroll Period 1", company="_Test Company") + employee = make_employee("test_employee_benefits@salary.com", company="_Test Company") + first_sunday = get_first_sunday("Salary Slip Test Holiday List") + + leave_application = make_leave_application( + employee, + add_days(first_sunday, 1), + add_days(first_sunday, 3), + "Leave Without Pay", + half_day=1, + half_day_date=add_days(first_sunday, 1), + submit=True, + ) + + frappe.db.set_value("Leave Type", "Leave Without Pay", "include_holiday", 0) + salary_structure = make_salary_structure( + "Test Employee Benefits", + "Monthly", + other_details={"max_benefits": 100000}, + include_flexi_benefits=True, + employee=employee, + payroll_period=payroll_period, + ) + salary_slip = make_salary_slip(salary_structure.name, employee=employee, posting_date=getdate()) + salary_slip.insert() + salary_slip.submit() + + application = make_employee_benefit_application( + employee, payroll_period.name, date=leave_application.to_date + ) + self.assertEqual(application.employee_benefits[0].max_benefit_amount, 15000) + + holidays = get_holiday_dates_for_employee(employee, payroll_period.start_date, application.date) + working_days = date_diff(application.date, payroll_period.start_date) + 1 + lwp = calculate_lwp(employee, payroll_period.start_date, holidays, working_days) + self.assertEqual(lwp, 2.5) + + +def make_employee_benefit_application(employee, payroll_period, date): + frappe.db.delete("Employee Benefit Application") + + return frappe.get_doc( + { + "doctype": "Employee Benefit Application", + "employee": employee, + "date": date, + "payroll_period": payroll_period, + "employee_benefits": [{"earning_component": "Medical Allowance", "amount": 1500}], + } + ).insert() diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 620fcadceb2..1524fb7c9e7 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -970,7 +970,7 @@ def get_payroll_entries_for_jv(doctype, txt, searchfield, start, page_len, filte and name not in (select reference_name from `tabJournal Entry Account` where reference_type="Payroll Entry") - order by name limit %(start)s, %(page_len)s""".format( + order by name limit %(page_len)s offset %(start)s""".format( key=searchfield ), {"txt": "%%%s%%" % txt, "start": start, "page_len": page_len}, @@ -1039,7 +1039,7 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, employee_name), locate(%(_txt)s, employee_name), 99999), idx desc, name, employee_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "key": searchfield, "fcond": get_filters_cond(doctype, filters, conditions), diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 4c5fea1e759..6a35985e64c 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -465,37 +465,14 @@ class SalarySlip(TransactionBase): ) for d in range(working_days): - dt = add_days(cstr(getdate(self.start_date)), d) - leave = frappe.db.sql( - """ - SELECT t1.name, - CASE WHEN (t1.half_day_date = %(dt)s or t1.to_date = t1.from_date) - THEN t1.half_day else 0 END, - t2.is_ppl, - t2.fraction_of_daily_salary_per_leave - FROM `tabLeave Application` t1, `tabLeave Type` t2 - WHERE t2.name = t1.leave_type - AND (t2.is_lwp = 1 or t2.is_ppl = 1) - AND t1.docstatus = 1 - AND t1.employee = %(employee)s - AND ifnull(t1.salary_slip, '') = '' - AND CASE - WHEN t2.include_holiday != 1 - THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date - WHEN t2.include_holiday - THEN %(dt)s between from_date and to_date - END - """.format( - holidays - ), - {"employee": self.employee, "dt": dt}, - ) + date = add_days(cstr(getdate(self.start_date)), d) + leave = get_lwp_or_ppl_for_date(date, self.employee, holidays) if leave: equivalent_lwp_count = 0 - is_half_day_leave = cint(leave[0][1]) - is_partially_paid_leave = cint(leave[0][2]) - fraction_of_daily_salary_per_leave = flt(leave[0][3]) + is_half_day_leave = cint(leave[0].is_half_day) + is_partially_paid_leave = cint(leave[0].is_ppl) + fraction_of_daily_salary_per_leave = flt(leave[0].fraction_of_daily_salary_per_leave) equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1 @@ -1742,3 +1719,46 @@ def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None): except Exception as e: frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e)) raise + + +def get_lwp_or_ppl_for_date(date, employee, holidays): + LeaveApplication = frappe.qb.DocType("Leave Application") + LeaveType = frappe.qb.DocType("Leave Type") + + is_half_day = ( + frappe.qb.terms.Case() + .when( + ( + (LeaveApplication.half_day_date == date) + | (LeaveApplication.from_date == LeaveApplication.to_date) + ), + LeaveApplication.half_day, + ) + .else_(0) + ).as_("is_half_day") + + query = ( + frappe.qb.from_(LeaveApplication) + .inner_join(LeaveType) + .on((LeaveType.name == LeaveApplication.leave_type)) + .select( + LeaveApplication.name, + LeaveType.is_ppl, + LeaveType.fraction_of_daily_salary_per_leave, + (is_half_day), + ) + .where( + (((LeaveType.is_lwp == 1) | (LeaveType.is_ppl == 1))) + & (LeaveApplication.docstatus == 1) + & (LeaveApplication.status == "Approved") + & (LeaveApplication.employee == employee) + & ((LeaveApplication.salary_slip.isnull()) | (LeaveApplication.salary_slip == "")) + & ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date)) + ) + ) + + # if it's a holiday only include if leave type has "include holiday" enabled + if date in holidays: + query = query.where((LeaveType.include_holiday == "1")) + + return query.run(as_dict=True) diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 5e3814b73cd..a8b6bb5714b 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -49,7 +49,7 @@ class TestSalarySlip(unittest.TestCase): "Payroll Settings", {"payroll_based_on": "Attendance", "daily_wages_fraction_for_half_day": 0.75} ) def test_payment_days_based_on_attendance(self): - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() emp_id = make_employee("test_payment_days_based_on_attendance@salary.com") frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) @@ -128,7 +128,7 @@ class TestSalarySlip(unittest.TestCase): }, ) def test_payment_days_for_mid_joinee_including_holidays(self): - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") @@ -196,7 +196,7 @@ class TestSalarySlip(unittest.TestCase): # tests mid month joining and relieving along with unmarked days from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") @@ -236,7 +236,7 @@ class TestSalarySlip(unittest.TestCase): def test_payment_days_for_mid_joinee_excluding_holidays(self): from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") @@ -267,7 +267,7 @@ class TestSalarySlip(unittest.TestCase): @change_settings("Payroll Settings", {"payroll_based_on": "Leave"}) def test_payment_days_based_on_leave_application(self): - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com") frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) @@ -366,7 +366,7 @@ class TestSalarySlip(unittest.TestCase): salary_slip.submit() salary_slip.reload() - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() days_in_month = no_of_days[0] no_of_holidays = no_of_days[1] @@ -441,7 +441,7 @@ class TestSalarySlip(unittest.TestCase): @change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 1}) def test_salary_slip_with_holidays_included(self): - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() make_employee("test_salary_slip_with_holidays_included@salary.com") frappe.db.set_value( "Employee", @@ -473,7 +473,7 @@ class TestSalarySlip(unittest.TestCase): @change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 0}) def test_salary_slip_with_holidays_excluded(self): - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() make_employee("test_salary_slip_with_holidays_excluded@salary.com") frappe.db.set_value( "Employee", @@ -510,7 +510,7 @@ class TestSalarySlip(unittest.TestCase): create_salary_structure_assignment, ) - no_of_days = self.get_no_of_days() + no_of_days = get_no_of_days() # set joinng date in the same month employee = make_employee("test_payment_days@salary.com") @@ -984,17 +984,18 @@ class TestSalarySlip(unittest.TestCase): activity_type.wage_rate = 25 activity_type.save() - def get_no_of_days(self): - no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month) - no_of_holidays_in_month = len( - [ - 1 - for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month) - if i[6] != 0 - ] - ) - return [no_of_days_in_month[1], no_of_holidays_in_month] +def get_no_of_days(): + no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month) + no_of_holidays_in_month = len( + [ + 1 + for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month) + if i[6] != 0 + ] + ) + + return [no_of_days_in_month[1], no_of_holidays_in_month] def make_employee_salary_slip(user, payroll_frequency, salary_structure=None, posting_date=None): @@ -1136,6 +1137,7 @@ def make_earning_salary_component( "pay_against_benefit_claim": 0, "type": "Earning", "max_benefit_amount": 15000, + "depends_on_payment_days": 1, }, ] ) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 8a8e1d1bf5e..c613fe633d0 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -391,7 +391,7 @@ def get_users_for_project(doctype, txt, searchfield, start, page_len, filters): if(locate(%(_txt)s, full_name), locate(%(_txt)s, full_name), 99999), idx desc, name, full_name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( **{ "key": searchfield, "fcond": get_filters_cond(doctype, filters, conditions), diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index 4575fb544c4..0e409fce9cc 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -288,7 +288,7 @@ def get_project(doctype, txt, searchfield, start, page_len, filters): %(mcond)s {search_condition} order by name - limit %(start)s, %(page_len)s""".format( + limit %(page_len)s offset %(start)s""".format( search_columns=search_columns, search_condition=search_cond ), { diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 2ef966b3192..88d5beef17e 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -328,7 +328,7 @@ def get_timesheet(doctype, txt, searchfield, start, page_len, filters): ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and tsd.docstatus = 1 and ts.total_billable_amount > 0 and tsd.parent LIKE %(txt)s {condition} - order by tsd.parent limit %(start)s, %(page_len)s""".format( + order by tsd.parent limit %(page_len)s offset %(start)s""".format( condition=condition ), { @@ -515,7 +515,7 @@ def get_timesheets_list( tsd.project IN %(projects)s ) ORDER BY `end_date` ASC - LIMIT {0}, {1} + LIMIT {1} offset {0} """.format( limit_start, limit_page_length ), diff --git a/erpnext/projects/utils.py b/erpnext/projects/utils.py index 000ea662756..3cc4da4f07d 100644 --- a/erpnext/projects/utils.py +++ b/erpnext/projects/utils.py @@ -25,7 +25,7 @@ def query_task(doctype, txt, searchfield, start, page_len, filters): case when `%s` like %s then 0 else 1 end, `%s`, subject - limit %s, %s""" + limit %s offset %s""" % (searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"), - (search_string, search_string, order_by_string, order_by_string, start, page_len), + (search_string, search_string, order_by_string, order_by_string, page_len, start), ) diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 58eb8916002..a5b7699040b 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -74,6 +74,7 @@ erpnext.buying.BuyingController = class BuyingController extends erpnext.Transac me.frm.set_query('supplier_address', erpnext.queries.address_query); me.frm.set_query('billing_address', erpnext.queries.company_address_query); + erpnext.accounts.dimensions.setup_dimension_filters(me.frm, me.frm.doctype); if(this.frm.fields_dict.supplier) { this.frm.set_query("supplier", function() { 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 d6210abf80d..090697b0102 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -148,7 +148,6 @@ class GSTR3BReport(Document): FROM `tabPurchase Invoice` p , `tabPurchase Invoice Item` i WHERE p.docstatus = 1 and p.name = i.parent and p.is_opening = 'No' - and p.gst_category != 'Registered Composition' and (i.is_nil_exempt = 1 or i.is_non_gst = 1 or p.gst_category = 'Registered Composition') and month(p.posting_date) = %s and year(p.posting_date) = %s and p.company = %s and p.company_gstin = %s @@ -245,11 +244,10 @@ class GSTR3BReport(Document): ) for d in item_details: - if d.item_code not in self.invoice_items.get(d.parent, {}): - self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) - self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) or d.get( - "base_net_amount", 0 - ) + self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) + self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) or d.get( + "base_net_amount", 0 + ) if d.is_nil_exempt and d.item_code not in self.is_nil_exempt: self.is_nil_exempt.append(d.item_code) @@ -336,7 +334,6 @@ class GSTR3BReport(Document): def set_outward_taxable_supplies(self): 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 = ( @@ -362,7 +359,6 @@ class GSTR3BReport(Document): else: self.report_dict["sup_details"]["osup_det"]["iamt"] += taxable_value * rate / 100 self.report_dict["sup_details"]["osup_det"]["txval"] += taxable_value - if ( gst_category in ["Unregistered", "Registered Composition", "UIN Holders"] and self.gst_details.get("gst_state") != place_of_supply.split("-")[1] diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 9add09beaf7..5eb14a5ddd3 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -55,6 +55,9 @@ def validate_eligibility(doc): return False invalid_company = not frappe.db.get_value("E Invoice User", {"company": doc.get("company")}) + invalid_company_gstin = not frappe.db.get_value( + "E Invoice User", {"gstin": doc.get("company_gstin")} + ) invalid_supply_type = doc.get("gst_category") not in [ "Registered Regular", "Registered Composition", @@ -71,6 +74,7 @@ def validate_eligibility(doc): if ( invalid_company + or invalid_company_gstin or invalid_supply_type or company_transaction or no_taxes_applied diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index ee48ccb24a4..02624695710 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -287,7 +287,7 @@ def get_regional_address_details(party_details, doctype, company): return party_details if ( - doctype in ("Sales Invoice", "Delivery Note", "Sales Order") + doctype in ("Sales Invoice", "Delivery Note", "Sales Order", "Quotation") and party_details.company_gstin and party_details.company_gstin[:2] != party_details.place_of_supply[:2] ) or ( diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 0bdbe56de60..6cbc12c7a12 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -1155,8 +1155,11 @@ def get_company_gstins(company): .inner_join(links) .on(address.name == links.parent) .select(address.gstin) + .distinct() .where(links.link_doctype == "Company") .where(links.link_name == company) + .where(address.gstin.isnotnull()) + .where(address.gstin != "") .run(as_dict=1) ) diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index 92aeb5ee6fe..0f578be1755 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -10,7 +10,7 @@ from frappe.utils.data import fmt_money from frappe.utils.jinja import render_template from frappe.utils.pdf import get_pdf from frappe.utils.print_format import read_multi_pdf -from PyPDF2 import PdfFileWriter +from PyPDF2 import PdfWriter from erpnext.accounts.utils import get_fiscal_year @@ -106,7 +106,7 @@ def irs_1099_print(filters): columns, data = execute(filters) template = frappe.get_doc("Print Format", "IRS 1099 Form").html - output = PdfFileWriter() + output = PdfWriter() for row in data: row["fiscal_year"] = fiscal_year diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 8889a5f939a..35e0b0de407 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -141,6 +141,9 @@ class Customer(TransactionBase): ) def validate_internal_customer(self): + if not self.is_internal_customer: + self.represents_company = "" + internal_customer = frappe.db.get_value( "Customer", { diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index 575b956686a..ac83c0f0462 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -78,7 +78,7 @@ def get_new_item_code(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name, item_name, description from tabItem where is_stock_item=0 and name not in (select name from `tabProduct Bundle`) - and %s like %s %s limit %s, %s""" + and %s like %s %s limit %s offset %s""" % (searchfield, "%s", get_match_cond(doctype), "%s", "%s"), - ("%%%s%%" % txt, start, page_len), + ("%%%s%%" % txt, page_len, start), ) diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 34e9a52e11e..70ae085051d 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -20,6 +20,20 @@ frappe.ui.form.on('Quotation', { frm.set_df_property('packed_items', 'cannot_add_rows', true); frm.set_df_property('packed_items', 'cannot_delete_rows', true); + + frm.set_query('company_address', function(doc) { + if(!doc.company) { + frappe.throw(__('Please set Company')); + } + + return { + query: 'frappe.contacts.doctype.address.address.address_query', + filters: { + link_doctype: 'Company', + link_name: doc.company + } + }; + }); }, refresh: function(frm) { @@ -70,7 +84,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. } } - if(doc.docstatus == 1 && doc.status!=='Lost') { + if(doc.docstatus == 1 && !(['Lost', 'Ordered']).includes(doc.status)) { if(!doc.valid_till || frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) { cur_frm.add_custom_button(__('Sales Order'), cur_frm.cscript['Make Sales Order'], __('Create')); diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index 75443abe493..5dfd8f29651 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -897,7 +897,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "Draft\nOpen\nReplied\nOrdered\nLost\nCancelled\nExpired", + "options": "Draft\nOpen\nReplied\nPartially Ordered\nOrdered\nLost\nCancelled\nExpired", "print_hide": 1, "read_only": 1, "reqd": 1 @@ -986,7 +986,7 @@ "idx": 82, "is_submittable": 1, "links": [], - "modified": "2022-04-07 11:01:31.157084", + "modified": "2022-06-11 20:35:32.635804", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 548813ddef2..d5fd946bdeb 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -70,8 +70,32 @@ class Quotation(SellingController): title=_("Unpublished Item"), ) - def has_sales_order(self): - return frappe.db.get_value("Sales Order Item", {"prevdoc_docname": self.name, "docstatus": 1}) + def get_ordered_status(self): + ordered_items = frappe._dict( + frappe.db.get_all( + "Sales Order Item", + {"prevdoc_docname": self.name, "docstatus": 1}, + ["item_code", "sum(qty)"], + group_by="item_code", + as_list=1, + ) + ) + + status = "Open" + if ordered_items: + status = "Ordered" + + for item in self.get("items"): + if item.qty > ordered_items.get(item.item_code, 0.0): + status = "Partially Ordered" + + return status + + def is_fully_ordered(self): + return self.get_ordered_status() == "Ordered" + + def is_partially_ordered(self): + return self.get_ordered_status() == "Partially Ordered" def update_lead(self): if self.quotation_to == "Lead" and self.party_name: diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js index 4c8f9c4f84c..32fce1f2ad8 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -25,6 +25,8 @@ frappe.listview_settings['Quotation'] = { get_indicator: function(doc) { if(doc.status==="Open") { return [__("Open"), "orange", "status,=,Open"]; + } else if (doc.status==="Partially Ordered") { + return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"]; } else if(doc.status==="Ordered") { return [__("Ordered"), "green", "status,=,Ordered"]; } else if(doc.status==="Lost") { diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index ff921c721df..74c5c07e47b 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1359,6 +1359,8 @@ "width": "50%" }, { + "fetch_from": "sales_partner.commission_rate", + "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "hide_days": 1, @@ -1547,7 +1549,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-04-26 14:38:18.350207", + "modified": "2022-06-10 03:52:22.212953", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", 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 99afe813cb9..13d5069ea6d 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -107,7 +107,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te ORDER BY item.name asc LIMIT - {start}, {page_length}""".format( + {page_length} offset {start}""".format( start=start, page_length=page_length, lft=lft, @@ -204,7 +204,7 @@ def item_group_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """ select distinct name from `tabItem Group` - where {condition} and (name like %(txt)s) limit {start}, {page_len}""".format( + where {condition} and (name like %(txt)s) limit {page_len} offset {start}""".format( condition=cond, start=start, page_len=page_len ), {"txt": "%%%s%%" % txt}, 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 dcfb10a9d53..cc61594af4a 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -1,11 +1,13 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import copy +from collections import OrderedDict import frappe -from frappe import _ +from frappe import _, qb +from frappe.query_builder import CustomFunction +from frappe.query_builder.functions import Max from frappe.utils import date_diff, flt, getdate @@ -18,11 +20,12 @@ def execute(filters=None): columns = get_columns(filters) conditions = get_conditions(filters) data = get_data(conditions, filters) + so_elapsed_time = get_so_elapsed_time(data) if not data: return [], [], None, [] - data, chart_data = prepare_data(data, filters) + data, chart_data = prepare_data(data, so_elapsed_time, filters) return columns, data, None, chart_data @@ -65,7 +68,6 @@ def get_data(conditions, filters): IF(so.status in ('Completed','To Bill'), 0, (SELECT delay_days)) as delay, soi.qty, soi.delivered_qty, (soi.qty - soi.delivered_qty) AS pending_qty, - IF((SELECT pending_qty) = 0, (TO_SECONDS(Max(dn.posting_date))-TO_SECONDS(so.transaction_date)), 0) as time_taken_to_deliver, IFNULL(SUM(sii.qty), 0) as billed_qty, soi.base_amount as amount, (soi.delivered_qty * soi.base_rate) as delivered_qty_amount, @@ -76,13 +78,9 @@ def get_data(conditions, filters): soi.description as description FROM `tabSales Order` so, - (`tabSales Order Item` soi + `tabSales Order Item` soi LEFT JOIN `tabSales Invoice Item` sii - ON sii.so_detail = soi.name and sii.docstatus = 1) - LEFT JOIN `tabDelivery Note Item` dni - on dni.so_detail = soi.name - LEFT JOIN `tabDelivery Note` dn - on dni.parent = dn.name and dn.docstatus = 1 + ON sii.so_detail = soi.name and sii.docstatus = 1 WHERE soi.parent = so.name and so.status not in ('Stopped', 'Closed', 'On Hold') @@ -100,7 +98,48 @@ def get_data(conditions, filters): return data -def prepare_data(data, filters): +def get_so_elapsed_time(data): + """ + query SO's elapsed time till latest delivery note + """ + so_elapsed_time = OrderedDict() + if data: + sales_orders = [x.sales_order for x in data] + + so = qb.DocType("Sales Order") + soi = qb.DocType("Sales Order Item") + dn = qb.DocType("Delivery Note") + dni = qb.DocType("Delivery Note Item") + + to_seconds = CustomFunction("TO_SECONDS", ["date"]) + + query = ( + qb.from_(so) + .inner_join(soi) + .on(soi.parent == so.name) + .left_join(dni) + .on(dni.so_detail == soi.name) + .left_join(dn) + .on(dni.parent == dn.name) + .select( + so.name.as_("sales_order"), + soi.item_code.as_("so_item_code"), + (to_seconds(Max(dn.posting_date)) - to_seconds(so.transaction_date)).as_("elapsed_seconds"), + ) + .where((so.name.isin(sales_orders)) & (dn.docstatus == 1)) + .orderby(so.name, soi.name) + .groupby(soi.name) + ) + dn_elapsed_time = query.run(as_dict=True) + + for e in dn_elapsed_time: + key = (e.sales_order, e.so_item_code) + so_elapsed_time[key] = e.elapsed_seconds + + return so_elapsed_time + + +def prepare_data(data, so_elapsed_time, filters): completed, pending = 0, 0 if filters.get("group_by_so"): @@ -115,6 +154,13 @@ def prepare_data(data, filters): row["qty_to_bill"] = flt(row["qty"]) - flt(row["billed_qty"]) row["delay"] = 0 if row["delay"] and row["delay"] < 0 else row["delay"] + + row["time_taken_to_deliver"] = ( + so_elapsed_time.get((row.sales_order, row.item_code)) + if row["status"] in ("To Bill", "Completed") + else 0 + ) + if filters.get("group_by_so"): so_name = row["sales_order"] diff --git a/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py index 25cbb734499..241f4358fba 100644 --- a/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py @@ -11,7 +11,7 @@ test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Delivery Note"] class TestSalesOrderAnalysis(FrappeTestCase): - def create_sales_order(self, transaction_date): + def create_sales_order(self, transaction_date, do_not_save=False, do_not_submit=False): item = create_item(item_code="_Test Excavator", is_stock_item=0) so = make_sales_order( transaction_date=transaction_date, @@ -24,25 +24,31 @@ class TestSalesOrderAnalysis(FrappeTestCase): so.taxes_and_charges = "" so.taxes = "" so.items[0].delivery_date = add_days(transaction_date, 15) - so.save() - so.submit() + if not do_not_save: + so.save() + if not do_not_submit: + so.submit() return item, so - def create_sales_invoice(self, so): + def create_sales_invoice(self, so, do_not_save=False, do_not_submit=False): sinv = make_sales_invoice(so.name) sinv.posting_date = so.transaction_date sinv.taxes_and_charges = "" sinv.taxes = "" - sinv.insert() - sinv.submit() + if not do_not_save: + sinv.save() + if not do_not_submit: + sinv.submit() return sinv - def create_delivery_note(self, so): + def create_delivery_note(self, so, do_not_save=False, do_not_submit=False): dn = make_delivery_note(so.name) dn.set_posting_time = True dn.posting_date = add_days(so.transaction_date, 1) - dn.save() - dn.submit() + if not do_not_save: + dn.save() + if not do_not_submit: + dn.submit() return dn def test_01_so_to_deliver_and_bill(self): @@ -164,3 +170,85 @@ class TestSalesOrderAnalysis(FrappeTestCase): ) # SO's from first 4 test cases should be in output self.assertEqual(len(data), 4) + + def test_06_so_pending_delivery_with_multiple_delivery_notes(self): + transaction_date = "2021-06-01" + item, so = self.create_sales_order(transaction_date) + + # bill 2 items + sinv1 = self.create_sales_invoice(so, do_not_save=True) + sinv1.items[0].qty = 2 + sinv1 = sinv1.save().submit() + # deliver 2 items + dn1 = self.create_delivery_note(so, do_not_save=True) + dn1.items[0].qty = 2 + dn1 = dn1.save().submit() + + # bill 2 items + sinv2 = self.create_sales_invoice(so, do_not_save=True) + sinv2.items[0].qty = 2 + sinv2 = sinv2.save().submit() + # deliver 1 item + dn2 = self.create_delivery_note(so, do_not_save=True) + dn2.items[0].qty = 1 + dn2 = dn2.save().submit() + + columns, data, message, chart = execute( + { + "company": "_Test Company", + "from_date": "2021-06-01", + "to_date": "2021-06-30", + "sales_order": [so.name], + } + ) + expected_value = { + "status": "To Deliver and Bill", + "sales_order": so.name, + "delay_days": frappe.utils.date_diff(frappe.utils.datetime.date.today(), so.delivery_date), + "qty": 10, + "delivered_qty": 3, + "pending_qty": 7, + "qty_to_bill": 6, + "billed_qty": 4, + "time_taken_to_deliver": 0, + } + self.assertEqual(len(data), 1) + for key, val in expected_value.items(): + with self.subTest(key=key, val=val): + self.assertEqual(data[0][key], val) + + def test_07_so_delivered_with_multiple_delivery_notes(self): + transaction_date = "2021-06-01" + item, so = self.create_sales_order(transaction_date) + + dn1 = self.create_delivery_note(so, do_not_save=True) + dn1.items[0].qty = 5 + dn1 = dn1.save().submit() + + dn2 = self.create_delivery_note(so, do_not_save=True) + dn2.items[0].qty = 5 + dn2 = dn2.save().submit() + + columns, data, message, chart = execute( + { + "company": "_Test Company", + "from_date": "2021-06-01", + "to_date": "2021-06-30", + "sales_order": [so.name], + } + ) + expected_value = { + "status": "To Bill", + "sales_order": so.name, + "delay_days": frappe.utils.date_diff(frappe.utils.datetime.date.today(), so.delivery_date), + "qty": 10, + "delivered_qty": 10, + "pending_qty": 0, + "qty_to_bill": 10, + "billed_qty": 0, + "time_taken_to_deliver": 86400, + } + self.assertEqual(len(data), 1) + for key, val in expected_value.items(): + with self.subTest(key=key, val=val): + self.assertEqual(data[0][key], val) diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index 0954de4e665..8ff01f5cb4c 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -12,8 +12,6 @@ frappe.provide("erpnext.selling"); erpnext.selling.SellingController = class SellingController extends erpnext.TransactionController { setup() { super.setup(); - this.frm.add_fetch("sales_partner", "commission_rate", "commission_rate"); - this.frm.add_fetch("sales_person", "commission_rate", "commission_rate"); } onload() { @@ -43,6 +41,7 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran me.frm.set_query('shipping_address_name', erpnext.queries.address_query); me.frm.set_query('dispatch_address_name', erpnext.queries.dispatch_address_query); + erpnext.accounts.dimensions.setup_dimension_filters(me.frm, me.frm.doctype); if(this.frm.fields_dict.selling_price_list) { this.frm.set_query("selling_price_list", function() { @@ -513,4 +512,4 @@ frappe.ui.form.on(cur_frm.doctype, { dialog.show(); } -}) \ No newline at end of file +}) diff --git a/erpnext/setup/doctype/authorization_control/authorization_control.py b/erpnext/setup/doctype/authorization_control/authorization_control.py index 309658d2601..cfe3d62b8c8 100644 --- a/erpnext/setup/doctype/authorization_control/authorization_control.py +++ b/erpnext/setup/doctype/authorization_control/authorization_control.py @@ -135,8 +135,8 @@ class AuthorizationControl(TransactionBase): price_list_rate, base_rate = 0, 0 for d in doc_obj.get("items"): if d.base_rate: - price_list_rate += flt(d.base_price_list_rate) or flt(d.base_rate) - base_rate += flt(d.base_rate) + price_list_rate += (flt(d.base_price_list_rate) or flt(d.base_rate)) * flt(d.qty) + base_rate += flt(d.base_rate) * flt(d.qty) if doc_obj.get("discount_amount"): base_rate -= flt(doc_obj.discount_amount) diff --git a/erpnext/setup/doctype/party_type/party_type.py b/erpnext/setup/doctype/party_type/party_type.py index d07ab084500..cf7cba84528 100644 --- a/erpnext/setup/doctype/party_type/party_type.py +++ b/erpnext/setup/doctype/party_type/party_type.py @@ -21,7 +21,7 @@ def get_party_type(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( """select name from `tabParty Type` where `{key}` LIKE %(txt)s {cond} - order by name limit %(start)s, %(page_len)s""".format( + order by name limit %(page_len)s offset %(start)s""".format( key=searchfield, cond=cond ), {"txt": "%" + txt + "%", "start": start, "page_len": page_len}, diff --git a/erpnext/setup/module_onboarding/home/home.json b/erpnext/setup/module_onboarding/home/home.json index 1b2dbc6fea7..f02fc454c00 100644 --- a/erpnext/setup/module_onboarding/home/home.json +++ b/erpnext/setup/module_onboarding/home/home.json @@ -25,7 +25,7 @@ "documentation_url": "https://docs.erpnext.com/docs/v13/user/manual/en/setting-up/company-setup", "idx": 0, "is_complete": 0, - "modified": "2021-12-15 14:23:52.460913", + "modified": "2022-06-07 14:31:00.575193", "modified_by": "Administrator", "module": "Setup", "name": "Home", diff --git a/erpnext/setup/onboarding_step/data_import/data_import.json b/erpnext/setup/onboarding_step/data_import/data_import.json index 48741dca01c..4999a368d37 100644 --- a/erpnext/setup/onboarding_step/data_import/data_import.json +++ b/erpnext/setup/onboarding_step/data_import/data_import.json @@ -2,14 +2,14 @@ "action": "Watch Video", "action_label": "Learn more about data migration", "creation": "2021-05-19 05:29:16.809610", - "description": "# Import Data from Spreadsheet\n\nIn ERPNext, you can easily migrate your historical data using spreadsheets. You can use it for migrating not just masters (like Customer, Supplier, Items), but also for transactions like (outstanding invoices, opening stock and accounting entries, etc). If you are migrating from [Tally](https://tallysolutions.com/) or [Quickbooks](https://quickbooks.intuit.com/in/), we got special migration tools for you.", + "description": "# Import Data from Spreadsheet\n\nIn ERPNext, you can easily migrate your historical data using spreadsheets. You can use it for migrating not just masters (like Customer, Supplier, Items), but also for transactions like (outstanding invoices, opening stock and accounting entries, etc).", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2021-12-15 13:10:57.346422", + "modified": "2022-06-07 14:28:51.390813", "modified_by": "Administrator", "name": "Data import", "owner": "Administrator", diff --git a/erpnext/setup/onboarding_step/navigation_help/navigation_help.json b/erpnext/setup/onboarding_step/navigation_help/navigation_help.json index 388853df79d..cf07968bc7f 100644 --- a/erpnext/setup/onboarding_step/navigation_help/navigation_help.json +++ b/erpnext/setup/onboarding_step/navigation_help/navigation_help.json @@ -9,7 +9,7 @@ "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2021-12-15 14:20:55.441678", + "modified": "2022-06-07 14:28:00.901082", "modified_by": "Administrator", "name": "Navigation Help", "owner": "Administrator", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 706ca365988..ea3cf1948b3 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -77,8 +77,6 @@ frappe.ui.form.on("Delivery Note", { } }); - erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); - frm.set_df_property('packed_items', 'cannot_add_rows', true); frm.set_df_property('packed_items', 'cannot_delete_rows', true); }, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index e3222bc8850..f9e934921d8 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1192,6 +1192,8 @@ "width": "50%" }, { + "fetch_from": "sales_partner.commission_rate", + "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "label": "Commission Rate (%)", @@ -1334,7 +1336,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2022-04-26 14:48:08.781837", + "modified": "2022-06-10 03:52:04.197415", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 4f3e8429957..2f6d4fb783d 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -11,7 +11,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "name_and_description_section", + "details", "naming_series", "item_code", "variant_of", @@ -35,11 +35,11 @@ "over_billing_allowance", "image", "section_break_11", - "brand", "description", - "sb_barcodes", - "barcodes", + "brand", + "dashboard_tab", "inventory_section", + "inventory_settings_section", "shelf_life_in_days", "end_of_life", "default_material_request_type", @@ -49,6 +49,8 @@ "weight_per_unit", "weight_uom", "allow_negative_stock", + "sb_barcodes", + "barcodes", "reorder_section", "reorder_levels", "unit_of_measure_conversion", @@ -67,13 +69,13 @@ "has_variants", "variant_based_on", "attributes", - "defaults", + "accounting", "item_defaults", - "purchase_details", - "is_purchase_item", + "purchasing_tab", "purchase_uom", "min_order_qty", "safety_stock", + "is_purchase_item", "purchase_details_cb", "lead_time_days", "last_purchase_rate", @@ -83,33 +85,31 @@ "delivered_by_supplier", "column_break2", "supplier_items", + "deferred_expense_section", + "enable_deferred_expense", + "deferred_expense_account", + "no_of_months_exp", "foreign_trade_details", "country_of_origin", "column_break_59", "customs_tariff_number", "sales_details", "sales_uom", - "is_sales_item", "grant_commission", + "is_sales_item", "column_break3", "max_discount", "deferred_revenue", - "deferred_revenue_account", "enable_deferred_revenue", - "column_break_85", + "deferred_revenue_account", "no_of_months", - "deferred_expense_section", - "deferred_expense_account", - "enable_deferred_expense", - "column_break_88", - "no_of_months_exp", "customer_details", "customer_items", "item_tax_section_break", "taxes", - "inspection_criteria", - "quality_inspection_template", + "quality_tab", "inspection_required_before_purchase", + "quality_inspection_template", "inspection_required_before_delivery", "manufacturing", "default_bom", @@ -118,17 +118,10 @@ "customer_code", "default_item_manufacturer", "default_manufacturer_part_no", - "more_information_section", "published_in_website", "total_projected_qty" ], "fields": [ - { - "fieldname": "name_and_description_section", - "fieldtype": "Section Break", - "oldfieldtype": "Section Break", - "options": "fa fa-flag" - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -315,7 +308,7 @@ "collapsible_depends_on": "is_stock_item", "depends_on": "is_stock_item", "fieldname": "inventory_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Inventory", "oldfieldtype": "Section Break", "options": "fa fa-truck" @@ -514,31 +507,17 @@ "label": "Attributes", "options": "Item Variant Attribute" }, - { - "depends_on": "eval:!doc.is_fixed_asset", - "fieldname": "defaults", - "fieldtype": "Section Break", - "label": "Sales, Purchase, Accounting Defaults" - }, { "fieldname": "item_defaults", "fieldtype": "Table", "label": "Item Defaults", "options": "Item Default" }, - { - "collapsible": 1, - "fieldname": "purchase_details", - "fieldtype": "Section Break", - "label": "Purchase, Replenishment Details", - "oldfieldtype": "Section Break", - "options": "fa fa-shopping-cart" - }, { "default": "1", "fieldname": "is_purchase_item", "fieldtype": "Check", - "label": "Is Purchase Item" + "label": "Allow Purchase" }, { "fieldname": "purchase_uom", @@ -646,8 +625,8 @@ { "collapsible": 1, "fieldname": "sales_details", - "fieldtype": "Section Break", - "label": "Sales Details", + "fieldtype": "Tab Break", + "label": "Sales", "oldfieldtype": "Section Break", "options": "fa fa-tag" }, @@ -661,7 +640,7 @@ "default": "1", "fieldname": "is_sales_item", "fieldtype": "Check", - "label": "Is Sales Item" + "label": "Allow Sales" }, { "fieldname": "column_break3", @@ -696,10 +675,6 @@ "fieldtype": "Check", "label": "Enable Deferred Revenue" }, - { - "fieldname": "column_break_85", - "fieldtype": "Column Break" - }, { "depends_on": "enable_deferred_revenue", "fieldname": "no_of_months", @@ -726,10 +701,6 @@ "fieldtype": "Check", "label": "Enable Deferred Expense" }, - { - "fieldname": "column_break_88", - "fieldtype": "Column Break" - }, { "depends_on": "enable_deferred_expense", "fieldname": "no_of_months_exp", @@ -753,8 +724,8 @@ "collapsible": 1, "collapsible_depends_on": "taxes", "fieldname": "item_tax_section_break", - "fieldtype": "Section Break", - "label": "Item Tax", + "fieldtype": "Tab Break", + "label": "Tax", "oldfieldtype": "Section Break", "options": "fa fa-money" }, @@ -767,15 +738,6 @@ "oldfieldtype": "Table", "options": "Item Tax" }, - { - "collapsible": 1, - "depends_on": "eval:!doc.is_fixed_asset", - "fieldname": "inspection_criteria", - "fieldtype": "Section Break", - "label": "Inspection Criteria", - "oldfieldtype": "Section Break", - "options": "fa fa-search" - }, { "default": "0", "fieldname": "inspection_required_before_purchase", @@ -801,7 +763,7 @@ "collapsible": 1, "depends_on": "is_stock_item", "fieldname": "manufacturing", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Manufacturing", "oldfieldtype": "Section Break", "options": "fa fa-cogs" @@ -880,12 +842,6 @@ "label": "Default Manufacturer Part No", "read_only": 1 }, - { - "collapsible": 1, - "fieldname": "more_information_section", - "fieldtype": "Section Break", - "label": "More Information" - }, { "default": "0", "depends_on": "published_in_website", @@ -912,6 +868,40 @@ "fieldname": "allow_negative_stock", "fieldtype": "Check", "label": "Allow Negative Stock" + }, + { + "fieldname": "inventory_settings_section", + "fieldtype": "Section Break", + "label": "Inventory Settings" + }, + { + "fieldname": "purchasing_tab", + "fieldtype": "Tab Break", + "label": "Purchasing" + }, + { + "fieldname": "quality_tab", + "fieldtype": "Tab Break", + "label": "Quality" + }, + { + "fieldname": "details", + "fieldtype": "Tab Break", + "label": "Details", + "oldfieldtype": "Section Break", + "options": "fa fa-flag" + }, + { + "fieldname": "dashboard_tab", + "fieldtype": "Tab Break", + "label": "Dashboard", + "show_dashboard": 1 + }, + { + "depends_on": "eval:!doc.is_fixed_asset", + "fieldname": "accounting", + "fieldtype": "Tab Break", + "label": "Accounting" } ], "icon": "fa fa-tag", @@ -919,7 +909,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-04-28 04:52:10.272256", + "modified": "2022-06-08 11:35:20.094546", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item_alternative/item_alternative.py b/erpnext/stock/doctype/item_alternative/item_alternative.py index 0f93bb9e95b..fb1a28d846b 100644 --- a/erpnext/stock/doctype/item_alternative/item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/item_alternative.py @@ -77,7 +77,7 @@ def get_alternative_items(doctype, txt, searchfield, start, page_len, filters): union (select item_code from `tabItem Alternative` where alternative_item_code = %(item_code)s and item_code like %(txt)s - and two_way = 1) limit {0}, {1} + and two_way = 1) limit {1} offset {0} """.format( start, page_len ), diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.py b/erpnext/stock/doctype/packing_slip/packing_slip.py index e9ccf5fc779..e5b9de8789f 100644 --- a/erpnext/stock/doctype/packing_slip/packing_slip.py +++ b/erpnext/stock/doctype/packing_slip/packing_slip.py @@ -203,7 +203,7 @@ def item_details(doctype, txt, searchfield, start, page_len, filters): where name in ( select item_code FROM `tabDelivery Note Item` where parent= %s) and %s like "%s" %s - limit %s, %s """ + limit %s offset %s """ % ("%s", searchfield, "%s", get_match_cond(doctype), "%s", "%s"), - ((filters or {}).get("delivery_note"), "%%%s%%" % txt, start, page_len), + ((filters or {}).get("delivery_note"), "%%%s%%" % txt, page_len, start), ) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 51ec598f726..754404b068b 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -46,8 +46,6 @@ frappe.ui.form.on("Purchase Receipt", { erpnext.queries.setup_queries(frm, "Warehouse", function() { return erpnext.queries.warehouse(frm.doc); }); - - erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 331d3e812b2..13abfad4557 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -232,7 +232,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): FROM `tab{doc}` WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s {qi_condition} {cond} {mcond} - ORDER BY item_code limit {start}, {page_len} + ORDER BY item_code limit {page_len} offset {start} """.format( doc=filters.get("from"), cond=cond, @@ -252,7 +252,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): WHERE name = %(reference_name)s and docstatus < 2 and production_item like %(txt)s {qi_condition} {cond} {mcond} ORDER BY production_item - LIMIT {start}, {page_len} + limit {page_len} offset {start} """.format( doc=filters.get("from"), cond=cond, diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json index 156f77f5ac1..e0939338294 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json @@ -25,7 +25,8 @@ "items_to_be_repost", "affected_transactions", "distinct_item_and_warehouse", - "current_index" + "current_index", + "gl_reposting_index" ], "fields": [ { @@ -181,12 +182,20 @@ "label": "Affected Transactions", "no_copy": 1, "read_only": 1 + }, + { + "default": "0", + "fieldname": "gl_reposting_index", + "fieldtype": "Int", + "hidden": 1, + "label": "GL reposting index", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-04-18 14:08:08.821602", + "modified": "2022-06-13 12:20:22.182322", "modified_by": "Administrator", "module": "Stock", "name": "Repost Item Valuation", 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 328afc86bee..ea24b47a295 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -192,6 +192,7 @@ def repost_gl_entries(doc): directly_dependent_transactions + list(repost_affected_transaction), doc.posting_date, doc.company, + repost_doc=doc, ) diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 3184f69aa45..3c74619b461 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -2,10 +2,14 @@ # See license.txt +from unittest.mock import MagicMock, call + import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate +from frappe.utils.data import today +from erpnext.accounts.utils import repost_gle_for_stock_vouchers from erpnext.controllers.stock_controller import create_item_wise_repost_entries from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -193,3 +197,31 @@ class TestRepostItemValuation(FrappeTestCase): [["a", "b"], ["c", "d"]], sorted(frappe.parse_json(frappe.as_json(set([("a", "b"), ("c", "d")])))), ) + + def test_gl_repost_progress(self): + from erpnext.accounts import utils + + # lower numbers to simplify test + orig_chunk_size = utils.GL_REPOSTING_CHUNK + utils.GL_REPOSTING_CHUNK = 1 + self.addCleanup(setattr, utils, "GL_REPOSTING_CHUNK", orig_chunk_size) + + doc = frappe.new_doc("Repost Item Valuation") + doc.db_set = MagicMock() + + vouchers = [] + company = "_Test Company with perpetual inventory" + posting_date = today() + + for _ in range(3): + se = make_stock_entry(company=company, qty=1, rate=2, target="Stores - TCP1") + vouchers.append((se.doctype, se.name)) + + repost_gle_for_stock_vouchers(stock_vouchers=vouchers, posting_date=posting_date, repost_doc=doc) + self.assertIn(call("gl_reposting_index", 1), doc.db_set.mock_calls) + doc.db_set.reset_mock() + + doc.gl_reposting_index = 1 + repost_gle_for_stock_vouchers(stock_vouchers=vouchers, posting_date=posting_date, repost_doc=doc) + + self.assertNotIn(call("gl_reposting_index", 1), doc.db_set.mock_calls) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 08ce83b7077..38c4886b036 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1923,23 +1923,30 @@ class StockEntry(StockController): ): # Get PO Supplied Items Details - item_wh = frappe._dict( - frappe.db.sql( - """ - select rm_item_code, reserve_warehouse - from `tabPurchase Order` po, `tabPurchase Order Item Supplied` poitemsup - where po.name = poitemsup.parent - and po.name = %s""", - self.purchase_order, - ) + po_supplied_items = frappe.db.get_all( + "Purchase Order Item Supplied", + filters={"parent": self.purchase_order}, + fields=["name", "rm_item_code", "reserve_warehouse"], ) + # Get Items Supplied in Stock Entries against PO supplied_items = get_supplied_items(self.purchase_order) - for name, item in supplied_items.items(): - frappe.db.set_value("Purchase Order Item Supplied", name, item) - # Update reserved sub contracted quantity in bin based on Supplied Item Details and + for row in po_supplied_items: + key, item = row.name, {} + if not supplied_items.get(key): + # no stock transferred against PO Supplied Items row + item = {"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0} + else: + item = supplied_items.get(key) + + frappe.db.set_value("Purchase Order Item Supplied", row.name, item) + + # RM Item-Reserve Warehouse Dict + item_wh = {x.get("rm_item_code"): x.get("reserve_warehouse") for x in po_supplied_items} + for d in self.get("items"): + # Update reserved sub contracted quantity in bin based on Supplied Item Details and 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): diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index c5c0cefe51c..41a3b8916de 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -2,11 +2,37 @@ # See license.txt +from typing import TYPE_CHECKING, Optional, overload + import frappe from frappe.utils import cint, flt import erpnext +if TYPE_CHECKING: + from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry + + +@overload +def make_stock_entry( + *, + item_code: str, + qty: float, + company: Optional[str] = None, + from_warehouse: Optional[str] = None, + to_warehouse: Optional[str] = None, + rate: Optional[float] = None, + serial_no: Optional[str] = None, + batch_no: Optional[str] = None, + posting_date: Optional[str] = None, + posting_time: Optional[str] = None, + purpose: Optional[str] = None, + do_not_save: bool = False, + do_not_submit: bool = False, + inspection_required: bool = False, +) -> "StockEntry": + ... + @frappe.whitelist() def make_stock_entry(**args): 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 eb1e0fc25fd..55a213ccc3f 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 @@ -24,9 +24,10 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation, ) from erpnext.stock.stock_ledger import get_previous_sle +from erpnext.stock.tests.test_utils import StockTestMixin -class TestStockLedgerEntry(FrappeTestCase): +class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): def setUp(self): items = create_items() reset("Stock Entry") @@ -541,30 +542,6 @@ class TestStockLedgerEntry(FrappeTestCase): "Incorrect 'Incoming Rate' values fetched for DN items", ) - def assertSLEs(self, doc, expected_sles, sle_filters=None): - """Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" - - filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} - if sle_filters: - filters.update(sle_filters) - sles = frappe.get_all( - "Stock Ledger Entry", - fields=["*"], - filters=filters, - 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} diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 9088eb802b5..191c03f5f1c 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cstr, flt, nowdate, nowtime, random_string from erpnext.accounts.utils import get_stock_and_account_balance -from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( @@ -19,10 +19,11 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( ) from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after +from erpnext.stock.tests.test_utils import StockTestMixin from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method -class TestStockReconciliation(FrappeTestCase): +class TestStockReconciliation(FrappeTestCase, StockTestMixin): @classmethod def setUpClass(cls): create_batch_or_serial_no_items() @@ -40,7 +41,7 @@ class TestStockReconciliation(FrappeTestCase): self._test_reco_sle_gle("Moving Average") def _test_reco_sle_gle(self, valuation_method): - item_code = make_item(properties={"valuation_method": valuation_method}).name + item_code = self.make_item(properties={"valuation_method": valuation_method}).name se1, se2, se3 = insert_existing_sle(warehouse="Stores - TCP1", item_code=item_code) company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -392,7 +393,7 @@ class TestStockReconciliation(FrappeTestCase): SR4 | Reco | 0 | 6 (posting date: today-1) [backdated] PR3 | PR | 1 | 7 (posting date: today) # can't post future PR """ - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" frappe.flags.dont_execute_stock_reposts = True @@ -458,7 +459,7 @@ class TestStockReconciliation(FrappeTestCase): from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.stock_ledger import NegativeStockError - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" pr1 = make_purchase_receipt( @@ -506,7 +507,7 @@ class TestStockReconciliation(FrappeTestCase): from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.stock_ledger import NegativeStockError - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" sr = create_stock_reconciliation( @@ -549,7 +550,7 @@ class TestStockReconciliation(FrappeTestCase): # repost will make this test useless, qty should update in realtime without reposts frappe.flags.dont_execute_stock_reposts = True - item_code = make_item().name + item_code = self.make_item().name warehouse = "_Test Warehouse - _TC" sr = create_stock_reconciliation( diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py index 9ee0c9f3b5a..b046dbda24f 100644 --- a/erpnext/stock/tests/test_utils.py +++ b/erpnext/stock/tests/test_utils.py @@ -1,16 +1,67 @@ +import json + import frappe from frappe.tests.utils import FrappeTestCase -from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.utils import scan_barcode -class TestStockUtilities(FrappeTestCase): +class StockTestMixin: + """Mixin to simplfy stock ledger tests, useful for all stock transactions.""" + + def make_item(self, item_code=None, properties=None, *args, **kwargs): + from erpnext.stock.doctype.item.test_item import make_item + + return make_item(item_code, properties, *args, **kwargs) + + def assertSLEs(self, doc, expected_sles, sle_filters=None): + """Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" + + filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} + if sle_filters: + filters.update(sle_filters) + sles = frappe.get_all( + "Stock Ledger Entry", + fields=["*"], + filters=filters, + 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 assertGLEs(self, doc, expected_gles, gle_filters=None, order_by=None): + filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} + + if gle_filters: + filters.update(gle_filters) + actual_gles = frappe.get_all( + "GL Entry", + fields=["*"], + filters=filters, + order_by=order_by or "posting_date, creation", + ) + + for exp_gle, act_gle in zip(expected_gles, actual_gles): + for k, exp_value in exp_gle.items(): + act_value = act_gle[k] + self.assertEqual(exp_value, act_value, msg=f"{k} doesn't match \n{exp_gle}\n{act_gle}") + + +class TestStockUtilities(FrappeTestCase, StockTestMixin): def test_barcode_scanning(self): - simple_item = make_item(properties={"barcodes": [{"barcode": "12399"}]}) + simple_item = self.make_item(properties={"barcodes": [{"barcode": "12399"}]}) self.assertEqual(scan_barcode("12399")["item_code"], simple_item.name) - batch_item = make_item(properties={"has_batch_no": 1, "create_new_batch": 1}) + batch_item = self.make_item(properties={"has_batch_no": 1, "create_new_batch": 1}) batch = frappe.get_doc(doctype="Batch", item=batch_item.name).insert() batch_scan = scan_barcode(batch.name) @@ -19,7 +70,7 @@ class TestStockUtilities(FrappeTestCase): self.assertEqual(batch_scan["has_batch_no"], 1) self.assertEqual(batch_scan["has_serial_no"], 0) - serial_item = make_item(properties={"has_serial_no": 1}) + serial_item = self.make_item(properties={"has_serial_no": 1}) serial = frappe.get_doc( doctype="Serial No", item_code=serial_item.name, serial_no=frappe.generate_hash() ).insert() diff --git a/erpnext/templates/pages/product_search.py b/erpnext/templates/pages/product_search.py index 3ed056f55e7..0768cc3fa69 100644 --- a/erpnext/templates/pages/product_search.py +++ b/erpnext/templates/pages/product_search.py @@ -56,7 +56,10 @@ def get_product_data(search=None, start=0, limit=12): search = "%" + cstr(search) + "%" # order by - query += """ ORDER BY ranking desc, modified desc limit %s, %s""" % (cint(start), cint(limit)) + query += """ ORDER BY ranking desc, modified desc limit %s offset %s""" % ( + cint(limit), + cint(start), + ) return frappe.db.sql(query, {"search": search}, as_dict=1) # nosemgrep diff --git a/erpnext/tests/test_exotel.py b/erpnext/tests/test_exotel.py index 76bbb3e05ad..31baf7594d0 100644 --- a/erpnext/tests/test_exotel.py +++ b/erpnext/tests/test_exotel.py @@ -59,7 +59,6 @@ class TestExotel(FrappeAPITestCase): f"/api/method/erpnext.erpnext_integrations.exotel_integration.{api_method}", data=frappe.as_json(data), content_type="application/json", - as_tuple=True, ) # restart db connection to get latest data frappe.connect() diff --git a/erpnext/tests/test_subcontracting.py b/erpnext/tests/test_subcontracting.py index bf12181c527..93d1c8e6a59 100644 --- a/erpnext/tests/test_subcontracting.py +++ b/erpnext/tests/test_subcontracting.py @@ -879,6 +879,55 @@ class TestSubcontracting(unittest.TestCase): for key, value in get_supplied_items(pr1).items(): self.assertEqual(value.qty, 2) + def test_po_supplied_qty(self): + """ + Check if 'Supplied Qty' in PO's Supplied Items table is reset on submit/cancel. + """ + set_backflush_based_on("Material Transferred for Subcontract") + items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Item SA1", + "qty": 5, + "rate": 100, + }, + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Item SA5", + "qty": 6, + "rate": 100, + }, + ] + + rm_items = [ + {"item_code": "Subcontracted SRM Item 1", "qty": 5, "main_item_code": "Subcontracted Item SA1"}, + {"item_code": "Subcontracted SRM Item 2", "qty": 5, "main_item_code": "Subcontracted Item SA1"}, + {"item_code": "Subcontracted SRM Item 3", "qty": 5, "main_item_code": "Subcontracted Item SA1"}, + {"item_code": "Subcontracted SRM Item 5", "qty": 6, "main_item_code": "Subcontracted Item SA5"}, + {"item_code": "Subcontracted SRM Item 4", "qty": 6, "main_item_code": "Subcontracted Item SA5"}, + ] + + itemwise_details = make_stock_in_entry(rm_items=rm_items) + po = create_purchase_order( + rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC" + ) + + for d in rm_items: + d["po_detail"] = po.items[0].name if d.get("qty") == 5 else po.items[1].name + + se = make_stock_transfer_entry( + po_no=po.name, rm_items=rm_items, itemwise_details=copy.deepcopy(itemwise_details) + ) + + po.reload() + for row in po.get("supplied_items"): + self.assertIn(row.supplied_qty, [5.0, 6.0]) + + se.cancel() + po.reload() + for row in po.get("supplied_items"): + self.assertEqual(row.supplied_qty, 0.0) + def add_second_row_in_pr(pr): item_dict = {} diff --git a/erpnext/translations/ar.csv b/erpnext/translations/ar.csv index 91a9da9f163..e62f61a4f55 100644 --- a/erpnext/translations/ar.csv +++ b/erpnext/translations/ar.csv @@ -4297,7 +4297,7 @@ Fetch Serial Numbers based on FIFO,إحضار الأرقام المسلسلة ب "To allow different rates, disable the {0} checkbox in {1}.",للسماح بمعدلات مختلفة ، قم بتعطيل مربع الاختيار {0} في {1}., Current Odometer Value should be greater than Last Odometer Value {0},يجب أن تكون قيمة عداد المسافات الحالية أكبر من قيمة آخر عداد المسافات {0}, No additional expenses has been added,لم يتم إضافة مصاريف إضافية, -Asset{} {assets_link} created for {},الأصل {} {asset_link} الذي تم إنشاؤه لـ {}, +Asset{} {assets_link} created for {},الأصل {} {assets_link} الذي تم إنشاؤه لـ {}, Row {}: Asset Naming Series is mandatory for the auto creation for item {},الصف {}: سلسلة تسمية الأصول إلزامية للإنشاء التلقائي للعنصر {}, Assets not created for {0}. You will have to create asset manually.,لم يتم إنشاء الأصول لـ {0}. سيكون عليك إنشاء الأصل يدويًا., {0} {1} has accounting entries in currency {2} for company {3}. Please select a receivable or payable account with currency {2}.,{0} يحتوي {1} على إدخالات محاسبية بالعملة {2} للشركة {3}. الرجاء تحديد حساب مستحق أو دائن بالعملة {2}., diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index 22e3c356d66..ffc46d2fd0d 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -1352,11 +1352,11 @@ Item Description,Description de l'Article, Item Group,Groupe d'Article, Item Group Tree,Arborescence de Groupe d'Article, Item Group not mentioned in item master for item {0},Le Groupe d'Articles n'est pas mentionné dans la fiche de l'article pour l'article {0}, -Item Name,Nom de l'article, +Item Name,Nom de l'article, Item Price added for {0} in Price List {1},Prix de l'Article ajouté pour {0} dans la Liste de Prix {1}, -"Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Le prix de l'article apparaît plusieurs fois en fonction de la liste de prix, du fournisseur / client, de la devise, de l'article, de l'unité de mesure, de la quantité et des dates.", +"Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Le prix de l'article apparaît plusieurs fois en fonction de la liste de prix, du fournisseur / client, de la devise, de l'article, de l'unité de mesure, de la quantité et des dates.", Item Price updated for {0} in Price List {1},Prix de l'Article mis à jour pour {0} dans la Liste des Prix {1}, -Item Row {0}: {1} {2} does not exist in above '{1}' table,Ligne d'objet {0}: {1} {2} n'existe pas dans la table '{1}' ci-dessus, +Item Row {0}: {1} {2} does not exist in above '{1}' table,Ligne d'objet {0}: {1} {2} n'existe pas dans la table '{1}' ci-dessus, Item Tax Row {0} must have account of type Tax or Income or Expense or Chargeable,La Ligne de Taxe d'Article {0} doit indiquer un compte de type Taxes ou Produit ou Charge ou Facturable, Item Template,Modèle d'article, Item Variant Settings,Paramètres de Variante d'Article, @@ -3661,7 +3661,7 @@ Chart,Graphique, Choose a corresponding payment,Choisissez un paiement correspondant, Click on the link below to verify your email and confirm the appointment,Cliquez sur le lien ci-dessous pour vérifier votre email et confirmer le rendez-vous, Close,Fermer, -Communication,la communication, +Communication,Communication, Compact Item Print,Impression de l'Article Compacté, Company,Société, Company of asset {0} and purchase document {1} doesn't matches.,La société de l'actif {0} et le document d'achat {1} ne correspondent pas., @@ -3969,7 +3969,7 @@ Quantity to Manufacture can not be zero for the operation {0},La quantité à fa Quarterly,Trimestriel, Queued,File d'Attente, Quick Entry,Écriture Rapide, -Quiz {0} does not exist,Le questionnaire {0} n'existe pas, +Quiz {0} does not exist,Le questionnaire {0} n'existe pas, Quotation Amount,Montant du devis, Rate or Discount is required for the price discount.,Le taux ou la remise est requis pour la remise de prix., Reason,Raison, @@ -4071,7 +4071,7 @@ Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and Stores - {0},Magasins - {0}, Student with email {0} does not exist,Étudiant avec le courrier électronique {0} n'existe pas, Submit Review,Poster un commentaire, -Submitted,Soumis, +Submitted,Valider, Supplier Addresses And Contacts,Adresses et contacts des fournisseurs, Synchronize this account,Synchroniser ce compte, Tag,Étiquette, @@ -9871,8 +9871,42 @@ Show Barcode Field in Stock Transactions,Afficher le champ Code Barre dans les t Convert Item Description to Clean HTML in Transactions,Convertir les descriptions d'articles en HTML valide lors des transactions Have Default Naming Series for Batch ID?,Nom de série par défaut pour les Lots ou Séries "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units","Le pourcentage de quantité que vous pourrez réceptionner en plus de la quantité commandée. Par exemple, vous avez commandé 100 unités, votre pourcentage de dépassement est de 10%, vous pourrez réceptionner 110 unités" -Unit Of Measure (UOM),Unité de mesure (UDM), Allowed Items,Articles autorisés Party Specific Item,Restriction d'article disponible Restrict Items Based On,Type de critére de restriction Based On Value,critére de restriction +Unit of Measure (UOM),Unité de mesure (UDM), +Unit Of Measure (UOM),Unité de mesure (UDM), +CRM Settings,Paramètres CRM +Do Not Explode,Ne pas décomposer +Quick Access, Accés rapides +{} Available,{} Disponible.s +{} Pending,{} En attente.s +{} To Bill,{} à facturer +{} To Receive,{} A recevoir +{} Active,{} Actif.ve(s) +{} Open,{} Ouvert.e(s) +Incorrect Data Report,Rapport de données incohérentes +Incorrect Serial No Valuation,Valorisation inccorecte par Num. Série / Lots +Incorrect Balance Qty After Transaction,Equilibre des quantités aprés une transaction +Interview Type,Type d'entretien +Interview Round,Cycle d'entretien +Interview,Entretien +Interview Feedback,Retour d'entretien +Journal Energy Point,Historique des points d'énergies +Billing Address Details,Adresse de facturation (détails) +Supplier Address Details,Adresse Fournisseur (détails) +Retail,Commerce +Users,Utilisateurs +Permission Manager,Gestion des permissions +Fetch Timesheet,Récuprer les temps saisis +Get Supplier Group Details,Appliquer les informations depuis le Groupe de fournisseur +Quality Inspection(s),Inspection(s) Qualité +Set Advances and Allocate (FIFO),Affecter les encours au réglement +Apply Putaway Rule,Appliquer la régle de routage d'entrepot +Delete Transactions,Supprimer les transactions +Default Payment Discount Account,Compte par défaut des paiements de remise +Unrealized Profit / Loss Account,Compte de perte +Enable Provisional Accounting For Non Stock Items,Activer la provision pour les articles non stockés +Publish in Website,Publier sur le Site Web +List View,Vue en liste diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 6b766e7dc0f..743b29493c2 100644 --- a/erpnext/translations/ru.csv +++ b/erpnext/translations/ru.csv @@ -288,7 +288,7 @@ Asset {0} must be submitted,Актив {0} должен быть проведе Assets,Активы, Assign,Назначить, Assign Salary Structure,Назначить структуру заработной платы, -Assign To,Назначить в, +Assign To,Назначить для, Assign to Employees,Назначить сотрудникам, Assigning Structures...,Назначение структур..., Associate,Помощник, @@ -421,7 +421,7 @@ Buildings,Здания, Bundle items at time of sale.,Собирать продукты в момент продажи., Business Development Manager,Менеджер по развитию бизнеса, Buy,Купить, -Buying,Покупки, +Buying,Закупки, Buying Amount,Сумма покупки, Buying Price List,Ценовой список покупок, Buying Rate,Частота покупки, @@ -490,7 +490,7 @@ Capital Equipments,Капитальные оборудование, Capital Stock,Капитал, Capital Work in Progress,Капитальная работа в процессе, Cart,Корзина, -Cart is Empty,Корзина Пусто, +Cart is Empty,Корзина пуста, Case No(s) already in use. Try from Case No {0},Случай Нет (ы) уже используется. Попробуйте из дела № {0}, Cash,Наличные, Cash Flow Statement,О движении денежных средств, @@ -578,7 +578,7 @@ Compensatory Off,Компенсационные Выкл, Compensatory leave request days not in valid holidays,Дни запроса на получение компенсационных отчислений не действительны, Complaint,Жалоба, Completion Date,Дата завершения, -Computer,компьютер, +Computer,Компьютер, Condition,Условия, Configure,Конфигурировать, Configure {0},Настроить {0}, @@ -643,7 +643,6 @@ Course Code: ,Код курса: , Course Enrollment {0} does not exists,Зачисление на курс {0} не существует, Course Schedule,Расписание курса, Course: ,Курс: , -Cr,Cr, Create,Создать, Create BOM,Создать спецификацию, Create Delivery Trip,Создать маршрут доставки, @@ -795,7 +794,6 @@ Defense,Оборона, Define Project type.,Установите тип проекта., Define budget for a financial year.,Определить бюджет на финансовый год., Define various loan types,Определение различных видов кредита, -Del,Del, Delay in payment (Days),Задержка в оплате (дни), Delete all the Transactions for this Company,Удалить все транзакции этой компании, Deletion is not permitted for country {0},Для страны не разрешено удаление {0}, @@ -1287,12 +1285,12 @@ Installing presets,Установка пресетов, Institute Abbreviation,институт Аббревиатура, Institute Name,Название института, Instructor,Инструктор, -Insufficient Stock,Недостаточный Stock, -Insurance Start date should be less than Insurance End date,"Дата страхование начала должна быть меньше, чем дата страхование End", +Insufficient Stock,Недостаточный запас, +Insurance Start date should be less than Insurance End date,"Дата начала страхования должна быть раньше, чем дата окончания", Integrated Tax,Интегрированный налог, Inter-State Supplies,Межгосударственные поставки, -Interest Amount,Проценты Сумма, -Interests,интересы, +Interest Amount,Сумма процентов, +Interests,Интересы, Intern,Стажер, Internet Publishing,Интернет издания, Intra-State Supplies,Внутригосударственные поставки, @@ -1397,7 +1395,7 @@ Job Card,Карточка работы, Job Description,Описание работы, Job Offer,Предложение работы, Job card {0} created,Карта работы {0} создана, -Jobs,Работы, +Jobs,Вакансии, Join,Присоединиться, Journal Entries {0} are un-linked,Записи в журнале {0} не-связаны, Journal Entry,Запись в журнале, @@ -1925,7 +1923,7 @@ Pending Amount,В ожидании Сумма, Pending Leaves,Ожидающие листья, Pending Qty,В ожидании кол-во, Pending Quantity,Количество в ожидании, -Pending Review,В ожидании отзыв, +Pending Review,В ожидании отзыва, Pending activities for today,В ожидании деятельность на сегодняшний день, Pension Funds,Пенсионные фонды, Percentage Allocation should be equal to 100%,Процент Распределение должно быть равно 100%, @@ -1949,7 +1947,7 @@ Planned Qty,Планируемое кол-во, Planning,Планирование, Plants and Machineries,Растения и Механизмов, Please Set Supplier Group in Buying Settings.,Установите группу поставщиков в разделе «Настройки покупок»., -Please add a Temporary Opening account in Chart of Accounts,"Пожалуйста, добавьте временный вступительный счет в План счетов", +Please add a Temporary Opening account in Chart of Accounts,"Пожалуйста, добавьте временный вступительный счет в план счетов", Please add the account to root level Company - ,"Пожалуйста, добавьте счет на корневой уровень компании -", Please add the remaining benefits {0} to any of the existing component,Добавьте оставшиеся преимущества {0} к любому из существующих компонентов, Please check Multi Currency option to allow accounts with other currency,"Пожалуйста, проверьте мультивалютный вариант, позволяющий счета другой валюте", @@ -2146,7 +2144,7 @@ Preview Salary Slip,Просмотр Зарплата скольжению, Previous Financial Year is not closed,Предыдущий финансовый год не закрыт, Price,Цена, Price List,Прайс-лист, -Price List Currency not selected,Прайс-лист Обмен не выбран, +Price List Currency not selected,Валюта прайс-листа не выбрана, Price List Rate,Прайс-лист Оценить, Price List master.,Мастер Прайс-лист., Price List must be applicable for Buying or Selling,Прайс-лист должен быть применим для покупки или продажи, @@ -2347,7 +2345,7 @@ Remaining,Осталось, Remaining Balance,Остаток средств, Remarks,Примечания, Reminder to update GSTIN Sent,Напоминание об обновлении отправленного GSTIN, -Remove item if charges is not applicable to that item,"Удалить продукт, если сборы не применимы к этому продукту", +Remove item if charges is not applicable to that item,"Удалить объект, если к нему не применяются сборы", Removed items with no change in quantity or value.,Удалены пункты без изменения в количестве или стоимости., Reopen,Возобновить, Reorder Level,Уровень переупорядочения, @@ -2509,7 +2507,7 @@ Salary Slip of employee {0} already created for this period,Зарплата С Salary Slip of employee {0} already created for time sheet {1},Зарплата Скольжение работника {0} уже создан для табеля {1}, Salary Slip submitted for period from {0} to {1},"Зарплатная ведомость отправлена за период с {0} по {1}", Salary Structure Assignment for Employee already exists,Присвоение структуры зарплаты сотруднику уже существует, -Salary Structure Missing,Структура заработной платы Отсутствующий, +Salary Structure Missing,Структура заработной платы отсутствует, Salary Structure must be submitted before submission of Tax Ememption Declaration,Структура заработной платы должна быть представлена до подачи декларации об освобождении от налогов, Salary Structure not found for employee {0} and date {1},Структура зарплаты не найдена для сотрудника {0} и даты {1}, Salary Structure should have flexible benefit component(s) to dispense benefit amount,Структура заработной платы должна иметь гибкий компонент (ы) выгоды для распределения суммы пособия, @@ -2701,10 +2699,10 @@ Setup default values for POS Invoices,Настройка значений по Setup mode of POS (Online / Offline),Режим настройки POS (Online / Offline), Setup your Institute in ERPNext,Установите свой институт в ERPNext, Share Balance,Баланс акций, -Share Ledger,Поделиться записями, +Share Ledger,Записи по акциям, Share Management,Управление долями, Share Transfer,Передача акций, -Share Type,Share Тип, +Share Type,Тип акций, Shareholder,Акционер, Ship To State,Корабль в штат, Shipments,Поставки, @@ -2796,8 +2794,8 @@ Stock Entry {0} is not submitted,Складской акт {0} не провед Stock Expenses,Расходы по Запасам, Stock In Hand,Запасы на руках, Stock Items,Позиции на складе, -Stock Ledger,Книга учета Запасов, -Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Записи складской книги и записи GL запасов отправляются для выбранных покупок, +Stock Ledger,Книга учета запасов, +Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Записи книги учета запасов и записи GL повторно публикуются для выбранных квитанций о покупках, Stock Levels,Уровень запасов, Stock Liabilities,Обязательства по запасам, Stock Options,Опционы, @@ -2829,9 +2827,9 @@ Student Email ID,Идентификация студента по электро Student Group,Учебная группа, Student Group Strength,Сила студенческой группы, Student Group is already updated.,Студенческая группа уже обновлена., -Student Group: ,Студенческая группа:, +Student Group: ,Студенческая группа: , Student ID,Студенческий билет, -Student ID: ,Студенческий билет:, +Student ID: ,Студенческий билет: , Student LMS Activity,Студенческая LMS Активность, Student Mobile No.,Мобильный номер студента, Student Name,Имя ученика, @@ -2864,9 +2862,9 @@ Successfully created payment entries,Успешно созданные плат Successfully deleted all transactions related to this company!,"Успешно удален все сделки, связанные с этой компанией!", Sum of Scores of Assessment Criteria needs to be {0}.,Сумма десятков критериев оценки должно быть {0}., Sum of points for all goals should be 100. It is {0},Сумма баллов за все цели должны быть 100. Это {0}, -Summary,Резюме, -Summary for this month and pending activities,Резюме для этого месяца и в ожидании деятельности, -Summary for this week and pending activities,Резюме на этой неделе и в ожидании деятельности, +Summary,Сводка, +Summary for this month and pending activities,Сводка за этот месяц и предстоящие мероприятия, +Summary for this week and pending activities,Сводка за эту неделю и предстоящие мероприятия, Sunday,Воскресенье, Suplier,Поставщик, Supplier,Поставщик, @@ -2880,7 +2878,7 @@ Supplier Name,наименование поставщика, Supplier Part No,Деталь поставщика №, Supplier Quotation,Предложение поставщика, Supplier Scorecard,Оценочная карта поставщика, -Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Поставщик Склад обязательным для субподрядчиком ТОВАРНЫЙ ЧЕК, +Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Наличие склада поставщика обязательно для субподрядной квитанции о покупке, Supplier database.,База данных поставщиков., Supplier {0} not found in {1},Поставщик {0} не найден в {1}, Supplier(s),Поставщик(и), @@ -3199,7 +3197,7 @@ Used Leaves,Используемые листы, User,Пользователь, User ID,ID пользователя, User ID not set for Employee {0},ID пользователя не установлен для сотрудника {0}, -User Remark,Примечание Пользователь, +User Remark,Примечание пользователя, User has not applied rule on the invoice {0},Пользователь не применил правило к счету {0}, User {0} already exists,Пользователь {0} уже существует, User {0} created,Пользователь {0} создан, @@ -3243,7 +3241,7 @@ View Fees Records,Посмотреть рекорды, View Form,Посмотреть форму, View Lab Tests,Просмотр лабораторных тестов, View Leads,Посмотреть лиды, -View Ledger,Посмотреть Леджер, +View Ledger,Посмотреть записи, View Now,Просмотр сейчас, View a list of all the help videos,Просмотреть список всех справочных видео, View in Cart,Смотрите в корзину, @@ -3314,7 +3312,7 @@ Work Orders Created: {0},Созданы рабочие задания: {0}, Work Summary for {0},Резюме работы для {0}, Work-in-Progress Warehouse is required before Submit,Работа-в-Прогресс Склад требуется перед Отправить, Workflow,Рабочий процесс, -Working,Работающий, +Working,В работе, Working Hours,Часы работы, Workstation,Рабочее место, Workstation is closed on the following dates as per Holiday List: {0},Рабочая место закрыто в следующие даты согласно списка праздников: {0}, @@ -3869,13 +3867,17 @@ Non stock items,Нет на складе, Not Allowed,Не разрешено, Not allowed to create accounting dimension for {0},Не разрешено создавать учетное измерение для {0}, Not permitted. Please disable the Lab Test Template,"Не разрешено Пожалуйста, отключите шаблон лабораторного теста", -Note,Заметки, +Note,Заметка, Notes: ,Заметки: , -On Converting Opportunity,О возможности конвертации, -On Purchase Order Submission,При подаче заказа на поставку, -On Sales Order Submission,На подаче заказа клиента, -On Task Completion,По завершении задачи, +On Converting Opportunity,Конвертацию возможности, +On Purchase Order Submission,Офомление заказа на закупку, +On Sales Order Submission,Оформление заказа на продажу, +On Task Completion,Завершении задачи, On {0} Creation,На {0} создании, +On Item Creation,Создание продукта, +On Lead Creation,Создание лида, +On Supplier Creation,Создание поставщика, +On Customer Creation,Создание клиента, Only .csv and .xlsx files are supported currently,В настоящее время поддерживаются только файлы .csv и .xlsx, Only expired allocation can be cancelled,Только истекшее распределение может быть отменено, Only users with the {0} role can create backdated leave applications,Только пользователи с ролью {0} могут создавать оставленные приложения с задним сроком действия, @@ -4217,7 +4219,7 @@ Mode Of Payment,Способ оплаты, No students Found,Студенты не найдены, Not in Stock,Нет в наличии, Please select a Customer,Выберите клиента, -Printed On,Отпечатано на, +Printed On,Напечатано на, Received From,Получено от, Sales Person,Продавец, To date cannot be before From date,На сегодняшний день не может быть раньше От даты, @@ -4945,14 +4947,14 @@ Max Qty,Макс. кол-во, Min Amt,Мин Amt, Max Amt,Макс Амт, Period Settings,Настройки периода, -Margin,Разница, +Margin,Маржа, Margin Type,Тип маржа, Margin Rate or Amount,Маржинальная ставка или сумма, Price Discount Scheme,Схема скидок, Rate or Discount,Стоимость или скидка, Discount Percentage,Скидка в процентах, Discount Amount,Сумма скидки, -For Price List,Для Прейскурантом, +For Price List,Для прайс-листа, Product Discount Scheme,Схема скидок на товары, Same Item,Тот же пункт, Free Item,Бесплатный товар, @@ -5385,18 +5387,18 @@ Insurance Start Date,Дата начала страхования, Insurance End Date,Дата окончания страхования, Comprehensive Insurance,Комплексное страхование, Maintenance Required,Требуется техническое обслуживание, -Check if Asset requires Preventive Maintenance or Calibration,"Проверьте, требуется ли Asset профилактическое обслуживание или калибровка", +Check if Asset requires Preventive Maintenance or Calibration,"Проверьте, требует ли актив профилактического обслуживания или калибровки", Booked Fixed Asset,Забронированные основные средства, Purchase Receipt Amount,Сумма покупки, Default Finance Book,Финансовая книга по умолчанию, Quality Manager,Менеджер по качеству, -Asset Category Name,Asset Категория Название, +Asset Category Name,Название категории активов, Depreciation Options,Варианты амортизации, Enable Capital Work in Progress Accounting,Включить капитальную работу в процессе учета, Finance Book Detail,Финансовая книга, Asset Category Account,Счет категории активов, Fixed Asset Account,Счет учета основных средств, -Accumulated Depreciation Account,Начисленной амортизации Счет, +Accumulated Depreciation Account,Счет накопленной амортизации, Depreciation Expense Account,Износ счет расходов, Capital Work In Progress Account,Счет капитальной работы, Asset Finance Book,Финансовая книга по активам, @@ -5441,7 +5443,7 @@ Failure Date,Дата отказа, Assign To Name,Назначить имя, Repair Status,Статус ремонта, Error Description,Описание ошибки, -Downtime,время простоя, +Downtime,Время простоя, Repair Cost,Стоимость ремонта, Manufacturing Manager,Менеджер производства, Current Asset Value,Текущая стоимость актива, @@ -6073,7 +6075,7 @@ Shopify Tax/Shipping Title,Изменить название налога / до ERPNext Account,Учетная запись ERPNext, Shopify Webhook Detail,Узнайте подробности веб-камеры, Webhook ID,Идентификатор Webhook, -Tally Migration,Tally Migration, +Tally Migration,Tally миграция, Master Data,Основные данные, "Data exported from Tally that consists of the Chart of Accounts, Customers, Suppliers, Addresses, Items and UOMs","Данные, экспортированные из Tally, которые состоят из плана счетов, клиентов, поставщиков, адресов, позиций и единиц измерения", Is Master Data Processed,Обработка основных данных, @@ -6082,7 +6084,7 @@ Tally Creditors Account,Счет Tally Creditors, Creditors Account set in Tally,Счет кредиторов установлен в Tally, Tally Debtors Account,Счет Tally должников, Debtors Account set in Tally,Счет дебитора установлен в Tally, -Tally Company,Талли Компания, +Tally Company,Tally Компания, Company Name as per Imported Tally Data,Название компании согласно импортированным данным подсчета, Default UOM,Единица измерения по умолчанию, UOM in case unspecified in imported data,"Единицы измерения, если они не указаны в импортированных данных", @@ -6108,7 +6110,7 @@ Freight and Forwarding Account,Фрахт и пересылка, Creation User,Создание пользователя, "The user that will be used to create Customers, Items and Sales Orders. This user should have the relevant permissions.","Пользователь, который будет использоваться для создания клиентов, товаров и заказов на продажу. Этот пользователь должен иметь соответствующие разрешения.", "This warehouse will be used to create Sales Orders. The fallback warehouse is ""Stores"".",Этот склад будет использоваться для создания заказов на продажу. Резервный склад "Магазины"., -"The fallback series is ""SO-WOO-"".",Аварийная серия "SO-WOO-"., +"The fallback series is ""SO-WOO-"".","Аварийная серия ""SO-WOO-"".", This company will be used to create Sales Orders.,Эта компания будет использоваться для создания заказов на продажу., Delivery After (Days),Доставка после (дней), This is the default offset (days) for the Delivery Date in Sales Orders. The fallback offset is 7 days from the order placement date.,Это смещение по умолчанию (дни) для даты поставки в заказах на продажу. Смещение отступления составляет 7 дней с даты размещения заказа., @@ -6441,7 +6443,7 @@ Job Applicant,Соискатель работы, Applicant Name,Имя заявителя, Appointment Date,Назначенная дата, Appointment Letter Template,Шаблон письма о назначении, -Body,Тело, +Body,Содержимое, Closing Notes,Заметки, Appointment Letter content,Письмо о назначении, Appraisal,Оценка, @@ -6455,7 +6457,7 @@ Appraisal Goal,Цель оценки, Key Responsibility Area,Основная зона ответственности, Weightage (%),Весовая нагрузка (%), Score (0-5),Оценка (0-5), -Score Earned,Оценка Заработано, +Score Earned,Оценка получена, Appraisal Template Title,Название шаблона оценки, Appraisal Template Goal,Цель шаблона оценки, KRA,КРА, @@ -6747,7 +6749,7 @@ Applicant Email Address,Адрес электронной почты заяви Awaiting Response,В ожидании ответа, Job Offer Terms,Условия работы, Select Terms and Conditions,Выберите Сроки и условия, -Printing Details,Печатать Подробности, +Printing Details,Подробности печати, Job Offer Term,Срок действия предложения, Offer Term,Условие предложения, Value / Description,Значение / Описание, @@ -7520,7 +7522,7 @@ Expected Time (in hours),Ожидаемое время (в часах), Is Milestone,Является этапом, Task Description,Описание задания, Dependencies,Зависимости, -Dependent Tasks,Зависимые задачи, +Dependent Tasks,Зависит от задач, Depends on Tasks,Зависит от задач, Actual Start Date (via Time Sheet),Фактическая дата начала (по табелю учета рабочего времени), Actual Time (in hours),Фактическое время (в часах), @@ -7645,7 +7647,7 @@ Campaign Schedules,Расписание кампаний, Buyer of Goods and Services.,Покупатель товаров и услуг., CUST-.YYYY.-,CUST-.YYYY.-, Default Company Bank Account,Стандартный банковский счет компании, -From Lead,Из Лида, +From Lead,Из лида, Account Manager,Менеджер по работе с клиентами, Allow Sales Invoice Creation Without Sales Order,Разрешить создание счета без заказа на продажу, Allow Sales Invoice Creation Without Delivery Note,Разрешить создание счета без накладной, @@ -7818,14 +7820,14 @@ Phone No,Номер телефона, Company Description,Описание компании, Registration Details,Регистрационные данные, Company registration numbers for your reference. Tax numbers etc.,Регистрационные номера компании для вашей справки. Налоговые числа и т.д., -Delete Company Transactions,Удалить Сделки Компания, +Delete Company Transactions,Удалить транзакции компании, Currency Exchange,Курс обмена валюты, Specify Exchange Rate to convert one currency into another,Укажите Курс конвертировать одну валюту в другую, From Currency,Из валюты, To Currency,В валюту, For Buying,Для покупки, For Selling,Для продажи, -Customer Group Name,Группа Имя клиента, +Customer Group Name,Название группы клиентов, Parent Customer Group,Родительская группа клиента, Only leaf nodes are allowed in transaction,Только листовые узлы допускаются в сделке, Mention if non-standard receivable account applicable,Упоминание если нестандартная задолженность счет применимо, @@ -7893,7 +7895,7 @@ This is the number of the last created transaction with this prefix,Это чи Update Series Number,Обновить Идентификаторы по Номеру, Quotation Lost Reason,Причина Отказа от Предложения, A third party distributor / dealer / commission agent / affiliate / reseller who sells the companies products for a commission.,"Сторонний дистрибьютер, дилер, агент, филиал или реселлер, который продаёт продукты компании за комиссионное вознаграждение.", -Sales Partner Name,Имя Партнера по продажам, +Sales Partner Name,Имя партнера по продажам, Partner Type,Тип партнера, Address & Contacts,Адрес и контакты, Address Desc,Адрес по убыванию, @@ -7914,7 +7916,7 @@ Sales Person Targets,Цели продавца, Set targets Item Group-wise for this Sales Person.,Задайте цели Продуктовых Групп для Продавца, Supplier Group Name,Название группы поставщиков, Parent Supplier Group,Родительская группа поставщиков, -Target Detail,Цель Подробности, +Target Detail,Подробности цели, Target Qty,Целевое количество, Target Amount,Целевая сумма, Target Distribution,Распределение цели, @@ -7973,13 +7975,13 @@ Is Return,Является Вернуться, Issue Credit Note,Кредитная кредитная карта, Return Against Delivery Note,Вернуться На накладной, Customer's Purchase Order No,Клиентам Заказ Нет, -Billing Address Name,Адрес для выставления счета Имя, +Billing Address Name,Название адреса для выставления счета, Required only for sample item.,Требуется только для образца пункта., "If you have created a standard template in Sales Taxes and Charges Template, select one and click on the button below.","Если вы создали стандартный шаблон в шаблонах Налоги с налогами и сбором платежей, выберите его и нажмите кнопку ниже.", In Words will be visible once you save the Delivery Note.,По словам будет виден только вы сохраните накладной., In Words (Export) will be visible once you save the Delivery Note.,В Слов (Экспорт) будут видны только вы сохраните накладной., Transporter Info,Информация для транспортировки, -Driver Name,Имя драйвера, +Driver Name,Имя водителя, Track this Delivery Note against any Project,Подписка на Delivery Note против любого проекта, Inter Company Reference,Справочник Интер, Print Without Amount,Распечатать без суммы, @@ -8079,7 +8081,7 @@ Delivered by Supplier (Drop Ship),Доставка поставщиком, Supplier Items,Продукты поставщика, Foreign Trade Details,Сведения о внешней торговле, Country of Origin,Страна происхождения, -Sales Details,Продажи Подробности, +Sales Details,Детали продажи, Default Sales Unit of Measure,Единица измерения продаж по умолчанию, Is Sales Item,Продаваемый продукт, Max Discount (%),Макс. скидка (%), @@ -8117,7 +8119,7 @@ Item Alternative,Альтернативный продукт, Alternative Item Code,Альтернативный код продукта, Two-way,Двусторонний, Alternative Item Name,Альтернативное название продукта, -Attribute Name,Имя атрибута, +Attribute Name,Название атрибута, Numeric Values,Числовые значения, From Range,От хребта, Increment,Приращение, @@ -8236,8 +8238,8 @@ Transporter Details,Детали транспорта, Vehicle Number,Номер транспортного средства, Vehicle Date,Дата транспортного средства, Received and Accepted,Получил и принял, -Accepted Quantity,Принято Количество, -Rejected Quantity,Отклонен Количество, +Accepted Quantity,Количество принятых, +Rejected Quantity,Количество отклоненных, Accepted Qty as per Stock UOM,Принятое количество в соответствии с единицами измерения запаса, Sample Quantity,Количество образцов, Rate and Amount,Ставку и сумму, @@ -8285,7 +8287,7 @@ Out of AMC,Из КУА, Warranty Period (Days),Гарантийный срок (дней), Serial No Details,Серийный номер подробнее, MAT-STE-.YYYY.-,MAT-STE-.YYYY.-, -Stock Entry Type,Тип входа, +Stock Entry Type,Тип складской записи, Stock Entry (Outward GIT),Вход в акции (внешний GIT), Material Consumption for Manufacture,Потребление материала для производства, Repack,Перепаковать, @@ -8447,7 +8449,7 @@ No of Sent SMS,Кол-во отправленных SMS, Sent To,Отправить, Absent Student Report,Отчет о пропуске занятия, Assessment Plan Status,Статус плана оценки, -Asset Depreciation Ledger,Износ Леджер активов, +Asset Depreciation Ledger,Книга амортизации основных средств, Asset Depreciations and Balances,Активов Амортизация и противовесов, Available Stock for Packing Items,Доступные Запасы для Комплектации Продуктов, Bank Clearance Summary,Банк уплата по счетам итого, @@ -8559,7 +8561,7 @@ Sales Order Trends,Динамика по сделкам, Sales Partner Commission Summary,Сводка комиссий партнеров по продажам, Sales Partner Target Variance based on Item Group,Целевое отклонение партнера по продажам на основе группы товаров, Sales Partner Transaction Summary,Сводка по сделкам с партнерами по продажам, -Sales Partners Commission,Комиссионные Партнеров по продажам, +Sales Partners Commission,Комиссия партнеров по продажам, Invoiced Amount (Exclusive Tax),Сумма счета (без учета налога), Average Commission Rate,Средний уровень комиссии, Sales Payment Summary,Сводка по продажам, @@ -8579,7 +8581,7 @@ Student Fee Collection,Сбор студенческой платы, Student Monthly Attendance Sheet,Ежемесячная посещаемость студентов, Subcontracted Item To Be Received,"Субподрядный предмет, подлежащий получению", Subcontracted Raw Materials To Be Transferred,Субподрядное сырье для передачи, -Supplier Ledger Summary,Список поставщиков, +Supplier Ledger Summary,Сводка книги поставщиков, Supplier-Wise Sales Analytics,Аналитика продаж в разрезе поставщиков, Support Hour Distribution,Распределение поддержки, TDS Computation Summary,Сводка расчетов TDS, @@ -9242,7 +9244,7 @@ Tasks Completed,Задачи выполнены, Tasks Overdue,Просроченные задачи, Completion,Завершение, Provident Fund Deductions,Отчисления в резервный фонд, -Purchase Order Analysis,Анализ заказа на закупку, +Purchase Order Analysis,Анализ заказов на закупку, From and To Dates are required.,Укажите даты от и до., To Date cannot be before From Date.,Дата не может быть раньше даты начала., Qty to Bill,Кол-во к счету, @@ -9267,7 +9269,7 @@ Sales Order Analysis,Анализ заказов на продажу, Amount Delivered,Сумма доставки, Delay (in Days),Задержка (в днях), Group by Sales Order,Группировать по заказу на продажу, - Sales Value,Объем продаж, + Sales Value, Объем продаж, Stock Qty vs Serial No Count,Кол-во на складе по сравнению с серийным номером, Serial No Count,Серийный номер, Work Order Summary,Сводка заказа на работу, @@ -9320,8 +9322,8 @@ Error creating membership entry for {0},Ошибка создания запис A customer is already linked to this Member,Клиент уже привязан к этому участнику, End Date must not be lesser than Start Date,Дата окончания не должна быть меньше даты начала., Employee {0} already has Active Shift {1}: {2},Сотрудник {0} уже имеет активную смену {1}: {2}, - from {0},от {0}, - to {0},в {0}, + from {0}, от {0}, + to {0}, в {0}, Please select Employee first.,"Пожалуйста, сначала выберите сотрудника.", Please set {0} for the Employee or for Department: {1},Установите {0} для сотрудника или отдела: {1}, To Date should be greater than From Date,"Дата до должна быть больше, чем Дата", @@ -9838,3 +9840,8 @@ Enable European Access,Включить европейский доступ, Creating Purchase Order ...,Создание заказа на поставку ..., "Select a Supplier from the Default Suppliers of the items below. On selection, a Purchase Order will be made against items belonging to the selected Supplier only.","Выберите поставщика из списка поставщиков по умолчанию для позиций ниже. При выборе Заказ на поставку будет сделан в отношении товаров, принадлежащих только выбранному Поставщику.", Row #{}: You must select {} serial numbers for item {}.,Строка № {}: необходимо выбрать {} серийных номеров для позиции {}., +Items & Pricing,Продукты и цены, +Overdue,Просрочено, +Completed,Завершенно, +Total Tasks,Всего задач, +Build,Конструктор, diff --git a/erpnext/utilities/bot.py b/erpnext/utilities/bot.py deleted file mode 100644 index 5c2e576dd20..00000000000 --- a/erpnext/utilities/bot.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - - -import frappe -from frappe import _ -from frappe.utils.bot import BotParser - - -class FindItemBot(BotParser): - def get_reply(self): - if self.startswith("where is", "find item", "locate"): - if not frappe.has_permission("Warehouse"): - raise frappe.PermissionError - - item = "%{0}%".format(self.strip_words(self.query, "where is", "find item", "locate")) - items = frappe.db.sql( - """select name from `tabItem` where item_code like %(txt)s - or item_name like %(txt)s or description like %(txt)s""", - dict(txt=item), - ) - - if items: - out = [] - warehouses = frappe.get_all("Warehouse") - for item in items: - found = False - for warehouse in warehouses: - qty = frappe.db.get_value( - "Bin", {"item_code": item[0], "warehouse": warehouse.name}, "actual_qty" - ) - if qty: - out.append( - _("{0} units of [{1}](/app/Form/Item/{1}) found in [{2}](/app/Form/Warehouse/{2})").format( - qty, item[0], warehouse.name - ) - ) - found = True - - if not found: - out.append(_("[{0}](/app/Form/Item/{0}) is out of stock").format(item[0])) - - return "\n\n".join(out) - - else: - return _("Did not find any item called {0}").format(item) diff --git a/requirements.txt b/requirements.txt index 85ff515772d..83e53758be2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ gocardless-pro~=1.22.0 googlemaps plaid-python~=7.2.1 pycountry~=20.7.3 -PyGithub~=1.55 python-stdnum~=1.16 python-youtube~=0.8.0 taxjar~=1.9.2